mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
units,lesson as standalone
This commit is contained in:
@@ -0,0 +1,10 @@
|
|||||||
|
// components/generic/AssetPageLoader.jsx
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
|
||||||
|
export default function AssetPageLoader() {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-96 rounded-lg border bg-muted/30">
|
||||||
|
<Loader2 className="size-8 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
className="fixed left-0 right-0 z-60 border-b shadow-sm px-4 md:px-6"
|
||||||
|
style={{ top: 0 }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={onClickBanner}
|
||||||
|
className="w-full cursor-pointer bg-card border rounded-none px-4 py-3 flex items-start justify-between gap-4"
|
||||||
|
style={inlineStyle ?? undefined}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3 min-w-0">
|
||||||
|
<div className={`flex h-9 w-9 items-center justify-center rounded ${accentClass}`}>
|
||||||
|
<NotificationIcon type={stickyAnnouncement.type} className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-semibold leading-snug truncate">
|
||||||
|
{stickyAnnouncement.title || "Announcement"}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground leading-snug line-clamp-2">
|
||||||
|
{stickyAnnouncement.message || ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="shrink-0 text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
void onDismiss();
|
||||||
|
}}
|
||||||
|
aria-label="Dismiss sticky announcement"
|
||||||
|
title="Dismiss"
|
||||||
|
>
|
||||||
|
<X className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -134,6 +134,23 @@ export function CoursesProvider({ children }) {
|
|||||||
[request],
|
[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(
|
const updateCourse = useCallback(
|
||||||
(courseId, payload) =>
|
(courseId, payload) =>
|
||||||
request(async () => {
|
request(async () => {
|
||||||
@@ -1163,6 +1180,7 @@ export function CoursesProvider({ children }) {
|
|||||||
fetchCourses,
|
fetchCourses,
|
||||||
fetchCourse,
|
fetchCourse,
|
||||||
createCourse,
|
createCourse,
|
||||||
|
createCourseFull,
|
||||||
updateCourse,
|
updateCourse,
|
||||||
archiveCourse,
|
archiveCourse,
|
||||||
archiveCourses,
|
archiveCourses,
|
||||||
|
|||||||
@@ -41,11 +41,11 @@ export function NotificationBroadcastsProvider({ children }) {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// ─── GET /api/admin/notification-broadcasts ────────────────────────────────
|
// ─── GET /api/admin/announcements ─────────────────────────────────────────
|
||||||
const fetchBroadcasts = useCallback(
|
const fetchBroadcasts = useCallback(
|
||||||
({ page = 1, limit = 10, filters = [], sort = [] } = {}) =>
|
({ page = 1, limit = 10, filters = [], sort = [] } = {}) =>
|
||||||
request(async () => {
|
request(async () => {
|
||||||
const { data } = await api.get("/admin/notification-broadcasts", {
|
const { data } = await api.get("/admin/announcements", {
|
||||||
params: {
|
params: {
|
||||||
page, limit,
|
page, limit,
|
||||||
filters: filters.length ? JSON.stringify(filters) : undefined,
|
filters: filters.length ? JSON.stringify(filters) : undefined,
|
||||||
@@ -61,22 +61,22 @@ export function NotificationBroadcastsProvider({ children }) {
|
|||||||
[request]
|
[request]
|
||||||
);
|
);
|
||||||
|
|
||||||
// ─── GET /api/admin/notification-broadcasts/:broadcastId ──────────────────
|
// ─── GET /api/admin/announcements/:broadcastId ───────────────────────────
|
||||||
const fetchBroadcast = useCallback(
|
const fetchBroadcast = useCallback(
|
||||||
(broadcastId) =>
|
(broadcastId) =>
|
||||||
request(async () => {
|
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);
|
setSelectedBroadcast(res.data?.data?.data ?? null);
|
||||||
return res.data;
|
return res.data;
|
||||||
}),
|
}),
|
||||||
[request]
|
[request]
|
||||||
);
|
);
|
||||||
|
|
||||||
// ─── GET /api/admin/notification-broadcasts/archived ───────────────────────
|
// ─── GET /api/admin/announcements/archived ────────────────────────────────
|
||||||
const fetchArchivedBroadcasts = useCallback(
|
const fetchArchivedBroadcasts = useCallback(
|
||||||
({ page = 1, limit = 10, filters = [], sort = [] } = {}) =>
|
({ page = 1, limit = 10, filters = [], sort = [] } = {}) =>
|
||||||
request(async () => {
|
request(async () => {
|
||||||
const { data } = await api.get("/admin/notification-broadcasts/archived", {
|
const { data } = await api.get("/admin/announcements/archived", {
|
||||||
params: {
|
params: {
|
||||||
page, limit,
|
page, limit,
|
||||||
filters: filters.length ? JSON.stringify(filters) : undefined,
|
filters: filters.length ? JSON.stringify(filters) : undefined,
|
||||||
@@ -92,11 +92,11 @@ export function NotificationBroadcastsProvider({ children }) {
|
|||||||
[request]
|
[request]
|
||||||
);
|
);
|
||||||
|
|
||||||
// ─── POST /api/admin/notification-broadcasts ───────────────────────────────
|
// ─── POST /api/admin/announcements ────────────────────────────────────────
|
||||||
const createBroadcast = useCallback(
|
const createBroadcast = useCallback(
|
||||||
(fields) =>
|
(fields) =>
|
||||||
request(async () => {
|
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;
|
const broadcast = res.data?.data?.data ?? null;
|
||||||
if (broadcast) {
|
if (broadcast) {
|
||||||
setBroadcasts((prev) => [broadcast, ...prev]);
|
setBroadcasts((prev) => [broadcast, ...prev]);
|
||||||
@@ -107,11 +107,11 @@ export function NotificationBroadcastsProvider({ children }) {
|
|||||||
[request]
|
[request]
|
||||||
);
|
);
|
||||||
|
|
||||||
// ─── PATCH /api/admin/notification-broadcasts/:broadcastId ────────────────
|
// ─── PATCH /api/admin/announcements/:broadcastId ─────────────────────────
|
||||||
const updateBroadcast = useCallback(
|
const updateBroadcast = useCallback(
|
||||||
(broadcastId, fields) =>
|
(broadcastId, fields) =>
|
||||||
request(async () => {
|
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;
|
const broadcast = res.data?.data?.data ?? null;
|
||||||
if (broadcast) {
|
if (broadcast) {
|
||||||
setBroadcasts((prev) => prev.map((b) => (b.broadcast_id === broadcastId ? broadcast : b)));
|
setBroadcasts((prev) => prev.map((b) => (b.broadcast_id === broadcastId ? broadcast : b)));
|
||||||
@@ -123,11 +123,11 @@ export function NotificationBroadcastsProvider({ children }) {
|
|||||||
[request]
|
[request]
|
||||||
);
|
);
|
||||||
|
|
||||||
// ─── PATCH /api/admin/notification-broadcasts/:broadcastId/send ───────────
|
// ─── PATCH /api/admin/announcements/:broadcastId/send ────────────────────
|
||||||
const sendBroadcast = useCallback(
|
const sendBroadcast = useCallback(
|
||||||
(broadcastId) =>
|
(broadcastId) =>
|
||||||
request(async () => {
|
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;
|
const broadcast = res.data?.data?.data ?? null;
|
||||||
if (broadcast) {
|
if (broadcast) {
|
||||||
setBroadcasts((prev) => prev.map((b) => (b.broadcast_id === broadcastId ? broadcast : b)));
|
setBroadcasts((prev) => prev.map((b) => (b.broadcast_id === broadcastId ? broadcast : b)));
|
||||||
@@ -139,11 +139,11 @@ export function NotificationBroadcastsProvider({ children }) {
|
|||||||
[request]
|
[request]
|
||||||
);
|
);
|
||||||
|
|
||||||
// ─── DELETE /api/admin/notification-broadcasts/:broadcastId ───────────────
|
// ─── DELETE /api/admin/announcements/:broadcastId ────────────────────────
|
||||||
const archiveBroadcast = useCallback(
|
const archiveBroadcast = useCallback(
|
||||||
(broadcastId, { deletedBy } = {}) =>
|
(broadcastId, { deletedBy } = {}) =>
|
||||||
request(async () => {
|
request(async () => {
|
||||||
const res = await api.delete(`/admin/notification-broadcasts/${broadcastId}`, {
|
const res = await api.delete(`/admin/announcements/${broadcastId}`, {
|
||||||
data: { deletedBy },
|
data: { deletedBy },
|
||||||
});
|
});
|
||||||
setBroadcasts((prev) => prev.filter((b) => b.broadcast_id !== broadcastId));
|
setBroadcasts((prev) => prev.filter((b) => b.broadcast_id !== broadcastId));
|
||||||
@@ -154,11 +154,11 @@ export function NotificationBroadcastsProvider({ children }) {
|
|||||||
[request]
|
[request]
|
||||||
);
|
);
|
||||||
|
|
||||||
// ─── DELETE /api/admin/notification-broadcasts/bulk ────────────────────────
|
// ─── DELETE /api/admin/announcements/bulk ─────────────────────────────────
|
||||||
const archiveBroadcasts = useCallback(
|
const archiveBroadcasts = useCallback(
|
||||||
({ ids }, { deletedBy } = {}) =>
|
({ ids }, { deletedBy } = {}) =>
|
||||||
request(async () => {
|
request(async () => {
|
||||||
const res = await api.delete("/admin/notification-broadcasts/bulk", {
|
const res = await api.delete("/admin/announcements/bulk", {
|
||||||
data: { ids, deletedBy },
|
data: { ids, deletedBy },
|
||||||
});
|
});
|
||||||
setBroadcasts((prev) => prev.filter((b) => !ids.includes(b.broadcast_id)));
|
setBroadcasts((prev) => prev.filter((b) => !ids.includes(b.broadcast_id)));
|
||||||
@@ -168,11 +168,11 @@ export function NotificationBroadcastsProvider({ children }) {
|
|||||||
[request]
|
[request]
|
||||||
);
|
);
|
||||||
|
|
||||||
// ─── PATCH /api/admin/notification-broadcasts/:broadcastId/restore ────────
|
// ─── PATCH /api/admin/announcements/:broadcastId/restore ─────────────────
|
||||||
const restoreBroadcast = useCallback(
|
const restoreBroadcast = useCallback(
|
||||||
(broadcastId) =>
|
(broadcastId) =>
|
||||||
request(async () => {
|
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;
|
const broadcast = res.data?.data?.data ?? null;
|
||||||
if (broadcast) {
|
if (broadcast) {
|
||||||
setBroadcasts((prev) => prev.filter((b) => b.broadcast_id !== broadcastId));
|
setBroadcasts((prev) => prev.filter((b) => b.broadcast_id !== broadcastId));
|
||||||
@@ -183,11 +183,11 @@ export function NotificationBroadcastsProvider({ children }) {
|
|||||||
[request]
|
[request]
|
||||||
);
|
);
|
||||||
|
|
||||||
// ─── PATCH /api/admin/notification-broadcasts/bulk-restore ─────────────────
|
// ─── PATCH /api/admin/announcements/bulk-restore ──────────────────────────
|
||||||
const restoreBroadcasts = useCallback(
|
const restoreBroadcasts = useCallback(
|
||||||
({ ids }) =>
|
({ ids }) =>
|
||||||
request(async () => {
|
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)));
|
setBroadcasts((prev) => prev.filter((b) => !ids.includes(b.broadcast_id)));
|
||||||
toast(`${ids.length} notification broadcast(s) restored.`);
|
toast(`${ids.length} notification broadcast(s) restored.`);
|
||||||
return res.data;
|
return res.data;
|
||||||
@@ -195,11 +195,11 @@ export function NotificationBroadcastsProvider({ children }) {
|
|||||||
[request]
|
[request]
|
||||||
);
|
);
|
||||||
|
|
||||||
// ─── DELETE /api/admin/notification-broadcasts/:broadcastId/permanent ─────
|
// ─── DELETE /api/admin/announcements/:broadcastId/permanent ──────────────
|
||||||
const permanentlyDeleteBroadcast = useCallback(
|
const permanentlyDeleteBroadcast = useCallback(
|
||||||
(broadcastId) =>
|
(broadcastId) =>
|
||||||
request(async () => {
|
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));
|
setBroadcasts((prev) => prev.filter((b) => b.broadcast_id !== broadcastId));
|
||||||
toast("Notification broadcast permanently deleted.");
|
toast("Notification broadcast permanently deleted.");
|
||||||
return res.data;
|
return res.data;
|
||||||
@@ -207,11 +207,11 @@ export function NotificationBroadcastsProvider({ children }) {
|
|||||||
[request]
|
[request]
|
||||||
);
|
);
|
||||||
|
|
||||||
// ─── DELETE /api/admin/notification-broadcasts/bulk/permanent ─────────────
|
// ─── DELETE /api/admin/announcements/bulk/permanent ──────────────────────
|
||||||
const permanentlyDeleteBroadcasts = useCallback(
|
const permanentlyDeleteBroadcasts = useCallback(
|
||||||
({ ids }) =>
|
({ ids }) =>
|
||||||
request(async () => {
|
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)));
|
setBroadcasts((prev) => prev.filter((b) => !ids.includes(b.broadcast_id)));
|
||||||
toast(`${ids.length} notification broadcast(s) permanently deleted.`);
|
toast(`${ids.length} notification broadcast(s) permanently deleted.`);
|
||||||
return res.data;
|
return res.data;
|
||||||
|
|||||||
@@ -26,21 +26,21 @@ export function AdminNotificationTemplateProvider({ children }) {
|
|||||||
|
|
||||||
const fetchTemplates = useCallback(() =>
|
const fetchTemplates = useCallback(() =>
|
||||||
request(async () => {
|
request(async () => {
|
||||||
const { data } = await api.get("/admin/notification-templates");
|
const { data } = await api.get("/admin/announcement-templates");
|
||||||
setTemplates(data.data ?? []);
|
setTemplates(data.data ?? []);
|
||||||
return data.data;
|
return data.data;
|
||||||
}), [request]);
|
}), [request]);
|
||||||
|
|
||||||
const fetchTemplate = useCallback((id) =>
|
const fetchTemplate = useCallback((id) =>
|
||||||
request(async () => {
|
request(async () => {
|
||||||
const { data } = await api.get(`/admin/notification-templates/${id}`);
|
const { data } = await api.get(`/admin/announcement-templates/${id}`);
|
||||||
setTemplate(data.data ?? null);
|
setTemplate(data.data ?? null);
|
||||||
return data.data;
|
return data.data;
|
||||||
}), [request]);
|
}), [request]);
|
||||||
|
|
||||||
const updateTemplate = useCallback((id, payload) =>
|
const updateTemplate = useCallback((id, payload) =>
|
||||||
request(async () => {
|
request(async () => {
|
||||||
const { data } = await api.put(`/admin/notification-templates/${id}`, payload);
|
const { data } = await api.put(`/admin/announcement-templates/${id}`, payload);
|
||||||
setTemplates((prev) =>
|
setTemplates((prev) =>
|
||||||
prev.map((t) => (String(t.notification_template_id) === String(id) ? data.data : t))
|
prev.map((t) => (String(t.notification_template_id) === String(id) ? data.data : t))
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -357,6 +357,16 @@ export function AdminTaskProvider({ children }) {
|
|||||||
[request]
|
[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(
|
const restoreTask = useCallback(
|
||||||
(taskListId, taskId) =>
|
(taskListId, taskId) =>
|
||||||
request(async () => {
|
request(async () => {
|
||||||
@@ -451,6 +461,18 @@ export function AdminTaskProvider({ children }) {
|
|||||||
[request]
|
[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
|
// TASK COMPLETIONS
|
||||||
// ══════════════════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════════════════
|
||||||
@@ -490,6 +512,24 @@ export function AdminTaskProvider({ children }) {
|
|||||||
[completionRequest]
|
[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 BY USER ──────────────────────────────────────────────────────────
|
||||||
// GET /admin/task-lists/:taskListId/tasks/:taskId/completions/user/:userId
|
// GET /admin/task-lists/:taskListId/tasks/:taskId/completions/user/:userId
|
||||||
const fetchCompletionsByUser = useCallback(
|
const fetchCompletionsByUser = useCallback(
|
||||||
@@ -607,6 +647,7 @@ export function AdminTaskProvider({ children }) {
|
|||||||
archiveTask, restoreTask,
|
archiveTask, restoreTask,
|
||||||
bulkArchiveTasks, bulkRestoreTasks,
|
bulkArchiveTasks, bulkRestoreTasks,
|
||||||
permanentlyDeleteTask, bulkPermanentlyDeleteTasks,
|
permanentlyDeleteTask, bulkPermanentlyDeleteTasks,
|
||||||
|
reorderTasks,
|
||||||
fetchTaskFieldValues,
|
fetchTaskFieldValues,
|
||||||
|
|
||||||
// ── Completion actions ────────────────────────────────────────────
|
// ── Completion actions ────────────────────────────────────────────
|
||||||
@@ -615,7 +656,10 @@ export function AdminTaskProvider({ children }) {
|
|||||||
bulkArchiveCompletions, bulkRestoreCompletions,
|
bulkArchiveCompletions, bulkRestoreCompletions,
|
||||||
|
|
||||||
// ── Flat lists for RequirementBuilder ────────────────────────────
|
// ── Flat lists for RequirementBuilder ────────────────────────────
|
||||||
fetchCoursesFlat, fetchUnitsFlat, fetchLessonsFlat,
|
fetchCoursesFlat, fetchUnitsFlat, fetchLessonsFlat, fetchQuizzesFlat,
|
||||||
|
|
||||||
|
// ── Review workflow ───────────────────────────────────────────────
|
||||||
|
reviewSubmission,
|
||||||
}}>
|
}}>
|
||||||
{children}
|
{children}
|
||||||
</AdminTaskContext.Provider>
|
</AdminTaskContext.Provider>
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ export function ClientLibraryProvider({ children }) {
|
|||||||
// ── List state
|
// ── List state
|
||||||
const [units, setUnits] = useState([]);
|
const [units, setUnits] = useState([]);
|
||||||
const [unitsLoading, setUnitsLoading] = useState(false);
|
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
|
// ── Detail state — GET /client/units/:uuid/lessons returns unit + lessons + quiz in one call
|
||||||
const [unitDetail, setUnitDetail] = useState(null);
|
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) => {
|
const getUnitDetail = useCallback(async (uuid) => {
|
||||||
setUnitDetailLoading(true);
|
setUnitDetailLoading(true);
|
||||||
setUnitBlocked(false);
|
setUnitBlocked(false);
|
||||||
@@ -155,11 +169,13 @@ export function ClientLibraryProvider({ children }) {
|
|||||||
|
|
||||||
const value = {
|
const value = {
|
||||||
units, unitsLoading,
|
units, unitsLoading,
|
||||||
|
lessons, lessonsLoading,
|
||||||
unitDetail, unitDetailLoading, unitBlocked, unitBlockedInfo,
|
unitDetail, unitDetailLoading, unitBlocked, unitBlockedInfo,
|
||||||
lesson, lessonLoading,
|
lesson, lessonLoading,
|
||||||
quiz, quizLoading,
|
quiz, quizLoading,
|
||||||
|
|
||||||
getUnits,
|
getUnits,
|
||||||
|
getLessons,
|
||||||
getUnitDetail,
|
getUnitDetail,
|
||||||
getLesson,
|
getLesson,
|
||||||
getUnitQuiz,
|
getUnitQuiz,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ const DEFAULT_PAGINATION = { page: 1, limit: 10, pages: 1, total: 0 };
|
|||||||
export function ClientNotificationProvider({ children }) {
|
export function ClientNotificationProvider({ children }) {
|
||||||
const [notifications, setNotifications] = useState([]);
|
const [notifications, setNotifications] = useState([]);
|
||||||
const [unseenCount, setUnseenCount] = useState(0);
|
const [unseenCount, setUnseenCount] = useState(0);
|
||||||
|
const [stickyAnnouncement, setStickyAnnouncement] = useState(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [pagination, setPagination] = useState(DEFAULT_PAGINATION);
|
const [pagination, setPagination] = useState(DEFAULT_PAGINATION);
|
||||||
const intervalRef = useRef(null);
|
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) => {
|
const fetchNotifications = useCallback(async (page = 1, limit = 10) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
@@ -52,6 +66,7 @@ export function ClientNotificationProvider({ children }) {
|
|||||||
setNotifications([]);
|
setNotifications([]);
|
||||||
setUnseenCount(0);
|
setUnseenCount(0);
|
||||||
setPagination(DEFAULT_PAGINATION);
|
setPagination(DEFAULT_PAGINATION);
|
||||||
|
setStickyAnnouncement(null);
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
@@ -64,17 +79,25 @@ export function ClientNotificationProvider({ children }) {
|
|||||||
setNotifications(prev =>
|
setNotifications(prev =>
|
||||||
prev.map(n => n.notification_id === id ? { ...n, seen: true } : n)
|
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 {
|
} catch {
|
||||||
// silent
|
// silent
|
||||||
}
|
}
|
||||||
}, []);
|
}, [stickyAnnouncement?.notification_id, fetchUnseen, fetchStickyAnnouncement]);
|
||||||
|
|
||||||
const markAllSeen = useCallback(async () => {
|
const markAllSeen = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
await api.patch("/client/notifications/seen-all");
|
await api.patch("/client/notifications/seen-all");
|
||||||
setNotifications(prev => prev.map(n => ({ ...n, seen: true })));
|
setNotifications(prev => prev.map(n => ({ ...n, seen: true })));
|
||||||
setUnseenCount(0);
|
setUnseenCount(0);
|
||||||
|
setStickyAnnouncement(null);
|
||||||
} catch {
|
} catch {
|
||||||
// silent
|
// silent
|
||||||
}
|
}
|
||||||
@@ -82,8 +105,8 @@ export function ClientNotificationProvider({ children }) {
|
|||||||
|
|
||||||
const restartPoll = useCallback((interval) => {
|
const restartPoll = useCallback((interval) => {
|
||||||
clearInterval(intervalRef.current);
|
clearInterval(intervalRef.current);
|
||||||
intervalRef.current = setInterval(fetchUnseen, interval);
|
intervalRef.current = setInterval(poll, interval);
|
||||||
}, [fetchUnseen]);
|
}, [poll]);
|
||||||
|
|
||||||
// Speed up polling while a student is mid-assessment; restore when done
|
// Speed up polling while a student is mid-assessment; restore when done
|
||||||
const accelerate = useCallback(() => {
|
const accelerate = useCallback(() => {
|
||||||
@@ -99,15 +122,16 @@ export function ClientNotificationProvider({ children }) {
|
|||||||
}, [restartPoll]);
|
}, [restartPoll]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchUnseen();
|
poll();
|
||||||
intervalRef.current = setInterval(fetchUnseen, POLL_INTERVAL_NORMAL);
|
intervalRef.current = setInterval(poll, POLL_INTERVAL_NORMAL);
|
||||||
return () => clearInterval(intervalRef.current);
|
return () => clearInterval(intervalRef.current);
|
||||||
}, [fetchUnseen]);
|
}, [poll]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ClientNotificationContext.Provider value={{
|
<ClientNotificationContext.Provider value={{
|
||||||
notifications,
|
notifications,
|
||||||
unseenCount,
|
unseenCount,
|
||||||
|
stickyAnnouncement,
|
||||||
loading,
|
loading,
|
||||||
pagination,
|
pagination,
|
||||||
fetchNotifications,
|
fetchNotifications,
|
||||||
|
|||||||
@@ -13,14 +13,23 @@ export const ADMIN_SECTIONS = [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "section-assets",
|
id: "section-resources",
|
||||||
tab: "Asset Management",
|
tab: "Resource Management",
|
||||||
title: "Asset Management",
|
title: "Resource Management",
|
||||||
description: "Upload images, documents and videos",
|
description: "It includes assets management and tier plans.",
|
||||||
tiles: [
|
tiles: [
|
||||||
{ key: "assets", label: "Assets", icon: FolderOpen, link: "/admin/assets" },
|
{ key: "resources", label: "Resources", icon: FolderOpen, link: "/admin/resources" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
// {
|
||||||
|
// id: "section-assets",
|
||||||
|
// tab: "Resource Management",
|
||||||
|
// title: "Resource Management",
|
||||||
|
// description: "It includes assets management and tier plans.",
|
||||||
|
// tiles: [
|
||||||
|
// { key: "assets", label: "Resources", icon: FolderOpen, link: "/admin/assets" },
|
||||||
|
// ],
|
||||||
|
// },
|
||||||
{
|
{
|
||||||
id: "section-courses",
|
id: "section-courses",
|
||||||
tab: "Content Management",
|
tab: "Content Management",
|
||||||
@@ -31,7 +40,7 @@ export const ADMIN_SECTIONS = [
|
|||||||
{ key: "units", label: "Units", icon: BookCheck, link: "/admin/units" },
|
{ key: "units", label: "Units", icon: BookCheck, link: "/admin/units" },
|
||||||
{ key: "lessons", label: "Lessons", icon: FileText, link: "/admin/lessons" },
|
{ key: "lessons", label: "Lessons", icon: FileText, link: "/admin/lessons" },
|
||||||
{ key: "tasks", label: "Tasks", icon: ListCheck, link: "/admin/taskList" },
|
{ key: "tasks", label: "Tasks", icon: ListCheck, link: "/admin/taskList" },
|
||||||
{ key: "tiers", label: "Tier Plans", icon: ShieldCheck, link: "/admin/tiers/plans" },
|
// { key: "tiers", label: "Tier Plans", icon: ShieldCheck, link: "/admin/tiers/plans" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -41,7 +50,7 @@ export const ADMIN_SECTIONS = [
|
|||||||
description: "Manage public-facing content",
|
description: "Manage public-facing content",
|
||||||
tiles: [
|
tiles: [
|
||||||
{ key: "advertisements", label: "Advertisements", icon: Megaphone, link: "/admin/advertisements" },
|
{ key: "advertisements", label: "Advertisements", icon: Megaphone, link: "/admin/advertisements" },
|
||||||
{ key: "notifications", label: "Notifications", icon: Bell, link: "/admin/notifications" },
|
{ key: "notifications", label: "Announcements", icon: Bell, link: "/admin/notifications" },
|
||||||
{ key: "achievements", label: "Achievements", icon: Trophy, link: "/admin/achievements" },
|
{ key: "achievements", label: "Achievements", icon: Trophy, link: "/admin/achievements" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
// modules/admin/components/courses/CreateLessonDialog.jsx
|
||||||
|
// Lightweight "create a brand-new lesson and attach it to this unit" dialog —
|
||||||
|
// the create-new counterpart to AttachLessonsDialog's attach-existing flow.
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useForm, useFieldArray } from "react-hook-form";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { Plus, Trash2 } from "lucide-react";
|
||||||
|
|
||||||
|
import { useCourses } from "@/contexts/AdminCoursesContext";
|
||||||
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
|
import {
|
||||||
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogClose,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
title: z.string().min(1, "Title is required."),
|
||||||
|
description: z.string().optional(),
|
||||||
|
order: z.coerce.number().min(0).default(0),
|
||||||
|
objectives: z.array(z.object({ value: z.string().min(1, "Objective cannot be empty.") })).default([]),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function CreateLessonDialog({ open, onOpenChange, courseId, unitId, nextOrder = 0, onCreated, draftMode = false }) {
|
||||||
|
const { createLesson, loading } = useCourses();
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
|
const { register, handleSubmit, reset, control, formState: { errors } } = useForm({
|
||||||
|
resolver: zodResolver(schema),
|
||||||
|
defaultValues: { title: "", description: "", order: nextOrder, objectives: [] },
|
||||||
|
});
|
||||||
|
|
||||||
|
const { fields, append, remove } = useFieldArray({ control, name: "objectives" });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
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 (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[440px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>New Lesson</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit(onValid)} className="space-y-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="lesson_title">
|
||||||
|
Title <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input id="lesson_title" placeholder="e.g. Welcome to the Course" {...register("title")} />
|
||||||
|
{errors.title && <p className="text-sm text-destructive">{errors.title.message}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="lesson_description">Description</Label>
|
||||||
|
<Textarea id="lesson_description" placeholder="Optional lesson description" rows={3} {...register("description")} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5 max-w-[120px]">
|
||||||
|
<Label htmlFor="lesson_order">Order</Label>
|
||||||
|
<Input id="lesson_order" type="number" min={0} {...register("order")} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs text-muted-foreground">Objectives</Label>
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={() => append({ value: "" })}>
|
||||||
|
<Plus className="h-3.5 w-3.5 mr-1" /> Add
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{fields.map((field, index) => (
|
||||||
|
<div key={field.id} className="flex items-start gap-2">
|
||||||
|
<div className="flex-1 space-y-1">
|
||||||
|
<Input placeholder={`Objective ${index + 1}`} {...register(`objectives.${index}.value`)} />
|
||||||
|
{errors.objectives?.[index]?.value && (
|
||||||
|
<p className="text-xs text-destructive">{errors.objectives[index].value.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button" variant="ghost" size="icon"
|
||||||
|
className="mt-0.5 text-muted-foreground hover:text-destructive"
|
||||||
|
onClick={() => remove(index)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button type="button" variant="outline" disabled={loading}>Cancel</Button>
|
||||||
|
</DialogClose>
|
||||||
|
<Button type="submit" disabled={loading}>
|
||||||
|
{loading && <Spinner className="h-4 w-4 mr-2" />}
|
||||||
|
Create Lesson
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
// modules/admin/components/courses/CreateUnitDialog.jsx
|
||||||
|
// Lightweight "create a brand-new unit and attach it to this course" dialog —
|
||||||
|
// the create-new counterpart to AttachUnitsDialog's attach-existing flow.
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
|
||||||
|
import { useCourses } from "@/contexts/AdminCoursesContext";
|
||||||
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
|
import {
|
||||||
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogClose,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
title: z.string().min(1, "Title is required."),
|
||||||
|
description: z.string().optional(),
|
||||||
|
order: z.coerce.number().min(0).default(0),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function CreateUnitDialog({ open, onOpenChange, courseId, nextOrder = 0, onCreated, draftMode = false }) {
|
||||||
|
const { createUnit, loading } = useCourses();
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
|
const { register, handleSubmit, reset, formState: { errors } } = useForm({
|
||||||
|
resolver: zodResolver(schema),
|
||||||
|
defaultValues: { title: "", description: "", order: nextOrder },
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) reset({ title: "", description: "", order: nextOrder });
|
||||||
|
}, [open, nextOrder, reset]);
|
||||||
|
|
||||||
|
const onValid = async (values) => {
|
||||||
|
if (draftMode) {
|
||||||
|
onCreated?.({
|
||||||
|
unit_id: null,
|
||||||
|
key: crypto.randomUUID(),
|
||||||
|
title: values.title,
|
||||||
|
description: values.description,
|
||||||
|
order_index: values.order,
|
||||||
|
lessons: [],
|
||||||
|
});
|
||||||
|
onOpenChange(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await createUnit(courseId, { ...values, createdBy: user?.user_id });
|
||||||
|
const unit = result?.data?.data ?? null;
|
||||||
|
if (!unit) return;
|
||||||
|
onCreated?.(unit);
|
||||||
|
onOpenChange(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[440px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>New Unit</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit(onValid)} className="space-y-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="unit_title">
|
||||||
|
Title <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input id="unit_title" placeholder="e.g. Getting Started" {...register("title")} />
|
||||||
|
{errors.title && <p className="text-sm text-destructive">{errors.title.message}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="unit_description">Description</Label>
|
||||||
|
<Textarea id="unit_description" placeholder="Optional unit description" rows={3} {...register("description")} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5 max-w-[120px]">
|
||||||
|
<Label htmlFor="unit_order">Order</Label>
|
||||||
|
<Input id="unit_order" type="number" min={0} {...register("order")} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button type="button" variant="outline" disabled={loading}>Cancel</Button>
|
||||||
|
</DialogClose>
|
||||||
|
<Button type="submit" disabled={loading}>
|
||||||
|
{loading && <Spinner className="h-4 w-4 mr-2" />}
|
||||||
|
Create Unit
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,312 @@
|
|||||||
|
// modules/admin/components/courses/RoadmapBuilder.jsx
|
||||||
|
// The "Roadmap" step content for AddCourse: compose a course's units — and
|
||||||
|
// each unit's lessons — by attaching existing library content or creating
|
||||||
|
// new items inline. Fully controlled/draft: nothing here ever calls the API.
|
||||||
|
// All picks just mutate the `units` array the parent wizard holds in memory;
|
||||||
|
// the whole roadmap is written in one shot when the wizard is finished.
|
||||||
|
|
||||||
|
import { useState, useMemo } from "react";
|
||||||
|
import {
|
||||||
|
ChevronRight, ChevronDown, Plus, Link2, Trash2, BookOpen, AlertTriangle,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
import { useLibrary } from "@/contexts/AdminLibraryContext";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
|
||||||
|
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import AttachUnitsDialog from "../library/AttachUnitsDialog";
|
||||||
|
import AttachLessonsDialog from "../library/AttachLessonsDialog";
|
||||||
|
import CreateUnitDialog from "./CreateUnitDialog";
|
||||||
|
import CreateLessonDialog from "./CreateLessonDialog";
|
||||||
|
|
||||||
|
export default function RoadmapBuilder({ units, onUnitsChange }) {
|
||||||
|
const { unitsFlat, lessonsFlat } = useLibrary();
|
||||||
|
|
||||||
|
const [expanded, setExpanded] = useState(() => new Set());
|
||||||
|
|
||||||
|
const [createUnitOpen, setCreateUnitOpen] = useState(false);
|
||||||
|
const [attachUnitOpen, setAttachUnitOpen] = useState(false);
|
||||||
|
const [createLessonUnitKey, setCreateLessonUnitKey] = useState(null);
|
||||||
|
const [attachLessonUnitKey, setAttachLessonUnitKey] = useState(null);
|
||||||
|
const [removeUnitTarget, setRemoveUnitTarget] = useState(null);
|
||||||
|
const [removeLessonTarget, setRemoveLessonTarget] = useState(null);
|
||||||
|
|
||||||
|
const toggleExpand = (key) => {
|
||||||
|
setExpanded((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(key)) next.delete(key);
|
||||||
|
else next.add(key);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const attachedUnitIds = useMemo(
|
||||||
|
() => units.map((u) => u.unit_id).filter(Boolean),
|
||||||
|
[units]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleAttachUnits = (unitIds) => {
|
||||||
|
const picked = unitIds
|
||||||
|
.map((id) => unitsFlat.find((u) => u.unit_id === id))
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((u) => ({
|
||||||
|
key: crypto.randomUUID(),
|
||||||
|
unit_id: u.unit_id,
|
||||||
|
title: u.title,
|
||||||
|
description: u.description ?? "",
|
||||||
|
existing_lesson_count: u.lesson_count ?? 0,
|
||||||
|
lessons: [],
|
||||||
|
}));
|
||||||
|
onUnitsChange([...units, ...picked]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUnitCreated = (draftUnit) => {
|
||||||
|
onUnitsChange([...units, draftUnit]);
|
||||||
|
setExpanded((prev) => new Set(prev).add(draftUnit.key));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAttachLessons = (unitKey, lessonIds) => {
|
||||||
|
const picked = lessonIds
|
||||||
|
.map((id) => lessonsFlat.find((l) => l.lesson_id === id))
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((l) => ({
|
||||||
|
key: crypto.randomUUID(),
|
||||||
|
lesson_id: l.lesson_id,
|
||||||
|
title: l.title,
|
||||||
|
description: l.description ?? "",
|
||||||
|
objectives: [],
|
||||||
|
}));
|
||||||
|
onUnitsChange(units.map((u) =>
|
||||||
|
u.key === unitKey ? { ...u, lessons: [...u.lessons, ...picked] } : u
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLessonCreated = (unitKey, draftLesson) => {
|
||||||
|
onUnitsChange(units.map((u) =>
|
||||||
|
u.key === unitKey ? { ...u, lessons: [...u.lessons, draftLesson] } : u
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmRemoveUnit = () => {
|
||||||
|
if (!removeUnitTarget) return;
|
||||||
|
onUnitsChange(units.filter((u) => u.key !== removeUnitTarget.key));
|
||||||
|
setRemoveUnitTarget(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmRemoveLesson = () => {
|
||||||
|
if (!removeLessonTarget) return;
|
||||||
|
const { unitKey, lesson } = removeLessonTarget;
|
||||||
|
onUnitsChange(units.map((u) =>
|
||||||
|
u.key === unitKey ? { ...u, lessons: u.lessons.filter((l) => l.key !== lesson.key) } : u
|
||||||
|
));
|
||||||
|
setRemoveLessonTarget(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const attachLessonUnit = units.find((u) => u.key === attachLessonUnitKey);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border bg-card p-6 space-y-5">
|
||||||
|
<div className="flex items-start justify-between gap-3 pb-1 border-b">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<h2 className="text-sm font-semibold">Course Roadmap</h2>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Add units to structure this course. Attach existing library units to reuse content, or create new ones from scratch.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={() => setCreateUnitOpen(true)}>
|
||||||
|
<Plus className="h-3.5 w-3.5 mr-1.5" /> New Unit
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={() => setAttachUnitOpen(true)}>
|
||||||
|
<Link2 className="h-3.5 w-3.5 mr-1.5" /> Attach
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{units.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center gap-3 py-10 text-center">
|
||||||
|
<BookOpen className="h-8 w-8 text-muted-foreground" />
|
||||||
|
<p className="text-sm font-medium">No units yet</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button type="button" size="sm" onClick={() => setCreateUnitOpen(true)}>
|
||||||
|
<Plus className="h-3.5 w-3.5 mr-1.5" /> New Unit
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={() => setAttachUnitOpen(true)}>
|
||||||
|
<Link2 className="h-3.5 w-3.5 mr-1.5" /> Attach Existing
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{units.map((unit, index) => {
|
||||||
|
const isOpen = expanded.has(unit.key);
|
||||||
|
const isAttached = !!unit.unit_id;
|
||||||
|
return (
|
||||||
|
<div key={unit.key} className="rounded-md border">
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleExpand(unit.key)}
|
||||||
|
className="flex items-center gap-2 flex-1 min-w-0 text-left"
|
||||||
|
>
|
||||||
|
{isOpen ? (
|
||||||
|
<ChevronDown className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||||
|
)}
|
||||||
|
<span className="text-xs text-muted-foreground shrink-0">{index + 1}</span>
|
||||||
|
<span className="text-sm font-medium truncate">{unit.title}</span>
|
||||||
|
{isAttached ? (
|
||||||
|
<Badge variant="outline" className="text-[10px] shrink-0">
|
||||||
|
{unit.existing_lesson_count} existing lesson{unit.existing_lesson_count === 1 ? "" : "s"}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="secondary" className="text-[10px] shrink-0">new</Badge>
|
||||||
|
)}
|
||||||
|
{unit.lessons.length > 0 && (
|
||||||
|
<Badge variant="outline" className="text-[10px] shrink-0">
|
||||||
|
+{unit.lessons.length} added
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="text-muted-foreground hover:text-destructive shrink-0"
|
||||||
|
onClick={() => setRemoveUnitTarget(unit)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div className="border-t bg-muted/30 px-3 py-3 space-y-2">
|
||||||
|
{isAttached && (
|
||||||
|
<p className="text-xs text-muted-foreground py-1">
|
||||||
|
This unit already has {unit.existing_lesson_count} lesson{unit.existing_lesson_count === 1 ? "" : "s"} in the library. Any lessons you add below are appended on top.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{unit.lessons.length === 0 && !isAttached && (
|
||||||
|
<p className="text-xs text-muted-foreground py-1">No lessons in this unit yet.</p>
|
||||||
|
)}
|
||||||
|
{unit.lessons.map((lesson, lIndex) => (
|
||||||
|
<div key={lesson.key} className="flex items-center gap-2 pl-1">
|
||||||
|
<span className="text-xs text-muted-foreground w-4 shrink-0">{lIndex + 1}.</span>
|
||||||
|
<span className="text-sm flex-1 truncate">{lesson.title}</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 text-muted-foreground hover:text-destructive shrink-0"
|
||||||
|
onClick={() => setRemoveLessonTarget({ unitKey: unit.key, lesson })}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="flex items-center gap-2 pt-1">
|
||||||
|
<Button
|
||||||
|
type="button" variant="outline" size="sm" className="h-7 text-xs"
|
||||||
|
onClick={() => setCreateLessonUnitKey(unit.key)}
|
||||||
|
>
|
||||||
|
<Plus className="h-3 w-3 mr-1" /> New Lesson
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button" variant="outline" size="sm" className="h-7 text-xs"
|
||||||
|
onClick={() => setAttachLessonUnitKey(unit.key)}
|
||||||
|
>
|
||||||
|
<Link2 className="h-3 w-3 mr-1" /> Attach Existing Lesson
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{units.length === 0 && (
|
||||||
|
<div className="flex items-center gap-2 text-xs text-amber-700 dark:text-amber-400">
|
||||||
|
<AlertTriangle className="h-3.5 w-3.5 shrink-0" />
|
||||||
|
Add at least one unit so learners have content to see. You can still continue and add units later.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Create / Attach dialogs (draft mode — nothing here hits the API) ── */}
|
||||||
|
<CreateUnitDialog
|
||||||
|
open={createUnitOpen}
|
||||||
|
onOpenChange={setCreateUnitOpen}
|
||||||
|
nextOrder={units.length}
|
||||||
|
onCreated={handleUnitCreated}
|
||||||
|
draftMode
|
||||||
|
/>
|
||||||
|
<AttachUnitsDialog
|
||||||
|
open={attachUnitOpen}
|
||||||
|
onOpenChange={setAttachUnitOpen}
|
||||||
|
attachedUnitIds={attachedUnitIds}
|
||||||
|
onAttach={handleAttachUnits}
|
||||||
|
/>
|
||||||
|
<CreateLessonDialog
|
||||||
|
open={!!createLessonUnitKey}
|
||||||
|
onOpenChange={(v) => !v && setCreateLessonUnitKey(null)}
|
||||||
|
nextOrder={(units.find((u) => u.key === createLessonUnitKey)?.lessons ?? []).length}
|
||||||
|
onCreated={(lesson) => handleLessonCreated(createLessonUnitKey, lesson)}
|
||||||
|
draftMode
|
||||||
|
/>
|
||||||
|
<AttachLessonsDialog
|
||||||
|
open={!!attachLessonUnitKey}
|
||||||
|
onOpenChange={(v) => !v && setAttachLessonUnitKey(null)}
|
||||||
|
attachedLessonIds={(attachLessonUnit?.lessons ?? []).map((l) => l.lesson_id).filter(Boolean)}
|
||||||
|
onAttach={(lessonIds) => handleAttachLessons(attachLessonUnitKey, lessonIds)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* ── Remove confirmations ── */}
|
||||||
|
<AlertDialog open={!!removeUnitTarget} onOpenChange={(v) => !v && setRemoveUnitTarget(null)}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Remove unit from roadmap</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Remove <span className="font-medium text-foreground">{removeUnitTarget?.title}</span> from this course's
|
||||||
|
roadmap? Nothing has been saved yet, so this just drops it from the draft.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={confirmRemoveUnit}
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
|
||||||
|
<AlertDialog open={!!removeLessonTarget} onOpenChange={(v) => !v && setRemoveLessonTarget(null)}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Remove lesson from unit</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Remove <span className="font-medium text-foreground">{removeLessonTarget?.lesson?.title}</span> from this
|
||||||
|
unit? Nothing has been saved yet, so this just drops it from the draft.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={confirmRemoveLesson}
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -42,10 +42,12 @@ export default function AttachUnitsDialog({ open, onOpenChange, attachedUnitIds
|
|||||||
.filter((u) => !q || u.title?.toLowerCase().includes(q));
|
.filter((u) => !q || u.title?.toLowerCase().includes(q));
|
||||||
}, [unitsFlat, attachedSet, query]);
|
}, [unitsFlat, attachedSet, query]);
|
||||||
|
|
||||||
const toggle = (unitId) =>
|
const toggle = (unitId, blocked) => {
|
||||||
|
if (blocked) return;
|
||||||
setSelected((prev) =>
|
setSelected((prev) =>
|
||||||
prev.includes(unitId) ? prev.filter((id) => id !== unitId) : [...prev, unitId]
|
prev.includes(unitId) ? prev.filter((id) => id !== unitId) : [...prev, unitId]
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const handleAttach = async () => {
|
const handleAttach = async () => {
|
||||||
if (!selected.length) return;
|
if (!selected.length) return;
|
||||||
@@ -86,14 +88,21 @@ export default function AttachUnitsDialog({ open, onOpenChange, attachedUnitIds
|
|||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="divide-y">
|
<div className="divide-y">
|
||||||
{candidates.map((u) => (
|
{candidates.map((u) => {
|
||||||
|
const blocked = Number(u.course_count) > 0;
|
||||||
|
return (
|
||||||
<label
|
<label
|
||||||
key={u.unit_id}
|
key={u.unit_id}
|
||||||
className="flex items-center gap-3 px-3 py-2.5 hover:bg-muted/60 cursor-pointer"
|
title={blocked ? "Already attached to another course — a unit can only belong to one course at a time." : undefined}
|
||||||
|
className={[
|
||||||
|
"flex items-center gap-3 px-3 py-2.5",
|
||||||
|
blocked ? "opacity-60 cursor-not-allowed" : "hover:bg-muted/60 cursor-pointer",
|
||||||
|
].join(" ")}
|
||||||
>
|
>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={selected.includes(u.unit_id)}
|
checked={selected.includes(u.unit_id)}
|
||||||
onCheckedChange={() => toggle(u.unit_id)}
|
disabled={blocked}
|
||||||
|
onCheckedChange={() => toggle(u.unit_id, blocked)}
|
||||||
/>
|
/>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm font-medium truncate">{u.title}</p>
|
<p className="text-sm font-medium truncate">{u.title}</p>
|
||||||
@@ -101,15 +110,16 @@ export default function AttachUnitsDialog({ open, onOpenChange, attachedUnitIds
|
|||||||
{u.lesson_count ?? 0} lesson{(u.lesson_count ?? 0) === 1 ? "" : "s"} · {formatDuration(u.duration_seconds ?? 0)}
|
{u.lesson_count ?? 0} lesson{(u.lesson_count ?? 0) === 1 ? "" : "s"} · {formatDuration(u.duration_seconds ?? 0)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{Number(u.course_count) > 0 ? (
|
{blocked ? (
|
||||||
<Badge variant="secondary" className="text-xs shrink-0">
|
<Badge variant="secondary" className="text-xs shrink-0 bg-amber-100 text-amber-700 border-amber-300 dark:bg-amber-950/40 dark:text-amber-400 dark:border-amber-700">
|
||||||
in {u.course_count} course{Number(u.course_count) === 1 ? "" : "s"}
|
already in a course
|
||||||
</Badge>
|
</Badge>
|
||||||
) : (
|
) : (
|
||||||
<Badge variant="outline" className="text-xs shrink-0 text-muted-foreground">standalone</Badge>
|
<Badge variant="outline" className="text-xs shrink-0 text-muted-foreground">standalone</Badge>
|
||||||
)}
|
)}
|
||||||
</label>
|
</label>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|||||||
+10
-10
@@ -48,7 +48,7 @@ export default function ArchivedNotificationBroadcastsTable() {
|
|||||||
allData: broadcasts,
|
allData: broadcasts,
|
||||||
attributes,
|
attributes,
|
||||||
filename: `${getTimestamp()}_ArchivedNotificationBroadcasts`,
|
filename: `${getTimestamp()}_ArchivedNotificationBroadcasts`,
|
||||||
sheetName: "Archived Notifications",
|
sheetName: "Archived Announcements",
|
||||||
};
|
};
|
||||||
|
|
||||||
const rowActions = buildRowActions({
|
const rowActions = buildRowActions({
|
||||||
@@ -97,7 +97,7 @@ export default function ArchivedNotificationBroadcastsTable() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DataTable
|
<DataTable
|
||||||
title="Archived Notifications"
|
title="Archived Announcements"
|
||||||
data={broadcasts}
|
data={broadcasts}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
attributes={attributes}
|
attributes={attributes}
|
||||||
@@ -120,8 +120,8 @@ export default function ArchivedNotificationBroadcastsTable() {
|
|||||||
columnPinning={columnPinning}
|
columnPinning={columnPinning}
|
||||||
toolbarActions={toolbarActions}
|
toolbarActions={toolbarActions}
|
||||||
selectionActions={selectionActions}
|
selectionActions={selectionActions}
|
||||||
recordLabel="archived notification"
|
recordLabel="archived announcement"
|
||||||
emptyMessage="No archived notifications found."
|
emptyMessage="No archived announcements found."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* ── Single restore ── */}
|
{/* ── Single restore ── */}
|
||||||
@@ -129,8 +129,8 @@ export default function ArchivedNotificationBroadcastsTable() {
|
|||||||
open={!!restoreTarget}
|
open={!!restoreTarget}
|
||||||
onOpenChange={(v) => !v && setRestoreTarget(null)}
|
onOpenChange={(v) => !v && setRestoreTarget(null)}
|
||||||
entity={restoreTarget}
|
entity={restoreTarget}
|
||||||
entityLabel="Notification"
|
entityLabel="Announcement"
|
||||||
getName={(b) => b?.title ?? "this notification"}
|
getName={(b) => b?.title ?? "this announcement"}
|
||||||
onRestore={(b) => restoreBroadcast(b?.broadcast_id)}
|
onRestore={(b) => restoreBroadcast(b?.broadcast_id)}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
onSuccess={handleRestoreSuccess}
|
onSuccess={handleRestoreSuccess}
|
||||||
@@ -141,7 +141,7 @@ export default function ArchivedNotificationBroadcastsTable() {
|
|||||||
open={!!restoreIds}
|
open={!!restoreIds}
|
||||||
onOpenChange={(v) => !v && setRestoreIds(null)}
|
onOpenChange={(v) => !v && setRestoreIds(null)}
|
||||||
ids={restoreIds ?? []}
|
ids={restoreIds ?? []}
|
||||||
entityLabel="Notification"
|
entityLabel="Announcement"
|
||||||
onRestore={(ids) => restoreBroadcasts(ids)}
|
onRestore={(ids) => restoreBroadcasts(ids)}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
onSuccess={handleRestoreSuccess}
|
onSuccess={handleRestoreSuccess}
|
||||||
@@ -152,8 +152,8 @@ export default function ArchivedNotificationBroadcastsTable() {
|
|||||||
open={!!deleteTarget}
|
open={!!deleteTarget}
|
||||||
onOpenChange={(v) => !v && setDeleteTarget(null)}
|
onOpenChange={(v) => !v && setDeleteTarget(null)}
|
||||||
entity={deleteTarget}
|
entity={deleteTarget}
|
||||||
entityLabel="Notification"
|
entityLabel="Announcement"
|
||||||
getName={(b) => b?.title ?? "this notification"}
|
getName={(b) => b?.title ?? "this announcement"}
|
||||||
onDelete={(b) => permanentlyDeleteBroadcast(b?.broadcast_id)}
|
onDelete={(b) => permanentlyDeleteBroadcast(b?.broadcast_id)}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
onSuccess={handleDeleteSuccess}
|
onSuccess={handleDeleteSuccess}
|
||||||
@@ -164,7 +164,7 @@ export default function ArchivedNotificationBroadcastsTable() {
|
|||||||
open={!!deleteIds}
|
open={!!deleteIds}
|
||||||
onOpenChange={(v) => !v && setDeleteIds(null)}
|
onOpenChange={(v) => !v && setDeleteIds(null)}
|
||||||
ids={deleteIds ?? []}
|
ids={deleteIds ?? []}
|
||||||
entityLabel="Notification"
|
entityLabel="Announcement"
|
||||||
onDelete={(ids) => permanentlyDeleteBroadcasts(ids)}
|
onDelete={(ids) => permanentlyDeleteBroadcasts(ids)}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
onSuccess={handleDeleteSuccess}
|
onSuccess={handleDeleteSuccess}
|
||||||
|
|||||||
@@ -35,6 +35,12 @@ const cellOverrides = {
|
|||||||
<Badge variant="outline" className="text-xs text-muted-foreground">standalone</Badge>
|
<Badge variant="outline" className="text-xs text-muted-foreground">standalone</Badge>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
course_bound: (info) =>
|
||||||
|
info.getValue() ? (
|
||||||
|
<Badge variant="default" className="text-xs">In a course</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="outline" className="text-xs text-muted-foreground">Not in a course</Badge>
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
export function buildDataColumns(attributes, rowActions) {
|
export function buildDataColumns(attributes, rowActions) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Eye, Archive, ArchiveRestore, Info, NotebookPen } from "lucide-react";
|
import { Eye, Archive, ArchiveRestore, Info, NotebookPen, ArrowUp, ArrowDown } from "lucide-react";
|
||||||
|
|
||||||
export function buildRowActions({ navigate, onArchive, onRestore, showArchived }) {
|
export function buildRowActions({ navigate, onArchive, onRestore, onMoveUp, onMoveDown, showArchived, tasks = [] }) {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
key: "view",
|
key: "view",
|
||||||
@@ -16,6 +16,23 @@ export function buildRowActions({ navigate, onArchive, onRestore, showArchived }
|
|||||||
separator: true,
|
separator: true,
|
||||||
className: "text-sky-600",
|
className: "text-sky-600",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "move-up",
|
||||||
|
label: "Move Up",
|
||||||
|
icon: <ArrowUp className="size-4" />,
|
||||||
|
onClick: (row) => onMoveUp(row),
|
||||||
|
hidden: () => showArchived,
|
||||||
|
disabled: (row) => tasks.findIndex((t) => t.task_id === row.task_id) <= 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "move-down",
|
||||||
|
label: "Move Down",
|
||||||
|
icon: <ArrowDown className="size-4" />,
|
||||||
|
onClick: (row) => onMoveDown(row),
|
||||||
|
hidden: () => showArchived,
|
||||||
|
separator: true,
|
||||||
|
disabled: (row) => tasks.findIndex((t) => t.task_id === row.task_id) >= tasks.length - 1,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: "archive",
|
key: "archive",
|
||||||
label: "Archive",
|
label: "Archive",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// config/task_completion/rowActions.config.jsx
|
// config/task_completion/rowActions.config.jsx
|
||||||
import { Eye, Archive, RotateCcw } from "lucide-react";
|
import { Eye, Archive, RotateCcw, ShieldCheck } from "lucide-react";
|
||||||
|
|
||||||
export function buildRowActions({ navigate, onArchive, onRestore, showArchived }) {
|
export function buildRowActions({ navigate, onArchive, onRestore, onReview, showArchived }) {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
key: "view",
|
key: "view",
|
||||||
@@ -9,6 +9,13 @@ export function buildRowActions({ navigate, onArchive, onRestore, showArchived }
|
|||||||
icon: <Eye className="size-4" />,
|
icon: <Eye className="size-4" />,
|
||||||
onClick: (row) => navigate(`${row.completion_id}/view`),
|
onClick: (row) => navigate(`${row.completion_id}/view`),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "review",
|
||||||
|
label: "Review",
|
||||||
|
icon: <ShieldCheck className="size-4" />,
|
||||||
|
onClick: (row) => onReview(row),
|
||||||
|
hidden: (row) => showArchived || row.status !== "submitted",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: "archive",
|
key: "archive",
|
||||||
label: "Archive",
|
label: "Archive",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { AudioBlock } from "@/components/generic/Blocks/Admin/AudioBlock";
|
import { AudioBlock } from "@/components/generic/Blocks/Admin/AudioBlock";
|
||||||
import { MediaFallback } from "@/components/generic/MediaFallback";
|
import { MediaFallback } from "@/components/generic/MediaFallback";
|
||||||
|
import AssetPageLoader from "@/components/generic/AssetLoader";
|
||||||
|
|
||||||
// ─── Shared meta row (same pattern as other viewers) ─────────────────────────
|
// ─── Shared meta row (same pattern as other viewers) ─────────────────────────
|
||||||
|
|
||||||
@@ -41,7 +42,7 @@ export default function ViewAudioAsset() {
|
|||||||
}, [assetId]);
|
}, [assetId]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <MediaFallback className="h-96 rounded-lg" />;
|
return <AssetPageLoader />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!selectedAsset) {
|
if (!selectedAsset) {
|
||||||
@@ -87,10 +88,7 @@ export default function ViewAudioAsset() {
|
|||||||
{streamUrl ? (
|
{streamUrl ? (
|
||||||
<AudioBlock content={audioContent} />
|
<AudioBlock content={audioContent} />
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-lg border bg-muted/30 flex flex-col items-center justify-center gap-4 py-20">
|
<MediaFallback className="size-full" />
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import { Spinner } from "@/components/ui/spinner";
|
|||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { MediaFallback } from "@/components/generic/MediaFallback";
|
||||||
|
import AssetPageLoader from "@/components/generic/AssetLoader";
|
||||||
|
|
||||||
function MetaRow({ label, value }) {
|
function MetaRow({ label, value }) {
|
||||||
if (!value && value !== 0) return null;
|
if (!value && value !== 0) return null;
|
||||||
@@ -38,11 +40,7 @@ export default function ViewDocumentAsset() {
|
|||||||
}, [assetId]);
|
}, [assetId]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return <AssetPageLoader />;
|
||||||
<div className="flex items-center justify-center h-96">
|
|
||||||
<Spinner className="size-8" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!selectedAsset) {
|
if (!selectedAsset) {
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import { Badge } from "@/components/ui/badge";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { MediaFallback } from "@/components/generic/MediaFallback";
|
import { MediaFallback } from "@/components/generic/MediaFallback";
|
||||||
|
import AssetPageLoader from "@/components/generic/AssetLoader";
|
||||||
|
|
||||||
|
|
||||||
function MetaRow({ label, value }) {
|
function MetaRow({ label, value }) {
|
||||||
if (!value && value !== 0) return null;
|
if (!value && value !== 0) return null;
|
||||||
@@ -36,7 +38,7 @@ export default function ViewImageAsset() {
|
|||||||
}, [assetId]);
|
}, [assetId]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <MediaFallback className="h-96 rounded-lg" />;
|
return <AssetPageLoader />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!selectedAsset) {
|
if (!selectedAsset) {
|
||||||
@@ -80,7 +82,7 @@ export default function ViewImageAsset() {
|
|||||||
onContextMenu={(e) => e.preventDefault()}
|
onContextMenu={(e) => e.preventDefault()}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-muted-foreground text-sm">No preview available.</p>
|
<MediaFallback className="size-full" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { Badge } from "@/components/ui/badge";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { MediaFallback } from "@/components/generic/MediaFallback";
|
import { MediaFallback } from "@/components/generic/MediaFallback";
|
||||||
|
import AssetPageLoader from "@/components/generic/AssetLoader";
|
||||||
|
|
||||||
function MetaRow({ label, value }) {
|
function MetaRow({ label, value }) {
|
||||||
if (!value && value !== 0) return null;
|
if (!value && value !== 0) return null;
|
||||||
@@ -46,7 +47,7 @@ export default function ViewVideoAsset() {
|
|||||||
}, [assetId]);
|
}, [assetId]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <MediaFallback className="h-96 rounded-lg" />;
|
return <AssetPageLoader />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!selectedAsset) {
|
if (!selectedAsset) {
|
||||||
@@ -96,7 +97,7 @@ export default function ViewVideoAsset() {
|
|||||||
Your browser does not support the video tag.
|
Your browser does not support the video tag.
|
||||||
</video>
|
</video>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-muted-foreground text-sm">No video source available.</p>
|
<MediaFallback className="size-full" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
} from "@/components/ui/command";
|
} from "@/components/ui/command";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import CourseBadge from "@/modules/admin/components/courses/CourseBadge";
|
import CourseBadge from "@/modules/admin/components/courses/CourseBadge";
|
||||||
|
import RoadmapBuilder from "@/modules/admin/components/courses/RoadmapBuilder";
|
||||||
import { TIER_COLOR_OPTIONS } from "@/utils/tierColors";
|
import { TIER_COLOR_OPTIONS } from "@/utils/tierColors";
|
||||||
import { AssetPickerSheet } from "@/components/generic/AssetPickerSheet";
|
import { AssetPickerSheet } from "@/components/generic/AssetPickerSheet";
|
||||||
|
|
||||||
@@ -38,29 +39,20 @@ import { AssetPickerSheet } from "@/components/generic/AssetPickerSheet";
|
|||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
title: z.string().min(1, "Title is required."),
|
title: z.string().min(1, "Title is required."),
|
||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
course_code: z.string().optional(),
|
course_code: z.string().min(1, "Course Code is required."),
|
||||||
order_index: z.coerce.number().min(0).default(0),
|
order_index: z.coerce.number().min(0).default(0),
|
||||||
level: z.enum(["beginner", "intermediate", "advanced"]).optional(),
|
level: z.enum(["beginner", "intermediate", "advanced"]).optional(),
|
||||||
subscription: z.string().min(1, "Subscription is required.").default("free"),
|
subscription: z.string().min(1, "Subscription is required.").default("free"),
|
||||||
objectives: z.array(z.object({ text: z.string().min(1, "Objective cannot be empty.") })).default([]),
|
objectives: z.array(z.object({ text: z.string().min(1, "Objective cannot be empty.") }))
|
||||||
|
.min(1, "At least one learning objective is required."),
|
||||||
achievement_keys: z.array(z.string()).max(1).default([]),
|
achievement_keys: z.array(z.string()).max(1).default([]),
|
||||||
|
|
||||||
unit_title: z.string().min(1, "Unit title is required."),
|
|
||||||
unit_description: z.string().optional(),
|
|
||||||
unit_order: z.coerce.number().min(0).default(0),
|
|
||||||
|
|
||||||
lesson_title: z.string().min(1, "Lesson title is required."),
|
|
||||||
lesson_description: z.string().optional(),
|
|
||||||
lesson_order: z.coerce.number().min(0).default(0),
|
|
||||||
lesson_objectives: z.array(z.object({ text: z.string().min(1, "Objective cannot be empty.") })).default([]),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Steps config ─────────────────────────────────────────────────────────────
|
// ─── Steps config ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const STEPS = [
|
const STEPS = [
|
||||||
{ label: "Basic Info", description: "Title, level & objectives" },
|
{ label: "Basic Info", description: "Title, level & objectives" },
|
||||||
{ label: "First Unit", description: "The course's first unit" },
|
{ label: "Roadmap", description: "Units & lessons" },
|
||||||
{ label: "First Lesson", description: "A lesson inside that unit" },
|
|
||||||
{ label: "Rewards", description: "Badge & achievements" },
|
{ label: "Rewards", description: "Badge & achievements" },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -139,10 +131,11 @@ function StepIndicator({ steps, current, onStepClick }) {
|
|||||||
|
|
||||||
export default function AddCourse() {
|
export default function AddCourse() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { createCourse, createUnit, createLesson, loading } = useCourses();
|
const { createCourseFull, loading } = useCourses();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
|
||||||
const [currentStep, setCurrentStep] = useState(0);
|
const [currentStep, setCurrentStep] = useState(0);
|
||||||
|
const [roadmapUnits, setRoadmapUnits] = useState([]);
|
||||||
const [tierCategories, setTierCategories] = useState([]);
|
const [tierCategories, setTierCategories] = useState([]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.get("/admin/tiers/categories")
|
api.get("/admin/tiers/categories")
|
||||||
@@ -159,9 +152,10 @@ export default function AddCourse() {
|
|||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
trigger,
|
|
||||||
control,
|
control,
|
||||||
setValue,
|
setValue,
|
||||||
|
getValues,
|
||||||
|
trigger,
|
||||||
formState: { errors },
|
formState: { errors },
|
||||||
} = useForm({
|
} = useForm({
|
||||||
resolver: zodResolver(schema),
|
resolver: zodResolver(schema),
|
||||||
@@ -174,22 +168,12 @@ export default function AddCourse() {
|
|||||||
subscription: "free",
|
subscription: "free",
|
||||||
objectives: [],
|
objectives: [],
|
||||||
achievement_keys: [],
|
achievement_keys: [],
|
||||||
unit_title: "",
|
|
||||||
unit_description: "",
|
|
||||||
unit_order: 0,
|
|
||||||
lesson_title: "",
|
|
||||||
lesson_description: "",
|
|
||||||
lesson_order: 0,
|
|
||||||
lesson_objectives: [],
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { fields: objectiveFields, append: appendObjective, remove: removeObjective } =
|
const { fields: objectiveFields, append: appendObjective, remove: removeObjective } =
|
||||||
useFieldArray({ control, name: "objectives" });
|
useFieldArray({ control, name: "objectives" });
|
||||||
|
|
||||||
const { fields: lessonObjectiveFields, append: appendLessonObjective, remove: removeLessonObjective } =
|
|
||||||
useFieldArray({ control, name: "lesson_objectives" });
|
|
||||||
|
|
||||||
const currentAchKeys = useWatch({ control, name: "achievement_keys" });
|
const currentAchKeys = useWatch({ control, name: "achievement_keys" });
|
||||||
const watchedTitle = useWatch({ control, name: "title" });
|
const watchedTitle = useWatch({ control, name: "title" });
|
||||||
const watchedLevel = useWatch({ control, name: "level" });
|
const watchedLevel = useWatch({ control, name: "level" });
|
||||||
@@ -210,29 +194,31 @@ export default function AddCourse() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const STEP_FIELDS = [
|
// Nothing here writes to the API until Finish — Basic Info just validates
|
||||||
["title", "subscription", "objectives"],
|
// and advances, Roadmap is held as local draft state (see roadmapUnits),
|
||||||
["unit_title", "unit_order"],
|
// and Finish fires the whole course + roadmap + rewards in one request.
|
||||||
["lesson_title", "lesson_order", "lesson_objectives"],
|
const onBasicInfoSubmit = () => {
|
||||||
[],
|
|
||||||
];
|
|
||||||
|
|
||||||
const handleNext = async (e) => {
|
|
||||||
// The Next button occupies the same DOM position as the eventual
|
|
||||||
// type="submit" Create Course button. Advancing the step re-renders
|
|
||||||
// that node's type attribute in place *before* the browser evaluates
|
|
||||||
// this click's default action, which would otherwise submit the form.
|
|
||||||
e.preventDefault();
|
|
||||||
const fields = STEP_FIELDS[currentStep];
|
|
||||||
if (fields?.length) {
|
|
||||||
const valid = await trigger(fields);
|
|
||||||
if (!valid) return;
|
|
||||||
}
|
|
||||||
setCurrentStep((s) => s + 1);
|
setCurrentStep((s) => s + 1);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onSubmit = async (values) => {
|
// Guards the step indicator: jumping straight to Roadmap/Rewards must not
|
||||||
const coursePayload = {
|
// bypass the Basic Info required fields, so re-validate before honoring
|
||||||
|
// any click that leaves step 0.
|
||||||
|
const goToStep = async (target) => {
|
||||||
|
if (target > 0) {
|
||||||
|
const valid = await trigger(["title", "course_code", "objectives"]);
|
||||||
|
if (!valid) {
|
||||||
|
setCurrentStep(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setCurrentStep(target);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFinish = async () => {
|
||||||
|
const values = getValues();
|
||||||
|
|
||||||
|
const result = await createCourseFull({
|
||||||
title: values.title,
|
title: values.title,
|
||||||
description: values.description,
|
description: values.description,
|
||||||
course_code: values.course_code || null,
|
course_code: values.course_code || null,
|
||||||
@@ -240,37 +226,26 @@ export default function AddCourse() {
|
|||||||
level: values.level || null,
|
level: values.level || null,
|
||||||
subscription: values.subscription,
|
subscription: values.subscription,
|
||||||
objectives: values.objectives.map((o) => o.text),
|
objectives: values.objectives.map((o) => o.text),
|
||||||
achievement_keys: values.achievement_keys,
|
achievement_keys: currentAchKeys,
|
||||||
badge_color: badgeColor,
|
badge_color: badgeColor,
|
||||||
badge_asset_id: badgeAssetId ?? null,
|
badge_asset_id: badgeAssetId ?? null,
|
||||||
badge_image_url: badgeImageUrl ?? null,
|
badge_image_url: badgeImageUrl ?? null,
|
||||||
|
units: roadmapUnits.map((u) => ({
|
||||||
|
unit_id: u.unit_id,
|
||||||
|
title: u.title,
|
||||||
|
description: u.description,
|
||||||
|
lessons: u.lessons.map((l) => ({
|
||||||
|
lesson_id: l.lesson_id,
|
||||||
|
title: l.title,
|
||||||
|
description: l.description,
|
||||||
|
objectives: l.objectives ?? [],
|
||||||
|
})),
|
||||||
|
})),
|
||||||
createdBy: user?.user_id ?? null,
|
createdBy: user?.user_id ?? null,
|
||||||
};
|
});
|
||||||
|
|
||||||
const courseResult = await createCourse(coursePayload);
|
const newCourse = result?.data?.data ?? null;
|
||||||
const newCourse = courseResult?.data?.data ?? null;
|
|
||||||
if (!newCourse) return;
|
if (!newCourse) return;
|
||||||
|
|
||||||
const unitResult = await createUnit(newCourse.course_id, {
|
|
||||||
title: values.unit_title,
|
|
||||||
description: values.unit_description,
|
|
||||||
order: values.unit_order,
|
|
||||||
createdBy: user?.user_id,
|
|
||||||
});
|
|
||||||
const newUnit = unitResult?.data?.data ?? null;
|
|
||||||
if (!newUnit) {
|
|
||||||
navigate(`/admin/courses/${newCourse.course_id}/view`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await createLesson(newCourse.course_id, newUnit.unit_id, {
|
|
||||||
title: values.lesson_title,
|
|
||||||
description: values.lesson_description,
|
|
||||||
order: values.lesson_order,
|
|
||||||
objectives: values.lesson_objectives.map((o) => o.text),
|
|
||||||
createdBy: user?.user_id,
|
|
||||||
});
|
|
||||||
|
|
||||||
navigate(`/admin/courses/${newCourse.course_id}/view`);
|
navigate(`/admin/courses/${newCourse.course_id}/view`);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -303,9 +278,9 @@ export default function AddCourse() {
|
|||||||
<div className="lg:container lg:mx-auto lg:px-6 px-4 py-6">
|
<div className="lg:container lg:mx-auto lg:px-6 px-4 py-6">
|
||||||
<div className="max-w-2xl mx-auto">
|
<div className="max-w-2xl mx-auto">
|
||||||
|
|
||||||
<StepIndicator steps={STEPS} current={currentStep} onStepClick={setCurrentStep} />
|
<StepIndicator steps={STEPS} current={currentStep} onStepClick={goToStep} />
|
||||||
|
|
||||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
|
<form onSubmit={handleSubmit(onBasicInfoSubmit)} className="space-y-5">
|
||||||
|
|
||||||
{/* ── Step 0: Basic Info ── */}
|
{/* ── Step 0: Basic Info ── */}
|
||||||
{currentStep === 0 && (
|
{currentStep === 0 && (
|
||||||
@@ -326,7 +301,9 @@ export default function AddCourse() {
|
|||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="course_code">Course Code</Label>
|
<Label htmlFor="course_code">
|
||||||
|
Course Code <span className="text-destructive">*</span>
|
||||||
|
</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} />
|
<FieldError message={errors.course_code?.message} />
|
||||||
</div>
|
</div>
|
||||||
@@ -382,9 +359,10 @@ export default function AddCourse() {
|
|||||||
|
|
||||||
<SectionCard
|
<SectionCard
|
||||||
title="Learning Objectives"
|
title="Learning Objectives"
|
||||||
description="What will learners be able to do after completing this course?"
|
description="What will learners be able to do after completing this course? At least one is required."
|
||||||
>
|
>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
<FieldError message={errors.objectives?.message} />
|
||||||
{objectiveFields.map((field, index) => (
|
{objectiveFields.map((field, index) => (
|
||||||
<div key={field.id} className="flex items-start gap-2">
|
<div key={field.id} className="flex items-start gap-2">
|
||||||
<div className="flex-1 space-y-1">
|
<div className="flex-1 space-y-1">
|
||||||
@@ -421,95 +399,13 @@ export default function AddCourse() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Step 1: First Unit ── */}
|
{/* ── Step 1: Roadmap ── */}
|
||||||
{currentStep === 1 && (
|
{currentStep === 1 && (
|
||||||
<SectionCard title="First Unit" description="Every course needs at least one unit to hold its lessons.">
|
<RoadmapBuilder units={roadmapUnits} onUnitsChange={setRoadmapUnits} />
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label htmlFor="unit_title">
|
|
||||||
Title <span className="text-destructive">*</span>
|
|
||||||
</Label>
|
|
||||||
<Input id="unit_title" placeholder="e.g. Getting Started" {...register("unit_title")} />
|
|
||||||
<FieldError message={errors.unit_title?.message} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label htmlFor="unit_description">Description</Label>
|
|
||||||
<Textarea id="unit_description" placeholder="Optional unit description" rows={3} {...register("unit_description")} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1.5 max-w-[120px]">
|
|
||||||
<Label htmlFor="unit_order">Order</Label>
|
|
||||||
<Input id="unit_order" type="number" min={0} {...register("unit_order")} />
|
|
||||||
</div>
|
|
||||||
</SectionCard>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Step 2: First Lesson ── */}
|
{/* ── Step 2: Rewards ── */}
|
||||||
{currentStep === 2 && (
|
{currentStep === 2 && (
|
||||||
<>
|
|
||||||
<SectionCard title="First Lesson" description="Add the first lesson inside that unit.">
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label htmlFor="lesson_title">
|
|
||||||
Title <span className="text-destructive">*</span>
|
|
||||||
</Label>
|
|
||||||
<Input id="lesson_title" placeholder="e.g. Welcome to the Course" {...register("lesson_title")} />
|
|
||||||
<FieldError message={errors.lesson_title?.message} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label htmlFor="lesson_description">Description</Label>
|
|
||||||
<Textarea id="lesson_description" placeholder="Optional lesson description" rows={3} {...register("lesson_description")} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1.5 max-w-[120px]">
|
|
||||||
<Label htmlFor="lesson_order">Order</Label>
|
|
||||||
<Input id="lesson_order" type="number" min={0} {...register("lesson_order")} />
|
|
||||||
</div>
|
|
||||||
</SectionCard>
|
|
||||||
|
|
||||||
<SectionCard
|
|
||||||
title="Lesson Objectives"
|
|
||||||
description="What will learners be able to do after this lesson?"
|
|
||||||
>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{lessonObjectiveFields.map((field, index) => (
|
|
||||||
<div key={field.id} className="flex items-start gap-2">
|
|
||||||
<div className="flex-1 space-y-1">
|
|
||||||
<Input
|
|
||||||
placeholder={`Objective ${index + 1}`}
|
|
||||||
{...register(`lesson_objectives.${index}.text`)}
|
|
||||||
/>
|
|
||||||
<FieldError message={errors.lesson_objectives?.[index]?.text?.message} />
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="mt-0.5 text-muted-foreground hover:text-destructive"
|
|
||||||
onClick={() => removeLessonObjective(index)}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="w-full mt-1"
|
|
||||||
onClick={() => appendLessonObjective({ text: "" })}
|
|
||||||
>
|
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
|
||||||
Add Objective
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</SectionCard>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ── Step 3: Rewards ── */}
|
|
||||||
{currentStep === 3 && (
|
|
||||||
<SectionCard
|
<SectionCard
|
||||||
title="Rewards"
|
title="Rewards"
|
||||||
description="Badge and achievements awarded to learners who complete this course."
|
description="Badge and achievements awarded to learners who complete this course."
|
||||||
@@ -716,15 +612,21 @@ export default function AddCourse() {
|
|||||||
{currentStep === 0 ? "Cancel" : "Back"}
|
{currentStep === 0 ? "Cancel" : "Back"}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{currentStep < STEPS.length - 1 ? (
|
{currentStep === 0 ? (
|
||||||
<Button type="button" onClick={handleNext}>
|
<Button type="submit" disabled={loading}>
|
||||||
|
{loading && <Spinner className="h-4 w-4 mr-2" />}
|
||||||
|
Next
|
||||||
|
<ArrowRight className="h-4 w-4 ml-2" />
|
||||||
|
</Button>
|
||||||
|
) : currentStep < STEPS.length - 1 ? (
|
||||||
|
<Button type="button" onClick={() => setCurrentStep((s) => s + 1)}>
|
||||||
Next
|
Next
|
||||||
<ArrowRight className="h-4 w-4 ml-2" />
|
<ArrowRight className="h-4 w-4 ml-2" />
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button type="submit" disabled={loading}>
|
<Button type="button" disabled={loading} onClick={handleFinish}>
|
||||||
{loading && <Spinner className="h-4 w-4 mr-2" />}
|
{loading && <Spinner className="h-4 w-4 mr-2" />}
|
||||||
Create Course
|
Finish
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm, useWatch } from "react-hook-form";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { ArrowLeft } from "lucide-react";
|
import { ArrowLeft } from "lucide-react";
|
||||||
@@ -7,15 +8,20 @@ import { ArrowLeft } from "lucide-react";
|
|||||||
import { useLibrary } from "@/contexts/AdminLibraryContext";
|
import { useLibrary } from "@/contexts/AdminLibraryContext";
|
||||||
import { useAuth } from "@/contexts/AuthContext";
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
import { PageMeta } from "@/contexts/MetadataContext";
|
import { PageMeta } from "@/contexts/MetadataContext";
|
||||||
|
import api from "@/utils/api.util";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
|
import {
|
||||||
|
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
title: z.string().min(1, "Title is required."),
|
title: z.string().min(1, "Title is required."),
|
||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
|
subscription: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
function FieldError({ message }) {
|
function FieldError({ message }) {
|
||||||
@@ -28,13 +34,22 @@ export default function AddLibraryUnit() {
|
|||||||
const { createUnit, loading } = useLibrary();
|
const { createUnit, loading } = useLibrary();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
|
||||||
const { register, handleSubmit, formState: { errors } } = useForm({
|
const [tierCategories, setTierCategories] = useState([]);
|
||||||
|
useEffect(() => {
|
||||||
|
api.get("/admin/tiers/categories")
|
||||||
|
.then(({ data }) => setTierCategories((data.data ?? []).filter((c) => c.is_active)))
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const { register, handleSubmit, control, setValue, formState: { errors } } = useForm({
|
||||||
resolver: zodResolver(schema),
|
resolver: zodResolver(schema),
|
||||||
defaultValues: { title: "", description: "" },
|
defaultValues: { title: "", description: "", subscription: "" },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const watchedSubscr = useWatch({ control, name: "subscription" });
|
||||||
|
|
||||||
const onSubmit = async (data) => {
|
const onSubmit = async (data) => {
|
||||||
const result = await createUnit({ ...data, createdBy: user?.user_id });
|
const result = await createUnit({ ...data, subscription: data.subscription || null, createdBy: user?.user_id });
|
||||||
if (!result) return;
|
if (!result) return;
|
||||||
navigate("/admin/units");
|
navigate("/admin/units");
|
||||||
};
|
};
|
||||||
@@ -71,6 +86,29 @@ export default function AddLibraryUnit() {
|
|||||||
<Textarea id="description" placeholder="Optional description" rows={3} {...register("description")} />
|
<Textarea id="description" placeholder="Optional description" rows={3} {...register("description")} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Subscription</Label>
|
||||||
|
<Select
|
||||||
|
value={watchedSubscr || "__open"}
|
||||||
|
onValueChange={(val) => setValue("subscription", val === "__open" ? "" : val, { shouldDirty: true })}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="No tier gate" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__open">No tier gate (open)</SelectItem>
|
||||||
|
{tierCategories.map((c) => (
|
||||||
|
<SelectItem key={c.slug} value={c.slug}>
|
||||||
|
{c.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Optional. Gates this unit directly, independent of any course it may later be attached to.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end gap-3">
|
<div className="flex justify-end gap-3">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm, useWatch } from "react-hook-form";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { ArrowLeft } from "lucide-react";
|
import { ArrowLeft } from "lucide-react";
|
||||||
@@ -8,15 +8,20 @@ import { ArrowLeft } from "lucide-react";
|
|||||||
import { useLibrary } from "@/contexts/AdminLibraryContext";
|
import { useLibrary } from "@/contexts/AdminLibraryContext";
|
||||||
import { useAuth } from "@/contexts/AuthContext";
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
import { PageMeta } from "@/contexts/MetadataContext";
|
import { PageMeta } from "@/contexts/MetadataContext";
|
||||||
|
import api from "@/utils/api.util";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
|
import {
|
||||||
|
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
title: z.string().min(1, "Title is required."),
|
title: z.string().min(1, "Title is required."),
|
||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
|
subscription: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
function FieldError({ message }) {
|
function FieldError({ message }) {
|
||||||
@@ -30,23 +35,32 @@ export default function EditLibraryUnit() {
|
|||||||
const { fetchUnit, updateUnit, unit, loading } = useLibrary();
|
const { fetchUnit, updateUnit, unit, loading } = useLibrary();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
|
||||||
const { register, handleSubmit, reset, formState: { errors } } = useForm({
|
const [tierCategories, setTierCategories] = useState([]);
|
||||||
|
useEffect(() => {
|
||||||
|
api.get("/admin/tiers/categories")
|
||||||
|
.then(({ data }) => setTierCategories((data.data ?? []).filter((c) => c.is_active)))
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const { register, handleSubmit, reset, control, setValue, formState: { errors } } = useForm({
|
||||||
resolver: zodResolver(schema),
|
resolver: zodResolver(schema),
|
||||||
defaultValues: { title: "", description: "" },
|
defaultValues: { title: "", description: "", subscription: "" },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const watchedSubscr = useWatch({ control, name: "subscription" });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchUnit(unitId);
|
fetchUnit(unitId);
|
||||||
}, [unitId]);
|
}, [unitId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (unit && String(unit.unit_id) === String(unitId)) {
|
if (unit && String(unit.unit_id) === String(unitId)) {
|
||||||
reset({ title: unit.title ?? "", description: unit.description ?? "" });
|
reset({ title: unit.title ?? "", description: unit.description ?? "", subscription: unit.subscription ?? "" });
|
||||||
}
|
}
|
||||||
}, [unit, unitId, reset]);
|
}, [unit, unitId, reset]);
|
||||||
|
|
||||||
const onSubmit = async (data) => {
|
const onSubmit = async (data) => {
|
||||||
const result = await updateUnit(unitId, { ...data, updatedBy: user?.user_id });
|
const result = await updateUnit(unitId, { ...data, subscription: data.subscription || null, updatedBy: user?.user_id });
|
||||||
if (!result) return;
|
if (!result) return;
|
||||||
navigate(`/admin/units/${unitId}/view`);
|
navigate(`/admin/units/${unitId}/view`);
|
||||||
};
|
};
|
||||||
@@ -83,6 +97,29 @@ export default function EditLibraryUnit() {
|
|||||||
<Textarea id="description" placeholder="Optional description" rows={3} {...register("description")} />
|
<Textarea id="description" placeholder="Optional description" rows={3} {...register("description")} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Subscription</Label>
|
||||||
|
<Select
|
||||||
|
value={watchedSubscr || "__open"}
|
||||||
|
onValueChange={(val) => setValue("subscription", val === "__open" ? "" : val, { shouldDirty: true })}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="No tier gate" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__open">No tier gate (open)</SelectItem>
|
||||||
|
{tierCategories.map((c) => (
|
||||||
|
<SelectItem key={c.slug} value={c.slug}>
|
||||||
|
{c.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Optional. Gates this unit directly, independent of any course it may later be attached to.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end gap-3">
|
<div className="flex justify-end gap-3">
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { useAuth } from "@/contexts/AuthContext";
|
|||||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||||
import { BroadcastTargetPicker } from "@/components/generic/BroadcastTargetPicker";
|
import { BroadcastTargetPicker } from "@/components/generic/BroadcastTargetPicker";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
@@ -26,6 +27,8 @@ const schema = z.object({
|
|||||||
message: z.string().min(1, "Message is required."),
|
message: z.string().min(1, "Message is required."),
|
||||||
target_type: z.enum(["admin", "user", "both", "task_list", "course", "tier_plan"], { required_error: "Target is required." }),
|
target_type: z.enum(["admin", "user", "both", "task_list", "course", "tier_plan"], { required_error: "Target is required." }),
|
||||||
target_id: z.string().nullable().optional(),
|
target_id: z.string().nullable().optional(),
|
||||||
|
show_in_sticky: z.boolean().optional(),
|
||||||
|
show_in_notifications: z.boolean().optional(),
|
||||||
}).superRefine((data, ctx) => {
|
}).superRefine((data, ctx) => {
|
||||||
if (TARGET_TYPE_MAP[data.target_type]?.needsTarget && !data.target_id) {
|
if (TARGET_TYPE_MAP[data.target_type]?.needsTarget && !data.target_id) {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
@@ -34,6 +37,14 @@ const schema = z.object({
|
|||||||
path: ["target_id"],
|
path: ["target_id"],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!data.show_in_sticky && !data.show_in_notifications) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "Select where to show this notification (Sticky or Notifications).",
|
||||||
|
path: ["show_in_sticky"],
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||||
@@ -77,16 +88,20 @@ export default function AddNotificationBroadcast() {
|
|||||||
message: "",
|
message: "",
|
||||||
target_type: undefined,
|
target_type: undefined,
|
||||||
target_id: null,
|
target_id: null,
|
||||||
|
show_in_sticky: false,
|
||||||
|
show_in_notifications: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const targetType = watch("target_type");
|
const targetType = watch("target_type");
|
||||||
const targetId = watch("target_id");
|
const targetId = watch("target_id");
|
||||||
const needsTarget = TARGET_TYPE_MAP[targetType]?.needsTarget;
|
const needsTarget = TARGET_TYPE_MAP[targetType]?.needsTarget;
|
||||||
|
const showInSticky = watch("show_in_sticky");
|
||||||
|
const showInNotifications = watch("show_in_notifications");
|
||||||
|
|
||||||
const breadcrumbItems = [
|
const breadcrumbItems = [
|
||||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
||||||
{ label: "Notifications", to: "/admin/notifications" },
|
{ label: "Announcements", to: "/admin/announcements" },
|
||||||
{ label: "New" },
|
{ label: "New" },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -94,11 +109,13 @@ export default function AddNotificationBroadcast() {
|
|||||||
const payload = {
|
const payload = {
|
||||||
...values,
|
...values,
|
||||||
target_id: TARGET_TYPE_MAP[values.target_type]?.needsTarget ? values.target_id : null,
|
target_id: TARGET_TYPE_MAP[values.target_type]?.needsTarget ? values.target_id : null,
|
||||||
|
show_in_sticky: values.show_in_sticky ?? false,
|
||||||
|
show_in_notifications: values.show_in_notifications ?? true,
|
||||||
createdBy: user?.user_id ?? null,
|
createdBy: user?.user_id ?? null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const res = await createBroadcast(payload);
|
const res = await createBroadcast(payload);
|
||||||
if (res) navigate("/admin/notifications");
|
if (res) navigate("/admin/announcements");
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -109,7 +126,7 @@ export default function AddNotificationBroadcast() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-full max-w-2xl pb-10">
|
<div className="w-full max-w-2xl pb-10">
|
||||||
<h1 className="text-2xl font-semibold tracking-tight mb-1">New notification</h1>
|
<h1 className="text-2xl font-semibold tracking-tight mb-1">New announcement</h1>
|
||||||
<p className="text-sm text-muted-foreground mb-6">Compose an announcement. It's saved as a draft until you send it.</p>
|
<p className="text-sm text-muted-foreground mb-6">Compose an announcement. It's saved as a draft until you send it.</p>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
|
||||||
@@ -127,7 +144,7 @@ export default function AddNotificationBroadcast() {
|
|||||||
</div>
|
</div>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
<SectionCard title="Target" description="Who receives this notification when it's sent.">
|
<SectionCard title="Target" description="Who receives this announcement when it's sent.">
|
||||||
<div>
|
<div>
|
||||||
<Select
|
<Select
|
||||||
value={targetType}
|
value={targetType}
|
||||||
@@ -165,6 +182,32 @@ export default function AddNotificationBroadcast() {
|
|||||||
)}
|
)}
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
|
<SectionCard title="Display" description="Where clients/admins can see this announcement.">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Checkbox
|
||||||
|
id="show_in_sticky"
|
||||||
|
checked={showInSticky === true}
|
||||||
|
onCheckedChange={(v) => setValue("show_in_sticky", v === true, { shouldValidate: true })}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="show_in_sticky" className="cursor-pointer">
|
||||||
|
Show in Sticky Announcements
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Checkbox
|
||||||
|
id="show_in_notifications"
|
||||||
|
checked={showInNotifications === true}
|
||||||
|
onCheckedChange={(v) => setValue("show_in_notifications", v === true, { shouldValidate: true })}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="show_in_notifications" className="cursor-pointer">
|
||||||
|
Show in Notifications
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
<div className="flex justify-end gap-2 pt-2">
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading}>
|
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading}>
|
||||||
Cancel
|
Cancel
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import ArchivedNotificationBroadcastsTable from "../../components/notifications/
|
|||||||
export default function ArchivedNotificationBroadcastList() {
|
export default function ArchivedNotificationBroadcastList() {
|
||||||
const items = [
|
const items = [
|
||||||
{ label: "Home", icon: <House className="size-4" />, to: `/admin` },
|
{ label: "Home", icon: <House className="size-4" />, to: `/admin` },
|
||||||
{ label: "Notifications", to: `/admin/notifications` },
|
{ label: "Announcements", to: `/admin/announcements` },
|
||||||
{ label: "Archived" },
|
{ label: "Archived" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { useAuth } from "@/contexts/AuthContext";
|
|||||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||||
import { BroadcastTargetPicker } from "@/components/generic/BroadcastTargetPicker";
|
import { BroadcastTargetPicker } from "@/components/generic/BroadcastTargetPicker";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
@@ -27,6 +28,8 @@ const schema = z.object({
|
|||||||
message: z.string().min(1, "Message is required."),
|
message: z.string().min(1, "Message is required."),
|
||||||
target_type: z.enum(["admin", "user", "both", "task_list", "course", "tier_plan"], { required_error: "Target is required." }),
|
target_type: z.enum(["admin", "user", "both", "task_list", "course", "tier_plan"], { required_error: "Target is required." }),
|
||||||
target_id: z.string().nullable().optional(),
|
target_id: z.string().nullable().optional(),
|
||||||
|
show_in_sticky: z.boolean().optional(),
|
||||||
|
show_in_notifications: z.boolean().optional(),
|
||||||
}).superRefine((data, ctx) => {
|
}).superRefine((data, ctx) => {
|
||||||
if (TARGET_TYPE_MAP[data.target_type]?.needsTarget && !data.target_id) {
|
if (TARGET_TYPE_MAP[data.target_type]?.needsTarget && !data.target_id) {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
@@ -35,6 +38,14 @@ const schema = z.object({
|
|||||||
path: ["target_id"],
|
path: ["target_id"],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!data.show_in_sticky && !data.show_in_notifications) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "Select where to show this notification (Sticky or Notifications).",
|
||||||
|
path: ["show_in_sticky"],
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||||
@@ -80,16 +91,20 @@ export default function EditNotificationBroadcast() {
|
|||||||
message: "",
|
message: "",
|
||||||
target_type: undefined,
|
target_type: undefined,
|
||||||
target_id: null,
|
target_id: null,
|
||||||
|
show_in_sticky: false,
|
||||||
|
show_in_notifications: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const targetType = watch("target_type");
|
const targetType = watch("target_type");
|
||||||
const targetId = watch("target_id");
|
const targetId = watch("target_id");
|
||||||
const needsTarget = TARGET_TYPE_MAP[targetType]?.needsTarget;
|
const needsTarget = TARGET_TYPE_MAP[targetType]?.needsTarget;
|
||||||
|
const showInSticky = watch("show_in_sticky");
|
||||||
|
const showInNotifications = watch("show_in_notifications");
|
||||||
|
|
||||||
const breadcrumbItems = [
|
const breadcrumbItems = [
|
||||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
||||||
{ label: "Notifications", to: "/admin/notifications" },
|
{ label: "Announcements", to: "/admin/announcements" },
|
||||||
{ label: "Edit" },
|
{ label: "Edit" },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -105,6 +120,8 @@ export default function EditNotificationBroadcast() {
|
|||||||
message: b.message ?? "",
|
message: b.message ?? "",
|
||||||
target_type: b.target_type ?? undefined,
|
target_type: b.target_type ?? undefined,
|
||||||
target_id: b.target_id ?? null,
|
target_id: b.target_id ?? null,
|
||||||
|
show_in_sticky: b.show_in_sticky ?? false,
|
||||||
|
show_in_notifications: b.show_in_notifications ?? true,
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
@@ -114,11 +131,13 @@ export default function EditNotificationBroadcast() {
|
|||||||
const payload = {
|
const payload = {
|
||||||
...values,
|
...values,
|
||||||
target_id: TARGET_TYPE_MAP[values.target_type]?.needsTarget ? values.target_id : null,
|
target_id: TARGET_TYPE_MAP[values.target_type]?.needsTarget ? values.target_id : null,
|
||||||
|
show_in_sticky: values.show_in_sticky ?? false,
|
||||||
|
show_in_notifications: values.show_in_notifications ?? true,
|
||||||
updatedBy: user?.user_id ?? null,
|
updatedBy: user?.user_id ?? null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const res = await updateBroadcast(broadcastId, payload);
|
const res = await updateBroadcast(broadcastId, payload);
|
||||||
if (res) navigate("/admin/notifications");
|
if (res) navigate("/admin/announcements");
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -129,8 +148,8 @@ export default function EditNotificationBroadcast() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-full max-w-2xl pb-10">
|
<div className="w-full max-w-2xl pb-10">
|
||||||
<h1 className="text-2xl font-semibold tracking-tight mb-1">Edit notification</h1>
|
<h1 className="text-2xl font-semibold tracking-tight mb-1">Edit announcement</h1>
|
||||||
<p className="text-sm text-muted-foreground mb-6">Only draft notifications can be edited.</p>
|
<p className="text-sm text-muted-foreground mb-6">Only draft announcements can be edited.</p>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
|
||||||
|
|
||||||
@@ -147,7 +166,7 @@ export default function EditNotificationBroadcast() {
|
|||||||
</div>
|
</div>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
<SectionCard title="Target" description="Who receives this notification when it's sent.">
|
<SectionCard title="Target" description="Who receives this announcement when it's sent.">
|
||||||
<div>
|
<div>
|
||||||
<Select
|
<Select
|
||||||
value={targetType}
|
value={targetType}
|
||||||
@@ -185,6 +204,32 @@ export default function EditNotificationBroadcast() {
|
|||||||
)}
|
)}
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
|
<SectionCard title="Display" description="Where clients/admins can see this announcement.">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Checkbox
|
||||||
|
id="show_in_sticky"
|
||||||
|
checked={showInSticky === true}
|
||||||
|
onCheckedChange={(v) => setValue("show_in_sticky", v === true, { shouldValidate: true })}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="show_in_sticky" className="cursor-pointer">
|
||||||
|
Show in Sticky Announcements
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Checkbox
|
||||||
|
id="show_in_notifications"
|
||||||
|
checked={showInNotifications === true}
|
||||||
|
onCheckedChange={(v) => setValue("show_in_notifications", v === true, { shouldValidate: true })}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="show_in_notifications" className="cursor-pointer">
|
||||||
|
Show in Notifications
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
<div className="flex justify-end gap-2 pt-2">
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading}>
|
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading}>
|
||||||
Cancel
|
Cancel
|
||||||
|
|||||||
@@ -77,20 +77,20 @@ function EditNotificationTemplateInner() {
|
|||||||
message: message.trim(),
|
message: message.trim(),
|
||||||
publish,
|
publish,
|
||||||
});
|
});
|
||||||
if (result) navigate("/admin/notification-templates");
|
if (result) navigate("/admin/announcement-templates");
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="bg-muted/60 min-h-full">
|
<section className="bg-muted/60 min-h-full">
|
||||||
<PageMeta title="Edit Notification Template - STARR" />
|
<PageMeta title="Edit Announcement Template - STARR" />
|
||||||
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4 py-10">
|
<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="w-full max-w-2xl mx-auto">
|
||||||
|
|
||||||
<div className="flex flex-col gap-2 mb-6">
|
<div className="flex flex-col gap-2 mb-6">
|
||||||
<AppBreadcrumb items={[
|
<AppBreadcrumb items={[
|
||||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
||||||
{ label: "Notifications", to: "/admin/notifications" },
|
{ label: "Announcements", to: "/admin/announcements" },
|
||||||
{ label: "Templates", to: "/admin/notification-templates" },
|
{ label: "Templates", to: "/admin/announcement-templates" },
|
||||||
{ label: template?.label ?? "Edit" },
|
{ label: template?.label ?? "Edit" },
|
||||||
]} />
|
]} />
|
||||||
</div>
|
</div>
|
||||||
@@ -101,14 +101,14 @@ function EditNotificationTemplateInner() {
|
|||||||
</Button>
|
</Button>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<h1 className="text-xl font-semibold">Edit Notification Template</h1>
|
<h1 className="text-xl font-semibold">Edit Announcement Template</h1>
|
||||||
{template && (
|
{template && (
|
||||||
<Badge variant="outline" className={cn("gap-1", status.badgeClass)}>
|
<Badge variant="outline" className={cn("gap-1", status.badgeClass)}>
|
||||||
<Send className="h-3 w-3" /> {status.label}
|
<Send className="h-3 w-3" /> {status.label}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">Update this notification's title and message.</p>
|
<p className="text-sm text-muted-foreground">Update this template's title and message.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export default function NotificationBroadcastList() {
|
|||||||
|
|
||||||
const items = [
|
const items = [
|
||||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
||||||
{ label: "Notifications" },
|
{ label: "Announcements" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const total = pagination?.totalRecords ?? broadcasts.length;
|
const total = pagination?.totalRecords ?? broadcasts.length;
|
||||||
@@ -68,25 +68,25 @@ export default function NotificationBroadcastList() {
|
|||||||
{/* ── Header ─────────────────────────────────────────────────── */}
|
{/* ── Header ─────────────────────────────────────────────────── */}
|
||||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">Notifications</h1>
|
<h1 className="text-2xl font-semibold tracking-tight">Announcements</h1>
|
||||||
<p className="text-sm text-muted-foreground">Compose and send announcements to admins and users</p>
|
<p className="text-sm text-muted-foreground">Compose and send announcements to admins and users</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button variant="outline" onClick={() => navigate("/admin/notification-templates")}>
|
<Button variant="outline" onClick={() => navigate("/admin/announcement-templates")}>
|
||||||
<FileText className="size-4" />
|
<FileText className="size-4" />
|
||||||
Templates
|
Templates
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" onClick={() => navigate("/admin/notifications/settings")}>
|
<Button variant="outline" onClick={() => navigate("/admin/announcements/settings")}>
|
||||||
<Settings className="size-4" />
|
<Settings className="size-4" />
|
||||||
Settings
|
Settings
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" onClick={() => navigate("/admin/notifications/archived")}>
|
<Button variant="outline" onClick={() => navigate("/admin/announcements/archived")}>
|
||||||
<Archive className="size-4" />
|
<Archive className="size-4" />
|
||||||
Archived
|
Archived
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={() => navigate("/admin/notifications/add")}>
|
<Button onClick={() => navigate("/admin/announcements/add")}>
|
||||||
<Plus className="size-4" />
|
<Plus className="size-4" />
|
||||||
New notification
|
New announcement
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -115,7 +115,7 @@ export default function NotificationBroadcastList() {
|
|||||||
<div className="relative flex-1 min-w-[160px]">
|
<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" />
|
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="Search notifications..."
|
placeholder="Search announcements..."
|
||||||
className="pl-8 bg-background"
|
className="pl-8 bg-background"
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
@@ -129,7 +129,7 @@ export default function NotificationBroadcastList() {
|
|||||||
<Spinner className="size-6" />
|
<Spinner className="size-6" />
|
||||||
</div>
|
</div>
|
||||||
) : broadcasts.length === 0 ? (
|
) : broadcasts.length === 0 ? (
|
||||||
<EmptyState onCreate={() => navigate("/admin/notifications/add")} />
|
<EmptyState onCreate={() => navigate("/admin/announcements/add")} />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
@@ -137,8 +137,8 @@ export default function NotificationBroadcastList() {
|
|||||||
<BroadcastCard
|
<BroadcastCard
|
||||||
key={b.broadcast_id}
|
key={b.broadcast_id}
|
||||||
broadcast={b}
|
broadcast={b}
|
||||||
onView={() => navigate(`/admin/notifications/${b.broadcast_id}/view`)}
|
onView={() => navigate(`/admin/announcements/${b.broadcast_id}/view`)}
|
||||||
onEdit={() => navigate(`/admin/notifications/${b.broadcast_id}/edit`)}
|
onEdit={() => navigate(`/admin/announcements/${b.broadcast_id}/edit`)}
|
||||||
onSend={() => handleSend(b.broadcast_id)}
|
onSend={() => handleSend(b.broadcast_id)}
|
||||||
onArchive={() => handleArchive(b.broadcast_id)}
|
onArchive={() => handleArchive(b.broadcast_id)}
|
||||||
/>
|
/>
|
||||||
@@ -272,12 +272,12 @@ function EmptyState({ onCreate }) {
|
|||||||
<div className="flex flex-col items-center justify-center gap-3 py-20 text-center border rounded-lg bg-background">
|
<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" />
|
<Megaphone className="size-8 text-muted-foreground" />
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium">No notifications yet</p>
|
<p className="font-medium">No announcements yet</p>
|
||||||
<p className="text-sm text-muted-foreground">Compose your first announcement to admins or users.</p>
|
<p className="text-sm text-muted-foreground">Compose your first announcement to admins or users.</p>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={onCreate}>
|
<Button onClick={onCreate}>
|
||||||
<Plus className="size-4" />
|
<Plus className="size-4" />
|
||||||
New notification
|
New announcement
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -31,14 +31,14 @@ export default function NotificationSettings() {
|
|||||||
|
|
||||||
const breadcrumbItems = [
|
const breadcrumbItems = [
|
||||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
||||||
{ label: "Notifications", to: "/admin/notifications" },
|
{ label: "Announcements", to: "/admin/announcements" },
|
||||||
{ label: "Settings" },
|
{ label: "Settings" },
|
||||||
];
|
];
|
||||||
|
|
||||||
async function fetchSettings() {
|
async function fetchSettings() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const { data } = await api.get("/admin/notification-settings");
|
const { data } = await api.get("/admin/announcement-settings");
|
||||||
setSettings(data?.data ?? []);
|
setSettings(data?.data ?? []);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast(err?.response?.data?.message ?? "Failed to load notification settings.");
|
toast(err?.response?.data?.message ?? "Failed to load notification settings.");
|
||||||
@@ -52,7 +52,7 @@ export default function NotificationSettings() {
|
|||||||
async function handleToggle(jobName, enabled) {
|
async function handleToggle(jobName, enabled) {
|
||||||
setSavingJob(jobName);
|
setSavingJob(jobName);
|
||||||
try {
|
try {
|
||||||
const { data } = await api.patch(`/admin/notification-settings/${jobName}`, {
|
const { data } = await api.patch(`/admin/announcement-settings/${jobName}`, {
|
||||||
enabled,
|
enabled,
|
||||||
updatedBy: user?.user_id ?? null,
|
updatedBy: user?.user_id ?? null,
|
||||||
});
|
});
|
||||||
@@ -71,7 +71,7 @@ export default function NotificationSettings() {
|
|||||||
async function handlePresetChange(jobName, preset) {
|
async function handlePresetChange(jobName, preset) {
|
||||||
setSavingJob(jobName);
|
setSavingJob(jobName);
|
||||||
try {
|
try {
|
||||||
const { data } = await api.patch(`/admin/notification-settings/${jobName}`, {
|
const { data } = await api.patch(`/admin/announcement-settings/${jobName}`, {
|
||||||
preset,
|
preset,
|
||||||
updatedBy: user?.user_id ?? null,
|
updatedBy: user?.user_id ?? null,
|
||||||
});
|
});
|
||||||
@@ -94,11 +94,11 @@ export default function NotificationSettings() {
|
|||||||
|
|
||||||
<div className="w-full max-w-2xl pb-10 space-y-5">
|
<div className="w-full max-w-2xl pb-10 space-y-5">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button variant="ghost" size="icon" onClick={() => navigate("/admin/notifications")} aria-label="Back">
|
<Button variant="ghost" size="icon" onClick={() => navigate("/admin/announcements")} aria-label="Back">
|
||||||
<ArrowLeft className="size-4" />
|
<ArrowLeft className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-semibold tracking-tight">Notification Settings</h1>
|
<h1 className="text-xl font-semibold tracking-tight">Announcement Settings</h1>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Toggle and reschedule automatic notifications without a deploy.
|
Toggle and reschedule automatic notifications without a deploy.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -85,21 +85,21 @@ function NotificationTemplatesInner() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="bg-muted/60 min-h-full">
|
<section className="bg-muted/60 min-h-full">
|
||||||
<PageMeta title="Notification Templates - STARR" />
|
<PageMeta title="Announcement Templates - STARR" />
|
||||||
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4 py-10">
|
<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-6xl mx-auto">
|
<div className="w-full max-w-6xl mx-auto">
|
||||||
|
|
||||||
<div className="flex flex-col gap-2 mb-6">
|
<div className="flex flex-col gap-2 mb-6">
|
||||||
<AppBreadcrumb items={[
|
<AppBreadcrumb items={[
|
||||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
||||||
{ label: "Notifications", to: "/admin/notifications" },
|
{ label: "Announcements", to: "/admin/announcements" },
|
||||||
{ label: "Templates" },
|
{ label: "Templates" },
|
||||||
]} />
|
]} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-semibold">Notification Templates</h1>
|
<h1 className="text-xl font-semibold">Announcement Templates</h1>
|
||||||
<p className="text-sm text-muted-foreground mt-0.5">
|
<p className="text-sm text-muted-foreground mt-0.5">
|
||||||
Title and message wording for every automated notification STARR sends.
|
Title and message wording for every automated notification STARR sends.
|
||||||
</p>
|
</p>
|
||||||
@@ -179,7 +179,7 @@ function NotificationTemplatesInner() {
|
|||||||
<TemplateCard
|
<TemplateCard
|
||||||
key={item.notification_template_id}
|
key={item.notification_template_id}
|
||||||
item={item}
|
item={item}
|
||||||
onEdit={(t) => navigate(`/admin/notification-templates/${t.notification_template_id}/edit`)}
|
onEdit={(t) => navigate(`/admin/announcement-templates/${t.notification_template_id}/edit`)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ export default function ViewNotificationBroadcast() {
|
|||||||
if (!broadcast) {
|
if (!broadcast) {
|
||||||
return (
|
return (
|
||||||
<div className="lg:container lg:mx-auto lg:px-6 px-4 py-6">
|
<div className="lg:container lg:mx-auto lg:px-6 px-4 py-6">
|
||||||
<p className="text-sm text-muted-foreground">Notification not found.</p>
|
<p className="text-sm text-muted-foreground">Announcement not found.</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -150,13 +150,13 @@ export default function ViewNotificationBroadcast() {
|
|||||||
>
|
>
|
||||||
<div className="lg:container lg:mx-auto lg:px-6 px-4">
|
<div className="lg:container lg:mx-auto lg:px-6 px-4">
|
||||||
<div className="flex items-center gap-3 py-3">
|
<div className="flex items-center gap-3 py-3">
|
||||||
<Button type="button" variant="ghost" size="icon" onClick={() => navigate("/admin/notifications")}>
|
<Button type="button" variant="ghost" size="icon" onClick={() => navigate("/admin/announcements")}>
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<h1 className="text-xl font-semibold leading-tight flex items-center gap-2">
|
<h1 className="text-xl font-semibold leading-tight flex items-center gap-2">
|
||||||
<Megaphone className="h-5 w-5 text-muted-foreground" />
|
<Megaphone className="h-5 w-5 text-muted-foreground" />
|
||||||
{broadcast.title || "Untitled notification"}
|
{broadcast.title || "Untitled announcement"}
|
||||||
</h1>
|
</h1>
|
||||||
<div className="flex items-center gap-1.5 mt-1">
|
<div className="flex items-center gap-1.5 mt-1">
|
||||||
<Badge variant={broadcast.status === "sent" ? "default" : "secondary"}>
|
<Badge variant={broadcast.status === "sent" ? "default" : "secondary"}>
|
||||||
@@ -180,9 +180,9 @@ export default function ViewNotificationBroadcast() {
|
|||||||
</AlertDialogTrigger>
|
</AlertDialogTrigger>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Send this notification?</AlertDialogTitle>
|
<AlertDialogTitle>Send this announcement?</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
"{broadcast.title || "This notification"}" will be delivered to {targetText?.toLowerCase() ?? broadcast.target_type} immediately. This cannot be undone.
|
"{broadcast.title || "This announcement"}" will be delivered to {targetText?.toLowerCase() ?? broadcast.target_type} immediately. This cannot be undone.
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
@@ -191,7 +191,7 @@ export default function ViewNotificationBroadcast() {
|
|||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
<Button size="sm" onClick={() => navigate(`/admin/notifications/${broadcastId}/edit`)}>
|
<Button size="sm" onClick={() => navigate(`/admin/announcements/${broadcastId}/edit`)}>
|
||||||
<Edit className="size-4" />
|
<Edit className="size-4" />
|
||||||
Edit
|
Edit
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb"
|
||||||
|
import { House, FileText, List, ShieldCheck } from "lucide-react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
|
||||||
|
export default function ResourceList() {
|
||||||
|
const items = [
|
||||||
|
{ label: "Home", icon: <House className="size-4" />, to: `/admin` },
|
||||||
|
{ label: "Resources" },
|
||||||
|
]
|
||||||
|
|
||||||
|
const handleNavigate = useNavigate();
|
||||||
|
|
||||||
|
return (
|
||||||
|
|
||||||
|
<div className="flex lg:items-center lg:container lg:mx-auto flex-col xs:px-6 lg:px-0">
|
||||||
|
|
||||||
|
<div className="max-w-lg h-full w-full lg:mt-4 space-y-4">
|
||||||
|
<div className="flex flex-col gap-2 mt-6">
|
||||||
|
<AppBreadcrumb items={items} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* This page will have tiles to redirect for Assets and Tier Plans (at the moment) */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h1 className="text-2xl font-medium">Resources</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">Manage content..</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid xs:grid-cols-1 sm:grid-cols-2 h-fit w-full gap-4">
|
||||||
|
<motion.div
|
||||||
|
onClick={(e) => handleNavigate('/admin/assets')}
|
||||||
|
className="group bg-card hover:bg-primary border rounded-lg relative overflow-hidden flex flex-col h-54 cursor-pointer transition-colors duration-200 ease-out"
|
||||||
|
whileHover={{ y: -6, scale: 1.02 }}
|
||||||
|
transition={{
|
||||||
|
y: { type: "spring", stiffness: 300, damping: 20 },
|
||||||
|
scale: { type: "spring", stiffness: 300, damping: 20 },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
className="w-full flex justify-end"
|
||||||
|
whileHover={{ rotate: -18, scale: 1.05 }}
|
||||||
|
transition={{ type: "spring", stiffness: 200 }}
|
||||||
|
>
|
||||||
|
<FileText className="size-32 opacity-50 -rotate-24 text-blue-500 transition-colors duration-200 group-hover:text-white group-hover:opacity-100" />
|
||||||
|
</motion.div>
|
||||||
|
<motion.div
|
||||||
|
className="p-4 font-medium mt-auto text-foreground transition-colors duration-200 ease-out group-hover:text-white"
|
||||||
|
whileHover={{ y: -2 }}
|
||||||
|
>
|
||||||
|
Assets
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
onClick={(e) => handleNavigate('/admin/tiers/plans')}
|
||||||
|
className="group bg-card hover:bg-primary border rounded-lg relative overflow-hidden flex flex-col h-54 cursor-pointer transition-colors duration-200 ease-out"
|
||||||
|
whileHover={{ y: -6, scale: 1.02 }}
|
||||||
|
transition={{
|
||||||
|
y: { type: "spring", stiffness: 300, damping: 20 },
|
||||||
|
scale: { type: "spring", stiffness: 300, damping: 20 },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
className="w-full flex justify-end"
|
||||||
|
whileHover={{ rotate: -18, scale: 1.05 }}
|
||||||
|
transition={{ type: "spring", stiffness: 200 }}
|
||||||
|
>
|
||||||
|
<List className="size-32 opacity-50 -rotate-24 text-blue-500 transition-colors duration-200 group-hover:text-white group-hover:opacity-100" />
|
||||||
|
</motion.div>
|
||||||
|
<motion.div
|
||||||
|
className="p-4 font-medium mt-auto text-foreground transition-colors duration-200 ease-out group-hover:text-white"
|
||||||
|
whileHover={{ y: -2 }}
|
||||||
|
>
|
||||||
|
Tier Plans
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import { Textarea } from '@/components/ui/textarea';
|
|||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
import { ArrowLeft, ChevronRight, ChevronLeft, Check, FileText, ListChecks, ClipboardList, Link as LinkIcon, Clock } from 'lucide-react';
|
import { ArrowLeft, ChevronRight, ChevronLeft, Check, FileText, ListChecks, ClipboardList, Link as LinkIcon, Clock } from 'lucide-react';
|
||||||
import DeadlinePicker from '@/components/generic/DeadlinePicker';
|
import DeadlinePicker from '@/components/generic/DeadlinePicker';
|
||||||
|
|
||||||
@@ -38,24 +39,27 @@ function SummaryRow({ label, value }) {
|
|||||||
export default function CreateTask() {
|
export default function CreateTask() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { taskListId } = useParams();
|
const { taskListId } = useParams();
|
||||||
const { createTask, fetchCoursesFlat, fetchUnitsFlat, fetchLessonsFlat, loading } = useAdminTask();
|
const { createTask, fetchCoursesFlat, fetchUnitsFlat, fetchLessonsFlat, fetchQuizzesFlat, loading } = useAdminTask();
|
||||||
|
|
||||||
const [step, setStep] = useState(0);
|
const [step, setStep] = useState(0);
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
name: '',
|
name: '',
|
||||||
description: '',
|
description: '',
|
||||||
deadline: '',
|
deadline: '',
|
||||||
|
is_required: true,
|
||||||
requirements: [],
|
requirements: [],
|
||||||
});
|
});
|
||||||
const [errors, setErrors] = useState({});
|
const [errors, setErrors] = useState({});
|
||||||
const [courses, setCourses] = useState([]);
|
const [courses, setCourses] = useState([]);
|
||||||
const [units, setUnits] = useState([]);
|
const [units, setUnits] = useState([]);
|
||||||
const [lessons, setLessons] = useState([]);
|
const [lessons, setLessons] = useState([]);
|
||||||
|
const [quizzes, setQuizzes] = useState([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchCoursesFlat().then((d) => d && setCourses(d));
|
fetchCoursesFlat().then((d) => d && setCourses(d));
|
||||||
fetchUnitsFlat().then((d) => d && setUnits(d));
|
fetchUnitsFlat().then((d) => d && setUnits(d));
|
||||||
fetchLessonsFlat().then((d) => d && setLessons(d));
|
fetchLessonsFlat().then((d) => d && setLessons(d));
|
||||||
|
fetchQuizzesFlat().then((d) => d && setQuizzes(d));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const validateStep = (s) => {
|
const validateStep = (s) => {
|
||||||
@@ -101,6 +105,7 @@ export default function CreateTask() {
|
|||||||
name: form.name.trim(),
|
name: form.name.trim(),
|
||||||
description: form.description.trim() || null,
|
description: form.description.trim() || null,
|
||||||
deadline: form.deadline || null,
|
deadline: form.deadline || null,
|
||||||
|
is_required: form.is_required,
|
||||||
// use result.data — it carries the schema's transforms (e.g. link_url scheme defaulting)
|
// use result.data — it carries the schema's transforms (e.g. link_url scheme defaulting)
|
||||||
// strip duration_seconds — it's only used for local validation
|
// strip duration_seconds — it's only used for local validation
|
||||||
requirements: result.data.requirements.map((r) => {
|
requirements: result.data.requirements.map((r) => {
|
||||||
@@ -203,6 +208,19 @@ export default function CreateTask() {
|
|||||||
disabled={loading}
|
disabled={loading}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between rounded-md border px-3 py-2.5">
|
||||||
|
<div>
|
||||||
|
<Label>Required</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Later tasks in this list stay locked until this one is complete. Turn off for optional tasks.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={form.is_required}
|
||||||
|
onCheckedChange={(v) => setForm({ ...form, is_required: v })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
@@ -223,6 +241,7 @@ export default function CreateTask() {
|
|||||||
courses={courses}
|
courses={courses}
|
||||||
units={units}
|
units={units}
|
||||||
lessons={lessons}
|
lessons={lessons}
|
||||||
|
quizzes={quizzes}
|
||||||
/>
|
/>
|
||||||
{errors.requirements && (
|
{errors.requirements && (
|
||||||
<p className="text-xs text-destructive pt-1">{errors.requirements}</p>
|
<p className="text-xs text-destructive pt-1">{errors.requirements}</p>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { Textarea } from '@/components/ui/textarea';
|
|||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import {
|
import {
|
||||||
AlertDialog, AlertDialogAction, AlertDialogCancel,
|
AlertDialog, AlertDialogAction, AlertDialogCancel,
|
||||||
@@ -31,13 +32,14 @@ const STATUS_OPTIONS = [
|
|||||||
export default function EditTask() {
|
export default function EditTask() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { taskListId, taskId } = useParams();
|
const { taskListId, taskId } = useParams();
|
||||||
const { fetchTask, updateTask, fetchCoursesFlat, fetchUnitsFlat, fetchLessonsFlat, loading } = useAdminTask();
|
const { fetchTask, updateTask, fetchCoursesFlat, fetchUnitsFlat, fetchLessonsFlat, fetchQuizzesFlat, loading } = useAdminTask();
|
||||||
|
|
||||||
const [form, setForm] = useState(null);
|
const [form, setForm] = useState(null);
|
||||||
const [errors, setErrors] = useState({});
|
const [errors, setErrors] = useState({});
|
||||||
const [courses, setCourses] = useState([]);
|
const [courses, setCourses] = useState([]);
|
||||||
const [units, setUnits] = useState([]);
|
const [units, setUnits] = useState([]);
|
||||||
const [lessons, setLessons] = useState([]);
|
const [lessons, setLessons] = useState([]);
|
||||||
|
const [quizzes, setQuizzes] = useState([]);
|
||||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||||
const initialRequirementsRef = useRef(null);
|
const initialRequirementsRef = useRef(null);
|
||||||
|
|
||||||
@@ -53,12 +55,14 @@ export default function EditTask() {
|
|||||||
? new Date(data.deadline).toISOString().slice(0, 16)
|
? new Date(data.deadline).toISOString().slice(0, 16)
|
||||||
: '',
|
: '',
|
||||||
status: data.status ?? 'pending',
|
status: data.status ?? 'pending',
|
||||||
|
is_required: data.is_required ?? true,
|
||||||
requirements: reqs,
|
requirements: reqs,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
fetchCoursesFlat().then((d) => d && setCourses(d));
|
fetchCoursesFlat().then((d) => d && setCourses(d));
|
||||||
fetchUnitsFlat().then((d) => d && setUnits(d));
|
fetchUnitsFlat().then((d) => d && setUnits(d));
|
||||||
fetchLessonsFlat().then((d) => d && setLessons(d));
|
fetchLessonsFlat().then((d) => d && setLessons(d));
|
||||||
|
fetchQuizzesFlat().then((d) => d && setQuizzes(d));
|
||||||
}, [taskListId, taskId]);
|
}, [taskListId, taskId]);
|
||||||
|
|
||||||
const requirementsChanged = () =>
|
const requirementsChanged = () =>
|
||||||
@@ -95,6 +99,7 @@ export default function EditTask() {
|
|||||||
description: form.description.trim() || null,
|
description: form.description.trim() || null,
|
||||||
deadline: form.deadline || null,
|
deadline: form.deadline || null,
|
||||||
status: form.status,
|
status: form.status,
|
||||||
|
is_required: form.is_required,
|
||||||
requirements,
|
requirements,
|
||||||
});
|
});
|
||||||
if (updated) navigate(`/admin/taskList/${taskListId}/tasks`);
|
if (updated) navigate(`/admin/taskList/${taskListId}/tasks`);
|
||||||
@@ -207,6 +212,19 @@ export default function EditTask() {
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between rounded-md border px-3 py-2.5">
|
||||||
|
<div>
|
||||||
|
<Label>Required</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Later tasks in this list stay locked until this one is complete. Turn off for optional tasks.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={!!form.is_required}
|
||||||
|
onCheckedChange={(v) => setForm({ ...form, is_required: v })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -222,6 +240,7 @@ export default function EditTask() {
|
|||||||
courses={courses}
|
courses={courses}
|
||||||
units={units}
|
units={units}
|
||||||
lessons={lessons}
|
lessons={lessons}
|
||||||
|
quizzes={quizzes}
|
||||||
/>
|
/>
|
||||||
{errors.requirements && (
|
{errors.requirements && (
|
||||||
<p className="text-xs text-destructive pt-1">{errors.requirements}</p>
|
<p className="text-xs text-destructive pt-1">{errors.requirements}</p>
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { useState, useEffect, useMemo } from 'react';
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
import { Plus, Trash2, GripVertical, Link, Upload, BookOpen, Layers, FileText, ChevronsUpDown, Tag, Lock, Clock, Search, AlertTriangle } from 'lucide-react';
|
import { Plus, Trash2, GripVertical, Link, Upload, BookOpen, Layers, FileText, ChevronsUpDown, Tag, Lock, Clock, Search, AlertTriangle, PenLine, ClipboardCheck, ShieldCheck } from 'lucide-react';
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
@@ -16,9 +18,11 @@ import api from '@/utils/api.util';
|
|||||||
const REQUIREMENT_TYPES = [
|
const REQUIREMENT_TYPES = [
|
||||||
{ value: 'visit_link', label: 'Visit a Link', icon: Link, category: 'Action' },
|
{ value: 'visit_link', label: 'Visit a Link', icon: Link, category: 'Action' },
|
||||||
{ value: 'upload_file', label: 'Upload a File', icon: Upload, category: 'Action' },
|
{ value: 'upload_file', label: 'Upload a File', icon: Upload, category: 'Action' },
|
||||||
|
{ value: 'submit_text', label: 'Submit a Response', icon: PenLine, category: 'Action' },
|
||||||
{ value: 'read_course', label: 'Read a Course', icon: BookOpen, category: 'Content' },
|
{ value: 'read_course', label: 'Read a Course', icon: BookOpen, category: 'Content' },
|
||||||
{ value: 'read_unit', label: 'Read a Unit', icon: Layers, category: 'Content' },
|
{ value: 'read_unit', label: 'Read a Unit', icon: Layers, category: 'Content' },
|
||||||
{ value: 'read_lesson', label: 'Read a Lesson', icon: FileText, category: 'Content' },
|
{ value: 'read_lesson', label: 'Read a Lesson', icon: FileText, category: 'Content' },
|
||||||
|
{ value: 'pass_quiz', label: 'Pass a Quiz', icon: ClipboardCheck, category: 'Content' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const TYPE_MAP = Object.fromEntries(REQUIREMENT_TYPES.map((t) => [t.value, t]));
|
const TYPE_MAP = Object.fromEntries(REQUIREMENT_TYPES.map((t) => [t.value, t]));
|
||||||
@@ -134,11 +138,13 @@ function createRequirement(type = 'visit_link') {
|
|||||||
max_file_count: 1,
|
max_file_count: 1,
|
||||||
reference_id: '',
|
reference_id: '',
|
||||||
reference_label: '',
|
reference_label: '',
|
||||||
|
prompt: '',
|
||||||
|
requires_review: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── RequirementBuilder ───────────────────────────────────────────────────────
|
// ─── RequirementBuilder ───────────────────────────────────────────────────────
|
||||||
export default function RequirementBuilder({ value = [], onChange, courses = [], units = [], lessons = [] }) {
|
export default function RequirementBuilder({ value = [], onChange, courses = [], units = [], lessons = [], quizzes = [] }) {
|
||||||
const [items, setItems] = useState(
|
const [items, setItems] = useState(
|
||||||
value.length > 0
|
value.length > 0
|
||||||
? value.map((r) => ({ _key: crypto.randomUUID(), ...r }))
|
? value.map((r) => ({ _key: crypto.randomUUID(), ...r }))
|
||||||
@@ -302,6 +308,37 @@ export default function RequirementBuilder({ value = [], onChange, courses = [],
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ── submit_text fields ── */}
|
||||||
|
{item.type === 'submit_text' && (
|
||||||
|
<div className="pl-7 space-y-1">
|
||||||
|
<Label className="text-xs">Prompt / Instructions</Label>
|
||||||
|
<Textarea
|
||||||
|
placeholder="What should the learner write about?"
|
||||||
|
rows={3}
|
||||||
|
value={item.prompt}
|
||||||
|
onChange={(e) => updateItem(item._key, { prompt: e.target.value })}
|
||||||
|
className="text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── requires_review toggle (upload_file / submit_text) ── */}
|
||||||
|
{['upload_file', 'submit_text'].includes(item.type) && (
|
||||||
|
<div className="pl-7 flex items-center justify-between rounded-md border px-3 py-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ShieldCheck className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">Requires Admin Review</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">Submission only counts as complete once approved.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={!!item.requires_review}
|
||||||
|
onCheckedChange={(v) => updateItem(item._key, { requires_review: v })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ── read_course / read_unit / read_lesson fields ── */}
|
{/* ── read_course / read_unit / read_lesson fields ── */}
|
||||||
{['read_course', 'read_unit', 'read_lesson'].includes(item.type) && (
|
{['read_course', 'read_unit', 'read_lesson'].includes(item.type) && (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
@@ -444,6 +481,52 @@ export default function RequirementBuilder({ value = [], onChange, courses = [],
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ── pass_quiz fields ── */}
|
||||||
|
{item.type === 'pass_quiz' && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">Quiz</Label>
|
||||||
|
<ContentPicker
|
||||||
|
value={item.reference_id}
|
||||||
|
options={quizzes}
|
||||||
|
idKey="uuid"
|
||||||
|
labelKey="title"
|
||||||
|
searchKey="_search"
|
||||||
|
placeholder="Select a quiz"
|
||||||
|
onSelect={(q) => handleContentSelect(item._key, q, 'quiz')}
|
||||||
|
renderTrigger={(q) => (
|
||||||
|
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||||
|
<TierBadge subscription={q.subscription} tierMap={tierMap} />
|
||||||
|
<div className="flex flex-col items-start flex-1 min-w-0">
|
||||||
|
<span className="text-xs leading-tight text-muted-foreground truncate">
|
||||||
|
{q.course_title ? `${q.course_title} | ` : ''}{q.unit_title}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm leading-tight truncate">{q.title}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
renderItem={(q) => (
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2">
|
||||||
|
<TierBadge subscription={q.subscription} tierMap={tierMap} />
|
||||||
|
<div className="flex flex-col flex-1 min-w-0">
|
||||||
|
<span className="text-sm leading-tight text-muted-foreground truncate">
|
||||||
|
{q.course_title ? `${q.course_title} | ` : ''}{q.unit_title}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm leading-tight truncate">{q.title}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* ── inline no-content error ── */}
|
||||||
|
{item.reference_id && (item.duration_seconds ?? -1) === 0 && (
|
||||||
|
<p className="flex items-center gap-1.5 text-xs text-destructive pl-7 pt-1">
|
||||||
|
<AlertTriangle className="size-3 shrink-0" />
|
||||||
|
This quiz has no questions yet.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,7 +13,13 @@ import { FilterSheet } from '@/components/generic/Sheet/FilterSheet';
|
|||||||
import { ArchiveDialog } from '@/components/generic/Dialogs/ArchiveDialog';
|
import { ArchiveDialog } from '@/components/generic/Dialogs/ArchiveDialog';
|
||||||
import { RestoreDialog } from '@/components/generic/Dialogs/RestoreDialog';
|
import { RestoreDialog } from '@/components/generic/Dialogs/RestoreDialog';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { House, NotebookPen, Users, Paperclip } from 'lucide-react';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import {
|
||||||
|
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
|
||||||
|
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
|
||||||
|
} from '@/components/ui/alert-dialog';
|
||||||
|
import { House, NotebookPen, Users, Paperclip, Check, X } from 'lucide-react';
|
||||||
import AppBreadcrumb from '@/components/generic/Breadcrumb/AppBreadcrumb';
|
import AppBreadcrumb from '@/components/generic/Breadcrumb/AppBreadcrumb';
|
||||||
import { formatDate } from '@/utils/table.util';
|
import { formatDate } from '@/utils/table.util';
|
||||||
import { getTimestamp } from '@/utils/timestamp.util';
|
import { getTimestamp } from '@/utils/timestamp.util';
|
||||||
@@ -46,7 +52,7 @@ export default function TaskCompletions() {
|
|||||||
completions, completionPagination, setCompletionPagination, completionLoading,
|
completions, completionPagination, setCompletionPagination, completionLoading,
|
||||||
completionAttributes,
|
completionAttributes,
|
||||||
fetchTask, fetchTaskList,
|
fetchTask, fetchTaskList,
|
||||||
fetchCompletions,
|
fetchCompletions, reviewSubmission,
|
||||||
archiveCompletion, restoreCompletion,
|
archiveCompletion, restoreCompletion,
|
||||||
bulkArchiveCompletions, bulkRestoreCompletions,
|
bulkArchiveCompletions, bulkRestoreCompletions,
|
||||||
} = useAdminTask();
|
} = useAdminTask();
|
||||||
@@ -56,6 +62,8 @@ export default function TaskCompletions() {
|
|||||||
const [restoreTarget, setRestoreTarget] = useState(null);
|
const [restoreTarget, setRestoreTarget] = useState(null);
|
||||||
const [bulkArchiveIds, setBulkArchiveIds] = useState(null);
|
const [bulkArchiveIds, setBulkArchiveIds] = useState(null);
|
||||||
const [bulkRestoreIds, setBulkRestoreIds] = useState(null);
|
const [bulkRestoreIds, setBulkRestoreIds] = useState(null);
|
||||||
|
const [reviewTarget, setReviewTarget] = useState(null);
|
||||||
|
const [reviewNote, setReviewNote] = useState('');
|
||||||
|
|
||||||
const tableRefsRef = useRef({
|
const tableRefsRef = useRef({
|
||||||
getFilters: () => [], getSort: () => [], resetSelection: () => {}, tableInstance: null,
|
getFilters: () => [], getSort: () => [], resetSelection: () => {}, tableInstance: null,
|
||||||
@@ -118,9 +126,21 @@ export default function TaskCompletions() {
|
|||||||
navigate,
|
navigate,
|
||||||
onArchive: (row) => setArchiveTarget(row),
|
onArchive: (row) => setArchiveTarget(row),
|
||||||
onRestore: (row) => setRestoreTarget(row),
|
onRestore: (row) => setRestoreTarget(row),
|
||||||
|
onReview: (row) => { setReviewTarget(row); setReviewNote(''); },
|
||||||
showArchived,
|
showArchived,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const handleReview = async (status) => {
|
||||||
|
if (!reviewTarget) return;
|
||||||
|
const result = await reviewSubmission(taskListId, taskId, reviewTarget.completion_id, {
|
||||||
|
status, review_note: reviewNote || null,
|
||||||
|
});
|
||||||
|
if (result) {
|
||||||
|
setReviewTarget(null);
|
||||||
|
afterMutation();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const toolbarActions = buildToolbarActions({
|
const toolbarActions = buildToolbarActions({
|
||||||
fetchCompletions,
|
fetchCompletions,
|
||||||
taskListId,
|
taskListId,
|
||||||
@@ -262,6 +282,44 @@ export default function TaskCompletions() {
|
|||||||
onSuccess={afterMutation}
|
onSuccess={afterMutation}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* ── Review submission ────────────────────────────────────────────── */}
|
||||||
|
<AlertDialog open={!!reviewTarget} onOpenChange={(v) => !v && setReviewTarget(null)}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Review Submission</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{reviewTarget?.user?.name ?? 'This learner'}'s submission requires review before it counts as complete.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
{reviewTarget?.note && (
|
||||||
|
<p className="text-sm text-muted-foreground border rounded-md p-3 bg-muted/40">{reviewTarget.note}</p>
|
||||||
|
)}
|
||||||
|
{reviewTarget?.response_text && (
|
||||||
|
<p className="text-sm border rounded-md p-3 whitespace-pre-wrap">{reviewTarget.response_text}</p>
|
||||||
|
)}
|
||||||
|
<Textarea
|
||||||
|
placeholder="Optional note for the learner..."
|
||||||
|
value={reviewNote}
|
||||||
|
onChange={(e) => setReviewNote(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel disabled={completionLoading}>Cancel</AlertDialogCancel>
|
||||||
|
<Button
|
||||||
|
type="button" variant="outline"
|
||||||
|
className="text-destructive border-destructive/50 hover:bg-destructive/5"
|
||||||
|
disabled={completionLoading}
|
||||||
|
onClick={() => handleReview('rejected')}
|
||||||
|
>
|
||||||
|
<X className="size-4 mr-1.5" /> Reject
|
||||||
|
</Button>
|
||||||
|
<AlertDialogAction disabled={completionLoading} onClick={() => handleReview('approved')}>
|
||||||
|
<Check className="size-4 mr-1.5" /> Approve
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -40,7 +40,7 @@ export default function Tasks() {
|
|||||||
taskList, tasks, attributes, pagination, loading,
|
taskList, tasks, attributes, pagination, loading,
|
||||||
fetchTaskList, fetchTasks, fetchArchivedTasks,
|
fetchTaskList, fetchTasks, fetchArchivedTasks,
|
||||||
archiveTask, restoreTask, fetchTaskFieldValues,
|
archiveTask, restoreTask, fetchTaskFieldValues,
|
||||||
bulkArchiveTasks, bulkRestoreTasks,
|
bulkArchiveTasks, bulkRestoreTasks, reorderTasks,
|
||||||
} = useAdminTask();
|
} = useAdminTask();
|
||||||
|
|
||||||
const [showArchived, setShowArchived] = useState(false);
|
const [showArchived, setShowArchived] = useState(false);
|
||||||
@@ -96,11 +96,28 @@ export default function Tasks() {
|
|||||||
sheetName: "Tasks",
|
sheetName: "Tasks",
|
||||||
}), [tasks, attributes]);
|
}), [tasks, attributes]);
|
||||||
|
|
||||||
|
// ── Reorder — swaps this row with its neighbor in the currently displayed
|
||||||
|
// (unfiltered/default-sorted) list, then persists the new order_index set.
|
||||||
|
const handleMove = async (row, direction) => {
|
||||||
|
const idx = tasks.findIndex((t) => t.task_id === row.task_id);
|
||||||
|
const swapIdx = idx + direction;
|
||||||
|
if (idx < 0 || swapIdx < 0 || swapIdx >= tasks.length) return;
|
||||||
|
|
||||||
|
const reordered = [...tasks];
|
||||||
|
[reordered[idx], reordered[swapIdx]] = [reordered[swapIdx], reordered[idx]];
|
||||||
|
|
||||||
|
const ok = await reorderTasks(taskListId, reordered.map((t) => t.task_id));
|
||||||
|
if (ok) afterMutation();
|
||||||
|
};
|
||||||
|
|
||||||
const rowActions = buildRowActions({
|
const rowActions = buildRowActions({
|
||||||
navigate,
|
navigate,
|
||||||
onArchive: (row) => setArchiveTarget(row),
|
onArchive: (row) => setArchiveTarget(row),
|
||||||
onRestore: (row) => setRestoreTarget(row),
|
onRestore: (row) => setRestoreTarget(row),
|
||||||
|
onMoveUp: (row) => handleMove(row, -1),
|
||||||
|
onMoveDown: (row) => handleMove(row, 1),
|
||||||
showArchived,
|
showArchived,
|
||||||
|
tasks,
|
||||||
});
|
});
|
||||||
|
|
||||||
const toolbarActions = buildToolbarActions({
|
const toolbarActions = buildToolbarActions({
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { FileText, Link as LinkIcon, Upload, BookOpen, Layers } from 'lucide-react';
|
import { FileText, Link as LinkIcon, Upload, BookOpen, Layers, PenLine, ClipboardCheck } from 'lucide-react';
|
||||||
|
|
||||||
// ── Requirement validation ────────────────────────────────────────────────────
|
// ── Requirement validation ────────────────────────────────────────────────────
|
||||||
const READ_TYPES = ['read_course', 'read_unit', 'read_lesson'];
|
// pass_quiz reuses the same "picked a reference, and it has content" check as
|
||||||
|
// the read_* types — its duration_seconds slot carries question_count instead.
|
||||||
|
const READ_TYPES = ['read_course', 'read_unit', 'read_lesson', 'pass_quiz'];
|
||||||
|
|
||||||
// Users commonly type bare domains ("google.com") — default the scheme to https
|
// Users commonly type bare domains ("google.com") — default the scheme to https
|
||||||
// so the link is actually clickable/navigable once the task is saved.
|
// so the link is actually clickable/navigable once the task is saved.
|
||||||
@@ -38,16 +40,21 @@ export const taskSchema = z.object({
|
|||||||
export const REQUIREMENT_TYPE_META = {
|
export const REQUIREMENT_TYPE_META = {
|
||||||
visit_link: { label: 'Visit a Link', icon: LinkIcon },
|
visit_link: { label: 'Visit a Link', icon: LinkIcon },
|
||||||
upload_file: { label: 'Upload a File', icon: Upload },
|
upload_file: { label: 'Upload a File', icon: Upload },
|
||||||
|
submit_text: { label: 'Submit a Response', icon: PenLine },
|
||||||
read_course: { label: 'Read a Course', icon: BookOpen },
|
read_course: { label: 'Read a Course', icon: BookOpen },
|
||||||
read_unit: { label: 'Read a Unit', icon: Layers },
|
read_unit: { label: 'Read a Unit', icon: Layers },
|
||||||
read_lesson: { label: 'Read a Lesson', icon: FileText },
|
read_lesson: { label: 'Read a Lesson', icon: FileText },
|
||||||
|
pass_quiz: { label: 'Pass a Quiz', icon: ClipboardCheck },
|
||||||
};
|
};
|
||||||
|
|
||||||
export function requirementSummaryText(req) {
|
export function requirementSummaryText(req) {
|
||||||
if (req.type === 'visit_link') return req.link_url || '—';
|
if (req.type === 'visit_link') return req.link_url || '—';
|
||||||
if (req.type === 'upload_file') {
|
if (req.type === 'upload_file') {
|
||||||
const types = (req.allowed_file_types ?? []).join(', ').toUpperCase();
|
const types = (req.allowed_file_types ?? []).join(', ').toUpperCase();
|
||||||
return `${types || 'Any type'} · max ${req.max_file_count ?? 1} file(s)`;
|
return `${types || 'Any type'} · max ${req.max_file_count ?? 1} file(s)${req.requires_review ? ' · reviewed' : ''}`;
|
||||||
|
}
|
||||||
|
if (req.type === 'submit_text') {
|
||||||
|
return `${req.prompt ? req.prompt.slice(0, 60) : 'Free-text response'}${req.requires_review ? ' · reviewed' : ''}`;
|
||||||
}
|
}
|
||||||
return req.reference_label || '—';
|
return req.reference_label || '—';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useEffect, useState, useMemo } from "react";
|
import { useEffect, useState, useMemo } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm, useFieldArray } from "react-hook-form";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { ArrowLeft, ArrowRight, Check, House } from "lucide-react";
|
import { ArrowLeft, ArrowRight, Check, House, Plus, Trash2 } from "lucide-react";
|
||||||
import { useTiers } from "@/contexts/AdminTiersContext";
|
import { useTiers } from "@/contexts/AdminTiersContext";
|
||||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -38,6 +38,7 @@ const schema = z.object({
|
|||||||
tier_category_id: z.string().min(1, "Tier category is required."),
|
tier_category_id: z.string().min(1, "Tier category is required."),
|
||||||
label: z.string().min(1, "Label is required."),
|
label: z.string().min(1, "Label is required."),
|
||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
|
features: z.array(z.object({ text: z.string().min(1, "Feature cannot be empty.") })).default([]),
|
||||||
duration_value: z.coerce.number().min(1, "Duration must be at least 1."),
|
duration_value: z.coerce.number().min(1, "Duration must be at least 1."),
|
||||||
duration_unit: z.string().min(1),
|
duration_unit: z.string().min(1),
|
||||||
price: z.coerce.number().min(0.01, "Price must be greater than 0."),
|
price: z.coerce.number().min(0.01, "Price must be greater than 0."),
|
||||||
@@ -153,11 +154,14 @@ export default function AddPlan() {
|
|||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const { register, handleSubmit, trigger, setValue, watch, formState: { errors } } = useForm({
|
const { register, handleSubmit, trigger, setValue, watch, control, formState: { errors } } = useForm({
|
||||||
resolver: zodResolver(schema),
|
resolver: zodResolver(schema),
|
||||||
defaultValues: { tier_category_id: "", label: "", description: "", duration_value: 30, duration_unit: "day", price: "", currency: "USD" },
|
defaultValues: { tier_category_id: "", label: "", description: "", features: [], duration_value: 30, duration_unit: "day", price: "", currency: "USD" },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { fields: featureFields, append: appendFeature, remove: removeFeature } =
|
||||||
|
useFieldArray({ control, name: "features" });
|
||||||
|
|
||||||
const selectedCategoryId = watch("tier_category_id");
|
const selectedCategoryId = watch("tier_category_id");
|
||||||
|
|
||||||
// Derive the subscription slug from the chosen category
|
// Derive the subscription slug from the chosen category
|
||||||
@@ -173,7 +177,7 @@ export default function AddPlan() {
|
|||||||
}, [categorySlug]);
|
}, [categorySlug]);
|
||||||
|
|
||||||
const STEP_FIELDS = [
|
const STEP_FIELDS = [
|
||||||
["tier_category_id", "label", "description"],
|
["tier_category_id", "label", "description", "features"],
|
||||||
["duration_value", "duration_unit", "price", "currency"],
|
["duration_value", "duration_unit", "price", "currency"],
|
||||||
[],
|
[],
|
||||||
];
|
];
|
||||||
@@ -286,6 +290,43 @@ export default function AddPlan() {
|
|||||||
/>
|
/>
|
||||||
<FieldError message={errors.description?.message} />
|
<FieldError message={errors.description?.message} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>What's included</Label>
|
||||||
|
<p className="text-xs text-muted-foreground -mt-1">
|
||||||
|
Bullet points shown on the plans page and the comparison table.
|
||||||
|
</p>
|
||||||
|
{featureFields.map((field, index) => (
|
||||||
|
<div key={field.id} className="flex items-start gap-2">
|
||||||
|
<div className="flex-1 space-y-1">
|
||||||
|
<Input
|
||||||
|
placeholder={`e.g. Access to all Premium courses`}
|
||||||
|
{...register(`features.${index}.text`)}
|
||||||
|
/>
|
||||||
|
<FieldError message={errors.features?.[index]?.text?.message} />
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="mt-0.5 text-muted-foreground hover:text-destructive"
|
||||||
|
onClick={() => removeFeature(index)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => appendFeature({ text: "" })}
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
Add Feature
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm, useFieldArray } from "react-hook-form";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { ArrowLeft, House, TriangleAlert } from "lucide-react";
|
import { ArrowLeft, House, TriangleAlert, Plus, Trash2 } from "lucide-react";
|
||||||
import { useTiers } from "@/contexts/AdminTiersContext";
|
import { useTiers } from "@/contexts/AdminTiersContext";
|
||||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -45,6 +45,7 @@ const DURATION_UNIT_LIMITS = {
|
|||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
label: z.string().min(1, "Label is required."),
|
label: z.string().min(1, "Label is required."),
|
||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
|
features: z.array(z.object({ text: z.string().min(1, "Feature cannot be empty.") })).default([]),
|
||||||
duration_value: z.coerce.number().min(1, "Duration must be at least 1."),
|
duration_value: z.coerce.number().min(1, "Duration must be at least 1."),
|
||||||
duration_unit: z.string().min(1),
|
duration_unit: z.string().min(1),
|
||||||
price: z.coerce.number().min(0.01, "Price must be greater than 0."),
|
price: z.coerce.number().min(0.01, "Price must be greater than 0."),
|
||||||
@@ -91,10 +92,13 @@ export default function EditPlan() {
|
|||||||
const [impactLoading, setImpactLoading] = useState(false);
|
const [impactLoading, setImpactLoading] = useState(false);
|
||||||
const [pendingValues, setPendingValues] = useState(null);
|
const [pendingValues, setPendingValues] = useState(null);
|
||||||
|
|
||||||
const { register, handleSubmit, setValue, watch, reset, formState: { errors } } = useForm({
|
const { register, handleSubmit, setValue, watch, reset, control, formState: { errors } } = useForm({
|
||||||
resolver: zodResolver(schema),
|
resolver: zodResolver(schema),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { fields: featureFields, append: appendFeature, remove: removeFeature } =
|
||||||
|
useFieldArray({ control, name: "features" });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchPlan(planId);
|
fetchPlan(planId);
|
||||||
api.get("/admin/tiers/currencies")
|
api.get("/admin/tiers/currencies")
|
||||||
@@ -108,6 +112,7 @@ export default function EditPlan() {
|
|||||||
reset({
|
reset({
|
||||||
label: plan.label,
|
label: plan.label,
|
||||||
description: plan.description ?? "",
|
description: plan.description ?? "",
|
||||||
|
features: (plan.features ?? []).map((f) => (typeof f === "string" ? { text: f } : f)),
|
||||||
duration_value: durationDaysToValue(plan.duration_days, unit),
|
duration_value: durationDaysToValue(plan.duration_days, unit),
|
||||||
duration_unit: unit,
|
duration_unit: unit,
|
||||||
price: plan.price,
|
price: plan.price,
|
||||||
@@ -223,6 +228,43 @@ export default function EditPlan() {
|
|||||||
<FieldError message={errors.description?.message} />
|
<FieldError message={errors.description?.message} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>What's included</Label>
|
||||||
|
<p className="text-xs text-muted-foreground -mt-1">
|
||||||
|
Bullet points shown on the plans page and the comparison table.
|
||||||
|
</p>
|
||||||
|
{featureFields.map((field, index) => (
|
||||||
|
<div key={field.id} className="flex items-start gap-2">
|
||||||
|
<div className="flex-1 space-y-1">
|
||||||
|
<Input
|
||||||
|
placeholder="e.g. Access to all Premium courses"
|
||||||
|
{...register(`features.${index}.text`)}
|
||||||
|
/>
|
||||||
|
<FieldError message={errors.features?.[index]?.text?.message} />
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="mt-0.5 text-muted-foreground hover:text-destructive"
|
||||||
|
onClick={() => removeFeature(index)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => appendFeature({ text: "" })}
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
Add Feature
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label>Duration</Label>
|
<Label>Duration</Label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useEffect, useState, useMemo } from "react";
|
|||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
ArrowLeft, CreditCard, Pencil, Tag, BadgeCheck, BookOpen, Clock,
|
ArrowLeft, CreditCard, Pencil, Tag, BadgeCheck, BookOpen, Clock,
|
||||||
ShieldCheck, Plus, Trash2, Loader2, Receipt,
|
ShieldCheck, Plus, Trash2, Loader2, Receipt, KeyRound, Users, Lock,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
@@ -481,6 +481,241 @@ function PaymentPolicyTab({ planId, plan }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Tab: Access Rules ─────────────────────────────────────────────────────────
|
||||||
|
// Configures plan_policies.access_rules — evaluateCourseAccess (utils/accessPolicy.util.js)
|
||||||
|
// reads these instead of falling back to plain tier-rank comparison once any
|
||||||
|
// rule exists here. Empty (default) = unchanged rank-comparison behavior.
|
||||||
|
|
||||||
|
const RULE_TYPES = [
|
||||||
|
{ value: "course_subscription_access", label: "Allowed subscription levels", icon: Tag,
|
||||||
|
description: "Only grant access to courses at these subscription levels." },
|
||||||
|
{ value: "required_active_tier", label: "Required active tier", icon: KeyRound,
|
||||||
|
description: "User's active tier must be at least this rank." },
|
||||||
|
{ value: "group_restriction", label: "Group restriction", icon: Users,
|
||||||
|
description: "User must belong to at least one of these groups." },
|
||||||
|
];
|
||||||
|
|
||||||
|
function ruleSummary(rule, tierCategories, groups) {
|
||||||
|
if (rule.type === "course_subscription_access") {
|
||||||
|
const names = (rule.levels ?? []).map((slug) => tierCategories.find((c) => c.slug === slug)?.name ?? slug);
|
||||||
|
return `Allowed levels: ${names.join(", ") || "—"}`;
|
||||||
|
}
|
||||||
|
if (rule.type === "required_active_tier") {
|
||||||
|
return `Requires active tier: ${tierCategories.find((c) => c.slug === rule.tier)?.name ?? rule.tier}`;
|
||||||
|
}
|
||||||
|
if (rule.type === "group_restriction") {
|
||||||
|
const names = (rule.group_ids ?? []).map((id) => groups.find((g) => String(g.group_id) === String(id))?.name ?? id);
|
||||||
|
return `Restricted to groups: ${names.join(", ") || "—"}`;
|
||||||
|
}
|
||||||
|
return rule.type;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AccessRulesTab({ planId }) {
|
||||||
|
const [rules, setRules] = useState([]);
|
||||||
|
const [rulesLoading, setRulesLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [tierCategories, setTierCategories] = useState([]);
|
||||||
|
const [groups, setGroups] = useState([]);
|
||||||
|
const [showAdd, setShowAdd] = useState(false);
|
||||||
|
const [newType, setNewType] = useState("course_subscription_access");
|
||||||
|
const [newLevels, setNewLevels] = useState([]);
|
||||||
|
const [newTier, setNewTier] = useState("");
|
||||||
|
const [newGroupIds, setNewGroupIds] = useState([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setRulesLoading(true);
|
||||||
|
api.get(`/admin/tier-policies/plans/${planId}/policy`)
|
||||||
|
.then(({ data }) => setRules(data.data?.access_rules ?? []))
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setRulesLoading(false));
|
||||||
|
|
||||||
|
api.get("/admin/tiers/categories").then(({ data }) => setTierCategories(data.data ?? [])).catch(() => {});
|
||||||
|
api.get("/admin/groups", { params: { limit: 100 } })
|
||||||
|
.then(({ data }) => setGroups(data.data?.data ?? []))
|
||||||
|
.catch(() => {});
|
||||||
|
}, [planId]);
|
||||||
|
|
||||||
|
const handleSave = async (next) => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await api.put(`/admin/tier-policies/plans/${planId}/policy`, { access_rules: next });
|
||||||
|
setRules(next);
|
||||||
|
toast("Access rules saved.");
|
||||||
|
} catch (err) {
|
||||||
|
toast(err?.response?.data?.message ?? "Could not save access rules.");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetAddForm = () => {
|
||||||
|
setShowAdd(false);
|
||||||
|
setNewType("course_subscription_access");
|
||||||
|
setNewLevels([]);
|
||||||
|
setNewTier("");
|
||||||
|
setNewGroupIds([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddRule = () => {
|
||||||
|
let rule;
|
||||||
|
if (newType === "course_subscription_access") {
|
||||||
|
if (!newLevels.length) { toast("Select at least one subscription level."); return; }
|
||||||
|
rule = { type: newType, levels: newLevels };
|
||||||
|
} else if (newType === "required_active_tier") {
|
||||||
|
if (!newTier) { toast("Select a required tier."); return; }
|
||||||
|
rule = { type: newType, tier: newTier };
|
||||||
|
} else {
|
||||||
|
if (!newGroupIds.length) { toast("Select at least one group."); return; }
|
||||||
|
rule = { type: newType, group_ids: newGroupIds.map(Number) };
|
||||||
|
}
|
||||||
|
handleSave([...rules, rule]);
|
||||||
|
resetAddForm();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveRule = (index) => {
|
||||||
|
handleSave(rules.filter((_, i) => i !== index));
|
||||||
|
};
|
||||||
|
|
||||||
|
if (rulesLoading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{[...Array(2)].map((_, i) => <Skeleton key={i} className="h-24 w-full" />)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<SectionCard
|
||||||
|
icon={Lock}
|
||||||
|
title="Access Rules"
|
||||||
|
description="Overrides the default rank-comparison access check for this plan. Leave empty to use plain tier-rank comparison."
|
||||||
|
>
|
||||||
|
{rules.length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{rules.map((rule, i) => {
|
||||||
|
const meta = RULE_TYPES.find((t) => t.value === rule.type);
|
||||||
|
const Icon = meta?.icon ?? Lock;
|
||||||
|
return (
|
||||||
|
<div key={i} className="flex items-center justify-between gap-3 rounded-lg border bg-muted/30 px-4 py-3">
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
<Icon className="size-4 text-muted-foreground shrink-0" />
|
||||||
|
<span className="text-sm">{ruleSummary(rule, tierCategories, groups)}</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost" size="icon"
|
||||||
|
className="text-destructive hover:text-destructive shrink-0"
|
||||||
|
disabled={saving}
|
||||||
|
onClick={() => handleRemoveRule(i)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-2 rounded-lg border border-dashed p-4 text-sm text-muted-foreground">
|
||||||
|
<Lock className="size-4 shrink-0" />
|
||||||
|
No access rules configured — falls back to plain tier-rank comparison.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showAdd ? (
|
||||||
|
<div className="rounded-lg border bg-muted/20 p-4 space-y-4">
|
||||||
|
<p className="text-sm font-medium">New Access Rule</p>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Rule Type</Label>
|
||||||
|
<Select value={newType} onValueChange={setNewType}>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{RULE_TYPES.map((t) => (
|
||||||
|
<SelectItem key={t.value} value={t.value}>{t.label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{RULE_TYPES.find((t) => t.value === newType)?.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{newType === "course_subscription_access" && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Allowed Levels</Label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{tierCategories.map((c) => (
|
||||||
|
<Badge
|
||||||
|
key={c.slug}
|
||||||
|
variant={newLevels.includes(c.slug) ? "default" : "outline"}
|
||||||
|
className="cursor-pointer select-none"
|
||||||
|
onClick={() => setNewLevels((prev) =>
|
||||||
|
prev.includes(c.slug) ? prev.filter((s) => s !== c.slug) : [...prev, c.slug]
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{c.name}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{newType === "required_active_tier" && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Required Tier</Label>
|
||||||
|
<Select value={newTier} onValueChange={setNewTier}>
|
||||||
|
<SelectTrigger><SelectValue placeholder="Select a tier" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{tierCategories.map((c) => (
|
||||||
|
<SelectItem key={c.slug} value={c.slug}>{c.name}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{newType === "group_restriction" && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Groups</Label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{groups.map((g) => (
|
||||||
|
<Badge
|
||||||
|
key={g.group_id}
|
||||||
|
variant={newGroupIds.includes(String(g.group_id)) ? "default" : "outline"}
|
||||||
|
className="cursor-pointer select-none"
|
||||||
|
onClick={() => setNewGroupIds((prev) =>
|
||||||
|
prev.includes(String(g.group_id))
|
||||||
|
? prev.filter((id) => id !== String(g.group_id))
|
||||||
|
: [...prev, String(g.group_id)]
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{g.name}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
{groups.length === 0 && (
|
||||||
|
<p className="text-xs text-muted-foreground">No groups found.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-2 justify-end pt-1">
|
||||||
|
<Button variant="outline" size="sm" onClick={resetAddForm}>Cancel</Button>
|
||||||
|
<Button size="sm" onClick={handleAddRule} disabled={saving}>
|
||||||
|
<Plus className="h-4 w-4 mr-1" /> Add Rule
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setShowAdd(true)}>
|
||||||
|
<Plus className="h-4 w-4 mr-1" /> Add Access Rule
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</SectionCard>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Tab: Payments ─────────────────────────────────────────────────────────────
|
// ─── Tab: Payments ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function PaymentsTab({ planId }) {
|
function PaymentsTab({ planId }) {
|
||||||
@@ -497,6 +732,7 @@ function PaymentsTab({ planId }) {
|
|||||||
|
|
||||||
const TABS = [
|
const TABS = [
|
||||||
{ key: "details", label: "Plan Details", icon: CreditCard },
|
{ key: "details", label: "Plan Details", icon: CreditCard },
|
||||||
|
{ key: "access", label: "Access Rules", icon: Lock },
|
||||||
{ key: "policy", label: "Payment Policy", icon: ShieldCheck },
|
{ key: "policy", label: "Payment Policy", icon: ShieldCheck },
|
||||||
{ key: "payments", label: "Payments", icon: Receipt },
|
{ key: "payments", label: "Payments", icon: Receipt },
|
||||||
];
|
];
|
||||||
@@ -601,6 +837,9 @@ export default function ViewPlan() {
|
|||||||
coursesLoading={coursesLoading}
|
coursesLoading={coursesLoading}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{activeTab === "access" && (
|
||||||
|
<AccessRulesTab planId={planId} />
|
||||||
|
)}
|
||||||
{activeTab === "policy" && (
|
{activeTab === "policy" && (
|
||||||
<PaymentPolicyTab planId={planId} plan={plan} />
|
<PaymentPolicyTab planId={planId} plan={plan} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -132,6 +132,7 @@ import ArchivedNotificationBroadcastList from '../pages/notifications/ArchivedNo
|
|||||||
// Activity
|
// Activity
|
||||||
import ActivityFeed from '../pages/activity/ActivityFeed'
|
import ActivityFeed from '../pages/activity/ActivityFeed'
|
||||||
import UserActivityPage from '../pages/activity/UserActivityPage'
|
import UserActivityPage from '../pages/activity/UserActivityPage'
|
||||||
|
import ResourceList from '../pages/resources/ResourceList'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -186,6 +187,14 @@ export const AdminRoutes = {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
path: 'resources',
|
||||||
|
element: <Outlet />,
|
||||||
|
children: [
|
||||||
|
{ index: true, element: <ResourceList /> },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
// Courses
|
// Courses
|
||||||
{
|
{
|
||||||
path: 'courses',
|
path: 'courses',
|
||||||
@@ -370,8 +379,29 @@ export const AdminRoutes = {
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
||||||
// Notifications
|
// Announcements (admin-authored broadcasts)
|
||||||
|
{
|
||||||
|
path: 'announcements',
|
||||||
|
element: <Outlet />,
|
||||||
|
children: [
|
||||||
|
{ index: true, element: <NotificationBroadcastList /> },
|
||||||
|
{ path: 'archived', element: <ArchivedNotificationBroadcastList /> },
|
||||||
|
{ path: 'add', element: <AddNotificationBroadcast /> },
|
||||||
|
{ path: 'settings', element: <NotificationSettings /> },
|
||||||
|
{ path: ':broadcastId/view', element: <ViewNotificationBroadcast /> },
|
||||||
|
{ path: ':broadcastId/edit', element: <EditNotificationBroadcast /> },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'announcement-templates',
|
||||||
|
element: <Outlet />,
|
||||||
|
children: [
|
||||||
|
{ index: true, element: <NotificationTemplates /> },
|
||||||
|
{ path: ':id/edit', element: <EditNotificationTemplate /> },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
// Backwards-compatible aliases (keep old URLs working)
|
||||||
{
|
{
|
||||||
path: 'notifications',
|
path: 'notifications',
|
||||||
element: <Outlet />,
|
element: <Outlet />,
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
// LessonCard — grid card for a standalone Lesson. Shared by LessonsList.jsx and
|
||||||
|
// Dashboard.jsx's "Featured Lessons" section. Mirrors UnitCard.jsx; a Lesson has
|
||||||
|
// no lesson_count/quiz_id of its own — shows unit_count instead (how many Units
|
||||||
|
// it's attached to).
|
||||||
|
|
||||||
|
import { Timer, LockIcon, Layers } from "lucide-react";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function formatDuration(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`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LessonCard = ({ lesson, onViewDetails }) => {
|
||||||
|
const locked = lesson.is_locked;
|
||||||
|
const duration = formatDuration(lesson.duration_seconds);
|
||||||
|
const unitCount = Number(lesson.unit_count ?? 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"bg-card rounded-2xl border p-4 flex flex-col gap-2.5 transition-all cursor-pointer group",
|
||||||
|
"hover:shadow-sm",
|
||||||
|
locked
|
||||||
|
? "opacity-80 hover:opacity-100 hover:border-muted-foreground/40"
|
||||||
|
: "hover:bg-muted/60 dark:hover:border-blue-500"
|
||||||
|
)}
|
||||||
|
onClick={() => onViewDetails(lesson)}
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{locked ? (
|
||||||
|
<Badge variant="secondary">
|
||||||
|
<LockIcon className="size-3" /> Locked
|
||||||
|
</Badge>
|
||||||
|
) : unitCount > 0 ? (
|
||||||
|
<Badge variant="outline"><Layers className="size-3" /> In {unitCount} unit{unitCount === 1 ? "" : "s"}</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="outline" className="text-muted-foreground">Standalone</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<h1 className="text-lg font-medium leading-snug line-clamp-3 transition-colors group-hover:text-blue-700 dark:group-hover:text-blue-400">
|
||||||
|
{lesson.title}
|
||||||
|
</h1>
|
||||||
|
{lesson.description && (
|
||||||
|
<p className="text-sm leading-relaxed line-clamp-2 group-hover:text-blue-700 dark:group-hover:text-blue-400">
|
||||||
|
{lesson.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between pt-2 mt-auto border-t">
|
||||||
|
<div className={`flex items-center gap-3 text-sm [&_svg]:size-4 ${locked ? "text-muted-foreground" : "text-card-foreground group-hover:text-blue-700 dark:group-hover:text-blue-400"}`}>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Timer /> {duration ?? "—"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{locked && (
|
||||||
|
<span className="text-xs text-muted-foreground">Upgrade to unlock</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LessonCardSkeleton = () => (
|
||||||
|
<div className="bg-card rounded-2xl border p-4 flex flex-col gap-2.5">
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<Skeleton className="h-5 w-20 rounded-full" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-6 w-3/4" />
|
||||||
|
<Skeleton className="h-4 w-full" />
|
||||||
|
<Skeleton className="h-4 w-2/3" />
|
||||||
|
<div className="pt-2 mt-auto border-t">
|
||||||
|
<Skeleton className="h-4 w-24" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
// LessonUpsellModal — shown when a learner clicks a locked standalone Lesson.
|
||||||
|
// Mirrors UnitUpsellModal.jsx: a Lesson isn't independently purchasable, so this
|
||||||
|
// lists every course that would unlock it (aggregated across all its attached
|
||||||
|
// Units, since a Lesson can sit in more than one) plus a generic "View Plans"
|
||||||
|
// fallback. Shared by LessonsList and Dashboard.
|
||||||
|
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { LockIcon, BookOpen } from "lucide-react";
|
||||||
|
import ResponsiveModal from "@/components/generic/ResponsiveModal";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { resolveTierBadge } from "@/utils/tierBadge.util";
|
||||||
|
|
||||||
|
export default function LessonUpsellModal({ open, onOpenChange, lesson, tierMap = {} }) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const courses = lesson?.courses ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ResponsiveModal
|
||||||
|
open={open}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
title={lesson?.title ?? "Lesson Details"}
|
||||||
|
description="This lesson is part of one or more courses that require a plan upgrade."
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>Close</Button>
|
||||||
|
<Button onClick={() => { onOpenChange(false); navigate("/plans"); }}>
|
||||||
|
<LockIcon /> View Plans
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-3 py-2">
|
||||||
|
{courses.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Upgrade your plan to access this content.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
courses.map((course) => {
|
||||||
|
const { label, cls } = resolveTierBadge(course.subscription, tierMap);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={course.course_id}
|
||||||
|
className="flex items-center justify-between gap-3 p-4 rounded-xl border bg-muted/40"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2.5 min-w-0">
|
||||||
|
<BookOpen className="size-4 text-muted-foreground shrink-0" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-medium truncate">{course.title}</p>
|
||||||
|
<Badge className={`${cls} mt-1`}>
|
||||||
|
<LockIcon className="size-3" /> {label}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="shrink-0"
|
||||||
|
onClick={() => { onOpenChange(false); navigate(`/course/${course.course_id}`); }}
|
||||||
|
>
|
||||||
|
View Course
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ResponsiveModal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
// LockedContentPanel — full-page inline panel shown when a deep-link lands on
|
||||||
|
// content the learner can't access (locked unit or a lesson under one).
|
||||||
|
// Distinct from UnitUpsellModal (a dialog triggered from card grids) — this
|
||||||
|
// renders in place of the page body itself. Shared by UnitDetails and
|
||||||
|
// LessonDetails.
|
||||||
|
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { LockIcon, Zap } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
export default function LockedContentPanel({ course, tierMap = {} }) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const tier = course?.subscription ? tierMap[course.subscription] : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-6 py-32 text-center px-4">
|
||||||
|
<div className="h-16 w-16 rounded-full bg-amber-100 dark:bg-amber-900/30 flex items-center justify-center">
|
||||||
|
<LockIcon className="size-7 text-amber-500" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 max-w-sm">
|
||||||
|
<h2 className="text-xl font-semibold">Premium / Exclusive Content</h2>
|
||||||
|
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||||
|
{course
|
||||||
|
? `This content is part of "${course.title}"${tier?.name ? ` (${tier.name} plan)` : ""}. Upgrade your plan or view the course to unlock it.`
|
||||||
|
: "Upgrade your plan to access this content."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<Button onClick={() => navigate('/plans')} className="gap-1.5">
|
||||||
|
<Zap className="size-4" /> View Available Plans
|
||||||
|
</Button>
|
||||||
|
<p className="text-xs text-muted-foreground">Already subscribed? Your plan may not cover this tier.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
// PlanComparisonTable — side-by-side feature matrix, an alternate view next to
|
||||||
|
// the card grid on PlanList.jsx. Rows are the union of every active plan's
|
||||||
|
// admin-authored `features` list; a plan gets a checkmark for a row if that
|
||||||
|
// exact feature text is present in its own list.
|
||||||
|
|
||||||
|
import * as LucideIcons from "lucide-react";
|
||||||
|
import { Check, Minus, Tag } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { resolveTierBadge } from "@/utils/tierBadge.util";
|
||||||
|
|
||||||
|
export default function PlanComparisonTable({ plans, myTier, tierMap, fmtCurrency, onSelect }) {
|
||||||
|
const featureRows = [...new Set(
|
||||||
|
plans.flatMap((p) => (p.features ?? []).map((f) => f.text))
|
||||||
|
)];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-auto rounded-xl border bg-card">
|
||||||
|
<table className="w-full text-sm border-collapse min-w-[640px]">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b">
|
||||||
|
<th className="text-left p-4 w-48 align-bottom text-muted-foreground font-medium">Plan</th>
|
||||||
|
{plans.map((plan) => {
|
||||||
|
const { label, cls } = resolveTierBadge(plan.tier, tierMap);
|
||||||
|
const Icon = LucideIcons[tierMap[plan.tier]?.badge_icon] ?? Tag;
|
||||||
|
const isCurrent = myTier?.tier === plan.tier && myTier?.status === "active";
|
||||||
|
return (
|
||||||
|
<th key={plan.plan_id} className="p-4 text-center align-bottom min-w-[160px]">
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<Badge className={cls}><Icon className="size-3" />{label}</Badge>
|
||||||
|
<span className="font-semibold text-foreground">{plan.label}</span>
|
||||||
|
<span className="text-lg font-bold">{fmtCurrency(plan.price, plan.currency)}</span>
|
||||||
|
{isCurrent && <Badge variant="secondary" className="text-xs">Current Plan</Badge>}
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr className="border-b bg-muted/30">
|
||||||
|
<td className="p-3 font-medium text-muted-foreground">Courses included</td>
|
||||||
|
{plans.map((plan) => (
|
||||||
|
<td key={plan.plan_id} className="p-3 text-center">
|
||||||
|
{plan.course_count > 0 ? plan.course_count : "—"}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
{featureRows.map((text, i) => (
|
||||||
|
<tr key={text} className={`border-b ${i % 2 === 1 ? "bg-muted/30" : ""}`}>
|
||||||
|
<td className="p-3 text-muted-foreground">{text}</td>
|
||||||
|
{plans.map((plan) => {
|
||||||
|
const included = (plan.features ?? []).some((f) => f.text === text);
|
||||||
|
return (
|
||||||
|
<td key={plan.plan_id} className="p-3 text-center">
|
||||||
|
{included
|
||||||
|
? <Check className="size-4 mx-auto text-green-500" />
|
||||||
|
: <Minus className="size-4 mx-auto text-muted-foreground/30" />
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{featureRows.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={plans.length + 1} className="p-6 text-center text-muted-foreground">
|
||||||
|
No features have been added to these plans yet.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr>
|
||||||
|
<td className="p-4" />
|
||||||
|
{plans.map((plan) => {
|
||||||
|
const isCurrent = myTier?.tier === plan.tier && myTier?.status === "active";
|
||||||
|
return (
|
||||||
|
<td key={plan.plan_id} className="p-4 text-center">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={isCurrent ? "secondary" : "default"}
|
||||||
|
disabled={isCurrent || !plan.is_active}
|
||||||
|
onClick={() => onSelect(plan)}
|
||||||
|
>
|
||||||
|
{isCurrent ? "Current" : !plan.is_active ? "Not Available" : "Select"}
|
||||||
|
</Button>
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { ClipboardCheck, CheckCheck, SendHorizonal, Lock, Zap, Info, Tag } from "lucide-react";
|
||||||
|
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
|
||||||
|
import ResponsiveModal from "@/components/generic/ResponsiveModal";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import api from "@/utils/api.util";
|
||||||
|
import { useClientTiers } from "@/contexts/ClientTiersProvider";
|
||||||
|
import { resolveTierBadge } from "@/utils/tierBadge.util";
|
||||||
|
|
||||||
|
function TierBadge({ tier }) {
|
||||||
|
const { tierMap } = useClientTiers();
|
||||||
|
const { rank, label, cls } = resolveTierBadge(tier ?? 'free', tierMap);
|
||||||
|
return (
|
||||||
|
<Badge className={`gap-1 ${cls} w-fit shrink-0`}>
|
||||||
|
{rank > 0 ? <Lock className="size-3" /> : <Tag className="size-3" />}
|
||||||
|
{label}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── PassQuiz — task requirement block for pass_quiz type ─────────────────────
|
||||||
|
// Mirrors ReadUnit.jsx's fetch-detail-and-navigate pattern. A quiz is always
|
||||||
|
// unit-scoped; navigation goes into the course reader if the unit is attached
|
||||||
|
// to a course, otherwise the standalone unit reader.
|
||||||
|
const PassQuiz = ({ title = "Pass Quizzes", quizzes = [], groupId, taskListId, taskId }) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [details, setDetails] = useState({});
|
||||||
|
const [locked, setLocked] = useState({});
|
||||||
|
const [lockedInfo, setLockedInfo] = useState({});
|
||||||
|
const [unavailable, setUnavailable] = useState({});
|
||||||
|
const [fetching, setFetching] = useState({});
|
||||||
|
const [selected, setSelected] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
quizzes.forEach(async (q) => {
|
||||||
|
if (!q.reference_id) return;
|
||||||
|
setFetching((prev) => ({ ...prev, [q.reference_id]: true }));
|
||||||
|
try {
|
||||||
|
const res = await api.get(`/client/courses/quiz/uuid/${q.reference_id}`);
|
||||||
|
const d = res.data?.data;
|
||||||
|
if (d) setDetails((prev) => ({ ...prev, [q.reference_id]: d }));
|
||||||
|
} catch (err) {
|
||||||
|
if (err?.response?.status === 403) {
|
||||||
|
setLocked((prev) => ({ ...prev, [q.reference_id]: true }));
|
||||||
|
const course = err.response?.data?.course;
|
||||||
|
if (course) setLockedInfo((prev) => ({ ...prev, [q.reference_id]: course }));
|
||||||
|
} else {
|
||||||
|
setUnavailable((prev) => ({ ...prev, [q.reference_id]: true }));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setFetching((prev) => ({ ...prev, [q.reference_id]: false }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const goToQuiz = (info) => {
|
||||||
|
if (!info) return;
|
||||||
|
const taskCtx = taskId ? { has_task: true, groupId, taskListId, taskId } : undefined;
|
||||||
|
if (info.unit?.course?.course_id) {
|
||||||
|
navigate(`/course/${info.unit.course.course_id}/unit`, {
|
||||||
|
state: { quizUnitId: info.unit.unit_id, ...(taskCtx ? { taskCtx } : {}) },
|
||||||
|
});
|
||||||
|
} else if (info.unit?.uuid) {
|
||||||
|
navigate(`/units/${info.unit.uuid}/read`, { state: { quizId: true } });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!quizzes.length) return null;
|
||||||
|
|
||||||
|
const hasLocked = Object.values(locked).some(Boolean);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="border rounded-lg bg-card overflow-hidden">
|
||||||
|
<div className="px-6 py-4 border-b flex items-center gap-2">
|
||||||
|
<ClipboardCheck className="size-4 text-muted-foreground" />
|
||||||
|
<h2 className="font-semibold text-sm">{title}</h2>
|
||||||
|
<Badge variant="secondary" className="ml-auto">{quizzes.length}</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasLocked && (
|
||||||
|
<div className="flex items-start gap-3 px-5 py-3.5 border-b bg-amber-50 dark:bg-amber-950/30 text-amber-800 dark:text-amber-300">
|
||||||
|
<Info className="size-4 mt-0.5 shrink-0" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium leading-snug">Subscription Required</p>
|
||||||
|
<p className="text-xs mt-0.5 text-amber-700 dark:text-amber-400 leading-relaxed">
|
||||||
|
To complete this activity, subscribe to one of our available tier plans.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" variant="outline" className="shrink-0 border-amber-300 dark:border-amber-700 text-amber-800 dark:text-amber-300 hover:bg-amber-100 dark:hover:bg-amber-900/40" onClick={() => navigate('/plans')}>
|
||||||
|
<Zap className="size-3.5" /> View Plans
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ScrollArea className="w-full bg-muted overflow-hidden">
|
||||||
|
<div className="flex gap-4 p-4">
|
||||||
|
{quizzes.map((q) => {
|
||||||
|
const info = details[q.reference_id];
|
||||||
|
const courseInfo = lockedInfo[q.reference_id];
|
||||||
|
const isLocked = locked[q.reference_id];
|
||||||
|
const isUnavailable = unavailable[q.reference_id];
|
||||||
|
const isFetching = fetching[q.reference_id];
|
||||||
|
const passed = info?.has_passed || q.completed;
|
||||||
|
|
||||||
|
if (isLocked) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={q.id}
|
||||||
|
onClick={() => navigate('/plans')}
|
||||||
|
className="bg-card rounded-2xl border border-dashed p-4 flex flex-col gap-3 hover:bg-muted/60 hover:shadow-sm transition-all cursor-pointer group w-80 shrink-0 opacity-80"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<ClipboardCheck className="size-3.5 text-muted-foreground shrink-0" />
|
||||||
|
<span className="text-xs text-muted-foreground font-medium">Quiz</span>
|
||||||
|
<div className="ml-auto"><TierBadge tier={courseInfo?.subscription} /></div>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-base font-semibold leading-snug line-clamp-2 text-muted-foreground">{q.title}</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Subscribe to <span className="font-medium">{courseInfo?.title ?? 'this course'}</span> to unlock this quiz.
|
||||||
|
</p>
|
||||||
|
<div className="mt-auto">
|
||||||
|
<Button size="sm" className="w-full gap-1.5" onClick={(e) => { e.stopPropagation(); navigate('/plans'); }}>
|
||||||
|
<Zap className="size-3.5" /> Upgrade to unlock
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isUnavailable) {
|
||||||
|
return (
|
||||||
|
<div key={q.id} className="bg-card rounded-2xl border border-dashed p-4 flex flex-col gap-3 w-80 shrink-0 opacity-50 cursor-not-allowed">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<ClipboardCheck className="size-3.5 text-muted-foreground shrink-0" />
|
||||||
|
<span className="text-xs text-muted-foreground font-medium">Quiz</span>
|
||||||
|
<Badge variant="secondary" className="ml-auto gap-1 text-muted-foreground text-xs">
|
||||||
|
<Lock className="size-3" /> Unavailable
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-base font-semibold leading-snug line-clamp-2 text-muted-foreground">{q.title}</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">This quiz is no longer available.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={q.id}
|
||||||
|
onClick={() => !isFetching && setSelected(q)}
|
||||||
|
className={`bg-card rounded-2xl border p-4 flex flex-col gap-3 transition-all w-80 shrink-0 ${
|
||||||
|
isFetching ? 'opacity-60 cursor-wait' : 'hover:bg-muted/60 hover:shadow-sm cursor-pointer group'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<ClipboardCheck className="size-3.5 text-muted-foreground shrink-0" />
|
||||||
|
<span className="text-xs text-muted-foreground font-medium">Quiz</span>
|
||||||
|
<div className="ml-auto">
|
||||||
|
{info?.unit?.course?.subscription
|
||||||
|
? <TierBadge tier={info.unit.course.subscription} />
|
||||||
|
: isFetching && <Badge variant="secondary" className="opacity-40 text-xs">Loading…</Badge>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{info?.unit?.title && (
|
||||||
|
<p className="text-xs text-muted-foreground truncate -mt-1">
|
||||||
|
in <span className="text-foreground/70 font-medium">{info.unit.title}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<h1 className="text-base font-semibold leading-snug line-clamp-2">{q.title}</h1>
|
||||||
|
<div className="flex items-center justify-between text-sm mt-auto pt-2 border-t">
|
||||||
|
{passed ? (
|
||||||
|
<span className="flex items-center gap-1.5 text-green-600 dark:text-green-400 font-medium">
|
||||||
|
<CheckCheck className="size-4" /> Passed
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground font-medium">Not Attempted</span>
|
||||||
|
)}
|
||||||
|
{info?.passing_score && (
|
||||||
|
<span className="text-xs text-muted-foreground">{info.passing_score}% to pass</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<ScrollBar orientation="horizontal" />
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ResponsiveModal
|
||||||
|
open={!!selected}
|
||||||
|
onOpenChange={(v) => !v && setSelected(null)}
|
||||||
|
title={selected?.title}
|
||||||
|
description="Quiz Info"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="outline" onClick={() => setSelected(null)}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => goToQuiz(details[selected?.reference_id])}
|
||||||
|
disabled={!details[selected?.reference_id]}
|
||||||
|
>
|
||||||
|
<SendHorizonal /> Take Quiz
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{selected && (() => {
|
||||||
|
const info = details[selected.reference_id];
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{(info?.has_passed || selected.completed) && (
|
||||||
|
<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" /> Already Passed
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{info?.unit?.title && (
|
||||||
|
<p className="text-sm">
|
||||||
|
<span className="text-muted-foreground">Unit: </span>
|
||||||
|
<span className="font-medium">{info.unit.title}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{info?.passing_score && (
|
||||||
|
<p className="text-sm">
|
||||||
|
<span className="text-muted-foreground">Passing score: </span>
|
||||||
|
<span className="font-medium">{info.passing_score}%</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</ResponsiveModal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PassQuiz;
|
||||||
@@ -34,6 +34,7 @@ import { useEffect, useRef, useState } from "react"
|
|||||||
import { AVATAR_COLORS } from "@/data/profile.data"
|
import { AVATAR_COLORS } from "@/data/profile.data"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import ClientNotificationBell from "@/components/generic/ClientNotificationBell"
|
import ClientNotificationBell from "@/components/generic/ClientNotificationBell"
|
||||||
|
import StickyAnnouncementBar from "@/components/generic/StickyAnnouncementBar"
|
||||||
import { QRCodeCanvas } from "qrcode.react"
|
import { QRCodeCanvas } from "qrcode.react"
|
||||||
|
|
||||||
// ─── Refer / Invite dialog ────────────────────────────────────────────────────
|
// ─── Refer / Invite dialog ────────────────────────────────────────────────────
|
||||||
@@ -142,6 +143,8 @@ function ClientNav() {
|
|||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { user, logout } = useAuth()
|
const { user, logout } = useAuth()
|
||||||
|
|
||||||
|
const navRef = useRef(null)
|
||||||
|
|
||||||
// Background fetches only — nav rendering never waits on these
|
// Background fetches only — nav rendering never waits on these
|
||||||
const { achievements, getAchievements } = useProfile()
|
const { achievements, getAchievements } = useProfile()
|
||||||
const { myTier, tierMap, tierCategories, getMyTier, getTierCategories } = useClientTiers()
|
const { myTier, tierMap, tierCategories, getMyTier, getTierCategories } = useClientTiers()
|
||||||
@@ -159,6 +162,19 @@ function ClientNav() {
|
|||||||
if (tierCategories.length === 0) getTierCategories();
|
if (tierCategories.length === 0) getTierCategories();
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
|
// Used by sticky overlays (e.g. StickyAnnouncementBar) to avoid overlapping the fixed header.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!navRef.current) return
|
||||||
|
const update = () => {
|
||||||
|
document.documentElement.style.setProperty('--navbar-h', `${navRef.current.offsetHeight}px`)
|
||||||
|
}
|
||||||
|
update()
|
||||||
|
|
||||||
|
const ro = new ResizeObserver(update)
|
||||||
|
ro.observe(navRef.current)
|
||||||
|
return () => ro.disconnect()
|
||||||
|
}, [])
|
||||||
|
|
||||||
// ── Derive directly from auth user — same pattern as admin UserMenu ──────
|
// ── Derive directly from auth user — same pattern as admin UserMenu ──────
|
||||||
// No extra fetch, no loading state, no flicker on reload.
|
// No extra fetch, no loading state, no flicker on reload.
|
||||||
const given = user?.personal_info?.name?.given_name ?? ""
|
const given = user?.personal_info?.name?.given_name ?? ""
|
||||||
@@ -219,7 +235,7 @@ function ClientNav() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<nav className="bg-card fixed w-full z-50 top-0 border-b border-default">
|
<nav ref={navRef} 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">
|
<div className="flex flex-wrap items-center justify-between mx-auto py-3 px-6">
|
||||||
|
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
@@ -345,6 +361,7 @@ const ClientLayout = () => {
|
|||||||
<ClientProvider>
|
<ClientProvider>
|
||||||
<div className="min-h-screen flex flex-col">
|
<div className="min-h-screen flex flex-col">
|
||||||
<ClientNav />
|
<ClientNav />
|
||||||
|
<StickyAnnouncementBar />
|
||||||
<div className="flex-1 flex flex-col">
|
<div className="flex-1 flex flex-col">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -249,13 +249,17 @@ const CoursesList = () => {
|
|||||||
onChange={(e) => { setSearch(e.target.value); setCurrentPage(1); }}
|
onChange={(e) => { setSearch(e.target.value); setCurrentPage(1); }}
|
||||||
/>
|
/>
|
||||||
<div className="flex gap-4 items-start w-full">
|
<div className="flex gap-4 items-start w-full">
|
||||||
<Select value="courses" onValueChange={(v) => { if (v === "units") navigate("/units"); }}>
|
<Select value="courses" onValueChange={(v) => {
|
||||||
|
if (v === "units") navigate("/units");
|
||||||
|
if (v === "lessons") navigate("/lessons");
|
||||||
|
}}>
|
||||||
<SelectTrigger className="w-full lg:w-48 bg-card">
|
<SelectTrigger className="w-full lg:w-48 bg-card">
|
||||||
<SelectValue placeholder="Browse" />
|
<SelectValue placeholder="Browse" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="courses">Courses</SelectItem>
|
<SelectItem value="courses">Courses</SelectItem>
|
||||||
<SelectItem value="units">Units</SelectItem>
|
<SelectItem value="units">Units</SelectItem>
|
||||||
|
<SelectItem value="lessons">Lessons</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import { useLibrary } from "@/contexts/ClientLibraryContext";
|
|||||||
import { useClientTiers } from "@/contexts/ClientTiersProvider";
|
import { useClientTiers } from "@/contexts/ClientTiersProvider";
|
||||||
import UnitUpsellModal from "../components/UnitUpsellModal";
|
import UnitUpsellModal from "../components/UnitUpsellModal";
|
||||||
import { UnitCard, UnitCardSkeleton } from "../components/UnitCard";
|
import { UnitCard, UnitCardSkeleton } from "../components/UnitCard";
|
||||||
|
import LessonUpsellModal from "../components/LessonUpsellModal";
|
||||||
|
import { LessonCard, LessonCardSkeleton } from "../components/LessonCard";
|
||||||
import { resolveTierBadge } from "@/utils/tierBadge.util";
|
import { resolveTierBadge } from "@/utils/tierBadge.util";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useGroup } from "@/contexts/ClientGroupContext";
|
import { useGroup } from "@/contexts/ClientGroupContext";
|
||||||
@@ -208,7 +210,7 @@ const Client = () => {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { state: navState } = useLocation();
|
const { state: navState } = useLocation();
|
||||||
const { courses, coursesLoading, getCourses } = useClientCourses();
|
const { courses, coursesLoading, getCourses } = useClientCourses();
|
||||||
const { units, unitsLoading, getUnits } = useLibrary();
|
const { units, unitsLoading, getUnits, lessons, lessonsLoading, getLessons } = useLibrary();
|
||||||
const { myTier, getMyTier, tierMap } = useClientTiers();
|
const { myTier, getMyTier, tierMap } = useClientTiers();
|
||||||
const { groups, fetchGroups, loading: groupLoading } = useGroup();
|
const { groups, fetchGroups, loading: groupLoading } = useGroup();
|
||||||
const {
|
const {
|
||||||
@@ -223,6 +225,9 @@ const Client = () => {
|
|||||||
const [unitModalOpen, setUnitModalOpen] = useState(false);
|
const [unitModalOpen, setUnitModalOpen] = useState(false);
|
||||||
const [selectedUnit, setSelectedUnit] = useState(null);
|
const [selectedUnit, setSelectedUnit] = useState(null);
|
||||||
|
|
||||||
|
const [lessonModalOpen, setLessonModalOpen] = useState(false);
|
||||||
|
const [selectedLesson, setSelectedLesson] = useState(null);
|
||||||
|
|
||||||
const [popupOpen, setPopupOpen] = useState(false);
|
const [popupOpen, setPopupOpen] = useState(false);
|
||||||
|
|
||||||
const userTier = myTier?.tier ?? "free";
|
const userTier = myTier?.tier ?? "free";
|
||||||
@@ -243,6 +248,7 @@ const Client = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getCourses();
|
getCourses();
|
||||||
getUnits();
|
getUnits();
|
||||||
|
getLessons();
|
||||||
if (!myTier) getMyTier();
|
if (!myTier) getMyTier();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -262,6 +268,7 @@ const Client = () => {
|
|||||||
// Show only first 3
|
// Show only first 3
|
||||||
const featuredCourses = courses.slice(0, 3);
|
const featuredCourses = courses.slice(0, 3);
|
||||||
const featuredUnits = units.slice(0, 3);
|
const featuredUnits = units.slice(0, 3);
|
||||||
|
const featuredLessons = lessons.slice(0, 3);
|
||||||
|
|
||||||
const breadcrumbItems = [
|
const breadcrumbItems = [
|
||||||
{ label: "My Groups", icon: <Users className="size-4" /> },
|
{ label: "My Groups", icon: <Users className="size-4" /> },
|
||||||
@@ -287,6 +294,15 @@ const Client = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleViewLessonDetails = (lesson) => {
|
||||||
|
if (lesson.is_locked) {
|
||||||
|
setSelectedLesson(lesson);
|
||||||
|
setLessonModalOpen(true);
|
||||||
|
} else {
|
||||||
|
navigate(`/lessons/${lesson.uuid}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -378,6 +394,34 @@ const Client = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ── Featured Lessons (first 3) ── */}
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="w-full flex items-center justify-between">
|
||||||
|
<h1 className="text-2xl font-medium">Lessons</h1>
|
||||||
|
<Button onClick={() => navigate(`/lessons`)}>View All</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{lessonsLoading ? (
|
||||||
|
<div className="grid lg:grid-cols-3 gap-4">
|
||||||
|
{Array.from({ length: 3 }).map((_, i) => (
|
||||||
|
<LessonCardSkeleton key={i} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : featuredLessons.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">No lessons available yet.</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid lg:grid-cols-3 gap-4">
|
||||||
|
{featuredLessons.map((lesson) => (
|
||||||
|
<LessonCard
|
||||||
|
key={lesson.lesson_id}
|
||||||
|
lesson={lesson}
|
||||||
|
onViewDetails={handleViewLessonDetails}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -437,6 +481,14 @@ const Client = () => {
|
|||||||
unit={selectedUnit}
|
unit={selectedUnit}
|
||||||
tierMap={tierMap}
|
tierMap={tierMap}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* ── Upsell Modal — only for locked lessons ── */}
|
||||||
|
<LessonUpsellModal
|
||||||
|
open={lessonModalOpen}
|
||||||
|
onOpenChange={setLessonModalOpen}
|
||||||
|
lesson={selectedLesson}
|
||||||
|
tierMap={tierMap}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||||
|
import { useParams, useNavigate } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
House, Timer, SendHorizonal, CheckCheck, ListChecks, ArrowRight, Layers, Hourglass,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useLibrary } from "@/contexts/ClientLibraryContext";
|
||||||
|
import { useClientTiers } from "@/contexts/ClientTiersProvider";
|
||||||
|
import { PageMeta } from "@/contexts/MetadataContext";
|
||||||
|
import LockedContentPanel from "@/modules/client/components/LockedContentPanel";
|
||||||
|
|
||||||
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function formatDuration(seconds = 0) {
|
||||||
|
if (!seconds) return null;
|
||||||
|
const h = Math.floor(seconds / 3600);
|
||||||
|
const m = Math.floor((seconds % 3600) / 60);
|
||||||
|
if (h > 0) return `${h}hr ${m}min`;
|
||||||
|
return `${m}min`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Lesson Details ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const LessonDetails = () => {
|
||||||
|
const { uuid } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const { getLesson, lesson, lessonLoading, unitBlocked, unitBlockedInfo, resetLesson } = useLibrary();
|
||||||
|
const { tierMap, getTierCategories } = useClientTiers();
|
||||||
|
|
||||||
|
const hasCompleted = lesson?.status === "completed";
|
||||||
|
const unit = lesson?.unit ?? null;
|
||||||
|
const hasUnit = !!unit?.uuid;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getTierCategories();
|
||||||
|
getLesson(uuid);
|
||||||
|
return () => resetLesson();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [uuid]);
|
||||||
|
|
||||||
|
const items = [
|
||||||
|
{ label: "Home", icon: <House className="size-4" />, to: `/dashboard` },
|
||||||
|
{ label: "Lessons", to: `/lessons` },
|
||||||
|
...(hasUnit ? [{ label: unit.title, to: `/units/${unit.uuid}` }] : []),
|
||||||
|
{ label: lesson?.title ?? "Lesson" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── Deep-link to a lesson under a locked unit — inline blocked panel ────
|
||||||
|
if (unitBlocked) {
|
||||||
|
return <LockedContentPanel course={unitBlockedInfo?.course} tierMap={tierMap} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lessonLoading || !lesson) {
|
||||||
|
return (
|
||||||
|
<div className="my-17 p-8 lg:container lg:mx-auto space-y-4">
|
||||||
|
<Skeleton className="h-4 w-48" />
|
||||||
|
<Skeleton className="h-10 w-2/3" />
|
||||||
|
<Skeleton className="h-5 w-full max-w-2xl" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleStart = () => {
|
||||||
|
if (!hasUnit) return;
|
||||||
|
navigate(`/units/${unit.uuid}/read`, { state: { lessonId: lesson.lesson_id } });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<PageMeta title={`${lesson.title} - STARR`} description={lesson.description} />
|
||||||
|
<div className="my-17">
|
||||||
|
<div className="flex flex-col gap-8">
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
|
||||||
|
{/* Hero */}
|
||||||
|
<div className="bg-primary dark:bg-accent/50">
|
||||||
|
<div className="lg:container lg:mx-auto flex flex-col gap-6 xs:px-6 xs:py-6 lg:px-4 lg:py-16">
|
||||||
|
<AppBreadcrumb
|
||||||
|
color={{ link: { color: "text-white" }, page: { color: "text-white" } }}
|
||||||
|
items={items}
|
||||||
|
/>
|
||||||
|
<div className="flex lg:flex-row items-start justify-between w-full text-white">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<h1 className="font-bold xs:text-2xl lg:text-4xl">{lesson.title}</h1>
|
||||||
|
<p className="max-w-2xl xs:text-sm lg:text-lg">{lesson.description ?? ""}</p>
|
||||||
|
{lesson.duration_seconds > 0 && (
|
||||||
|
<div className="[&_svg]:size-4 flex gap-1.5 items-center">
|
||||||
|
<Timer />
|
||||||
|
{formatDuration(lesson.duration_seconds)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="w-fit">
|
||||||
|
{!hasUnit ? (
|
||||||
|
<div className="flex items-center gap-2 rounded-lg border bg-muted px-4 py-2.5 text-sm text-muted-foreground">
|
||||||
|
<Hourglass className="size-4 shrink-0" />
|
||||||
|
This lesson isn't part of a unit yet. Check back later.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
className="w-fit bg-blue-500"
|
||||||
|
onClick={handleStart}
|
||||||
|
>
|
||||||
|
{hasCompleted
|
||||||
|
? <><CheckCheck /> Start Again</>
|
||||||
|
: <><SendHorizonal /> Start Lesson</>
|
||||||
|
}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div className="lg:container lg:mx-auto flex flex-col gap-6 xs:px-3 xs:-mt-5 lg:-mt-0">
|
||||||
|
<div className="flex flex-col gap-8 max-w-3xl">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="font-bold text-2xl">About this lesson</div>
|
||||||
|
<div className="space-y-4 text-muted-foreground lg:text-lg">
|
||||||
|
<p>{lesson.description ?? ""}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{lesson.objectives?.length > 0 && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="font-bold text-2xl flex items-center gap-2">
|
||||||
|
<ListChecks className="size-5 text-muted-foreground" />
|
||||||
|
Objectives
|
||||||
|
</div>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{lesson.objectives.map((obj) => (
|
||||||
|
<li key={obj.objective_id} className="flex items-start gap-2 text-muted-foreground lg:text-lg">
|
||||||
|
<span className="mt-2.5 size-1.5 rounded-full bg-muted-foreground/60 shrink-0" />
|
||||||
|
{obj.text}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasUnit && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate(`/units/${unit.uuid}`)}
|
||||||
|
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors w-fit"
|
||||||
|
>
|
||||||
|
<Layers className="size-4" />
|
||||||
|
Part of unit: <span className="font-medium text-foreground">{unit.title}</span>
|
||||||
|
<ArrowRight className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LessonDetails;
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { House, ChevronLeft, ChevronRight, Layers } from "lucide-react";
|
||||||
|
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { useLibrary } from "@/contexts/ClientLibraryContext";
|
||||||
|
import { useClientTiers } from "@/contexts/ClientTiersProvider";
|
||||||
|
import LessonUpsellModal from "../components/LessonUpsellModal";
|
||||||
|
import { LessonCard, LessonCardSkeleton } from "../components/LessonCard";
|
||||||
|
import { PageMeta } from "@/contexts/MetadataContext";
|
||||||
|
|
||||||
|
const ITEMS_PER_PAGE = 10;
|
||||||
|
|
||||||
|
// ─── Pagination ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const Pagination = ({ currentPage, totalPages, totalItems, itemsPerPage, onPageChange }) => {
|
||||||
|
const start = (currentPage - 1) * itemsPerPage + 1;
|
||||||
|
const end = Math.min(currentPage * itemsPerPage, totalItems);
|
||||||
|
|
||||||
|
const getPages = () => {
|
||||||
|
const pages = [];
|
||||||
|
if (totalPages <= 5) {
|
||||||
|
for (let i = 1; i <= totalPages; i++) pages.push(i);
|
||||||
|
} else {
|
||||||
|
pages.push(1);
|
||||||
|
if (currentPage > 3) pages.push("...");
|
||||||
|
for (let i = Math.max(2, currentPage - 1); i <= Math.min(totalPages - 1, currentPage + 1); i++) pages.push(i);
|
||||||
|
if (currentPage < totalPages - 2) pages.push("...");
|
||||||
|
pages.push(totalPages);
|
||||||
|
}
|
||||||
|
return pages;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between w-full pt-4 border-t">
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
Showing <span className="font-medium text-foreground">{start}–{end}</span> of{" "}
|
||||||
|
<span className="font-medium text-foreground">{totalItems}</span> lessons
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button onClick={() => onPageChange(currentPage - 1)} disabled={currentPage === 1} variant="ghost" size="sm">
|
||||||
|
<ChevronLeft />
|
||||||
|
</Button>
|
||||||
|
{getPages().map((page, i) =>
|
||||||
|
page === "..." ? (
|
||||||
|
<span key={`ellipsis-${i}`} className="w-7 h-7 flex items-center justify-center text-xs text-muted-foreground">···</span>
|
||||||
|
) : (
|
||||||
|
<Button key={page} size="sm" variant={currentPage === page ? "default" : "outline"} onClick={() => onPageChange(page)}>
|
||||||
|
{page}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
<Button onClick={() => onPageChange(currentPage + 1)} disabled={currentPage === totalPages} variant="ghost" size="sm">
|
||||||
|
<ChevronRight />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Main Page ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const LessonsList = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { lessons, lessonsLoading, getLessons } = useLibrary();
|
||||||
|
const { tierMap, getTierCategories } = useClientTiers();
|
||||||
|
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [lockFilter, setLockFilter] = useState("All");
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const [selectedLesson, setSelectedLesson] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getLessons();
|
||||||
|
getTierCategories();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const filtered = useMemo(() =>
|
||||||
|
lessons
|
||||||
|
.filter((l) => {
|
||||||
|
const matchSearch = l.title.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
(l.description ?? "").toLowerCase().includes(search.toLowerCase());
|
||||||
|
const matchLock = lockFilter === "All"
|
||||||
|
|| (lockFilter === "Unlocked" && !l.is_locked)
|
||||||
|
|| (lockFilter === "Locked" && l.is_locked);
|
||||||
|
return matchSearch && matchLock;
|
||||||
|
})
|
||||||
|
.sort((a, b) => a.title.localeCompare(b.title)),
|
||||||
|
[lessons, search, lockFilter]
|
||||||
|
);
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(filtered.length / ITEMS_PER_PAGE));
|
||||||
|
const paginated = filtered.slice((currentPage - 1) * ITEMS_PER_PAGE, currentPage * ITEMS_PER_PAGE);
|
||||||
|
|
||||||
|
const handleViewDetails = (lesson) => {
|
||||||
|
if (lesson.is_locked) {
|
||||||
|
setSelectedLesson(lesson);
|
||||||
|
setModalOpen(true);
|
||||||
|
} else {
|
||||||
|
navigate(`/lessons/${lesson.uuid}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const items = [
|
||||||
|
{ label: "Home", icon: <House className="size-4" />, to: `/dashboard` },
|
||||||
|
{ label: "Lessons" },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<PageMeta title="Lessons - STARR" description="Browse standalone lessons you can start right away." />
|
||||||
|
<div className="py-24 bg-accent/70 min-h-screen">
|
||||||
|
<div className="flex flex-col gap-4 justify-between lg:container lg:mx-auto pt-2">
|
||||||
|
<AppBreadcrumb items={items} />
|
||||||
|
|
||||||
|
{/* Search & Filters */}
|
||||||
|
<div className="fixed top-[67px] left-0 right-0 z-10 bg-card border-b lg:static lg:bg-transparent lg:border-none py-3 xs:px-4 md:px-6 lg:px-0">
|
||||||
|
<div className="flex items-center xs:flex-col lg:flex-row gap-4">
|
||||||
|
<Input
|
||||||
|
placeholder="Search lessons..."
|
||||||
|
className="w-full bg-card lg:max-w-64 text-sm"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => { setSearch(e.target.value); setCurrentPage(1); }}
|
||||||
|
/>
|
||||||
|
<Select value={lockFilter} onValueChange={(v) => { setLockFilter(v); setCurrentPage(1); }}>
|
||||||
|
<SelectTrigger className="w-full lg:w-48 bg-card">
|
||||||
|
<SelectValue placeholder="Access" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="All">All Lessons</SelectItem>
|
||||||
|
<SelectItem value="Unlocked">Unlocked</SelectItem>
|
||||||
|
<SelectItem value="Locked">Locked</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Select value="lessons" onValueChange={(v) => {
|
||||||
|
if (v === "courses") navigate("/course");
|
||||||
|
if (v === "units") navigate("/units");
|
||||||
|
}}>
|
||||||
|
<SelectTrigger className="w-full lg:w-48 bg-card">
|
||||||
|
<SelectValue placeholder="Browse" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="courses">Courses</SelectItem>
|
||||||
|
<SelectItem value="units">Units</SelectItem>
|
||||||
|
<SelectItem value="lessons">Lessons</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Lesson Grid */}
|
||||||
|
{lessonsLoading ? (
|
||||||
|
<div className="xs:pt-12 lg:pt-0 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||||
|
{Array.from({ length: 8 }).map((_, i) => <LessonCardSkeleton key={i} />)}
|
||||||
|
</div>
|
||||||
|
) : paginated.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-20">
|
||||||
|
<Layers className="size-40 text-primary" />
|
||||||
|
<p className="text-md">No lessons found</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="xs:pt-12 lg:pt-0 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 mb-4 xs:px-4 lg:px-0">
|
||||||
|
{paginated.map((lesson) => (
|
||||||
|
<LessonCard
|
||||||
|
key={lesson.lesson_id}
|
||||||
|
lesson={lesson}
|
||||||
|
onViewDetails={handleViewDetails}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!lessonsLoading && filtered.length > ITEMS_PER_PAGE && (
|
||||||
|
<Pagination
|
||||||
|
currentPage={currentPage}
|
||||||
|
totalPages={totalPages}
|
||||||
|
totalItems={filtered.length}
|
||||||
|
itemsPerPage={ITEMS_PER_PAGE}
|
||||||
|
onPageChange={setCurrentPage}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<LessonUpsellModal
|
||||||
|
open={modalOpen}
|
||||||
|
onOpenChange={setModalOpen}
|
||||||
|
lesson={selectedLesson}
|
||||||
|
tierMap={tierMap}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LessonsList;
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import * as LucideIcons from "lucide-react";
|
||||||
import {
|
import {
|
||||||
Card, CardContent, CardDescription,
|
Card, CardContent, CardDescription,
|
||||||
CardFooter, CardHeader, CardTitle,
|
CardFooter, CardHeader, CardTitle,
|
||||||
@@ -14,7 +15,7 @@ import {
|
|||||||
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
|
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
|
||||||
import {
|
import {
|
||||||
BookOpen, Clock, Check,
|
BookOpen, Clock, Check,
|
||||||
Tag, LockIcon, Zap, RotateCcw,
|
Tag, RotateCcw, LaptopMinimal, Table as TableIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useClientTiers } from "@/contexts/ClientTiersProvider";
|
import { useClientTiers } from "@/contexts/ClientTiersProvider";
|
||||||
import ResponsiveModal from "@/components/generic/ResponsiveModal";
|
import ResponsiveModal from "@/components/generic/ResponsiveModal";
|
||||||
@@ -24,6 +25,8 @@ import { PageMeta } from "@/contexts/MetadataContext";
|
|||||||
import { useDateFormat } from "@/hooks/useDateFormat";
|
import { useDateFormat } from "@/hooks/useDateFormat";
|
||||||
import { useClientAdvertisements } from "@/contexts/ClientAdvertisementContext";
|
import { useClientAdvertisements } from "@/contexts/ClientAdvertisementContext";
|
||||||
import { Banner, BannerSkeleton } from "@/components/generic/Blocks/Client/Advertisements/Banner";
|
import { Banner, BannerSkeleton } from "@/components/generic/Blocks/Client/Advertisements/Banner";
|
||||||
|
import { resolveTierBadge } from "@/utils/tierBadge.util";
|
||||||
|
import PlanComparisonTable from "../components/PlanComparisonTable";
|
||||||
|
|
||||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -53,31 +56,6 @@ function formatCourseDuration(seconds = 0) {
|
|||||||
return `${m}m`;
|
return `${m}m`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Badge styles per tier
|
|
||||||
const TIER_STYLES = {
|
|
||||||
free: {
|
|
||||||
badge: "bg-gradient-to-r from-lime-400 to-lime-600 text-white",
|
|
||||||
button: "default",
|
|
||||||
icon: Tag,
|
|
||||||
label: "Free",
|
|
||||||
ring: "",
|
|
||||||
},
|
|
||||||
premium: {
|
|
||||||
badge: "bg-gradient-to-r from-fuchsia-600 to-purple-600 text-white",
|
|
||||||
button: "default",
|
|
||||||
icon: Zap,
|
|
||||||
label: "Premium",
|
|
||||||
ring: "ring-2 ring-fuchsia-400/40",
|
|
||||||
},
|
|
||||||
exclusive: {
|
|
||||||
badge: "bg-gradient-to-r from-rose-500 to-red-600 text-white",
|
|
||||||
button: "default",
|
|
||||||
icon: LockIcon,
|
|
||||||
label: "Exclusive",
|
|
||||||
ring: "ring-2 ring-rose-400/40",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── Skeleton ──────────────────────────────────────────────────────────────────
|
// ─── Skeleton ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const PlanSkeleton = () => (
|
const PlanSkeleton = () => (
|
||||||
@@ -104,20 +82,22 @@ const PlanSkeleton = () => (
|
|||||||
|
|
||||||
const PREVIEW_COURSE_LIMIT = 2;
|
const PREVIEW_COURSE_LIMIT = 2;
|
||||||
|
|
||||||
const PlanCard = ({ plan, myTier, onSelect, onView, onRefund, refundSecsLeft }) => {
|
const PlanCard = ({ plan, myTier, tierMap, onSelect, onView, onRefund, refundSecsLeft }) => {
|
||||||
const fmtPlanPrice = (p) => `${p.currency} ${Number(p.price).toFixed(2)}`;
|
const { fmtCurrency } = useDateFormat();
|
||||||
const [coursesOpen, setCoursesOpen] = useState(false);
|
const [coursesOpen, setCoursesOpen] = useState(false);
|
||||||
const [notAvailableOpen, setNotAvailableOpen] = useState(false);
|
const [notAvailableOpen, setNotAvailableOpen] = useState(false);
|
||||||
const style = TIER_STYLES[plan.tier] ?? TIER_STYLES.free;
|
const { label: tierLabel, cls: badgeCls, rank } = resolveTierBadge(plan.tier, tierMap);
|
||||||
const Icon = style.icon;
|
const Icon = LucideIcons[tierMap[plan.tier]?.badge_icon] ?? Tag;
|
||||||
|
const ring = rank > 0 ? "ring-2 ring-primary/30" : "";
|
||||||
const isCurrent = myTier?.tier === plan.tier && myTier?.status === "active";
|
const isCurrent = myTier?.tier === plan.tier && myTier?.status === "active";
|
||||||
const duration = formatDuration(plan.duration_days, plan.duration_unit);
|
const duration = formatDuration(plan.duration_days, plan.duration_unit);
|
||||||
|
const features = plan.features ?? [];
|
||||||
const previewCourses = plan.courses?.slice(0, PREVIEW_COURSE_LIMIT) ?? [];
|
const previewCourses = plan.courses?.slice(0, PREVIEW_COURSE_LIMIT) ?? [];
|
||||||
const extraCount = (plan.courses?.length ?? 0) - PREVIEW_COURSE_LIMIT;
|
const extraCount = (plan.courses?.length ?? 0) - PREVIEW_COURSE_LIMIT;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Card className={`relative flex flex-col ${style.ring}`}>
|
<Card className={`relative flex flex-col ${ring}`}>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<CardTitle>{plan.label}</CardTitle>
|
<CardTitle>{plan.label}</CardTitle>
|
||||||
@@ -125,15 +105,15 @@ const PlanCard = ({ plan, myTier, onSelect, onView, onRefund, refundSecsLeft })
|
|||||||
{isCurrent && (
|
{isCurrent && (
|
||||||
<Badge className="bg-green-500 text-white">Current Plan</Badge>
|
<Badge className="bg-green-500 text-white">Current Plan</Badge>
|
||||||
)}
|
)}
|
||||||
<Badge className={style.badge}>
|
<Badge className={badgeCls}>
|
||||||
<Icon />
|
<Icon />
|
||||||
{style.label}
|
{tierLabel}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
<span className="text-3xl font-bold text-foreground">
|
<span className="text-3xl font-bold text-foreground">
|
||||||
{fmtPlanPrice(plan)}
|
{fmtCurrency(plan.price, plan.currency)}
|
||||||
</span>
|
</span>
|
||||||
{duration && (
|
{duration && (
|
||||||
<span className="text-sm text-muted-foreground ml-1">/ {duration}</span>
|
<span className="text-sm text-muted-foreground ml-1">/ {duration}</span>
|
||||||
@@ -143,6 +123,17 @@ const PlanCard = ({ plan, myTier, onSelect, onView, onRefund, refundSecsLeft })
|
|||||||
|
|
||||||
<CardContent className="flex-1 space-y-4">
|
<CardContent className="flex-1 space-y-4">
|
||||||
|
|
||||||
|
{features.length > 0 && (
|
||||||
|
<ul className="space-y-1.5">
|
||||||
|
{features.slice(0, 4).map((f, i) => (
|
||||||
|
<li key={i} className="flex items-start gap-2 text-sm">
|
||||||
|
<Check className="size-3.5 mt-0.5 shrink-0 text-green-500" />
|
||||||
|
<span>{f.text}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
{plan.courses?.length > 0 ? (
|
{plan.courses?.length > 0 ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide flex items-center gap-1.5">
|
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide flex items-center gap-1.5">
|
||||||
@@ -219,10 +210,9 @@ const PlanCard = ({ plan, myTier, onSelect, onView, onRefund, refundSecsLeft })
|
|||||||
) : !isCurrent ? (
|
) : !isCurrent ? (
|
||||||
<Button
|
<Button
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
variant={style.button}
|
|
||||||
onClick={() => onSelect(plan)}
|
onClick={() => onSelect(plan)}
|
||||||
>
|
>
|
||||||
{plan.tier === "free" ? "Current" : `Get ${style.label}`}
|
{plan.tier === "free" ? "Current" : `Get ${tierLabel}`}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
</CardFooter>
|
</CardFooter>
|
||||||
@@ -253,9 +243,9 @@ const PlanCard = ({ plan, myTier, onSelect, onView, onRefund, refundSecsLeft })
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
<DialogTitle className="leading-snug">{plan.label}</DialogTitle>
|
<DialogTitle className="leading-snug">{plan.label}</DialogTitle>
|
||||||
<Badge className={`${style.badge} shrink-0`}>
|
<Badge className={`${badgeCls} shrink-0`}>
|
||||||
<Icon className="size-3" />
|
<Icon className="size-3" />
|
||||||
{style.label}
|
{tierLabel}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
@@ -263,13 +253,25 @@ const PlanCard = ({ plan, myTier, onSelect, onView, onRefund, refundSecsLeft })
|
|||||||
{/* Price + Duration */}
|
{/* Price + Duration */}
|
||||||
<div className="flex items-baseline gap-1.5">
|
<div className="flex items-baseline gap-1.5">
|
||||||
<span className="text-2xl font-bold">
|
<span className="text-2xl font-bold">
|
||||||
{fmtPlanPrice(plan)}
|
{fmtCurrency(plan.price, plan.currency)}
|
||||||
</span>
|
</span>
|
||||||
{duration && (
|
{duration && (
|
||||||
<span className="text-sm text-muted-foreground">/ {duration}</span>
|
<span className="text-sm text-muted-foreground">/ {duration}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Features */}
|
||||||
|
{features.length > 0 && (
|
||||||
|
<ul className="space-y-1.5">
|
||||||
|
{features.map((f, i) => (
|
||||||
|
<li key={i} className="flex items-start gap-2 text-sm">
|
||||||
|
<Check className="size-3.5 mt-0.5 shrink-0 text-green-500" />
|
||||||
|
<span>{f.text}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
{plan.description && (
|
{plan.description && (
|
||||||
<p className="text-sm text-muted-foreground leading-relaxed -mt-1">
|
<p className="text-sm text-muted-foreground leading-relaxed -mt-1">
|
||||||
@@ -326,7 +328,7 @@ const PlanCard = ({ plan, myTier, onSelect, onView, onRefund, refundSecsLeft })
|
|||||||
className="w-full"
|
className="w-full"
|
||||||
onClick={() => { setCoursesOpen(false); onSelect(plan); }}
|
onClick={() => { setCoursesOpen(false); onSelect(plan); }}
|
||||||
>
|
>
|
||||||
Get {style.label}
|
Get {tierLabel}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
)}
|
)}
|
||||||
@@ -340,11 +342,11 @@ const PlanCard = ({ plan, myTier, onSelect, onView, onRefund, refundSecsLeft })
|
|||||||
|
|
||||||
export default function PlanList() {
|
export default function PlanList() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { plans, plansLoading, myTier, tierLoading, getPlans, getMyTier, resetMyTier } = useClientTiers();
|
const { plans, plansLoading, myTier, tierLoading, tierMap, getPlans, getMyTier, getTierCategories, resetMyTier } = useClientTiers();
|
||||||
const { fmtDate } = useDateFormat();
|
const { fmtDate, fmtCurrency } = useDateFormat();
|
||||||
const { advertisements, loading: adLoading, getActiveAdvertisement, handleAdCtaClick } = useClientAdvertisements();
|
const { advertisements, loading: adLoading, getActiveAdvertisement, handleAdCtaClick } = useClientAdvertisements();
|
||||||
const fmtPlanPrice = (p) => `${p.currency} ${Number(p.price).toFixed(2)}`;
|
|
||||||
|
|
||||||
|
const [view, setView] = useState("grid");
|
||||||
const [refundPlan, setRefundPlan] = useState(null); // plan being refunded
|
const [refundPlan, setRefundPlan] = useState(null); // plan being refunded
|
||||||
const [refundLoading, setRefundLoading] = useState(false);
|
const [refundLoading, setRefundLoading] = useState(false);
|
||||||
const [refundSecsLeft, setRefundSecsLeft] = useState(0);
|
const [refundSecsLeft, setRefundSecsLeft] = useState(0);
|
||||||
@@ -353,6 +355,7 @@ export default function PlanList() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getPlans();
|
getPlans();
|
||||||
getMyTier();
|
getMyTier();
|
||||||
|
getTierCategories();
|
||||||
getActiveAdvertisement("plans.banner");
|
getActiveAdvertisement("plans.banner");
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [getPlans, getMyTier]);
|
}, [getPlans, getMyTier]);
|
||||||
@@ -410,36 +413,59 @@ export default function PlanList() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Section Header */}
|
{/* Section Header */}
|
||||||
<div className="text-center mt-6">
|
<div className="flex flex-col items-center gap-4 mt-6">
|
||||||
|
<div className="text-center">
|
||||||
<h2 className="text-3xl font-bold">Available Plans</h2>
|
<h2 className="text-3xl font-bold">Available Plans</h2>
|
||||||
<p className="text-muted-foreground mt-2">
|
<p className="text-muted-foreground mt-2">
|
||||||
Choose a subscription that matches your goals.
|
Choose a subscription that matches your goals.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
{!plansLoading && plans.length > 0 && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button size="sm" variant={view === "grid" ? "default" : "outline"} onClick={() => setView("grid")}>
|
||||||
|
<LaptopMinimal /> Cards
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant={view === "table" ? "default" : "outline"} onClick={() => setView("table")}>
|
||||||
|
<TableIcon /> Compare
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Plan Cards */}
|
{/* Plan Cards / Comparison Table */}
|
||||||
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
|
|
||||||
{plansLoading || tierLoading ? (
|
{plansLoading || tierLoading ? (
|
||||||
Array.from({ length: 3 }).map((_, i) => <PlanSkeleton key={i} />)
|
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{Array.from({ length: 3 }).map((_, i) => <PlanSkeleton key={i} />)}
|
||||||
|
</div>
|
||||||
) : plans.length === 0 ? (
|
) : plans.length === 0 ? (
|
||||||
<div className="col-span-full flex flex-col items-center justify-center py-20">
|
<div className="flex flex-col items-center justify-center py-20">
|
||||||
<BookOpen className="size-10 mb-3" />
|
<BookOpen className="size-10 mb-3" />
|
||||||
<p className="text-sm">No plans available at the moment.</p>
|
<p className="text-sm">No plans available at the moment.</p>
|
||||||
</div>
|
</div>
|
||||||
|
) : view === "table" ? (
|
||||||
|
<PlanComparisonTable
|
||||||
|
plans={plans}
|
||||||
|
myTier={myTier}
|
||||||
|
tierMap={tierMap}
|
||||||
|
fmtCurrency={fmtCurrency}
|
||||||
|
onSelect={handleSelectPlan}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
plans.map((plan) => (
|
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{plans.map((plan) => (
|
||||||
<PlanCard
|
<PlanCard
|
||||||
key={plan.plan_id}
|
key={plan.plan_id}
|
||||||
plan={plan}
|
plan={plan}
|
||||||
myTier={myTier}
|
myTier={myTier}
|
||||||
|
tierMap={tierMap}
|
||||||
onSelect={handleSelectPlan}
|
onSelect={handleSelectPlan}
|
||||||
onView={handleViewPlan}
|
onView={handleViewPlan}
|
||||||
onRefund={handleRefundClick}
|
onRefund={handleRefundClick}
|
||||||
refundSecsLeft={refundSecsLeft}
|
refundSecsLeft={refundSecsLeft}
|
||||||
/>
|
/>
|
||||||
))
|
))}
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -479,7 +505,7 @@ export default function PlanList() {
|
|||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-muted-foreground">Refund amount</span>
|
<span className="text-muted-foreground">Refund amount</span>
|
||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
{refundPlan ? fmtPlanPrice(refundPlan) : "—"}
|
{refundPlan ? fmtCurrency(refundPlan.price, refundPlan.currency) : "—"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{myTier?.expires_at && (
|
{myTier?.expires_at && (
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||||
import { useParams, useNavigate } from "react-router-dom";
|
import { useParams, useNavigate } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
House, Timer, Layers, LockIcon, SendHorizonal, CheckCheck, CheckCircle2, Circle,
|
House, Timer, Layers, SendHorizonal, CheckCheck, CheckCircle2, Circle,
|
||||||
FileQuestion, Hourglass, Zap, ClipboardList,
|
FileQuestion, Hourglass, ClipboardList,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
@@ -13,6 +13,7 @@ import { useEffect } from "react";
|
|||||||
import { useLibrary } from "@/contexts/ClientLibraryContext";
|
import { useLibrary } from "@/contexts/ClientLibraryContext";
|
||||||
import { useClientTiers } from "@/contexts/ClientTiersProvider";
|
import { useClientTiers } from "@/contexts/ClientTiersProvider";
|
||||||
import { PageMeta } from "@/contexts/MetadataContext";
|
import { PageMeta } from "@/contexts/MetadataContext";
|
||||||
|
import LockedContentPanel from "@/modules/client/components/LockedContentPanel";
|
||||||
|
|
||||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -63,12 +64,17 @@ const UnitContentCard = ({ unitDetail, onLessonClick, onQuizClick }) => {
|
|||||||
className="flex items-center justify-between py-2 px-3 rounded-lg hover:bg-slate-200 dark:hover:bg-blue-500 transition-colors cursor-pointer"
|
className="flex items-center justify-between py-2 px-3 rounded-lg hover:bg-slate-200 dark:hover:bg-blue-500 transition-colors cursor-pointer"
|
||||||
onClick={() => onLessonClick(lesson)}
|
onClick={() => onLessonClick(lesson)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3 select-none min-w-0">
|
<div className="flex items-start gap-3 select-none min-w-0">
|
||||||
{lesson.status === "completed"
|
{lesson.status === "completed"
|
||||||
? <CheckCircle2 className="size-4 text-emerald-500 shrink-0" />
|
? <CheckCircle2 className="size-4 text-emerald-500 shrink-0 mt-0.5" />
|
||||||
: <Circle className="size-4 text-muted-foreground/40 shrink-0" />
|
: <Circle className="size-4 text-muted-foreground/40 shrink-0 mt-0.5" />
|
||||||
}
|
}
|
||||||
<span className="text-md text-card-foreground truncate">{lesson.title}</span>
|
<div className="min-w-0">
|
||||||
|
<span className="text-md text-card-foreground truncate block">{lesson.title}</span>
|
||||||
|
{lesson.description && (
|
||||||
|
<span className="text-sm text-muted-foreground line-clamp-1 block">{lesson.description}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{lesson.duration_seconds > 0 && (
|
{lesson.duration_seconds > 0 && (
|
||||||
<span className="text-sm shrink-0 ml-2">{formatDuration(lesson.duration_seconds)}</span>
|
<span className="text-sm shrink-0 ml-2">{formatDuration(lesson.duration_seconds)}</span>
|
||||||
@@ -130,29 +136,7 @@ const UnitDetails = () => {
|
|||||||
|
|
||||||
// ── Deep-link to a locked unit — inline blocked panel, not a redirect ────
|
// ── Deep-link to a locked unit — inline blocked panel, not a redirect ────
|
||||||
if (unitBlocked) {
|
if (unitBlocked) {
|
||||||
const course = unitBlockedInfo?.course;
|
return <LockedContentPanel course={unitBlockedInfo?.course} tierMap={tierMap} />;
|
||||||
const tier = course?.subscription ? tierMap[course.subscription] : null;
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center gap-6 py-32 text-center px-4">
|
|
||||||
<div className="h-16 w-16 rounded-full bg-amber-100 dark:bg-amber-900/30 flex items-center justify-center">
|
|
||||||
<LockIcon className="size-7 text-amber-500" />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2 max-w-sm">
|
|
||||||
<h2 className="text-xl font-semibold">Premium / Exclusive Content</h2>
|
|
||||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
|
||||||
{course
|
|
||||||
? `This unit is part of "${course.title}"${tier?.name ? ` (${tier.name} plan)` : ""}. Upgrade your plan or view the course to unlock it.`
|
|
||||||
: "Upgrade your plan to access this unit."}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col items-center gap-2">
|
|
||||||
<Button onClick={() => navigate('/plans')} className="gap-1.5">
|
|
||||||
<Zap className="size-4" /> View Available Plans
|
|
||||||
</Button>
|
|
||||||
<p className="text-xs text-muted-foreground">Already subscribed? Your plan may not cover this tier.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (unitDetailLoading) {
|
if (unitDetailLoading) {
|
||||||
@@ -166,7 +150,7 @@ const UnitDetails = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleLessonClick = (lesson) => {
|
const handleLessonClick = (lesson) => {
|
||||||
navigate(`/units/${uuid}/read`, { state: { lessonId: lesson.lesson_id } });
|
navigate(`/lessons/${lesson.uuid}`);
|
||||||
};
|
};
|
||||||
const handleQuizClick = () => {
|
const handleQuizClick = () => {
|
||||||
navigate(`/units/${uuid}/read`, { state: { quizId: true } });
|
navigate(`/units/${uuid}/read`, { state: { quizId: true } });
|
||||||
|
|||||||
@@ -138,13 +138,17 @@ const UnitsList = () => {
|
|||||||
<SelectItem value="Locked">Locked</SelectItem>
|
<SelectItem value="Locked">Locked</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<Select value="units" onValueChange={(v) => { if (v === "courses") navigate("/course"); }}>
|
<Select value="units" onValueChange={(v) => {
|
||||||
|
if (v === "courses") navigate("/course");
|
||||||
|
if (v === "lessons") navigate("/lessons");
|
||||||
|
}}>
|
||||||
<SelectTrigger className="w-full lg:w-48 bg-card">
|
<SelectTrigger className="w-full lg:w-48 bg-card">
|
||||||
<SelectValue placeholder="Browse" />
|
<SelectValue placeholder="Browse" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="courses">Courses</SelectItem>
|
<SelectItem value="courses">Courses</SelectItem>
|
||||||
<SelectItem value="units">Units</SelectItem>
|
<SelectItem value="units">Units</SelectItem>
|
||||||
|
<SelectItem value="lessons">Lessons</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
import * as LucideIcons from "lucide-react";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import {
|
import {
|
||||||
ArrowLeft, BookOpen, Clock, Check,
|
ArrowLeft, BookOpen, Clock, Check,
|
||||||
Tag, LockIcon, Zap, CalendarDays,
|
Tag, CalendarDays,
|
||||||
Star, Users, Trophy, Shield, Flame,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useClientTiers } from "@/contexts/ClientTiersProvider";
|
import { useClientTiers } from "@/contexts/ClientTiersProvider";
|
||||||
import { useProfile } from "@/contexts/ProfileProvider";
|
import { useProfile } from "@/contexts/ProfileProvider";
|
||||||
import { PageMeta } from "@/contexts/MetadataContext";
|
import { PageMeta } from "@/contexts/MetadataContext";
|
||||||
|
import { useDateFormat } from "@/hooks/useDateFormat";
|
||||||
|
import { resolveTierBadge } from "@/utils/tierBadge.util";
|
||||||
|
import { getTierColor } from "@/utils/tierColors";
|
||||||
|
|
||||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -33,59 +36,6 @@ function formatCourseDuration(seconds = 0) {
|
|||||||
return `${m}m`;
|
return `${m}m`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Tier config ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const TIER_STYLES = {
|
|
||||||
free: {
|
|
||||||
badge: "bg-gradient-to-r from-lime-400 to-lime-600 text-white",
|
|
||||||
heroBg: "from-lime-500 via-green-600 to-emerald-700",
|
|
||||||
accentColor: "text-lime-600 dark:text-lime-400",
|
|
||||||
accentBg: "bg-lime-50 dark:bg-lime-950/30",
|
|
||||||
accentBorder: "border-lime-200 dark:border-lime-800",
|
|
||||||
icon: Tag,
|
|
||||||
label: "Free",
|
|
||||||
tagline: "Start your learning journey — no cost, no commitment.",
|
|
||||||
perks: [
|
|
||||||
{ icon: BookOpen, text: "Access to free course library" },
|
|
||||||
{ icon: Users, text: "Join our learning community" },
|
|
||||||
{ icon: Shield, text: "Track your progress & achievements" },
|
|
||||||
{ icon: Star, text: "No credit card required" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
premium: {
|
|
||||||
badge: "bg-gradient-to-r from-fuchsia-500 to-purple-600 text-white",
|
|
||||||
heroBg: "from-fuchsia-600 via-purple-700 to-violet-800",
|
|
||||||
accentColor: "text-fuchsia-600 dark:text-fuchsia-400",
|
|
||||||
accentBg: "bg-fuchsia-50 dark:bg-fuchsia-950/30",
|
|
||||||
accentBorder: "border-fuchsia-200 dark:border-fuchsia-800",
|
|
||||||
icon: Zap,
|
|
||||||
label: "Premium",
|
|
||||||
tagline: "Unlock expert knowledge and accelerate your career.",
|
|
||||||
perks: [
|
|
||||||
{ icon: BookOpen, text: "Full access to all premium courses" },
|
|
||||||
{ icon: Clock, text: "Learn at your own pace, anytime" },
|
|
||||||
{ icon: Trophy, text: "Earn certificates of completion" },
|
|
||||||
{ icon: Shield, text: "Priority support & guidance" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
exclusive: {
|
|
||||||
badge: "bg-gradient-to-r from-rose-500 to-red-600 text-white",
|
|
||||||
heroBg: "from-rose-600 via-red-700 to-orange-800",
|
|
||||||
accentColor: "text-rose-600 dark:text-rose-400",
|
|
||||||
accentBg: "bg-rose-50 dark:bg-rose-950/30",
|
|
||||||
accentBorder: "border-rose-200 dark:border-rose-800",
|
|
||||||
icon: LockIcon,
|
|
||||||
label: "Exclusive",
|
|
||||||
tagline: "The ultimate learning experience for serious professionals.",
|
|
||||||
perks: [
|
|
||||||
{ icon: Star, text: "Everything in Premium unlocked" },
|
|
||||||
{ icon: Users, text: "1-on-1 mentorship sessions" },
|
|
||||||
{ icon: Trophy, text: "Exclusive expert-only content" },
|
|
||||||
{ icon: Flame, text: "Early access to new releases" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── Skeleton ─────────────────────────────────────────────────────────────────
|
// ─── Skeleton ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const ViewPlanSkeleton = () => (
|
const ViewPlanSkeleton = () => (
|
||||||
@@ -103,14 +53,16 @@ const ViewPlanSkeleton = () => (
|
|||||||
const ViewPlan = () => {
|
const ViewPlan = () => {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { myTier, getMyTier, plans, plansLoading, getPlans } = useClientTiers();
|
const { myTier, getMyTier, plans, plansLoading, getPlans, tierMap, getTierCategories } = useClientTiers();
|
||||||
const { getProfile } = useProfile();
|
const { getProfile } = useProfile();
|
||||||
const fmtPlanPrice = (p) => `${p.currency} ${Number(p.price).toFixed(2)}`;
|
const { fmtCurrency } = useDateFormat();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getProfile();
|
getProfile();
|
||||||
getMyTier();
|
getMyTier();
|
||||||
|
getTierCategories();
|
||||||
if (!plans.length) getPlans();
|
if (!plans.length) getPlans();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
const plan = plans.find((p) => String(p.plan_id) === String(id)) ?? null;
|
const plan = plans.find((p) => String(p.plan_id) === String(id)) ?? null;
|
||||||
@@ -119,8 +71,14 @@ const ViewPlan = () => {
|
|||||||
if (loading) return <ViewPlanSkeleton />;
|
if (loading) return <ViewPlanSkeleton />;
|
||||||
if (!plan) return null;
|
if (!plan) return null;
|
||||||
|
|
||||||
const style = TIER_STYLES[plan.tier] ?? TIER_STYLES.free;
|
const { label: tierLabel, cls: badgeCls } = resolveTierBadge(plan.tier, tierMap);
|
||||||
const Icon = style.icon;
|
const Icon = LucideIcons[tierMap[plan.tier]?.badge_icon] ?? Tag;
|
||||||
|
const colors = getTierColor(tierMap[plan.tier]?.color ?? "purple");
|
||||||
|
const accentSwatch = colors.swatch;
|
||||||
|
const accentStyle = { color: accentSwatch };
|
||||||
|
const accentBg = colors.panel.bg;
|
||||||
|
const accentBorder = colors.panel.border;
|
||||||
|
const features = plan.features ?? [];
|
||||||
const duration = formatDuration(plan.duration_days, plan.duration_unit);
|
const duration = formatDuration(plan.duration_days, plan.duration_unit);
|
||||||
const isCurrent = myTier?.tier === plan.tier && myTier?.status === "active";
|
const isCurrent = myTier?.tier === plan.tier && myTier?.status === "active";
|
||||||
|
|
||||||
@@ -132,7 +90,7 @@ const ViewPlan = () => {
|
|||||||
<PageMeta title={plan ? `${plan.label} - STARR` : undefined} />
|
<PageMeta title={plan ? `${plan.label} - STARR` : undefined} />
|
||||||
|
|
||||||
{/* ── Hero Banner ───────────────────────────────────────────── */}
|
{/* ── Hero Banner ───────────────────────────────────────────── */}
|
||||||
<div className={`relative bg-gradient-to-br ${style.heroBg} overflow-hidden`}>
|
<div className={`relative ${badgeCls} overflow-hidden`}>
|
||||||
{/* Decorative blobs */}
|
{/* Decorative blobs */}
|
||||||
<div className="absolute inset-0 pointer-events-none overflow-hidden">
|
<div className="absolute inset-0 pointer-events-none overflow-hidden">
|
||||||
<div className="absolute -top-24 -right-24 w-96 h-96 rounded-full bg-white/5" />
|
<div className="absolute -top-24 -right-24 w-96 h-96 rounded-full bg-white/5" />
|
||||||
@@ -148,21 +106,21 @@ const ViewPlan = () => {
|
|||||||
<ArrowLeft className="size-4" /> Back to Plans
|
<ArrowLeft className="size-4" /> Back to Plans
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<Badge className={`${style.badge} mb-4 text-sm px-3 py-1`}>
|
<Badge className="bg-white/20 border border-white/30 text-white mb-4 text-sm px-3 py-1">
|
||||||
<Icon className="size-3.5 mr-1" /> {style.label}
|
<Icon className="size-3.5 mr-1" /> {tierLabel}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|
||||||
<h1 className="text-3xl sm:text-4xl font-extrabold text-white mb-2 leading-tight tracking-tight">
|
<h1 className="text-3xl sm:text-4xl font-extrabold text-white mb-2 leading-tight tracking-tight">
|
||||||
{plan.label}
|
{plan.label}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-white/70 text-sm mb-8 max-w-md leading-relaxed">
|
<p className="text-white/70 text-sm mb-8 max-w-md leading-relaxed">
|
||||||
{plan.description || style.tagline}
|
{plan.description || `Everything you need with the ${tierLabel} plan.`}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-end gap-4">
|
<div className="flex flex-wrap items-end gap-4">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-5xl font-black text-white leading-none">
|
<span className="text-5xl font-black text-white leading-none">
|
||||||
{fmtPlanPrice(plan)}
|
{fmtCurrency(plan.price, plan.currency)}
|
||||||
</span>
|
</span>
|
||||||
{duration && (
|
{duration && (
|
||||||
<span className="text-white/60 text-sm ml-2">/ {duration}</span>
|
<span className="text-white/60 text-sm ml-2">/ {duration}</span>
|
||||||
@@ -193,21 +151,23 @@ const ViewPlan = () => {
|
|||||||
<div className="px-6 lg:container lg:max-w-3xl lg:mx-auto space-y-5 py-6">
|
<div className="px-6 lg:container lg:max-w-3xl lg:mx-auto space-y-5 py-6">
|
||||||
|
|
||||||
{/* ── What's included ───────────────────────────────────── */}
|
{/* ── What's included ───────────────────────────────────── */}
|
||||||
<div className={`rounded-2xl border ${style.accentBorder} ${style.accentBg} p-5`}>
|
{features.length > 0 && (
|
||||||
<p className={`text-xs font-bold uppercase tracking-widest ${style.accentColor} mb-4`}>
|
<div className={`rounded-2xl border ${accentBorder} ${accentBg} p-5`}>
|
||||||
|
<p className="text-xs font-bold uppercase tracking-widest mb-4" style={accentStyle}>
|
||||||
What's included
|
What's included
|
||||||
</p>
|
</p>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
{style.perks.map(({ icon: PerkIcon, text }, i) => (
|
{features.map(({ text }, i) => (
|
||||||
<div key={i} className="flex items-center gap-3">
|
<div key={i} className="flex items-center gap-3">
|
||||||
<div className={`h-8 w-8 rounded-lg ${style.accentBg} border ${style.accentBorder} flex items-center justify-center shrink-0`}>
|
<div className={`h-8 w-8 rounded-lg ${accentBg} border ${accentBorder} flex items-center justify-center shrink-0`}>
|
||||||
<PerkIcon className={`size-4 ${style.accentColor}`} />
|
<Check className="size-4" style={accentStyle} />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm font-medium">{text}</span>
|
<span className="text-sm font-medium">{text}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ── Stats row ─────────────────────────────────────────── */}
|
{/* ── Stats row ─────────────────────────────────────────── */}
|
||||||
<div className="grid grid-cols-3 gap-3">
|
<div className="grid grid-cols-3 gap-3">
|
||||||
@@ -217,7 +177,7 @@ const ViewPlan = () => {
|
|||||||
{ label: "Access", value: duration ?? "Lifetime", icon: CalendarDays },
|
{ label: "Access", value: duration ?? "Lifetime", icon: CalendarDays },
|
||||||
].map(({ label, value, icon: StatIcon }) => (
|
].map(({ label, value, icon: StatIcon }) => (
|
||||||
<div key={label} className="rounded-xl bg-card border p-4 flex flex-col items-center gap-1 text-center">
|
<div key={label} className="rounded-xl bg-card border p-4 flex flex-col items-center gap-1 text-center">
|
||||||
<StatIcon className={`size-4 ${style.accentColor}`} />
|
<StatIcon className="size-4" style={accentStyle} />
|
||||||
<p className="text-2xl font-bold leading-none mt-1">{value}</p>
|
<p className="text-2xl font-bold leading-none mt-1">{value}</p>
|
||||||
<p className="text-xs text-muted-foreground">{label}</p>
|
<p className="text-xs text-muted-foreground">{label}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -228,7 +188,7 @@ const ViewPlan = () => {
|
|||||||
<div className="rounded-2xl bg-card border overflow-hidden">
|
<div className="rounded-2xl bg-card border overflow-hidden">
|
||||||
<div className="px-5 py-4 border-b flex items-center justify-between">
|
<div className="px-5 py-4 border-b flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<BookOpen className={`size-4 ${style.accentColor}`} />
|
<BookOpen className="size-4" style={accentStyle} />
|
||||||
<span className="text-sm font-semibold">Included Courses</span>
|
<span className="text-sm font-semibold">Included Courses</span>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="secondary">{plan.course_count ?? 0}</Badge>
|
<Badge variant="secondary">{plan.course_count ?? 0}</Badge>
|
||||||
@@ -238,8 +198,8 @@ const ViewPlan = () => {
|
|||||||
{plan.courses?.length > 0 ? (
|
{plan.courses?.length > 0 ? (
|
||||||
plan.courses.map((course) => (
|
plan.courses.map((course) => (
|
||||||
<div key={course.course_id} className="flex items-start gap-4 px-5 py-4">
|
<div key={course.course_id} className="flex items-start gap-4 px-5 py-4">
|
||||||
<div className={`h-10 w-10 rounded-xl ${style.accentBg} border ${style.accentBorder} flex items-center justify-center shrink-0`}>
|
<div className={`h-10 w-10 rounded-xl ${accentBg} border ${accentBorder} flex items-center justify-center shrink-0`}>
|
||||||
<BookOpen className={`size-5 ${style.accentColor}`} />
|
<BookOpen className="size-5" style={accentStyle} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm font-semibold line-clamp-1">{course.title}</p>
|
<p className="text-sm font-semibold line-clamp-1">{course.title}</p>
|
||||||
@@ -267,8 +227,8 @@ const ViewPlan = () => {
|
|||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col items-center justify-center py-10 text-center px-6">
|
<div className="flex flex-col items-center justify-center py-10 text-center px-6">
|
||||||
<div className={`h-14 w-14 rounded-2xl ${style.accentBg} border ${style.accentBorder} flex items-center justify-center mb-3`}>
|
<div className={`h-14 w-14 rounded-2xl ${accentBg} border ${accentBorder} flex items-center justify-center mb-3`}>
|
||||||
<BookOpen className={`size-7 ${style.accentColor}`} />
|
<BookOpen className="size-7" style={accentStyle} />
|
||||||
</div>
|
</div>
|
||||||
<p className="font-semibold text-sm">Courses coming soon</p>
|
<p className="font-semibold text-sm">Courses coming soon</p>
|
||||||
<p className="text-xs text-muted-foreground mt-1 max-w-xs">
|
<p className="text-xs text-muted-foreground mt-1 max-w-xs">
|
||||||
@@ -281,7 +241,7 @@ const ViewPlan = () => {
|
|||||||
|
|
||||||
{/* ── Bottom CTA ────────────────────────────────────────── */}
|
{/* ── Bottom CTA ────────────────────────────────────────── */}
|
||||||
{!isCurrent && (
|
{!isCurrent && (
|
||||||
<div className={`rounded-2xl bg-gradient-to-br ${style.heroBg} p-6 text-center relative overflow-hidden`}>
|
<div className={`rounded-2xl ${badgeCls} p-6 text-center relative overflow-hidden`}>
|
||||||
<div className="absolute inset-0 pointer-events-none overflow-hidden">
|
<div className="absolute inset-0 pointer-events-none overflow-hidden">
|
||||||
<div className="absolute -top-10 -right-10 w-40 h-40 rounded-full bg-white/5" />
|
<div className="absolute -top-10 -right-10 w-40 h-40 rounded-full bg-white/5" />
|
||||||
<div className="absolute -bottom-8 -left-8 w-32 h-32 rounded-full bg-white/5" />
|
<div className="absolute -bottom-8 -left-8 w-32 h-32 rounded-full bg-white/5" />
|
||||||
@@ -291,7 +251,9 @@ const ViewPlan = () => {
|
|||||||
{plan.is_active ? "Ready to get started?" : "Coming Soon"}
|
{plan.is_active ? "Ready to get started?" : "Coming Soon"}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-white/70 text-sm mb-5 max-w-xs mx-auto">
|
<p className="text-white/70 text-sm mb-5 max-w-xs mx-auto">
|
||||||
{plan.is_active ? style.tagline : "This plan is not available for purchase at the moment. Check back later."}
|
{plan.is_active
|
||||||
|
? (plan.description || `Everything you need with the ${tierLabel} plan.`)
|
||||||
|
: "This plan is not available for purchase at the moment. Check back later."}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-col sm:flex-row gap-3 justify-center">
|
<div className="flex flex-col sm:flex-row gap-3 justify-center">
|
||||||
<Button
|
<Button
|
||||||
@@ -307,7 +269,7 @@ const ViewPlan = () => {
|
|||||||
className="bg-white text-gray-900 hover:bg-white/90 font-bold shadow-lg"
|
className="bg-white text-gray-900 hover:bg-white/90 font-bold shadow-lg"
|
||||||
onClick={() => navigate(`/plans/checkout?plan_id=${plan.plan_id}`)}
|
onClick={() => navigate(`/plans/checkout?plan_id=${plan.plan_id}`)}
|
||||||
>
|
>
|
||||||
Get {style.label} Plan — {fmtPlanPrice(plan)}
|
Get {tierLabel} Plan — {fmtCurrency(plan.price, plan.currency)}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { useParams, useNavigate } from 'react-router-dom';
|
|||||||
import {
|
import {
|
||||||
Trophy, Clock, Paperclip, Plus,
|
Trophy, Clock, Paperclip, Plus,
|
||||||
Link, BookOpen, LayoutList, FileText, House,
|
Link, BookOpen, LayoutList, FileText, House,
|
||||||
Image, Video, Music,
|
Image, Video, Music, PenLine, ClipboardCheck, Hourglass, XCircle,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
@@ -29,6 +29,7 @@ import VisitLink from '../components/blocks/VisitLink';
|
|||||||
import ReadCourse from '../components/blocks/ReadCourse';
|
import ReadCourse from '../components/blocks/ReadCourse';
|
||||||
import ReadUnit from '../components/blocks/ReadUnit';
|
import ReadUnit from '../components/blocks/ReadUnit';
|
||||||
import ReadLesson from '../components/blocks/ReadLesson';
|
import ReadLesson from '../components/blocks/ReadLesson';
|
||||||
|
import PassQuiz from '../components/blocks/PassQuiz';
|
||||||
|
|
||||||
import { useTask } from '@/contexts/ClientTaskContext';
|
import { useTask } from '@/contexts/ClientTaskContext';
|
||||||
import { PageMeta } from '@/contexts/MetadataContext';
|
import { PageMeta } from '@/contexts/MetadataContext';
|
||||||
@@ -38,9 +39,18 @@ import { formatDate } from '@/utils/table.util';
|
|||||||
import api from '@/utils/api.util';
|
import api from '@/utils/api.util';
|
||||||
|
|
||||||
// ─── Status badge ─────────────────────────────────────────────────────────────
|
// ─── Status badge ─────────────────────────────────────────────────────────────
|
||||||
const StatusBadge = ({ hasCompletion }) => {
|
const StatusBadge = ({ latestCompletion, requiresReview }) => {
|
||||||
if (hasCompletion) return <Badge variant="outline">Turned in</Badge>;
|
if (!latestCompletion) return <Badge variant="outline">Assigned</Badge>;
|
||||||
return <Badge variant="outline">Assigned</Badge>;
|
if (requiresReview) {
|
||||||
|
if (latestCompletion.status === 'approved') {
|
||||||
|
return <Badge className="bg-green-100 text-green-700 border-green-400 dark:bg-green-900/40 dark:text-green-400 dark:border-green-700">Approved</Badge>;
|
||||||
|
}
|
||||||
|
if (latestCompletion.status === 'rejected') {
|
||||||
|
return <Badge variant="destructive" className="gap-1"><XCircle className="size-3" /> Rejected — resubmit</Badge>;
|
||||||
|
}
|
||||||
|
return <Badge className="bg-amber-100 text-amber-700 border-amber-400 dark:bg-amber-900/40 dark:text-amber-400 dark:border-amber-700 gap-1"><Hourglass className="size-3" /> Pending Review</Badge>;
|
||||||
|
}
|
||||||
|
return <Badge variant="outline">Turned in</Badge>;
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── Requirements status panel ────────────────────────────────────────────────
|
// ─── Requirements status panel ────────────────────────────────────────────────
|
||||||
@@ -48,16 +58,44 @@ const RequirementsStatusPanel = ({ requirements = [], latestCompletion, isVisite
|
|||||||
|
|
||||||
const reqTypes = requirements.map((r) => r.type);
|
const reqTypes = requirements.map((r) => r.type);
|
||||||
|
|
||||||
|
// upload_file and submit_text share one TaskCompletion — "done" also needs
|
||||||
|
// status === 'approved' when either requirement opted into requires_review.
|
||||||
|
const submissionDone = (type) => {
|
||||||
|
const reqs = requirements.filter((r) => r.type === type);
|
||||||
|
if (!reqs.length || !latestCompletion) return false;
|
||||||
|
const needsReview = reqs.some((r) => r.requires_review);
|
||||||
|
return needsReview ? latestCompletion.status === 'approved' : true;
|
||||||
|
};
|
||||||
|
|
||||||
const items = [
|
const items = [
|
||||||
{
|
{
|
||||||
key: 'upload_file',
|
key: 'upload_file',
|
||||||
label: 'File upload',
|
label: 'File upload',
|
||||||
icon: <Paperclip className="size-4 shrink-0 text-muted-foreground" />,
|
icon: <Paperclip className="size-4 shrink-0 text-muted-foreground" />,
|
||||||
getValue: () => {
|
getValue: () => {
|
||||||
const done = !!latestCompletion;
|
const done = submissionDone('upload_file');
|
||||||
return { done: done ? 1 : 0, total: 1, binary: true };
|
return { done: done ? 1 : 0, total: 1, binary: true };
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'submit_text',
|
||||||
|
label: 'Written response',
|
||||||
|
icon: <PenLine className="size-4 shrink-0 text-muted-foreground" />,
|
||||||
|
getValue: () => {
|
||||||
|
const done = submissionDone('submit_text');
|
||||||
|
return { done: done ? 1 : 0, total: 1, binary: true };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'pass_quiz',
|
||||||
|
label: 'Pass quizzes',
|
||||||
|
icon: <ClipboardCheck className="size-4 shrink-0 text-muted-foreground" />,
|
||||||
|
getValue: () => {
|
||||||
|
const reqs = requirements.filter((r) => r.type === 'pass_quiz');
|
||||||
|
const done = reqs.filter((r) => isCompleted(r.requirement_id, r.reference_id)).length;
|
||||||
|
return { done, total: reqs.length, binary: false };
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'visit_link',
|
key: 'visit_link',
|
||||||
label: 'Visit links',
|
label: 'Visit links',
|
||||||
@@ -176,8 +214,8 @@ const FileRow = ({ file, onClick }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── File upload panel (Your Work) ────────────────────────────────────────────
|
// ─── Submission panel (Your Work) — upload_file and/or submit_text ────────────
|
||||||
const FileUploadPanel = ({ latestCompletion, onAddAttachment, submitting, onFileClick }) => {
|
const SubmissionPanel = ({ latestCompletion, onAddAttachment, submitting, onFileClick, requiresReview, hasTextRequirement }) => {
|
||||||
const files = latestCompletion?.files ?? [];
|
const files = latestCompletion?.files ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -185,7 +223,7 @@ const FileUploadPanel = ({ latestCompletion, onAddAttachment, submitting, onFile
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h2 className="font-semibold text-base">Your work</h2>
|
<h2 className="font-semibold text-base">Your work</h2>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<StatusBadge hasCompletion={!!latestCompletion} />
|
<StatusBadge latestCompletion={latestCompletion} requiresReview={requiresReview} />
|
||||||
{files.length >= 2 && (
|
{files.length >= 2 && (
|
||||||
<Badge>
|
<Badge>
|
||||||
<Paperclip className="size-3 mr-1" />
|
<Paperclip className="size-3 mr-1" />
|
||||||
@@ -195,6 +233,16 @@ const FileUploadPanel = ({ latestCompletion, onAddAttachment, submitting, onFile
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{latestCompletion?.status === 'rejected' && latestCompletion?.review_note && (
|
||||||
|
<p className="text-sm text-destructive bg-destructive/5 border border-destructive/30 rounded-md p-3">
|
||||||
|
{latestCompletion.review_note}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasTextRequirement && latestCompletion?.response_text && (
|
||||||
|
<p className="text-sm border rounded-md p-3 whitespace-pre-wrap bg-muted/40">{latestCompletion.response_text}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{files.length > 0 ? (
|
{files.length > 0 ? (
|
||||||
files.length >= 2 ? (
|
files.length >= 2 ? (
|
||||||
<ScrollArea className="max-h-[210px]">
|
<ScrollArea className="max-h-[210px]">
|
||||||
@@ -211,13 +259,13 @@ const FileUploadPanel = ({ latestCompletion, onAddAttachment, submitting, onFile
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
) : (
|
) : !(hasTextRequirement && latestCompletion?.response_text) ? (
|
||||||
<p className="text-sm text-center py-6 text-muted-foreground">No work attached</p>
|
<p className="text-sm text-center py-6 text-muted-foreground">No work attached</p>
|
||||||
)}
|
) : null}
|
||||||
|
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<Button className="w-full" onClick={onAddAttachment} disabled={submitting}>
|
<Button className="w-full" onClick={onAddAttachment} disabled={submitting}>
|
||||||
<Plus /> {latestCompletion ? 'Resubmit' : 'Add Attachment'}
|
<Plus /> {latestCompletion ? 'Resubmit' : 'Turn In'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -249,6 +297,7 @@ const ViewTask = () => {
|
|||||||
const [taskModal, setTaskModal] = useState(false);
|
const [taskModal, setTaskModal] = useState(false);
|
||||||
const [uploadState, setUploadState] = useState({ files: [], isUploading: false });
|
const [uploadState, setUploadState] = useState({ files: [], isUploading: false });
|
||||||
const [note, setNote] = useState('');
|
const [note, setNote] = useState('');
|
||||||
|
const [responseText, setResponseText] = useState('');
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [previewFile, setPreviewFile] = useState(null);
|
const [previewFile, setPreviewFile] = useState(null);
|
||||||
|
|
||||||
@@ -268,11 +317,15 @@ const ViewTask = () => {
|
|||||||
const requirements = task?.requirements ?? [];
|
const requirements = task?.requirements ?? [];
|
||||||
const reqTypes = requirements.map((r) => r.type);
|
const reqTypes = requirements.map((r) => r.type);
|
||||||
const hasFileUpload = reqTypes.includes('upload_file');
|
const hasFileUpload = reqTypes.includes('upload_file');
|
||||||
|
const hasTextReq = reqTypes.includes('submit_text');
|
||||||
|
const hasSubmission = hasFileUpload || hasTextReq;
|
||||||
|
const requiresReview = requirements.some((r) => ['upload_file', 'submit_text'].includes(r.type) && r.requires_review);
|
||||||
|
|
||||||
// ── Upload file requirement config (allowed types, max count) ─────────────
|
// ── Upload file requirement config (allowed types, max count) ─────────────
|
||||||
const uploadFileReq = requirements.find((r) => r.type === 'upload_file');
|
const uploadFileReq = requirements.find((r) => r.type === 'upload_file');
|
||||||
const allowedFileTypes = uploadFileReq?.allowed_file_types ?? [];
|
const allowedFileTypes = uploadFileReq?.allowed_file_types ?? [];
|
||||||
const maxFileCount = uploadFileReq?.max_file_count ?? null;
|
const maxFileCount = uploadFileReq?.max_file_count ?? null;
|
||||||
|
const textReq = requirements.find((r) => r.type === 'submit_text');
|
||||||
|
|
||||||
// ── Visit link handler (passed to VisitLink block) ────────────────────────
|
// ── Visit link handler (passed to VisitLink block) ────────────────────────
|
||||||
const handleVisitLink = useCallback(async (requirementId) => {
|
const handleVisitLink = useCallback(async (requirementId) => {
|
||||||
@@ -286,7 +339,8 @@ const ViewTask = () => {
|
|||||||
// ── Submit handler ────────────────────────────────────────────────────────
|
// ── Submit handler ────────────────────────────────────────────────────────
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (uploadState.isUploading) return;
|
if (uploadState.isUploading) return;
|
||||||
if (!uploadState.files.length) return;
|
if (hasFileUpload && !uploadState.files.length) return;
|
||||||
|
if (!hasFileUpload && hasTextReq && !responseText.trim()) return;
|
||||||
|
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
@@ -314,19 +368,21 @@ const ViewTask = () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!uploadedFiles.length) {
|
if (hasFileUpload && !uploadedFiles.length) {
|
||||||
toast('No files were uploaded successfully.');
|
toast('No files were uploaded successfully.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Submit completion with uploaded file references
|
// 2. Submit completion with uploaded file references + response text
|
||||||
await completeTask(groupId, taskListId, taskId, {
|
await completeTask(groupId, taskListId, taskId, {
|
||||||
note: note.trim() || null,
|
note: note.trim() || null,
|
||||||
files: uploadedFiles,
|
files: uploadedFiles,
|
||||||
|
response_text: hasTextReq ? (responseText.trim() || null) : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
setTaskModal(false);
|
setTaskModal(false);
|
||||||
setNote('');
|
setNote('');
|
||||||
|
setResponseText('');
|
||||||
setUploadState({ files: [], isUploading: false });
|
setUploadState({ files: [], isUploading: false });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast('Failed to submit. Please try again.');
|
toast('Failed to submit. Please try again.');
|
||||||
@@ -340,6 +396,7 @@ const ViewTask = () => {
|
|||||||
const readCourseReqs = requirements.filter((r) => r.type === 'read_course');
|
const readCourseReqs = requirements.filter((r) => r.type === 'read_course');
|
||||||
const readUnitReqs = requirements.filter((r) => r.type === 'read_unit');
|
const readUnitReqs = requirements.filter((r) => r.type === 'read_unit');
|
||||||
const readLessonReqs = requirements.filter((r) => r.type === 'read_lesson');
|
const readLessonReqs = requirements.filter((r) => r.type === 'read_lesson');
|
||||||
|
const passQuizReqs = requirements.filter((r) => r.type === 'pass_quiz');
|
||||||
|
|
||||||
const breadcrumbItems = [
|
const breadcrumbItems = [
|
||||||
{ label: 'Home', icon: <House className="size-4" />, to: '/dashboard' },
|
{ label: 'Home', icon: <House className="size-4" />, to: '/dashboard' },
|
||||||
@@ -352,12 +409,12 @@ const ViewTask = () => {
|
|||||||
<div className="mt-17">
|
<div className="mt-17">
|
||||||
<PageMeta title={task ? `${task.name} - STARR` : undefined} />
|
<PageMeta title={task ? `${task.name} - STARR` : undefined} />
|
||||||
|
|
||||||
{/* ── File upload modal ──────────────────────────────────────────── */}
|
{/* ── Submission modal (files and/or text response) ────────────────── */}
|
||||||
{hasFileUpload && (
|
{hasSubmission && (
|
||||||
<ResponsiveModal
|
<ResponsiveModal
|
||||||
open={taskModal}
|
open={taskModal}
|
||||||
onOpenChange={setTaskModal}
|
onOpenChange={setTaskModal}
|
||||||
title="Add Attachment"
|
title={hasFileUpload ? 'Add Attachment' : 'Submit Response'}
|
||||||
description={task?.name ?? ''}
|
description={task?.name ?? ''}
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
@@ -369,7 +426,8 @@ const ViewTask = () => {
|
|||||||
disabled={
|
disabled={
|
||||||
submitting ||
|
submitting ||
|
||||||
uploadState.isUploading ||
|
uploadState.isUploading ||
|
||||||
uploadState.files.length === 0
|
(hasFileUpload && uploadState.files.length === 0) ||
|
||||||
|
(!hasFileUpload && hasTextReq && !responseText.trim())
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{submitting ? 'Submitting…' : uploadState.isUploading ? 'Uploading…' : 'Turn in'}
|
{submitting ? 'Submitting…' : uploadState.isUploading ? 'Uploading…' : 'Turn in'}
|
||||||
@@ -377,6 +435,21 @@ const ViewTask = () => {
|
|||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
{hasTextReq && (
|
||||||
|
<div className="flex flex-col gap-1.5 mb-3">
|
||||||
|
<label className="text-sm font-medium">
|
||||||
|
{textReq?.prompt || 'Your response'}
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
className="w-full rounded-md border bg-muted/50 px-3 py-2 text-sm resize-none focus:outline-none focus:ring-1 focus:ring-ring"
|
||||||
|
rows={5}
|
||||||
|
placeholder="Write your response…"
|
||||||
|
value={responseText}
|
||||||
|
onChange={(e) => setResponseText(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Optional note */}
|
{/* Optional note */}
|
||||||
<div className="flex flex-col gap-1.5 mb-3">
|
<div className="flex flex-col gap-1.5 mb-3">
|
||||||
<label className="text-sm font-medium">Note (optional)</label>
|
<label className="text-sm font-medium">Note (optional)</label>
|
||||||
@@ -389,11 +462,13 @@ const ViewTask = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{hasFileUpload && (
|
||||||
<FileUpload
|
<FileUpload
|
||||||
allowedFileTypes={allowedFileTypes}
|
allowedFileTypes={allowedFileTypes}
|
||||||
maxFileCount={maxFileCount}
|
maxFileCount={maxFileCount}
|
||||||
onChange={(state) => setUploadState(state)}
|
onChange={(state) => setUploadState(state)}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</ResponsiveModal>
|
</ResponsiveModal>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -519,17 +594,35 @@ const ViewTask = () => {
|
|||||||
taskId={taskId}
|
taskId={taskId}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* pass_quiz */}
|
||||||
|
{passQuizReqs.length > 0 && (
|
||||||
|
<PassQuiz
|
||||||
|
quizzes={passQuizReqs.map((r) => ({
|
||||||
|
id: r.requirement_id,
|
||||||
|
requirement_id: r.requirement_id,
|
||||||
|
reference_id: r.reference_id,
|
||||||
|
title: r.reference_label ?? 'Quiz',
|
||||||
|
completed: isCompleted(r.requirement_id, r.reference_id),
|
||||||
|
}))}
|
||||||
|
groupId={groupId}
|
||||||
|
taskListId={taskListId}
|
||||||
|
taskId={taskId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Your work — mobile only, at the very end */}
|
{/* Your work — mobile only, at the very end */}
|
||||||
{hasFileUpload && (
|
{hasSubmission && (
|
||||||
<div className="lg:hidden">
|
<div className="lg:hidden">
|
||||||
<FileUploadPanel
|
<SubmissionPanel
|
||||||
latestCompletion={latestCompletion}
|
latestCompletion={latestCompletion}
|
||||||
onAddAttachment={() => setTaskModal(true)}
|
onAddAttachment={() => setTaskModal(true)}
|
||||||
submitting={submitting}
|
submitting={submitting}
|
||||||
onFileClick={(file) => setPreviewFile(file)}
|
onFileClick={(file) => setPreviewFile(file)}
|
||||||
|
requiresReview={requiresReview}
|
||||||
|
hasTextRequirement={hasTextReq}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -537,12 +630,14 @@ const ViewTask = () => {
|
|||||||
|
|
||||||
{/* ── Right (desktop sidebar only) ───────────────────────── */}
|
{/* ── Right (desktop sidebar only) ───────────────────────── */}
|
||||||
<div className="hidden lg:flex lg:flex-col gap-4 lg:sticky lg:top-24 lg:self-start select-none">
|
<div className="hidden lg:flex lg:flex-col gap-4 lg:sticky lg:top-24 lg:self-start select-none">
|
||||||
{hasFileUpload && (
|
{hasSubmission && (
|
||||||
<FileUploadPanel
|
<SubmissionPanel
|
||||||
latestCompletion={latestCompletion}
|
latestCompletion={latestCompletion}
|
||||||
onAddAttachment={() => setTaskModal(true)}
|
onAddAttachment={() => setTaskModal(true)}
|
||||||
submitting={submitting}
|
submitting={submitting}
|
||||||
onFileClick={(file) => setPreviewFile(file)}
|
onFileClick={(file) => setPreviewFile(file)}
|
||||||
|
requiresReview={requiresReview}
|
||||||
|
hasTextRequirement={hasTextReq}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<RequirementsStatusPanel
|
<RequirementsStatusPanel
|
||||||
|
|||||||
@@ -11,14 +11,16 @@ import { useParams, useNavigate } from 'react-router-dom';
|
|||||||
import { useTask } from '@/contexts/ClientTaskContext';
|
import { useTask } from '@/contexts/ClientTaskContext';
|
||||||
import { useGroup } from '@/contexts/ClientGroupContext';
|
import { useGroup } from '@/contexts/ClientGroupContext';
|
||||||
import { PageMeta } from '@/contexts/MetadataContext';
|
import { PageMeta } from '@/contexts/MetadataContext';
|
||||||
|
import api from '@/utils/api.util';
|
||||||
|
|
||||||
import AppBreadcrumb from '@/components/generic/Breadcrumb/AppBreadcrumb';
|
import AppBreadcrumb from '@/components/generic/Breadcrumb/AppBreadcrumb';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { Progress } from '@/components/ui/progress';
|
||||||
import {
|
import {
|
||||||
House, Calendar, AlertTriangle, Check, ArrowRight, ListChecks, LayoutList,
|
House, Calendar, AlertTriangle, Check, ArrowRight, ListChecks, LayoutList,
|
||||||
LaptopMinimal, Table,
|
LaptopMinimal, Table, Lock,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Tabs, TabsList, TabsPanel, TabsTab } from '@/components/coss/tabs';
|
import { Tabs, TabsList, TabsPanel, TabsTab } from '@/components/coss/tabs';
|
||||||
import { formatDate } from '@/utils/table.util';
|
import { formatDate } from '@/utils/table.util';
|
||||||
@@ -73,22 +75,31 @@ const TaskStatusBadge = ({ task }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// ─── Task card ────────────────────────────────────────────────────────────────
|
// ─── Task card ────────────────────────────────────────────────────────────────
|
||||||
const TaskCard = ({ task, onClick }) => {
|
const TaskCard = ({ task, onClick, locked }) => {
|
||||||
const reqCount = task.requirements?.length ?? 0;
|
const reqCount = task.requirements?.length ?? 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
onClick={onClick}
|
onClick={() => !locked && onClick()}
|
||||||
className={cn(
|
className={cn(
|
||||||
'border bg-card rounded-lg flex flex-col cursor-pointer transition-colors',
|
'border bg-card rounded-lg flex flex-col transition-colors',
|
||||||
'hover:border-blue-400 dark:hover:border-blue-500',
|
locked
|
||||||
|
? 'opacity-60 cursor-not-allowed'
|
||||||
|
: 'cursor-pointer hover:border-blue-400 dark:hover:border-blue-500',
|
||||||
task.has_completed && 'opacity-90',
|
task.has_completed && 'opacity-90',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="p-4 flex flex-col gap-2.5 flex-1">
|
<div className="p-4 flex flex-col gap-2.5 flex-1">
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<h1 className="text-base font-medium leading-snug line-clamp-2 min-w-0 flex-1">{task.name}</h1>
|
<h1 className="text-base font-medium leading-snug line-clamp-2 min-w-0 flex-1">{task.name}</h1>
|
||||||
<TaskStatusBadge task={task} />
|
{locked
|
||||||
|
? (
|
||||||
|
<span className="inline-flex items-center gap-1 text-xs font-medium bg-muted text-muted-foreground rounded-full px-2.5 py-1">
|
||||||
|
<Lock className="size-3" /> Locked
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
: <TaskStatusBadge task={task} />
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
{task.description && (
|
{task.description && (
|
||||||
<p className="text-sm text-muted-foreground leading-relaxed line-clamp-2">
|
<p className="text-sm text-muted-foreground leading-relaxed line-clamp-2">
|
||||||
@@ -99,6 +110,9 @@ const TaskCard = ({ task, onClick }) => {
|
|||||||
<Calendar />
|
<Calendar />
|
||||||
{task.deadline ? `Due ${formatDate(task.deadline)}` : 'No due date'}
|
{task.deadline ? `Due ${formatDate(task.deadline)}` : 'No due date'}
|
||||||
</div>
|
</div>
|
||||||
|
{locked && (
|
||||||
|
<p className="text-xs text-muted-foreground">Complete the earlier required tasks to unlock.</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="px-4 py-2.5 border-t flex items-center justify-between">
|
<div className="px-4 py-2.5 border-t flex items-center justify-between">
|
||||||
<span className="inline-flex items-center gap-1 text-xs font-medium bg-muted text-muted-foreground rounded-full px-2.5 py-1">
|
<span className="inline-flex items-center gap-1 text-xs font-medium bg-muted text-muted-foreground rounded-full px-2.5 py-1">
|
||||||
@@ -155,6 +169,7 @@ const ViewTaskDetails = () => {
|
|||||||
|
|
||||||
const [view, setView] = useState('grid');
|
const [view, setView] = useState('grid');
|
||||||
const [activeTab, setActiveTab] = useState('tab-ongoing');
|
const [activeTab, setActiveTab] = useState('tab-ongoing');
|
||||||
|
const [allTasks, setAllTasks] = useState([]);
|
||||||
|
|
||||||
// ── Fetch group info once ─────────────────────────────────────────────────
|
// ── Fetch group info once ─────────────────────────────────────────────────
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -167,6 +182,26 @@ const ViewTaskDetails = () => {
|
|||||||
if (tab) fetchTaskList(groupId, taskListId, { status: tab.status });
|
if (tab) fetchTaskList(groupId, taskListId, { status: tab.status });
|
||||||
}, [groupId, taskListId, activeTab]);
|
}, [groupId, taskListId, activeTab]);
|
||||||
|
|
||||||
|
// ── Fetch the FULL, unfiltered, order_index-sorted task array separately —
|
||||||
|
// the tab fetch above is server-side status-filtered, so it alone can't
|
||||||
|
// tell us whether an earlier task (possibly in a different tab) is done.
|
||||||
|
// Kept in local state rather than context so it never clobbers the
|
||||||
|
// tab-driven fetch's `taskList.tasks`.
|
||||||
|
useEffect(() => {
|
||||||
|
api.get(`/client/groups/${groupId}/task-lists/${taskListId}`)
|
||||||
|
.then(({ data }) => setAllTasks(data?.data?.tasks ?? []))
|
||||||
|
.catch(() => {});
|
||||||
|
}, [groupId, taskListId]);
|
||||||
|
|
||||||
|
// ── Sequencing lock — same pattern as UnitList.jsx's quiz lock ───────────
|
||||||
|
const lockedTaskIds = new Set(
|
||||||
|
allTasks
|
||||||
|
.filter((t, i, arr) => arr.slice(0, i).some((prev) => prev.is_required && !prev.has_completed))
|
||||||
|
.map((t) => t.task_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
const completedCount = allTasks.filter((t) => t.has_completed).length;
|
||||||
|
|
||||||
const handleTabChange = useCallback((val) => {
|
const handleTabChange = useCallback((val) => {
|
||||||
setActiveTab(val);
|
setActiveTab(val);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -214,6 +249,7 @@ const ViewTaskDetails = () => {
|
|||||||
<TaskCard
|
<TaskCard
|
||||||
key={task.task_id}
|
key={task.task_id}
|
||||||
task={task}
|
task={task}
|
||||||
|
locked={lockedTaskIds.has(task.task_id)}
|
||||||
onClick={() => navigate(`task/${task.task_id}`)}
|
onClick={() => navigate(`task/${task.task_id}`)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -244,6 +280,26 @@ const ViewTaskDetails = () => {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ── List-level progress rollup ── */}
|
||||||
|
{allTasks.length > 0 && (
|
||||||
|
<div className="max-w-md flex flex-col gap-1.5">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{completedCount} of {allTasks.length} task{allTasks.length !== 1 ? 's' : ''} complete
|
||||||
|
</span>
|
||||||
|
{completedCount >= allTasks.length && (
|
||||||
|
<span className="flex items-center gap-1 text-green-600 dark:text-green-400 font-medium">
|
||||||
|
<Check className="size-3.5" /> All done
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Progress
|
||||||
|
value={(completedCount / allTasks.length) * 100}
|
||||||
|
className={cn('h-1.5', completedCount >= allTasks.length && '[&>div]:bg-green-500')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Tabs + view toggle ────────────────────────────────────── */}
|
{/* ── Tabs + view toggle ────────────────────────────────────── */}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import CourseDetails from '../pages/CourseDetails'
|
|||||||
import UnitList from '../pages/UnitList'
|
import UnitList from '../pages/UnitList'
|
||||||
import UnitsList from '../pages/UnitsList'
|
import UnitsList from '../pages/UnitsList'
|
||||||
import UnitDetails from '../pages/UnitDetails'
|
import UnitDetails from '../pages/UnitDetails'
|
||||||
|
import LessonDetails from '../pages/LessonDetails'
|
||||||
|
import LessonsList from '../pages/LessonsList'
|
||||||
import UnitReader from '../pages/UnitReader'
|
import UnitReader from '../pages/UnitReader'
|
||||||
import { Fragment } from 'react'
|
import { Fragment } from 'react'
|
||||||
import GroupList from '../pages/GroupList'
|
import GroupList from '../pages/GroupList'
|
||||||
@@ -105,6 +107,15 @@ export const ClientRoutes = {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
path: 'lessons',
|
||||||
|
element: <Outlet />,
|
||||||
|
children: [
|
||||||
|
{ index: true, element: <LessonsList /> },
|
||||||
|
{ path: ':uuid', element: <LessonDetails /> },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
path: 'group', element: <Outlet />,
|
path: 'group', element: <Outlet />,
|
||||||
children: [
|
children: [
|
||||||
|
|||||||
Reference in New Issue
Block a user