diff --git a/src/api/courses.api.ts b/src/api/courses.api.ts index 3542ae5..84aa61e 100644 --- a/src/api/courses.api.ts +++ b/src/api/courses.api.ts @@ -62,6 +62,7 @@ import type { SubCourse, GetSubCourseEntryAssessmentResponse, ReorderItem, + ReorderOrderedIdsRequest, GetRatingsResponse, GetRatingsParams, GetVimeoSampleResponse, @@ -109,6 +110,8 @@ import type { UpdateParentLinkedPracticeRequest, UpdateParentLinkedPracticeResponse, PublishParentLinkedPracticeRequest, + PublishStatusOnlyRequest, + AccessTierOnlyRequest, UpdateTopLevelModuleLessonRequest, PublishTopLevelModuleLessonRequest, CreateTopLevelModuleLessonRequest, @@ -468,6 +471,28 @@ export const getProgramCourses = ( params?: { limit?: number; offset?: number }, ) => http.get(`/programs/${programId}/courses`, { params }) +/** PUT /programs/reorder */ +export const reorderLearningPrograms = (data: ReorderOrderedIdsRequest) => + http.put("/programs/reorder", data) + +/** PUT /programs/:programId/courses/reorder */ +export const reorderProgramCourses = ( + programId: number, + data: ReorderOrderedIdsRequest, +) => http.put(`/programs/${programId}/courses/reorder`, data) + +/** PUT /courses/:courseId/modules/reorder */ +export const reorderTopLevelCourseModules = ( + courseId: number, + data: ReorderOrderedIdsRequest, +) => http.put(`/courses/${courseId}/modules/reorder`, data) + +/** PUT /modules/:moduleId/lessons/reorder */ +export const reorderModuleLessons = ( + moduleId: number, + data: ReorderOrderedIdsRequest, +) => http.put(`/modules/${moduleId}/lessons/reorder`, data) + export const createProgramCourse = ( programId: number, data: CreateProgramCourseRequest, @@ -597,6 +622,12 @@ export const publishExamPrepModuleLesson = ( data: PublishExamPrepModuleLessonRequest, ) => http.put(`/exam-prep/lessons/${lessonId}`, data) +/** PUT /exam-prep/lessons/:lessonId — set access_tier only. */ +export const setExamPrepModuleLessonAccessTier = ( + lessonId: number, + data: AccessTierOnlyRequest, +) => http.put(`/exam-prep/lessons/${lessonId}`, data) + /** English proficiency lesson — DELETE /exam-prep/lessons/:lessonId */ export const deleteExamPrepModuleLesson = (lessonId: number) => http.delete(`/exam-prep/lessons/${lessonId}`) @@ -627,6 +658,84 @@ export const deleteExamPrepPractice = (practiceId: number) => `/exam-prep/practices/${practiceId}`, ) +/** PUT /exam-prep/practices/:practiceId — set publish_status only. */ +export const setExamPrepPracticePublishStatus = ( + practiceId: number, + data: PublishStatusOnlyRequest, +) => http.put(`/exam-prep/practices/${practiceId}`, data) + +/** PUT /programs/:programId — set publish_status only. */ +export const setLearningProgramPublishStatus = ( + programId: number, + data: PublishStatusOnlyRequest, +) => http.put(`/programs/${programId}`, data) + +/** PUT /programs/:programId — set access_tier only. */ +export const setLearningProgramAccessTier = ( + programId: number, + data: AccessTierOnlyRequest, +) => http.put(`/programs/${programId}`, data) + +/** PUT /courses/:courseId — set publish_status only (program-linked course). */ +export const setProgramCoursePublishStatus = ( + courseId: number, + data: PublishStatusOnlyRequest, +) => http.put(`/courses/${courseId}`, data) + +/** PUT /courses/:courseId — set access_tier only. */ +export const setProgramCourseAccessTier = ( + courseId: number, + data: AccessTierOnlyRequest, +) => http.put(`/courses/${courseId}`, data) + +/** PUT /exam-prep/catalog-courses/:catalogCourseId — set publish_status only. */ +export const setExamPrepCatalogCoursePublishStatus = ( + catalogCourseId: number, + data: PublishStatusOnlyRequest, +) => http.put(`/exam-prep/catalog-courses/${catalogCourseId}`, data) + +/** PUT /exam-prep/catalog-courses/:catalogCourseId — set access_tier only. */ +export const setExamPrepCatalogCourseAccessTier = ( + catalogCourseId: number, + data: AccessTierOnlyRequest, +) => http.put(`/exam-prep/catalog-courses/${catalogCourseId}`, data) + +/** PUT /exam-prep/units/:unitId — set publish_status only. */ +export const setExamPrepCatalogUnitPublishStatus = ( + unitId: number, + data: PublishStatusOnlyRequest, +) => http.put(`/exam-prep/units/${unitId}`, data) + +/** PUT /exam-prep/units/:unitId — set access_tier only. */ +export const setExamPrepCatalogUnitAccessTier = ( + unitId: number, + data: AccessTierOnlyRequest, +) => http.put(`/exam-prep/units/${unitId}`, data) + +/** PUT /exam-prep/modules/:moduleId — set publish_status only. */ +export const setExamPrepUnitModulePublishStatus = ( + moduleId: number, + data: PublishStatusOnlyRequest, +) => http.put(`/exam-prep/modules/${moduleId}`, data) + +/** PUT /exam-prep/modules/:moduleId — set access_tier only. */ +export const setExamPrepUnitModuleAccessTier = ( + moduleId: number, + data: AccessTierOnlyRequest, +) => http.put(`/exam-prep/modules/${moduleId}`, data) + +/** PUT /modules/:moduleId — set publish_status only (Learn English module). */ +export const setTopLevelCourseModulePublishStatus = ( + moduleId: number, + data: PublishStatusOnlyRequest, +) => http.put(`/modules/${moduleId}`, data) + +/** PUT /modules/:moduleId — set access_tier only. */ +export const setTopLevelCourseModuleAccessTier = ( + moduleId: number, + data: AccessTierOnlyRequest, +) => http.put(`/modules/${moduleId}`, data) + /** Top-level course resource (Learn English track) — PUT /courses/:id */ export const updateTopLevelCourse = (courseId: number, data: UpdateTopLevelCourseRequest) => http.put(`/courses/${courseId}`, data) @@ -690,6 +799,12 @@ export const publishTopLevelModuleLesson = ( data: PublishTopLevelModuleLessonRequest, ) => http.put(`/lessons/${lessonId}`, data) +/** PUT /lessons/:id — set access_tier only. */ +export const setTopLevelModuleLessonAccessTier = ( + lessonId: number, + data: AccessTierOnlyRequest, +) => http.put(`/lessons/${lessonId}`, data) + /** Learn English top-level module lesson — DELETE /lessons/:id */ export const deleteTopLevelModuleLesson = (lessonId: number) => http.delete(`/lessons/${lessonId}`) @@ -725,11 +840,18 @@ export const updateParentLinkedPractice = ( data: UpdateParentLinkedPracticeRequest, ) => http.put(`/practices/${practiceId}`, data) -/** PUT /practices/:id — set publish_status (e.g. publish a draft). */ +/** PUT /practices/:id — set publish_status only. */ +export const setParentLinkedPracticePublishStatus = ( + practiceId: number, + data: PublishParentLinkedPracticeRequest, +) => + http.put(`/practices/${practiceId}`, data) + +/** PUT /practices/:id — publish a draft practice. */ export const publishParentLinkedPractice = (practiceId: number) => - http.put(`/practices/${practiceId}`, { + setParentLinkedPracticePublishStatus(practiceId, { publish_status: "PUBLISHED", - } satisfies PublishParentLinkedPracticeRequest) + }) /** DELETE /practices/:id */ export const deleteParentLinkedPractice = (practiceId: number) => diff --git a/src/lib/accessTier.ts b/src/lib/accessTier.ts new file mode 100644 index 0000000..0017b85 --- /dev/null +++ b/src/lib/accessTier.ts @@ -0,0 +1,28 @@ +import type { ContentAccessTier } from "../types/course.types" + +export function normalizeAccessTier( + raw?: string | null, +): ContentAccessTier | null { + if (raw === "FREE" || raw === "PREMIUM") return raw + if (typeof raw === "string") { + const upper = raw.trim().toUpperCase() + if (upper === "FREE" || upper === "PREMIUM") { + return upper as ContentAccessTier + } + } + return null +} + +export function isPremiumAccessTier(raw?: string | null): boolean { + return normalizeAccessTier(raw) === "PREMIUM" +} + +export function accessTierLabel(raw?: string | null): string { + return normalizeAccessTier(raw) ?? "FREE" +} + +export function nextAccessTier( + current?: string | null, +): ContentAccessTier { + return isPremiumAccessTier(current) ? "FREE" : "PREMIUM" +} diff --git a/src/lib/contentListFilters.ts b/src/lib/contentListFilters.ts new file mode 100644 index 0000000..848b23a --- /dev/null +++ b/src/lib/contentListFilters.ts @@ -0,0 +1,49 @@ +import type { PracticePublishStatus } from "../types/course.types" +import { normalizePublishStatus } from "./publishStatus" + +export type PublishStatusFilter = "all" | PracticePublishStatus + +export function textMatchesSearch( + query: string, + ...fields: (string | null | undefined)[] +): boolean { + const needle = query.trim().toLowerCase() + if (!needle) return true + return fields.some((field) => (field ?? "").toLowerCase().includes(needle)) +} + +export function matchesPublishStatusFilter( + publishStatus: string | null | undefined, + filter: PublishStatusFilter, +): boolean { + if (filter === "all") return true + return normalizePublishStatus(publishStatus) === filter +} + +export function hasActiveContentFilters( + search: string, + publishStatusFilter: PublishStatusFilter, +): boolean { + return Boolean(search.trim()) || publishStatusFilter !== "all" +} + +export function filterBySearchAndPublishStatus( + items: T[], + options: { + search: string + publishStatusFilter: PublishStatusFilter + getSearchFields: (item: T) => (string | null | undefined)[] + getPublishStatus: (item: T) => string | null | undefined + }, +): T[] { + const { search, publishStatusFilter, getSearchFields, getPublishStatus } = + options + return items.filter((item) => { + if ( + !matchesPublishStatusFilter(getPublishStatus(item), publishStatusFilter) + ) { + return false + } + return textMatchesSearch(search, ...getSearchFields(item)) + }) +} diff --git a/src/lib/parentContextPractice.ts b/src/lib/parentContextPractice.ts index cce0e25..887cea7 100644 --- a/src/lib/parentContextPractice.ts +++ b/src/lib/parentContextPractice.ts @@ -3,6 +3,7 @@ import type { ParentContextPractice, PracticePublishStatus, } from "../types/course.types" +import { isPublishedPublishStatus, normalizePublishStatus } from "./publishStatus" export function unwrapPracticesList( res: { @@ -21,19 +22,11 @@ export function unwrapPracticesList( export function practicePublishStatus( practice: ParentContextPractice, ): PracticePublishStatus | null { - const raw = practice.publish_status - if (raw === "DRAFT" || raw === "PUBLISHED") return raw - if (typeof raw === "string") { - const upper = raw.toUpperCase() - if (upper === "DRAFT" || upper === "PUBLISHED") { - return upper as PracticePublishStatus - } - } - return null + return normalizePublishStatus(practice.publish_status) } export function isPracticePublished(practice: ParentContextPractice): boolean { - return practicePublishStatus(practice) === "PUBLISHED" + return isPublishedPublishStatus(practice.publish_status) } export function isPracticeDraft(practice: ParentContextPractice): boolean { diff --git a/src/lib/publishStatus.ts b/src/lib/publishStatus.ts new file mode 100644 index 0000000..e7fc744 --- /dev/null +++ b/src/lib/publishStatus.ts @@ -0,0 +1,28 @@ +import type { PracticePublishStatus } from "../types/course.types" + +export function normalizePublishStatus( + raw?: string | null, +): PracticePublishStatus | null { + if (raw === "DRAFT" || raw === "PUBLISHED") return raw + if (typeof raw === "string") { + const upper = raw.trim().toUpperCase() + if (upper === "DRAFT" || upper === "PUBLISHED") { + return upper as PracticePublishStatus + } + } + return null +} + +export function isPublishedPublishStatus(raw?: string | null): boolean { + return normalizePublishStatus(raw) === "PUBLISHED" +} + +export function publishStatusLabel(raw?: string | null): string { + return normalizePublishStatus(raw) ?? "DRAFT" +} + +export function nextPublishStatus( + current?: string | null, +): PracticePublishStatus { + return isPublishedPublishStatus(current) ? "DRAFT" : "PUBLISHED" +} diff --git a/src/pages/content-management/CourseDetailPage.tsx b/src/pages/content-management/CourseDetailPage.tsx index 2c7b5aa..4469cc7 100644 --- a/src/pages/content-management/CourseDetailPage.tsx +++ b/src/pages/content-management/CourseDetailPage.tsx @@ -29,21 +29,28 @@ import { getPracticesByParentCourse, getProgramCourses, getTopLevelCourseModules, - publishParentLinkedPractice, - updateParentLinkedPractice, + setParentLinkedPracticePublishStatus, + setTopLevelCourseModuleAccessTier, + setTopLevelCourseModulePublishStatus, updateTopLevelCourseModule, } from "../../api/courses.api"; import { refreshFileUrl, resolveFileUrl } from "../../api/files.api"; import type { ParentContextPractice, + ContentAccessTier, + PracticePublishStatus, ProgramCourseListItem, TopLevelCourseModuleItem, } from "../../types/course.types"; +import { ContentPublishStatusChip } from "./components/ContentPublishStatusChip"; +import { ContentAccessTierChip } from "./components/ContentAccessTierChip"; +import { ContentListSearchFilterBar } from "./components/ContentListSearchFilterBar"; +import { ContentPageDescription } from "./components/ContentPageDescription"; import { - isPracticeDraft, - isPracticePublished, - unwrapPracticesList, -} from "../../lib/parentContextPractice"; + filterBySearchAndPublishStatus, + type PublishStatusFilter, +} from "../../lib/contentListFilters"; +import { unwrapPracticesList } from "../../lib/parentContextPractice"; import { AddModuleModal } from "./components/AddModuleModal"; import { ModuleIconUploadField } from "./components/ModuleIconUploadField"; import { ModulePracticeCard } from "./components/ModulePracticeCard"; @@ -166,7 +173,12 @@ export function CourseDetailPage() { const [deletingModuleInFlight, setDeletingModuleInFlight] = useState(false); const [activeTab, setActiveTab] = useState<"modules" | "practice">("modules"); - const [practiceFilter, setPracticeFilter] = useState("All"); + const [moduleSearch, setModuleSearch] = useState(""); + const [modulePublishStatusFilter, setModulePublishStatusFilter] = + useState("all"); + const [practiceSearch, setPracticeSearch] = useState(""); + const [practicePublishStatusFilter, setPracticePublishStatusFilter] = + useState("all"); const [practices, setPractices] = useState([]); const [practicesLoading, setPracticesLoading] = useState(false); const [practicesLoadError, setPracticesLoadError] = useState( @@ -175,6 +187,12 @@ export function CourseDetailPage() { const [publishStatusPracticeId, setPublishStatusPracticeId] = useState< number | null >(null); + const [publishStatusModuleId, setPublishStatusModuleId] = useState< + number | null + >(null); + const [accessTierModuleId, setAccessTierModuleId] = useState( + null, + ); const openEditModule = (module: TopLevelCourseModuleItem) => { setEditingModule(module); @@ -309,60 +327,115 @@ export function CourseDetailPage() { void loadCoursePractices(); }, [activeTab, loadCoursePractices]); - const filteredPractices = useMemo(() => { - if (practiceFilter === "Published") { - return practices.filter(isPracticePublished); - } - if (practiceFilter === "Draft") { - return practices.filter(isPracticeDraft); - } - if (practiceFilter === "Archived") { - return []; - } - return practices; - }, [practices, practiceFilter]); + const filteredModules = useMemo( + () => + filterBySearchAndPublishStatus(modules, { + search: moduleSearch, + publishStatusFilter: modulePublishStatusFilter, + getSearchFields: (m) => [m.name, m.description], + getPublishStatus: (m) => m.publish_status, + }), + [modulePublishStatusFilter, moduleSearch, modules], + ); - const handlePublishPractice = async (practiceId: number) => { + const filteredPractices = useMemo( + () => + filterBySearchAndPublishStatus(practices, { + search: practiceSearch, + publishStatusFilter: practicePublishStatusFilter, + getSearchFields: (p) => [ + p.title, + p.story_description, + p.quick_tips, + ], + getPublishStatus: (p) => p.publish_status, + }), + [practicePublishStatusFilter, practiceSearch, practices], + ); + + const handlePracticePublishStatus = async ( + practiceId: number, + nextStatus: PracticePublishStatus, + ) => { setPublishStatusPracticeId(practiceId); try { - await publishParentLinkedPractice(practiceId); + await setParentLinkedPracticePublishStatus(practiceId, { + publish_status: nextStatus, + }); setPractices((prev) => prev.map((p) => - p.id === practiceId ? { ...p, publish_status: "PUBLISHED" } : p, + p.id === practiceId ? { ...p, publish_status: nextStatus } : p, ), ); - toast.success("Practice published"); + toast.success( + nextStatus === "PUBLISHED" + ? "Practice published" + : "Practice saved as draft", + ); } catch (e: unknown) { console.error(e); const msg = (e as { response?: { data?: { message?: string } } })?.response?.data - ?.message ?? "Failed to publish practice"; + ?.message ?? "Failed to update practice status"; toast.error(msg); } finally { setPublishStatusPracticeId(null); } }; - const handleSavePracticeAsDraft = async (practiceId: number) => { - setPublishStatusPracticeId(practiceId); + const handleModulePublishStatus = async ( + moduleId: number, + nextStatus: PracticePublishStatus, + ) => { + setPublishStatusModuleId(moduleId); try { - await updateParentLinkedPractice(practiceId, { - publish_status: "DRAFT", + await setTopLevelCourseModulePublishStatus(moduleId, { + publish_status: nextStatus, }); - setPractices((prev) => - prev.map((p) => - p.id === practiceId ? { ...p, publish_status: "DRAFT" } : p, + setModules((prev) => + prev.map((m) => + m.id === moduleId ? { ...m, publish_status: nextStatus } : m, ), ); - toast.success("Practice saved as draft"); + toast.success( + nextStatus === "PUBLISHED" ? "Module published" : "Module saved as draft", + ); } catch (e: unknown) { console.error(e); const msg = (e as { response?: { data?: { message?: string } } })?.response?.data - ?.message ?? "Failed to save practice as draft"; + ?.message ?? "Failed to update module status"; toast.error(msg); } finally { - setPublishStatusPracticeId(null); + setPublishStatusModuleId(null); + } + }; + + const handleModuleAccessTier = async ( + moduleId: number, + nextTier: ContentAccessTier, + ) => { + setAccessTierModuleId(moduleId); + try { + await setTopLevelCourseModuleAccessTier(moduleId, { + access_tier: nextTier, + }); + setModules((prev) => + prev.map((m) => + m.id === moduleId ? { ...m, access_tier: nextTier } : m, + ), + ); + toast.success( + nextTier === "PREMIUM" ? "Module set to Premium" : "Module set to Free", + ); + } catch (e: unknown) { + console.error(e); + const msg = + (e as { response?: { data?: { message?: string } } })?.response?.data + ?.message ?? "Failed to update module access tier"; + toast.error(msg); + } finally { + setAccessTierModuleId(null); } }; @@ -471,9 +544,9 @@ export function CourseDetailPage() {

{displayTitle}

-

+ {displayDescription} -

+
) : ( +
+ + {filteredModules.length === 0 ? ( +
+

+ No modules match your search or status filter +

+
+ ) : (
- {modules.map((module, index) => { + {filteredModules.map((module, index) => { const iconSrc = module.icon?.trim() ?? ""; return (
+
+ + void handleModulePublishStatus( + module.id, + nextStatus, + ) + } + /> + + void handleModuleAccessTier( + module.id, + nextTier, + ) + } + /> +

{module.name}

@@ -708,31 +821,19 @@ export function CourseDetailPage() { ); })}
+ )} +
) ) : (
-
-
- STATUS: -
-
- {["All", "Published", "Draft", "Archived"].map((label) => ( - - ))} -
-
+ {practicesLoading ? (
@@ -752,9 +853,14 @@ export function CourseDetailPage() { onEdit={() => navigate(`/content/practices?type=course&id=${courseIdNum}`) } - onPublish={() => void handlePublishPractice(practice.id)} + onPublish={() => + void handlePracticePublishStatus( + practice.id, + "PUBLISHED", + ) + } onSaveAsDraft={() => - void handleSavePracticeAsDraft(practice.id) + void handlePracticePublishStatus(practice.id, "DRAFT") } /> ))} @@ -769,7 +875,7 @@ export function CourseDetailPage() {

{practices.length === 0 ? "No practices for this course yet" - : "No practices match this filter"} + : "No practices match your search or status filter"}

{practices.length === 0 diff --git a/src/pages/content-management/CourseManagementPage.tsx b/src/pages/content-management/CourseManagementPage.tsx index 09eb225..4c208b6 100644 --- a/src/pages/content-management/CourseManagementPage.tsx +++ b/src/pages/content-management/CourseManagementPage.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Link, useParams, useNavigate } from "react-router-dom"; import { ArrowLeft, @@ -28,11 +28,21 @@ import { toast } from "sonner"; import { ResolvedImage } from "../../components/media/ResolvedImage"; import { createExamPrepCatalogUnit, + setExamPrepCatalogUnitAccessTier, + setExamPrepCatalogUnitPublishStatus, updateExamPrepCatalogUnit, deleteExamPrepCatalogUnit, getExamPrepCatalogUnits, } from "../../api/courses.api"; import { uploadImageFile } from "../../api/files.api"; +import { ContentPublishStatusChip } from "./components/ContentPublishStatusChip"; +import { ContentAccessTierChip } from "./components/ContentAccessTierChip"; +import { ContentListSearchFilterBar } from "./components/ContentListSearchFilterBar"; +import type { ContentAccessTier, PracticePublishStatus } from "../../types/course.types"; +import { + filterBySearchAndPublishStatus, + type PublishStatusFilter, +} from "../../lib/contentListFilters"; export function CourseManagementPage() { const navigate = useNavigate(); @@ -55,12 +65,20 @@ export function CourseManagementPage() { description: string; thumbnail: string; sortOrder: number; + publishStatus: PracticePublishStatus | string | null; + accessTier: ContentAccessTier | string | null; modules: number; lessons: number; practices: number; gradient: string; }> >([]); + const [publishStatusUpdatingId, setPublishStatusUpdatingId] = useState< + number | null + >(null); + const [accessTierUpdatingId, setAccessTierUpdatingId] = useState< + number | null + >(null); const [unitsLoading, setUnitsLoading] = useState(false); const [editingUnitId, setEditingUnitId] = useState(null); const [editName, setEditName] = useState(""); @@ -71,6 +89,20 @@ export function CourseManagementPage() { const editThumbnailFileInputRef = useRef(null); const [deletingUnitId, setDeletingUnitId] = useState(null); const [deletingUnit, setDeletingUnit] = useState(false); + const [listSearch, setListSearch] = useState(""); + const [publishStatusFilter, setPublishStatusFilter] = + useState("all"); + + const filteredUnits = useMemo( + () => + filterBySearchAndPublishStatus(units, { + search: listSearch, + publishStatusFilter, + getSearchFields: (u) => [u.name, u.description], + getPublishStatus: (u) => u.publishStatus, + }), + [listSearch, publishStatusFilter, units], + ); // Mock data for display titles const courseTitles: Record = { @@ -101,6 +133,8 @@ export function CourseManagementPage() { description: row.description?.trim() || "—", thumbnail: row.thumbnail?.trim() || "", sortOrder: Number(row.sort_order ?? 0), + publishStatus: row.publish_status ?? null, + accessTier: row.access_tier ?? null, modules: Number(row.modules_count ?? 0), lessons: Number(row.lessons_count ?? row.videos_count ?? 0), practices: Number(row.practices_count ?? 0), @@ -125,6 +159,58 @@ export function CourseManagementPage() { void loadUnits(); }, [loadUnits]); + const handleUnitPublishStatus = async ( + unitId: number, + nextStatus: PracticePublishStatus, + ) => { + setPublishStatusUpdatingId(unitId); + try { + await setExamPrepCatalogUnitPublishStatus(unitId, { + publish_status: nextStatus, + }); + setUnits((prev) => + prev.map((u) => + u.id === unitId ? { ...u, publishStatus: nextStatus } : u, + ), + ); + toast.success( + nextStatus === "PUBLISHED" ? "Unit published" : "Unit saved as draft", + ); + } catch (error: unknown) { + const message = + (error as { response?: { data?: { message?: string } } })?.response?.data + ?.message ?? "Failed to update unit status"; + toast.error(message); + } finally { + setPublishStatusUpdatingId(null); + } + }; + + const handleUnitAccessTier = async ( + unitId: number, + nextTier: ContentAccessTier, + ) => { + setAccessTierUpdatingId(unitId); + try { + await setExamPrepCatalogUnitAccessTier(unitId, { access_tier: nextTier }); + setUnits((prev) => + prev.map((u) => + u.id === unitId ? { ...u, accessTier: nextTier } : u, + ), + ); + toast.success( + nextTier === "PREMIUM" ? "Unit set to Premium" : "Unit set to Free", + ); + } catch (error: unknown) { + const message = + (error as { response?: { data?: { message?: string } } })?.response?.data + ?.message ?? "Failed to update unit access tier"; + toast.error(message); + } finally { + setAccessTierUpdatingId(null); + } + }; + const isHttpUrl = (value: string) => value.startsWith("http://") || value.startsWith("https://"); @@ -574,7 +660,18 @@ export function CourseManagementPage() {

{/* Grid of Units */} -
+
+ {!unitsLoading && units.length > 0 ? ( + + ) : null} +
{unitsLoading ? (

Loading units...

) : units.length === 0 ? ( @@ -586,8 +683,14 @@ export function CourseManagementPage() { Create your first unit to start organizing modules, lessons, and practices.

+ ) : filteredUnits.length === 0 ? ( +
+

+ No units match your search or status filter +

+
) : ( - units.map((unit) => ( + filteredUnits.map((unit) => (
+
+ + void handleUnitPublishStatus(unit.id, nextStatus) + } + /> + + void handleUnitAccessTier(unit.id, nextTier) + } + /> +

{unit.name}

@@ -679,6 +800,7 @@ export function CourseManagementPage() { )) )} +
>([]); @@ -81,6 +92,23 @@ export function CourseModuleDetailPage() { const [publishStatusLessonId, setPublishStatusLessonId] = useState< number | null >(null); + const [accessTierLessonId, setAccessTierLessonId] = useState( + null, + ); + const [lessonSearch, setLessonSearch] = useState(""); + const [lessonPublishStatusFilter, setLessonPublishStatusFilter] = + useState("all"); + + const filteredLessons = useMemo( + () => + filterBySearchAndPublishStatus(lessons, { + search: lessonSearch, + publishStatusFilter: lessonPublishStatusFilter, + getSearchFields: (l) => [l.title, l.description], + getPublishStatus: (l) => l.publishStatus, + }), + [lessonPublishStatusFilter, lessonSearch, lessons], + ); const [createLessonOpen, setCreateLessonOpen] = useState(false); const [createTitle, setCreateTitle] = useState(""); const [createVideoUrl, setCreateVideoUrl] = useState(""); @@ -159,6 +187,7 @@ export function CourseModuleDetailPage() { thumbnail: row.thumbnail?.trim() || "", sortOrder: Number(row.sort_order ?? 0), publishStatus: row.publish_status ?? null, + accessTier: row.access_tier ?? null, durationSeconds, }; }), @@ -505,6 +534,34 @@ export function CourseModuleDetailPage() { } }; + const handleToggleLessonAccessTier = async ( + lessonId: number, + nextTier: ContentAccessTier, + ) => { + setAccessTierLessonId(lessonId); + try { + await setExamPrepModuleLessonAccessTier(lessonId, { + access_tier: nextTier, + }); + setLessons((prev) => + prev.map((l) => + l.id === lessonId ? { ...l, accessTier: nextTier } : l, + ), + ); + toast.success( + nextTier === "PREMIUM" ? "Lesson set to Premium" : "Lesson set to Free", + ); + } catch (error: unknown) { + console.error(error); + const message = + (error as { response?: { data?: { message?: string } } })?.response?.data + ?.message ?? "Failed to update lesson access tier"; + toast.error(message); + } finally { + setAccessTierLessonId(null); + } + }; + const lessonAttachPracticePath = (lesson: (typeof lessons)[number]) => `/new-content/courses/${programType}/${courseId}/${unitId}/${moduleId}/add-practice?lessonId=${lesson.id}&lessonTitle=${encodeURIComponent(lesson.title)}`; @@ -528,9 +585,9 @@ export function CourseModuleDetailPage() {

{moduleTitle}

-

+ {moduleDescription} -

+
@@ -780,8 +837,24 @@ export function CourseModuleDetailPage() { {lessonsLoadError}
) : lessons.length > 0 ? ( +
+ + {filteredLessons.length === 0 ? ( +
+

+ No lessons match your search or status filter +

+
+ ) : (
- {lessons.map((lesson, i) => ( + {filteredLessons.map((lesson, i) => ( + void handleToggleLessonAccessTier(lesson.id, nextTier) + } + accessTierUpdating={accessTierLessonId === lesson.id} /> ))}
+ )} +
) : (
diff --git a/src/pages/content-management/LearnEnglishPage.tsx b/src/pages/content-management/LearnEnglishPage.tsx index 8788679..d0db8e2 100644 --- a/src/pages/content-management/LearnEnglishPage.tsx +++ b/src/pages/content-management/LearnEnglishPage.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Plus, ArrowRight, Pencil, Trash2, X } from "lucide-react"; import { Link } from "react-router-dom"; import { toast } from "sonner"; @@ -21,9 +21,20 @@ import alertSrc from "../../assets/Alert.svg"; import { getLearningPrograms, createLearningProgram, + setLearningProgramAccessTier, + setLearningProgramPublishStatus, updateLearningProgram, deleteLearningProgram, } from "../../api/courses.api"; +import { ContentPublishStatusChip } from "./components/ContentPublishStatusChip"; +import { ContentAccessTierChip } from "./components/ContentAccessTierChip"; +import { ContentListSearchFilterBar } from "./components/ContentListSearchFilterBar"; +import type { ContentAccessTier, PracticePublishStatus } from "../../types/course.types"; +import { + filterBySearchAndPublishStatus, + hasActiveContentFilters, + type PublishStatusFilter, +} from "../../lib/contentListFilters"; import { refreshFileUrl, uploadImageFile } from "../../api/files.api"; import type { LearningProgramListItem } from "../../types/course.types"; @@ -71,6 +82,26 @@ export function LearnEnglishPage() { const [deletingProgram, setDeletingProgram] = useState(null); const [deleting, setDeleting] = useState(false); + const [publishStatusUpdatingId, setPublishStatusUpdatingId] = useState< + number | null + >(null); + const [accessTierUpdatingId, setAccessTierUpdatingId] = useState< + number | null + >(null); + const [listSearch, setListSearch] = useState(""); + const [publishStatusFilter, setPublishStatusFilter] = + useState("all"); + + const filteredPrograms = useMemo( + () => + filterBySearchAndPublishStatus(programs, { + search: listSearch, + publishStatusFilter, + getSearchFields: (p) => [p.name, p.description], + getPublishStatus: (p) => p.publish_status, + }), + [programs, listSearch, publishStatusFilter], + ); const openEdit = (program: LearningProgramListItem) => { setEditingProgram(program); @@ -254,6 +285,58 @@ export function LearnEnglishPage() { } }; + const handleProgramPublishStatus = async ( + programId: number, + nextStatus: PracticePublishStatus, + ) => { + setPublishStatusUpdatingId(programId); + try { + await setLearningProgramPublishStatus(programId, { + publish_status: nextStatus, + }); + setPrograms((prev) => + prev.map((p) => + p.id === programId ? { ...p, publish_status: nextStatus } : p, + ), + ); + toast.success( + nextStatus === "PUBLISHED" ? "Program published" : "Program saved as draft", + ); + } catch (e: unknown) { + const msg = + (e as { response?: { data?: { message?: string } } })?.response?.data + ?.message ?? "Failed to update program status"; + toast.error(msg); + } finally { + setPublishStatusUpdatingId(null); + } + }; + + const handleProgramAccessTier = async ( + programId: number, + nextTier: ContentAccessTier, + ) => { + setAccessTierUpdatingId(programId); + try { + await setLearningProgramAccessTier(programId, { access_tier: nextTier }); + setPrograms((prev) => + prev.map((p) => + p.id === programId ? { ...p, access_tier: nextTier } : p, + ), + ); + toast.success( + nextTier === "PREMIUM" ? "Program set to Premium" : "Program set to Free", + ); + } catch (e: unknown) { + const msg = + (e as { response?: { data?: { message?: string } } })?.response?.data + ?.message ?? "Failed to update program access tier"; + toast.error(msg); + } finally { + setAccessTierUpdatingId(null); + } + }; + const handleConfirmDelete = async () => { if (!deletingProgram) return; setDeleting(true); @@ -559,8 +642,29 @@ export function LearnEnglishPage() {

) : ( +
+ + {filteredPrograms.length === 0 ? ( +
+

+ No programs match your search or status filter +

+ {hasActiveContentFilters(listSearch, publishStatusFilter) ? ( +

+ Try different keywords or clear the publish status filter. +

+ ) : null} +
+ ) : (
- {programs.map((program) => ( + {filteredPrograms.map((program) => (
+
+ + void handleProgramPublishStatus(program.id, nextStatus) + } + /> + + void handleProgramAccessTier(program.id, nextTier) + } + /> +

{program.name}

@@ -627,6 +749,8 @@ export function LearnEnglishPage() { ))}
+ )} +
)} void; + onTogglePublishStatus?: (nextStatus: PracticePublishStatus) => void; + publishStatusUpdating?: boolean; }) { const [imgFailed, setImgFailed] = useState(false); const thumb = resolveThumbnailForPreview(practice.story_image); @@ -141,14 +154,12 @@ function PracticeCard({ ID {practice.id} - {practice.publish_status ? ( - - {practice.publish_status} - - ) : null} +
{onDelete ? (
diff --git a/src/pages/content-management/ModuleDetailPage.tsx b/src/pages/content-management/ModuleDetailPage.tsx index 80b6b90..fc3de2e 100644 --- a/src/pages/content-management/ModuleDetailPage.tsx +++ b/src/pages/content-management/ModuleDetailPage.tsx @@ -7,21 +7,18 @@ import { getModuleLessons, getPracticesByParentModule, getTopLevelCourseModules, - publishParentLinkedPractice, publishTopLevelModuleLesson, - updateParentLinkedPractice, + setParentLinkedPracticePublishStatus, + setTopLevelModuleLessonAccessTier, updateTopLevelModuleLesson, } from "../../api/courses.api"; import type { + ContentAccessTier, ParentContextPractice, PracticePublishStatus, TopLevelModuleLessonItem, } from "../../types/course.types"; -import { - isPracticeDraft, - isPracticePublished, - unwrapPracticesList, -} from "../../lib/parentContextPractice"; +import { unwrapPracticesList } from "../../lib/parentContextPractice"; import { Button } from "../../components/ui/button"; import { Dialog, @@ -38,6 +35,12 @@ import { cn } from "../../lib/utils"; import { LessonMediaUploadField } from "./components/LessonMediaUploadField"; import { ModulePracticeCard } from "./components/ModulePracticeCard"; import { VideoCard } from "./components/VideoCard"; +import { ContentListSearchFilterBar } from "./components/ContentListSearchFilterBar"; +import { ContentPageDescription } from "./components/ContentPageDescription"; +import { + filterBySearchAndPublishStatus, + type PublishStatusFilter, +} from "../../lib/contentListFilters"; const LESSON_THUMB_GRADIENTS = [ "from-[#CBD5E1] to-[#94A3B8]", @@ -61,7 +64,12 @@ export function ModuleDetailPage() { moduleId: string; }>(); const [activeTab, setActiveTab] = useState<"video" | "practice">("video"); - const [activeFilter, setActiveFilter] = useState("All"); + const [lessonSearch, setLessonSearch] = useState(""); + const [lessonPublishStatusFilter, setLessonPublishStatusFilter] = + useState("all"); + const [practiceSearch, setPracticeSearch] = useState(""); + const [practicePublishStatusFilter, setPracticePublishStatusFilter] = + useState("all"); const [lessons, setLessons] = useState([]); const [lessonsLoading, setLessonsLoading] = useState(true); const [lessonsLoadError, setLessonsLoadError] = useState(null); @@ -82,6 +90,9 @@ export function ModuleDetailPage() { const [publishStatusLessonId, setPublishStatusLessonId] = useState< number | null >(null); + const [accessTierLessonId, setAccessTierLessonId] = useState( + null, + ); const [practices, setPractices] = useState([]); const [practicesLoading, setPracticesLoading] = useState(false); const [practicesLoadError, setPracticesLoadError] = useState( @@ -247,57 +258,56 @@ export function ModuleDetailPage() { void loadModulePractices(); }, [activeTab, loadModulePractices]); - const filteredPractices = useMemo(() => { - if (activeFilter === "Published") { - return practices.filter(isPracticePublished); - } - if (activeFilter === "Draft") { - return practices.filter(isPracticeDraft); - } - if (activeFilter === "Archived") { - return []; - } - return practices; - }, [practices, activeFilter]); + const filteredLessons = useMemo( + () => + filterBySearchAndPublishStatus(lessons, { + search: lessonSearch, + publishStatusFilter: lessonPublishStatusFilter, + getSearchFields: (l) => [l.title, l.description], + getPublishStatus: (l) => l.publish_status, + }), + [lessonPublishStatusFilter, lessonSearch, lessons], + ); - const handlePublishPractice = async (practiceId: number) => { + const filteredPractices = useMemo( + () => + filterBySearchAndPublishStatus(practices, { + search: practiceSearch, + publishStatusFilter: practicePublishStatusFilter, + getSearchFields: (p) => [ + p.title, + p.story_description, + p.quick_tips, + ], + getPublishStatus: (p) => p.publish_status, + }), + [practicePublishStatusFilter, practiceSearch, practices], + ); + + const handlePracticePublishStatus = async ( + practiceId: number, + nextStatus: PracticePublishStatus, + ) => { setPublishStatusPracticeId(practiceId); try { - await publishParentLinkedPractice(practiceId); - setPractices((prev) => - prev.map((p) => - p.id === practiceId ? { ...p, publish_status: "PUBLISHED" } : p, - ), - ); - toast.success("Practice published"); - } catch (e: unknown) { - console.error(e); - const msg = - (e as { response?: { data?: { message?: string } } })?.response?.data - ?.message ?? "Failed to publish practice"; - toast.error(msg); - } finally { - setPublishStatusPracticeId(null); - } - }; - - const handleSavePracticeAsDraft = async (practiceId: number) => { - setPublishStatusPracticeId(practiceId); - try { - await updateParentLinkedPractice(practiceId, { - publish_status: "DRAFT", + await setParentLinkedPracticePublishStatus(practiceId, { + publish_status: nextStatus, }); setPractices((prev) => prev.map((p) => - p.id === practiceId ? { ...p, publish_status: "DRAFT" } : p, + p.id === practiceId ? { ...p, publish_status: nextStatus } : p, ), ); - toast.success("Practice saved as draft"); + toast.success( + nextStatus === "PUBLISHED" + ? "Practice published" + : "Practice saved as draft", + ); } catch (e: unknown) { console.error(e); const msg = (e as { response?: { data?: { message?: string } } })?.response?.data - ?.message ?? "Failed to save practice as draft"; + ?.message ?? "Failed to update practice status"; toast.error(msg); } finally { setPublishStatusPracticeId(null); @@ -391,6 +401,34 @@ export function ModuleDetailPage() { } }; + const handleToggleLessonAccessTier = async ( + lessonId: number, + nextTier: ContentAccessTier, + ) => { + setAccessTierLessonId(lessonId); + try { + await setTopLevelModuleLessonAccessTier(lessonId, { + access_tier: nextTier, + }); + setLessons((prev) => + prev.map((l) => + l.id === lessonId ? { ...l, access_tier: nextTier } : l, + ), + ); + toast.success( + nextTier === "PREMIUM" ? "Lesson set to Premium" : "Lesson set to Free", + ); + } catch (e: unknown) { + console.error(e); + const msg = + (e as { response?: { data?: { message?: string } } })?.response?.data + ?.message ?? "Failed to update lesson access tier"; + toast.error(msg); + } finally { + setAccessTierLessonId(null); + } + }; + const handleConfirmDeleteLesson = async () => { if (!deletingLesson) return; setDeletingLessonInFlight(true); @@ -429,9 +467,9 @@ export function ModuleDetailPage() {

{displayModuleName}

-

+ {displayModuleDescription} -

+
- ))} -
-
+ {practicesLoading ? (
@@ -613,9 +660,14 @@ export function ModuleDetailPage() { `/content/practices?type=module&id=${moduleId}`, ) } - onPublish={() => void handlePublishPractice(practice.id)} + onPublish={() => + void handlePracticePublishStatus( + practice.id, + "PUBLISHED", + ) + } onSaveAsDraft={() => - void handleSavePracticeAsDraft(practice.id) + void handlePracticePublishStatus(practice.id, "DRAFT") } /> ))} @@ -630,12 +682,12 @@ export function ModuleDetailPage() {

{practices.length === 0 ? "No practices in this module yet" - : "No practices match this filter"} + : "No practices match your search or status filter"}

{practices.length === 0 ? "Add a practice to give learners speaking exercises for this module." - : "Try another status filter or add a new practice."} + : "Try different keywords or clear the publish status filter."}

{practices.length === 0 ? (
@@ -582,7 +677,18 @@ export function ProgramDetailPage() {
{/* Cards Grid */} -
+
+ {programType === "proficiency" && !catalogLoading && proficiencyCourses.length > 0 ? ( + + ) : null} +
{programType === "proficiency" && catalogLoading ? (

Loading catalog courses...

) : null} @@ -598,9 +704,20 @@ export function ProgramDetailPage() { Create your first exam-prep catalog course to start organizing units, modules, and lessons.

+ ) : programType === "proficiency" && filteredProficiencyCourses.length === 0 ? ( +
+

+ No courses match your search or status filter +

+ {hasActiveContentFilters(listSearch, publishStatusFilter) ? ( +

+ Try different keywords or clear the publish status filter. +

+ ) : null} +
) : ( (programType === "proficiency" - ? proficiencyCourses + ? filteredProficiencyCourses : currentProgram.courses ).map((course: any) => ( + {programType === "proficiency" ? ( +
+ + void handleCoursePublishStatus(Number(course.id), nextStatus) + } + /> + + void handleCourseAccessTier(Number(course.id), nextTier) + } + /> +
+ ) : null}

{course.name}

@@ -693,6 +830,7 @@ export function ProgramDetailPage() {
)) )} +

Drag and drop programs, courses, modules, and lessons to change - their display order. + their display order. Changes are saved automatically when you drop an + item.

diff --git a/src/pages/content-management/UnitManagementPage.tsx b/src/pages/content-management/UnitManagementPage.tsx index e02267b..ac78416 100644 --- a/src/pages/content-management/UnitManagementPage.tsx +++ b/src/pages/content-management/UnitManagementPage.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Link, useParams, useNavigate } from "react-router-dom"; import { ArrowLeft, @@ -28,10 +28,20 @@ import { ResolvedImage } from "../../components/media/ResolvedImage"; import { createExamPrepUnitModule, getExamPrepUnitModules, + setExamPrepUnitModuleAccessTier, + setExamPrepUnitModulePublishStatus, updateExamPrepUnitModule, deleteExamPrepUnitModule, } from "../../api/courses.api"; import { uploadImageFile } from "../../api/files.api"; +import { ContentPublishStatusChip } from "./components/ContentPublishStatusChip"; +import { ContentAccessTierChip } from "./components/ContentAccessTierChip"; +import { ContentListSearchFilterBar } from "./components/ContentListSearchFilterBar"; +import type { ContentAccessTier, PracticePublishStatus } from "../../types/course.types"; +import { + filterBySearchAndPublishStatus, + type PublishStatusFilter, +} from "../../lib/contentListFilters"; export function UnitManagementPage() { const navigate = useNavigate(); @@ -70,11 +80,19 @@ export function UnitManagementPage() { thumbnail: string; icon: string; sortOrder: number; + publishStatus: PracticePublishStatus | string | null; + accessTier: ContentAccessTier | string | null; lessons: number; practices: number; gradient: string; }> >([]); + const [publishStatusUpdatingId, setPublishStatusUpdatingId] = useState< + number | null + >(null); + const [accessTierUpdatingId, setAccessTierUpdatingId] = useState< + number | null + >(null); const [editingModuleId, setEditingModuleId] = useState(null); const [editName, setEditName] = useState(""); const [editThumbnail, setEditThumbnail] = useState(""); @@ -87,6 +105,20 @@ export function UnitManagementPage() { const editIconFileInputRef = useRef(null); const [deletingModuleId, setDeletingModuleId] = useState(null); const [deletingModule, setDeletingModule] = useState(false); + const [listSearch, setListSearch] = useState(""); + const [publishStatusFilter, setPublishStatusFilter] = + useState("all"); + + const filteredModules = useMemo( + () => + filterBySearchAndPublishStatus(modules, { + search: listSearch, + publishStatusFilter, + getSearchFields: (m) => [m.name, m.description], + getPublishStatus: (m) => m.publishStatus, + }), + [listSearch, modules, publishStatusFilter], + ); const isHttpUrl = (value: string) => value.startsWith("http://") || value.startsWith("https://"); @@ -131,6 +163,8 @@ export function UnitManagementPage() { thumbnail: row.thumbnail?.trim() || "", icon: row.icon?.trim() || "", sortOrder: Number(row.sort_order ?? 0), + publishStatus: row.publish_status ?? null, + accessTier: row.access_tier ?? null, lessons: Number(row.lessons_count ?? row.videos_count ?? 0), practices: Number(row.practices_count ?? 0), gradient: @@ -154,6 +188,58 @@ export function UnitManagementPage() { void loadModules(); }, [loadModules]); + const handleModulePublishStatus = async ( + moduleId: number, + nextStatus: PracticePublishStatus, + ) => { + setPublishStatusUpdatingId(moduleId); + try { + await setExamPrepUnitModulePublishStatus(moduleId, { + publish_status: nextStatus, + }); + setModules((prev) => + prev.map((m) => + m.id === moduleId ? { ...m, publishStatus: nextStatus } : m, + ), + ); + toast.success( + nextStatus === "PUBLISHED" ? "Module published" : "Module saved as draft", + ); + } catch (error: unknown) { + const message = + (error as { response?: { data?: { message?: string } } })?.response?.data + ?.message ?? "Failed to update module status"; + toast.error(message); + } finally { + setPublishStatusUpdatingId(null); + } + }; + + const handleModuleAccessTier = async ( + moduleId: number, + nextTier: ContentAccessTier, + ) => { + setAccessTierUpdatingId(moduleId); + try { + await setExamPrepUnitModuleAccessTier(moduleId, { access_tier: nextTier }); + setModules((prev) => + prev.map((m) => + m.id === moduleId ? { ...m, accessTier: nextTier } : m, + ), + ); + toast.success( + nextTier === "PREMIUM" ? "Module set to Premium" : "Module set to Free", + ); + } catch (error: unknown) { + const message = + (error as { response?: { data?: { message?: string } } })?.response?.data + ?.message ?? "Failed to update module access tier"; + toast.error(message); + } finally { + setAccessTierUpdatingId(null); + } + }; + const clearCreateModuleForm = () => { setCreateName(""); setCreateThumbnail(""); @@ -655,7 +741,18 @@ export function UnitManagementPage() { {/* Grid of Modules */} -
+
+ {!modulesLoading && modules.length > 0 ? ( + + ) : null} +
{modulesLoading ? (

Loading modules...

) : modules.length === 0 ? ( @@ -667,8 +764,14 @@ export function UnitManagementPage() { Create your first module to start organizing lessons and practices.

+ ) : filteredModules.length === 0 ? ( +
+

+ No modules match your search or status filter +

+
) : ( - modules.map((module, index) => ( + filteredModules.map((module, index) => (
+
+ + void handleModulePublishStatus(module.id, nextStatus) + } + /> + + void handleModuleAccessTier(module.id, nextTier) + } + /> +

{module.name}

@@ -771,6 +892,7 @@ export function UnitManagementPage() { )) )} +
void + nextTier: ContentAccessTier | null + contentLabel?: string + onConfirm: () => void + confirming?: boolean +} + +export function AccessTierConfirmDialog({ + open, + onOpenChange, + nextTier, + contentLabel = "content", + onConfirm, + confirming = false, +}: AccessTierConfirmDialogProps) { + if (!nextTier) return null + + return ( + { + if (!confirming) onOpenChange(nextOpen) + }} + > + + + + {accessTierConfirmTitle(nextTier, contentLabel)} + + + {accessTierConfirmDescription(nextTier, contentLabel)} + + + + + + + + + ) +} diff --git a/src/pages/content-management/components/ContentAccessTierChip.tsx b/src/pages/content-management/components/ContentAccessTierChip.tsx new file mode 100644 index 0000000..4c8ea07 --- /dev/null +++ b/src/pages/content-management/components/ContentAccessTierChip.tsx @@ -0,0 +1,104 @@ +import { useState } from "react" +import { Crown, Loader2, Sparkles } from "lucide-react" +import type { ContentAccessTier } from "../../../types/course.types" +import { + accessTierLabel, + isPremiumAccessTier, + nextAccessTier, +} from "../../../lib/accessTier" +import { cn } from "../../../lib/utils" +import { AccessTierConfirmDialog } from "./AccessTierConfirmDialog" + +type ContentAccessTierChipProps = { + accessTier?: string | null + updating?: boolean + onToggle?: (nextTier: ContentAccessTier) => void + contentLabel?: string + className?: string +} + +export function ContentAccessTierChip({ + accessTier, + updating = false, + onToggle, + contentLabel = "content", + className, +}: ContentAccessTierChipProps) { + const [confirmOpen, setConfirmOpen] = useState(false) + const [pendingTier, setPendingTier] = useState(null) + + const label = accessTierLabel(accessTier) + const isPremium = isPremiumAccessTier(accessTier) + const interactive = Boolean(onToggle) && !updating + + const body = ( + <> + {updating ? ( + + ) : isPremium ? ( + + ) : ( + + )} + {label} + + ) + + const chipClass = cn( + "inline-flex min-w-0 items-center gap-1.5 rounded-full border px-2.5 py-1 text-[10px] font-bold uppercase tracking-wider", + isPremium + ? "border-amber-200 bg-gradient-to-r from-amber-50 to-amber-100/80 text-amber-800 shadow-sm shadow-amber-100" + : "border-sky-200 bg-gradient-to-r from-sky-50 to-sky-100/60 text-sky-700", + interactive && "cursor-pointer transition-all hover:brightness-[0.98]", + updating && "opacity-70", + className, + ) + + const requestToggle = (e?: React.MouseEvent) => { + e?.stopPropagation() + if (!onToggle || updating) return + const next = nextAccessTier(accessTier) + setPendingTier(next) + setConfirmOpen(true) + } + + const handleConfirm = () => { + if (!onToggle || !pendingTier) return + onToggle(pendingTier) + setConfirmOpen(false) + setPendingTier(null) + } + + if (!onToggle) { + return {body} + } + + return ( + <> + + { + setConfirmOpen(open) + if (!open) setPendingTier(null) + }} + nextTier={pendingTier} + contentLabel={contentLabel} + confirming={updating} + onConfirm={handleConfirm} + /> + + ) +} diff --git a/src/pages/content-management/components/ContentHierarchyList.tsx b/src/pages/content-management/components/ContentHierarchyList.tsx index e3950b6..7f27222 100644 --- a/src/pages/content-management/components/ContentHierarchyList.tsx +++ b/src/pages/content-management/components/ContentHierarchyList.tsx @@ -36,13 +36,24 @@ import { Loader2, } from "lucide-react"; import { cn } from "../../../lib/utils"; +import { toast } from "sonner"; import { getLearningPrograms, getProgramCourses, getTopLevelCourseModules, getModuleLessons, + reorderLearningPrograms, + reorderProgramCourses, + reorderTopLevelCourseModules, + reorderModuleLessons, } from "../../../api/courses.api"; +function sortBySortOrder(items: T[]): T[] { + return [...items].sort( + (a, b) => Number(a.sort_order ?? 0) - Number(b.sort_order ?? 0), + ); +} + // --- Types --- export type ItemType = "program" | "course" | "module" | "lesson"; @@ -327,7 +338,9 @@ export function ContentHierarchyList() { // 1. Fetch Programs const programsRes = await getLearningPrograms(); const programData = programsRes.data?.data; - const fetchedPrograms: Program[] = (programData?.programs || []).map( + const fetchedPrograms: Program[] = sortBySortOrder( + programData?.programs || [], + ).map( (p) => ({ id: String(p.id), name: p.name, @@ -347,7 +360,7 @@ export function ContentHierarchyList() { const coursesResults = await Promise.all(coursesPromises); const fetchedCourses: Course[] = coursesResults.flatMap((res, idx) => { const courseData = res.data?.data; - return (courseData?.courses || []).map((c) => ({ + return sortBySortOrder(courseData?.courses || []).map((c) => ({ id: String(c.id), name: c.name, thumbnail: c.thumbnail_url || c.thumbnail || undefined, @@ -367,7 +380,7 @@ export function ContentHierarchyList() { const modulesResults = await Promise.all(modulesPromises); const fetchedModules: Module[] = modulesResults.flatMap((res, idx) => { const moduleData = res.data?.data; - return (moduleData?.modules || []).map((m) => ({ + return sortBySortOrder(moduleData?.modules || []).map((m) => ({ id: String(m.id), name: m.name, thumbnail: m.icon || undefined, @@ -387,7 +400,7 @@ export function ContentHierarchyList() { const lessonsResults = await Promise.all(lessonsPromises); const fetchedLessons: Lesson[] = lessonsResults.flatMap((res, idx) => { const lessonData = res.data?.data; - return (lessonData?.lessons || []).map((l) => ({ + return sortBySortOrder(lessonData?.lessons || []).map((l) => ({ id: String(l.id), name: l.title, thumbnail: l.thumbnail || undefined, @@ -410,16 +423,115 @@ export function ContentHierarchyList() { setOpenSections((prev) => ({ ...prev, [id]: !prev[id] })); }; - const reorder = ( - list: T[], - setList: React.Dispatch>, - activeId: UniqueIdentifier, - overId: UniqueIdentifier, + const toOrderedIds = (items: BaseItem[]) => + items.map((item) => Number(item.id)); + + const reorderSiblings = ( + siblings: T[], + activeId: string, + overId: string, + ): T[] | null => { + const oldIndex = siblings.findIndex((i) => i.id === activeId); + const newIndex = siblings.findIndex((i) => i.id === overId); + if (oldIndex === -1 || newIndex === -1 || oldIndex === newIndex) { + return null; + } + return arrayMove(siblings, oldIndex, newIndex); + }; + + const reorderErrorMessage = (error: unknown, fallback: string) => { + const message = (error as { response?: { data?: { message?: string } } }) + ?.response?.data?.message; + return typeof message === "string" && message.trim() ? message : fallback; + }; + + const handleProgramReorder = async (activeId: string, overId: string) => { + const reordered = reorderSiblings(programs, activeId, overId); + if (!reordered) return; + + const previous = programs; + setPrograms(reordered); + try { + await reorderLearningPrograms({ ordered_ids: toOrderedIds(reordered) }); + toast.success("Programs reordered"); + } catch (error) { + setPrograms(previous); + toast.error(reorderErrorMessage(error, "Failed to reorder programs")); + } + }; + + const handleCourseReorder = async ( + programId: string, + activeId: string, + overId: string, ) => { - const oldIndex = list.findIndex((i) => i.id === String(activeId)); - const newIndex = list.findIndex((i) => i.id === String(overId)); - if (oldIndex !== -1 && newIndex !== -1) { - setList(arrayMove(list, oldIndex, newIndex)); + const siblings = courses.filter((course) => course.programId === programId); + const reordered = reorderSiblings(siblings, activeId, overId); + if (!reordered) return; + + const previous = courses; + setCourses((prev) => [ + ...prev.filter((course) => course.programId !== programId), + ...reordered, + ]); + try { + await reorderProgramCourses(Number(programId), { + ordered_ids: toOrderedIds(reordered), + }); + toast.success("Courses reordered"); + } catch (error) { + setCourses(previous); + toast.error(reorderErrorMessage(error, "Failed to reorder courses")); + } + }; + + const handleModuleReorder = async ( + courseId: string, + activeId: string, + overId: string, + ) => { + const siblings = modules.filter((module) => module.courseId === courseId); + const reordered = reorderSiblings(siblings, activeId, overId); + if (!reordered) return; + + const previous = modules; + setModules((prev) => [ + ...prev.filter((module) => module.courseId !== courseId), + ...reordered, + ]); + try { + await reorderTopLevelCourseModules(Number(courseId), { + ordered_ids: toOrderedIds(reordered), + }); + toast.success("Modules reordered"); + } catch (error) { + setModules(previous); + toast.error(reorderErrorMessage(error, "Failed to reorder modules")); + } + }; + + const handleLessonReorder = async ( + moduleId: string, + activeId: string, + overId: string, + ) => { + const siblings = lessons.filter((lesson) => lesson.moduleId === moduleId); + const reordered = reorderSiblings(siblings, activeId, overId); + if (!reordered) return; + + const previous = lessons; + setLessons((prev) => [ + ...prev.filter((lesson) => lesson.moduleId !== moduleId), + ...reordered, + ]); + try { + await reorderModuleLessons(Number(moduleId), { + ordered_ids: toOrderedIds(reordered), + }); + toast.success("Lessons reordered"); + } catch (error) { + setLessons(previous); + toast.error(reorderErrorMessage(error, "Failed to reorder lessons")); } }; @@ -487,7 +599,7 @@ export function ContentHierarchyList() { - reorder(programs, setPrograms, active, over) + void handleProgramReorder(active, over) } icon={} onEdit={(id) => handleEdit("program", id)} @@ -521,7 +633,7 @@ export function ContentHierarchyList() { - reorder(courses, setCourses, active, over) + void handleCourseReorder(program.id, active, over) } icon={} onEdit={(id) => handleEdit("course", id)} @@ -558,7 +670,7 @@ export function ContentHierarchyList() { - reorder(modules, setModules, active, over) + void handleModuleReorder(course.id, active, over) } icon={} onEdit={(id) => handleEdit("module", id)} @@ -595,7 +707,7 @@ export function ContentHierarchyList() { - reorder(lessons, setLessons, active, over) + void handleLessonReorder(module.id, active, over) } icon={} onEdit={(id) => handleEdit("lesson", id)} diff --git a/src/pages/content-management/components/ContentListSearchFilterBar.tsx b/src/pages/content-management/components/ContentListSearchFilterBar.tsx new file mode 100644 index 0000000..aa385b4 --- /dev/null +++ b/src/pages/content-management/components/ContentListSearchFilterBar.tsx @@ -0,0 +1,60 @@ +import { Search } from "lucide-react" +import { Input } from "../../../components/ui/input" +import { Select } from "../../../components/ui/select" +import type { PublishStatusFilter } from "../../../lib/contentListFilters" +import { cn } from "../../../lib/utils" + +type ContentListSearchFilterBarProps = { + search: string + onSearchChange: (value: string) => void + publishStatusFilter: PublishStatusFilter + onPublishStatusFilterChange: (value: PublishStatusFilter) => void + searchPlaceholder?: string + searchAriaLabel?: string + className?: string +} + +export function ContentListSearchFilterBar({ + search, + onSearchChange, + publishStatusFilter, + onPublishStatusFilterChange, + searchPlaceholder = "Search by name or description…", + searchAriaLabel = "Search content", + className, +}: ContentListSearchFilterBarProps) { + return ( +
+
+ + onSearchChange(e.target.value)} + aria-label={searchAriaLabel} + /> +
+ +
+ ) +} diff --git a/src/pages/content-management/components/ContentPageDescription.tsx b/src/pages/content-management/components/ContentPageDescription.tsx new file mode 100644 index 0000000..bd418ee --- /dev/null +++ b/src/pages/content-management/components/ContentPageDescription.tsx @@ -0,0 +1,79 @@ +import { useEffect, useRef, useState, type ReactNode } from "react" +import { cn } from "../../../lib/utils" + +const PLACEHOLDER_VALUES = new Set(["—", "-", "Loading…", "Loading..."]) + +type ContentPageDescriptionProps = { + children: ReactNode + className?: string + collapsedLines?: 2 | 3 +} + +function getTextContent(children: ReactNode): string { + if (typeof children === "string") return children.trim() + if (typeof children === "number") return String(children).trim() + return "" +} + +export function ContentPageDescription({ + children, + className, + collapsedLines = 2, +}: ContentPageDescriptionProps) { + const text = getTextContent(children) + const isPlaceholder = !text || PLACEHOLDER_VALUES.has(text) + const [expanded, setExpanded] = useState(false) + const [isTruncated, setIsTruncated] = useState(false) + const ref = useRef(null) + + useEffect(() => { + setExpanded(false) + }, [text]) + + useEffect(() => { + if (isPlaceholder || expanded) return + + const el = ref.current + if (!el) return + + const checkTruncation = () => { + setIsTruncated(el.scrollHeight > el.clientHeight + 1) + } + + checkTruncation() + + const observer = new ResizeObserver(checkTruncation) + observer.observe(el) + return () => observer.disconnect() + }, [text, expanded, isPlaceholder, collapsedLines]) + + if (!isPlaceholder && typeof children !== "string" && typeof children !== "number") { + return
{children}
+ } + + const lineClampClass = collapsedLines === 3 ? "line-clamp-3" : "line-clamp-2" + + return ( +
+

+ {children} +

+ {!isPlaceholder && (isTruncated || expanded) ? ( + + ) : null} +
+ ) +} diff --git a/src/pages/content-management/components/ContentPublishStatusChip.tsx b/src/pages/content-management/components/ContentPublishStatusChip.tsx new file mode 100644 index 0000000..c9b2bc7 --- /dev/null +++ b/src/pages/content-management/components/ContentPublishStatusChip.tsx @@ -0,0 +1,107 @@ +import { useState } from "react" +import { Loader2 } from "lucide-react" +import type { PracticePublishStatus } from "../../../types/course.types" +import { + isPublishedPublishStatus, + nextPublishStatus, + publishStatusLabel, +} from "../../../lib/publishStatus" +import { cn } from "../../../lib/utils" +import { PublishStatusConfirmDialog } from "./PublishStatusConfirmDialog" + +type ContentPublishStatusChipProps = { + publishStatus?: string | null + updating?: boolean + onToggle?: (nextStatus: PracticePublishStatus) => void + contentLabel?: string + className?: string +} + +export function ContentPublishStatusChip({ + publishStatus, + updating = false, + onToggle, + contentLabel = "content", + className, +}: ContentPublishStatusChipProps) { + const [confirmOpen, setConfirmOpen] = useState(false) + const [pendingStatus, setPendingStatus] = useState( + null, + ) + + const label = publishStatusLabel(publishStatus) + const isPublished = isPublishedPublishStatus(publishStatus) + const interactive = Boolean(onToggle) && !updating + + const body = ( + <> + {updating ? ( + + ) : ( + + )} + {label} + + ) + + const chipClass = cn( + "inline-flex min-w-0 items-center gap-1.5 rounded-full border px-2.5 py-1 text-[10px] font-bold uppercase tracking-wider", + isPublished + ? "border-[#DCFCE7] bg-[#F0FDF4] text-[#16A34A]" + : "border-grayScale-100 bg-grayScale-50 text-grayScale-500", + interactive && "cursor-pointer transition-colors hover:opacity-90", + updating && "opacity-70", + className, + ) + + const requestToggle = (e?: React.MouseEvent) => { + e?.stopPropagation() + if (!onToggle || updating) return + const next = nextPublishStatus(publishStatus) + setPendingStatus(next) + setConfirmOpen(true) + } + + const handleConfirm = () => { + if (!onToggle || !pendingStatus) return + onToggle(pendingStatus) + setConfirmOpen(false) + setPendingStatus(null) + } + + if (!onToggle) { + return {body} + } + + return ( + <> + + { + setConfirmOpen(open) + if (!open) setPendingStatus(null) + }} + nextStatus={pendingStatus} + contentLabel={contentLabel} + confirming={updating} + onConfirm={handleConfirm} + /> + + ) +} diff --git a/src/pages/content-management/components/ModulePracticeCard.tsx b/src/pages/content-management/components/ModulePracticeCard.tsx index 33dc5a4..e40c0c2 100644 --- a/src/pages/content-management/components/ModulePracticeCard.tsx +++ b/src/pages/content-management/components/ModulePracticeCard.tsx @@ -9,13 +9,17 @@ import { DropdownMenuTrigger, } from "../../../components/ui/dropdown-menu"; import { ResolvedImage } from "../../../components/media/ResolvedImage"; -import type { ParentContextPractice } from "../../../types/course.types"; +import type { + ParentContextPractice, + PracticePublishStatus, +} from "../../../types/course.types"; import { isPracticePublished, practicePublishStatus, } from "../../../lib/parentContextPractice"; import { resolveThumbnailForPreview } from "../../../lib/videoPreview"; import { cn } from "../../../lib/utils"; +import { PublishStatusConfirmDialog } from "./PublishStatusConfirmDialog"; type ModulePracticeCardProps = { practice: ParentContextPractice; @@ -39,117 +43,152 @@ export function ModulePracticeCard({ [practice.story_image], ); const [thumbFailed, setThumbFailed] = useState(false); + const [confirmOpen, setConfirmOpen] = useState(false); + const [pendingStatus, setPendingStatus] = + useState(null); useEffect(() => { setThumbFailed(false); }, [thumbnailSrc]); - return ( - -
- {thumbnailSrc && !thumbFailed ? ( - setThumbFailed(true)} - /> - ) : null} -
+ const requestStatusChange = ( + nextStatus: PracticePublishStatus, + e?: React.MouseEvent, + ) => { + e?.stopPropagation(); + if (statusUpdating) return; + setPendingStatus(nextStatus); + setConfirmOpen(true); + }; -
-
-
+ const confirmStatusChange = () => { + if (!pendingStatus) return; + if (pendingStatus === "PUBLISHED") { + onPublish?.(); + } else { + onSaveAsDraft?.(); + } + setConfirmOpen(false); + setPendingStatus(null); + }; + + return ( + <> + +
+ {thumbnailSrc && !thumbFailed ? ( + setThumbFailed(true)} + /> + ) : null} +
+ +
+
- {statusLabel} -
- - -
+ + + + + + { + requestStatusChange( + isPublished ? "DRAFT" : "PUBLISHED", + e, + ); + }} + > + {isPublished ? "Save as draft" : "Publish practice"} + + + +
-

- {practice.title} -

+

+ {practice.title} +

-
- - +
+ + +
-
- + + { + setConfirmOpen(open); + if (!open) setPendingStatus(null); + }} + nextStatus={pendingStatus} + contentLabel="practice" + confirming={statusUpdating} + onConfirm={confirmStatusChange} + /> + ); } diff --git a/src/pages/content-management/components/PublishStatusConfirmDialog.tsx b/src/pages/content-management/components/PublishStatusConfirmDialog.tsx new file mode 100644 index 0000000..ec55345 --- /dev/null +++ b/src/pages/content-management/components/PublishStatusConfirmDialog.tsx @@ -0,0 +1,96 @@ +import type { PracticePublishStatus } from "../../../types/course.types" +import { Button } from "../../../components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "../../../components/ui/dialog" + +export function publishStatusConfirmTitle( + nextStatus: PracticePublishStatus, + contentLabel = "content", +): string { + if (nextStatus === "PUBLISHED") { + return `Publish this ${contentLabel}?` + } + return `Save this ${contentLabel} as draft?` +} + +export function publishStatusConfirmDescription( + nextStatus: PracticePublishStatus, + contentLabel = "content", +): string { + if (nextStatus === "PUBLISHED") { + return `This ${contentLabel} will be visible to learners once published.` + } + return `This ${contentLabel} will be hidden from learners while saved as a draft.` +} + +type PublishStatusConfirmDialogProps = { + open: boolean + onOpenChange: (open: boolean) => void + nextStatus: PracticePublishStatus | null + contentLabel?: string + onConfirm: () => void + confirming?: boolean +} + +export function PublishStatusConfirmDialog({ + open, + onOpenChange, + nextStatus, + contentLabel = "content", + onConfirm, + confirming = false, +}: PublishStatusConfirmDialogProps) { + if (!nextStatus) return null + + return ( + { + if (!confirming) onOpenChange(nextOpen) + }} + > + + + + {publishStatusConfirmTitle(nextStatus, contentLabel)} + + + {publishStatusConfirmDescription(nextStatus, contentLabel)} + + + + + + + + + ) +} diff --git a/src/pages/content-management/components/VideoCard.tsx b/src/pages/content-management/components/VideoCard.tsx index 50c590e..11cc7c9 100644 --- a/src/pages/content-management/components/VideoCard.tsx +++ b/src/pages/content-management/components/VideoCard.tsx @@ -33,7 +33,12 @@ import { isDirectVideoFileUrl, } from "../../../lib/videoPreview"; import { PreviewLimitedFileVideo } from "./PreviewLimitedFileVideo"; -import type { PracticePublishStatus } from "../../../types/course.types"; +import { PublishStatusConfirmDialog } from "./PublishStatusConfirmDialog"; +import type { + ContentAccessTier, + PracticePublishStatus, +} from "../../../types/course.types"; +import { ContentAccessTierChip } from "./ContentAccessTierChip"; function resolvePublishBadge( publishStatus?: PracticePublishStatus | string | null, @@ -90,6 +95,9 @@ interface VideoCardProps { /** Toggle draft ↔ published via PUT /lessons/:id (module lesson cards). */ onTogglePublishStatus?: (nextStatus: PracticePublishStatus) => void; publishStatusUpdating?: boolean; + accessTier?: ContentAccessTier | string | null; + onToggleAccessTier?: (nextTier: ContentAccessTier) => void; + accessTierUpdating?: boolean; /** Shown under title on module lesson cards; reserved height keeps grid rows even. */ description?: string | null; } @@ -110,6 +118,9 @@ export function VideoCard({ onViewPractices, onTogglePublishStatus, publishStatusUpdating = false, + accessTier, + onToggleAccessTier, + accessTierUpdating = false, hoverModuleActions = false, description, }: VideoCardProps) { @@ -118,6 +129,9 @@ export function VideoCard({ number | null >(null); const [previewOpen, setPreviewOpen] = useState(false); + const [publishConfirmOpen, setPublishConfirmOpen] = useState(false); + const [pendingPublishStatus, setPendingPublishStatus] = + useState(null); /** Iframe players ignore URL limits in many cases — unmount after real time. */ const [iframeSessionDone, setIframeSessionDone] = useState(false); const [iframeSessionKey, setIframeSessionKey] = useState(0); @@ -138,6 +152,23 @@ export function VideoCard({ const previewLengthLabel = formatPreviewLength( DEFAULT_PREVIEW_MAX_SECONDS, ); + const requestPublishStatusChange = ( + nextStatus: PracticePublishStatus, + e?: React.MouseEvent, + ) => { + e?.stopPropagation(); + if (publishStatusUpdating) return; + setPendingPublishStatus(nextStatus); + setPublishConfirmOpen(true); + }; + + const confirmPublishStatusChange = () => { + if (!pendingPublishStatus || !onTogglePublishStatus) return; + onTogglePublishStatus(pendingPublishStatus); + setPublishConfirmOpen(false); + setPendingPublishStatus(null); + }; + const publishBadge = resolvePublishBadge( publishStatus, status, @@ -242,6 +273,7 @@ export function VideoCard({ }; return ( + <>
- {/* Publish status badge */} - {publishBadge ? ( -
+
+ {publishBadge ? (
+
+ {publishBadge.label} +
+ ) : ( +
+
+ Lesson +
+ )} + {accessTier != null || onToggleAccessTier ? ( + - {publishBadge.label} -
- ) : ( -
-
- Lesson -
- )} + ) : null} +
{hoverModuleActions && onTogglePublishStatus ? ( @@ -485,11 +526,11 @@ export function VideoCard({ variant="ghost" size="icon" className="h-8 w-8 flex-shrink-0 rounded-full text-grayScale-400 hover:bg-grayScale-50 hover:text-grayScale-600" - disabled={publishStatusUpdating} + disabled={publishStatusUpdating || accessTierUpdating} aria-label={`Lesson options: ${title}`} onClick={(e) => e.stopPropagation()} > - {publishStatusUpdating ? ( + {publishStatusUpdating || accessTierUpdating ? ( ) : ( @@ -498,11 +539,11 @@ export function VideoCard({ { - e.stopPropagation(); - onTogglePublishStatus( + requestPublishStatusChange( publishBadge?.isPublished ? "DRAFT" : "PUBLISHED", + e, ); }} > @@ -578,5 +619,17 @@ export function VideoCard({ ) : null}
+ { + setPublishConfirmOpen(open); + if (!open) setPendingPublishStatus(null); + }} + nextStatus={pendingPublishStatus} + contentLabel="lesson" + confirming={publishStatusUpdating} + onConfirm={confirmPublishStatusChange} + /> + ); } diff --git a/src/pages/team/TeamManagementPage.tsx b/src/pages/team/TeamManagementPage.tsx index 255e329..f967fb8 100644 --- a/src/pages/team/TeamManagementPage.tsx +++ b/src/pages/team/TeamManagementPage.tsx @@ -24,6 +24,7 @@ import { TABLE_PAGE_SIZE_OPTIONS } from "../../lib/tablePagination"; import { getTeamMembers, updateTeamMemberStatus } from "../../api/team.api"; import type { TeamMember } from "../../types/team.types"; import { toast } from "sonner"; +import { InviteTeamMemberDialog } from "../role-management/components/InviteTeamMemberDialog"; function formatDate(dateStr: string): string { return new Date(dateStr).toLocaleDateString("en-US", { @@ -96,9 +97,9 @@ export function TeamManagementPage() { const [confirmDialog, setConfirmDialog] = useState<{ id: number; name: string; newStatus: string } | null>(null); const [updating, setUpdating] = useState(false); const [loading, setLoading] = useState(false); + const [inviteOpen, setInviteOpen] = useState(false); - useEffect(() => { - const fetchMembers = async () => { + const fetchMembers = async () => { setLoading(true); try { const batchSize = 100; @@ -128,7 +129,8 @@ export function TeamManagementPage() { } }; - fetchMembers(); + useEffect(() => { + void fetchMembers(); }, []); const filteredMembers = useMemo(() => { @@ -224,7 +226,7 @@ export function TeamManagementPage() {
{/* Status Update Confirmation Modal */} + void fetchMembers()} + /> + {confirmDialog && (
diff --git a/src/types/course.types.ts b/src/types/course.types.ts index c500fb4..eb85059 100644 --- a/src/types/course.types.ts +++ b/src/types/course.types.ts @@ -66,6 +66,8 @@ export interface LearningProgramListItem { description?: string | null thumbnail?: string | null sort_order: number + publish_status?: PracticePublishStatus | string | null + access_tier?: ContentAccessTier | string | null created_at: string } @@ -111,6 +113,8 @@ export interface ProgramCourseListItem { name: string description: string sort_order: number + publish_status?: PracticePublishStatus | string | null + access_tier?: ContentAccessTier | string | null created_at: string thumbnail?: string | null /** Some list endpoints may expose the image as `thumbnail_url` instead. */ @@ -159,6 +163,8 @@ export interface ExamPrepCatalogCourseItem { units_count?: number modules_count?: number lessons_count?: number + publish_status?: PracticePublishStatus | string | null + access_tier?: ContentAccessTier | string | null created_at?: string updated_at?: string } @@ -212,6 +218,8 @@ export interface ExamPrepCatalogUnitItem { description?: string | null thumbnail?: string | null sort_order?: number + publish_status?: PracticePublishStatus | string | null + access_tier?: ContentAccessTier | string | null modules_count?: number lessons_count?: number videos_count?: number @@ -271,6 +279,8 @@ export interface ExamPrepUnitModuleItem { thumbnail?: string | null icon?: string | null sort_order?: number + publish_status?: PracticePublishStatus | string | null + access_tier?: ContentAccessTier | string | null lessons_count?: number videos_count?: number practices_count?: number @@ -331,6 +341,7 @@ export interface ExamPrepModuleLessonItem { description?: string | null sort_order?: number publish_status?: PracticePublishStatus | string | null + access_tier?: ContentAccessTier | string | null /** Total length in seconds when the API provides it. */ duration?: number | null duration_seconds?: number | null @@ -457,6 +468,8 @@ export interface TopLevelCourseModuleItem { description: string icon?: string | null sort_order: number + publish_status?: PracticePublishStatus | string | null + access_tier?: ContentAccessTier | string | null created_at: string } @@ -507,6 +520,7 @@ export interface TopLevelModuleLessonItem { description: string sort_order: number publish_status?: PracticePublishStatus | string | null + access_tier?: ContentAccessTier | string | null has_practice?: boolean /** Total length in seconds when the API provides it. */ duration?: number | null @@ -559,6 +573,18 @@ export type PracticeParentKind = "COURSE" | "MODULE" | "LESSON" export type PracticePublishStatus = "DRAFT" | "PUBLISHED" +export type ContentAccessTier = "FREE" | "PREMIUM" + +/** PUT body when only toggling access_tier on a content resource. */ +export interface AccessTierOnlyRequest { + access_tier: ContentAccessTier +} + +/** PUT body when only toggling draft/published on a content resource. */ +export interface PublishStatusOnlyRequest { + publish_status: PracticePublishStatus +} + /** POST /practices — create practice linked to a course, module, or lesson (Learn English). */ export interface CreateParentLinkedPracticeRequest { parent_kind: PracticeParentKind @@ -1639,6 +1665,11 @@ export interface ReorderItem { position: number } +/** Reorder endpoints: PUT with { ordered_ids: number[] } */ +export interface ReorderOrderedIdsRequest { + ordered_ids: number[] +} + // Ratings export interface Rating { id: number