sync: learning operations frontend 2/2

This commit is contained in:
Admin 2026-09-27 23:14:52 +00:00
parent 7c7aec1984
commit 0dd318d7c9
10 changed files with 710 additions and 460 deletions

View File

@ -26,11 +26,33 @@ export type LearnerCommunityMembership = {
source?: string source?: string
} }
export type LearnerCommunityAccess = { export type LearnerCommunityAuthor = { id: string; name: string }
allowed: boolean
reason?: string export type LearnerCommunityReply = {
id: string
postId: string
body: string
status?: string
createdAt?: number
editedAt?: number
author: LearnerCommunityAuthor
} }
export type LearnerCommunityPost = {
id: string
communityId: string
channelId: string
body: string
status?: string
pinned?: boolean
createdAt?: number
editedAt?: number
author: LearnerCommunityAuthor
replies: LearnerCommunityReply[]
}
export type LearnerCommunityAccess = { allowed: boolean; reason?: string }
export type LearnerCommunityItem = { export type LearnerCommunityItem = {
community: LearnerCommunity community: LearnerCommunity
channels: LearnerCommunityChannel[] channels: LearnerCommunityChannel[]
@ -38,83 +60,76 @@ export type LearnerCommunityItem = {
access: LearnerCommunityAccess access: LearnerCommunityAccess
} }
function asString(value: unknown): string { function asString(value: unknown): string { return typeof value === 'string' ? value.trim() : '' }
return typeof value === 'string' ? value.trim() : '' function asNumber(value: unknown): number | undefined { return typeof value === 'number' && Number.isFinite(value) ? value : undefined }
} function asBool(value: unknown): boolean | undefined { return typeof value === 'boolean' ? value : undefined }
function asBool(value: unknown): boolean | undefined {
return typeof value === 'boolean' ? value : undefined
}
function normalizeCommunity(raw: unknown): LearnerCommunity { function normalizeCommunity(raw: unknown): LearnerCommunity {
const source = (raw ?? {}) as Record<string, unknown> const source = (raw ?? {}) as Record<string, unknown>
return { return { id: asString(source.id), name: asString(source.name), slug: asString(source.slug) || undefined, description: asString(source.description) || undefined, visibility: asString(source.visibility) || undefined, accessPolicy: asString(source.accessPolicy) || undefined }
id: asString(source.id),
name: asString(source.name),
slug: asString(source.slug) || undefined,
description: asString(source.description) || undefined,
visibility: asString(source.visibility) || undefined,
accessPolicy: asString(source.accessPolicy) || undefined,
}
} }
function normalizeChannel(raw: unknown): LearnerCommunityChannel { function normalizeChannel(raw: unknown): LearnerCommunityChannel {
const source = (raw ?? {}) as Record<string, unknown> const source = (raw ?? {}) as Record<string, unknown>
return { return { id: asString(source.id), name: asString(source.name), slug: asString(source.slug) || undefined, topic: asString(source.topic) || undefined, isPrivate: asBool(source.isPrivate) }
id: asString(source.id),
name: asString(source.name),
slug: asString(source.slug) || undefined,
topic: asString(source.topic) || undefined,
isPrivate: asBool(source.isPrivate),
}
} }
function normalizeMembership(raw: unknown): LearnerCommunityMembership | undefined { function normalizeMembership(raw: unknown): LearnerCommunityMembership | undefined {
const source = (raw ?? {}) as Record<string, unknown> const source = (raw ?? {}) as Record<string, unknown>
const id = asString(source.id) const id = asString(source.id)
if (!id) return undefined if (!id) return undefined
return { return { id, communityId: asString(source.communityId), userId: asString(source.userId), status: asString(source.status) || undefined, role: asString(source.role) || undefined, source: asString(source.source) || undefined }
id,
communityId: asString(source.communityId),
userId: asString(source.userId),
status: asString(source.status) || undefined,
role: asString(source.role) || undefined,
source: asString(source.source) || undefined,
}
} }
function normalizeAccess(raw: unknown): LearnerCommunityAccess { function normalizeAccess(raw: unknown): LearnerCommunityAccess {
const source = (raw ?? {}) as Record<string, unknown> const source = (raw ?? {}) as Record<string, unknown>
return { return { allowed: Boolean(source.allowed), reason: asString(source.reason) || undefined }
allowed: Boolean(source.allowed),
reason: asString(source.reason) || undefined,
} }
function normalizeAuthor(raw: unknown): LearnerCommunityAuthor {
const source = (raw ?? {}) as Record<string, unknown>
return { id: asString(source.id), name: asString(source.name) || 'Member' }
}
function normalizeReply(raw: unknown): LearnerCommunityReply {
const source = (raw ?? {}) as Record<string, unknown>
return { id: asString(source.id), postId: asString(source.postId), body: asString(source.body), status: asString(source.status) || undefined, createdAt: asNumber(source.createdAt), editedAt: asNumber(source.editedAt), author: normalizeAuthor(source.author) }
}
function normalizePost(raw: unknown): LearnerCommunityPost {
const source = (raw ?? {}) as Record<string, unknown>
const replies = Array.isArray(source.replies) ? source.replies.map(normalizeReply) : []
return { id: asString(source.id), communityId: asString(source.communityId), channelId: asString(source.channelId), body: asString(source.body), status: asString(source.status) || undefined, pinned: Boolean(source.pinned), createdAt: asNumber(source.createdAt), editedAt: asNumber(source.editedAt), author: normalizeAuthor(source.author), replies }
} }
export async function fetchLearnerCommunities(): Promise<LearnerCommunityItem[]> { export async function fetchLearnerCommunities(): Promise<LearnerCommunityItem[]> {
const result = await apiRequest<unknown[]>('/api/learner/communities') const result = await apiRequest<unknown[]>('/api/learner/communities')
if (!Array.isArray(result)) return [] if (!Array.isArray(result)) return []
return result.map(raw => { return result.map(raw => {
const source = (raw ?? {}) as Record<string, unknown> const source = (raw ?? {}) as Record<string, unknown>
const channelsRaw = Array.isArray(source.channels) ? source.channels : [] const channelsRaw = Array.isArray(source.channels) ? source.channels : []
return { return { community: normalizeCommunity(source.community), channels: channelsRaw.map(normalizeChannel).filter(channel => Boolean(channel.id && channel.name)), membership: normalizeMembership(source.membership), access: normalizeAccess(source.access) }
community: normalizeCommunity(source.community),
channels: channelsRaw.map(normalizeChannel).filter(channel => Boolean(channel.id && channel.name)),
membership: normalizeMembership(source.membership),
access: normalizeAccess(source.access),
}
}).filter(item => Boolean(item.community.id && item.community.name)) }).filter(item => Boolean(item.community.id && item.community.name))
} }
export async function joinLearnerCommunity(communityID: string): Promise<LearnerCommunityMembership> { export async function joinLearnerCommunity(communityID: string): Promise<LearnerCommunityMembership> {
const result = await apiRequest<unknown>(`/api/learner/communities/${encodeURIComponent(communityID)}/join`, { const result = await apiRequest<unknown>(`/api/learner/communities/${encodeURIComponent(communityID)}/join`, { method: 'POST', body: JSON.stringify({}) })
method: 'POST',
body: JSON.stringify({}),
})
const membership = normalizeMembership(result) const membership = normalizeMembership(result)
if (!membership) { if (!membership) throw new Error('Join response did not include membership details')
throw new Error('Join response did not include membership details')
}
return membership return membership
} }
export async function fetchCommunityPosts(communityID: string, channelID: string): Promise<LearnerCommunityPost[]> {
const result = await apiRequest<unknown[]>(`/api/learner/communities/${encodeURIComponent(communityID)}/channels/${encodeURIComponent(channelID)}/posts`)
return Array.isArray(result) ? result.map(normalizePost).filter(post => Boolean(post.id)) : []
}
export async function createCommunityPost(communityID: string, channelID: string, body: string): Promise<LearnerCommunityPost> {
const result = await apiRequest<unknown>(`/api/learner/communities/${encodeURIComponent(communityID)}/channels/${encodeURIComponent(channelID)}/posts`, { method: 'POST', body: JSON.stringify({ body }) })
return normalizePost(result)
}
export async function createCommunityReply(communityID: string, postID: string, body: string): Promise<LearnerCommunityReply> {
const result = await apiRequest<unknown>(`/api/learner/communities/${encodeURIComponent(communityID)}/posts/${encodeURIComponent(postID)}/replies`, { method: 'POST', body: JSON.stringify({ body }) })
return normalizeReply(result)
}

View File

@ -1,5 +1,6 @@
import { fetchLearnerLiveCourses, type LearnerLiveCourseItem } from './learner-live-courses' import { fetchLearnerLiveCourses, type LearnerLiveCourseItem } from './learner-live-courses'
import { fetchPublicLiveCourses, fetchPublicLiveCourseBySlug, type LiveCourse } from './live-courses' import { fetchPublicLiveCourses, fetchPublicLiveCourseBySlug, type LiveCourse } from './live-courses'
import { apiRequest } from './api/client'
export type LearnerCourseCard = { export type LearnerCourseCard = {
id: string id: string
@ -15,13 +16,39 @@ export type LearnerCourseCard = {
lockMessage?: string lockMessage?: string
} }
export type LearnerCourseDetail = { export type LearnerCourseLesson = {
card: LearnerCourseCard
cohorts: {
id: string id: string
title: string title: string
status?: string description?: string
}[] contentType: 'text' | 'video' | 'link' | 'document'
content?: string
sourceUrl?: string
durationMinutes?: number
completed: boolean
}
export type LearnerCourseModule = {
id: string
title: string
description?: string
lessons: LearnerCourseLesson[]
}
export type LearnerCourseAsset = {
id: string
title: string
description?: string
fileName?: string
protectionLevel?: string
allowDownload: boolean
}
export type LearnerCourseDetail = {
card: LearnerCourseCard
cohorts: { id: string; title: string; status?: string }[]
modules: LearnerCourseModule[]
assets: LearnerCourseAsset[]
progress: { total: number; completed: number; percent: number }
} }
function normalizeCourseSlug(slug: string | undefined, fallbackID: string): string { function normalizeCourseSlug(slug: string | undefined, fallbackID: string): string {
@ -42,7 +69,7 @@ function buildEnrolledCard(item: LearnerLiveCourseItem): LearnerCourseCard {
description: item.course.description, description: item.course.description,
source: 'enrolled', source: 'enrolled',
statusLabel: status, statusLabel: status,
progressLabel: 'Progress tracking is not yet available for this course.', progressLabel: typeof item.enrollment.progress === 'number' ? `${Math.round(item.enrollment.progress)}% complete` : 'Ready to continue',
actionLabel: 'Resume', actionLabel: 'Resume',
locked, locked,
lockReason: reason, lockReason: reason,
@ -131,9 +158,60 @@ export async function fetchLearnerCourseDetailBySlug(slug: string): Promise<Lear
status: item.enrollment.status, status: item.enrollment.status,
})) }))
const [content, assets] = await Promise.all([
apiRequest<Record<string, unknown>>(`/api/learner/live-courses/${encodeURIComponent(card.id)}/content`),
apiRequest<unknown[]>(`/api/learner/live-courses/${encodeURIComponent(card.id)}/assets`),
])
const modulesRaw = Array.isArray(content.modules) ? content.modules : []
const progressRaw = (content.progress ?? {}) as Record<string, unknown>
const modules: LearnerCourseModule[] = modulesRaw.map(raw => {
const source = (raw ?? {}) as Record<string, unknown>
const module = (source.module ?? {}) as Record<string, unknown>
const lessonsRaw = Array.isArray(source.lessons) ? source.lessons : []
return {
id: String(module.id ?? ''),
title: String(module.title ?? ''),
description: typeof module.description === 'string' ? module.description : undefined,
lessons: lessonsRaw.map(itemRaw => {
const item = (itemRaw ?? {}) as Record<string, unknown>
const lesson = (item.lesson ?? {}) as Record<string, unknown>
const lessonProgress = (item.progress ?? {}) as Record<string, unknown>
const contentType = String(lesson.contentType ?? 'text') as LearnerCourseLesson['contentType']
return {
id: String(lesson.id ?? ''),
title: String(lesson.title ?? ''),
description: typeof lesson.description === 'string' ? lesson.description : undefined,
contentType,
content: typeof lesson.content === 'string' ? lesson.content : undefined,
sourceUrl: typeof lesson.sourceUrl === 'string' ? lesson.sourceUrl : undefined,
durationMinutes: typeof lesson.durationMinutes === 'number' ? lesson.durationMinutes : undefined,
completed: String(lessonProgress.status ?? '').toLowerCase() === 'completed',
}
}).filter(lesson => lesson.id && lesson.title),
}
}).filter(module => module.id && module.title)
const normalizedAssets: LearnerCourseAsset[] = Array.isArray(assets) ? assets.map(raw => {
const source = (raw ?? {}) as Record<string, unknown>
return {
id: String(source.id ?? ''),
title: String(source.title ?? ''),
description: typeof source.description === 'string' ? source.description : undefined,
fileName: typeof source.fileName === 'string' ? source.fileName : undefined,
protectionLevel: typeof source.protectionLevel === 'string' ? source.protectionLevel : undefined,
allowDownload: Boolean(source.allowDownload),
}
}).filter(asset => asset.id && asset.title) : []
return { return {
card, card,
cohorts: courseCohorts, cohorts: courseCohorts,
modules,
assets: normalizedAssets,
progress: {
total: Number(progressRaw.total ?? 0),
completed: Number(progressRaw.completed ?? 0),
percent: Number(progressRaw.percent ?? 0),
},
} }
} }
@ -145,5 +223,26 @@ export async function fetchLearnerCourseDetailBySlug(slug: string): Promise<Lear
description: course.description || card.description, description: course.description || card.description,
}, },
cohorts: [], cohorts: [],
modules: [],
assets: [],
progress: { total: 0, completed: 0, percent: 0 },
} }
} }
export async function completeLearnerLesson(courseID: string, lessonID: string): Promise<{ percent: number }> {
const result = await apiRequest<Record<string, unknown>>(`/api/learner/live-courses/${encodeURIComponent(courseID)}/lessons/${encodeURIComponent(lessonID)}/complete`, {
method: 'POST',
body: JSON.stringify({}),
})
const summary = (result.courseProgress ?? {}) as Record<string, unknown>
return { percent: Number(summary.percent ?? 0) }
}
export async function accessLearnerCourseAsset(courseID: string, assetID: string): Promise<string> {
const result = await apiRequest<{ url?: string }>(`/api/learner/live-courses/${encodeURIComponent(courseID)}/assets/${encodeURIComponent(assetID)}/access`, {
method: 'POST',
body: JSON.stringify({}),
})
return String(result.url ?? '')
}

View File

@ -38,6 +38,7 @@ export type LearnerLiveCourseItem = {
enrollment: { enrollment: {
id: string id: string
status?: string status?: string
progress?: number
} }
access: LearnerAccess access: LearnerAccess
upcomingSessions: LearnerLiveSession[] upcomingSessions: LearnerLiveSession[]
@ -119,6 +120,7 @@ export async function fetchLearnerLiveCourses(): Promise<LearnerLiveCourseItem[]
enrollment: { enrollment: {
id: asString(((source.enrollment ?? {}) as Record<string, unknown>).id), id: asString(((source.enrollment ?? {}) as Record<string, unknown>).id),
status: asString(((source.enrollment ?? {}) as Record<string, unknown>).status) || undefined, status: asString(((source.enrollment ?? {}) as Record<string, unknown>).status) || undefined,
progress: asNumber(((source.enrollment ?? {}) as Record<string, unknown>).progress),
}, },
access: normalizeAccess(source.access), access: normalizeAccess(source.access),
upcomingSessions: sessionItems, upcomingSessions: sessionItems,

View File

@ -1,19 +1,8 @@
import { apiRequest } from './api/client' import { apiRequest } from './api/client'
import { normalizeCourseForSubjects, type ScormCourse } from './pages/scorm-shared'
export type LearnerScormCourse = { export type LearnerScormCourse = ScormCourse & { moduleTypes: string[] }
id: string export type LearnerScormEnrollment = { id: string; status?: string; progress?: number; score?: number }
title: string
description?: string
subject?: string
gradeLevel?: string
moduleTypes: string[]
}
export type LearnerScormEnrollment = {
id: string
status?: string
}
export type LearnerScormLaunchState = { export type LearnerScormLaunchState = {
allowed: boolean allowed: boolean
state: 'ready' | 'locked' | 'missing_package' | 'export_not_ready' state: 'ready' | 'locked' | 'missing_package' | 'export_not_ready'
@ -23,95 +12,90 @@ export type LearnerScormLaunchState = {
resumeAvailable: boolean resumeAvailable: boolean
progressNote?: string progressNote?: string
} }
export type LearnerScormCourseItem = { course: LearnerScormCourse; enrollment: LearnerScormEnrollment; launchState: LearnerScormLaunchState }
export type LearnerScormLaunchDetail = LearnerScormCourseItem
export type LearnerScormCourseItem = { export type LearnerScormRuntime = {
course: LearnerScormCourse id?: string
enrollment: LearnerScormEnrollment userId?: string
launchState: LearnerScormLaunchState courseId: string
enrollmentId?: string
scoId: string
status?: string
location?: string
suspendData?: string
lessonStatus?: string
completionStatus?: string
successStatus?: string
score?: number
progress?: number
values: Record<string, unknown>
} }
export type LearnerScormLaunchDetail = { function asString(value: unknown): string { return typeof value === 'string' ? value.trim() : '' }
course: LearnerScormCourse function asNumber(value: unknown): number | undefined { return typeof value === 'number' && Number.isFinite(value) ? value : undefined }
enrollment: LearnerScormEnrollment
launchState: LearnerScormLaunchState
}
function asString(value: unknown): string {
return typeof value === 'string' ? value.trim() : ''
}
function normalizeCourse(raw: unknown): LearnerScormCourse { function normalizeCourse(raw: unknown): LearnerScormCourse {
const source = (raw ?? {}) as Record<string, unknown> const source = (raw ?? {}) as Record<string, unknown>
const scos = Array.isArray(source.scos) ? source.scos : [] const normalized = normalizeCourseForSubjects(source as unknown as ScormCourse)
const moduleTypes = scos const moduleTypes = normalized.scos.map(sco => sco.moduleType).filter(Boolean)
.map(item => { return { ...normalized, moduleTypes: [...new Set(moduleTypes)] }
const entry = (item ?? {}) as Record<string, unknown>
return asString(entry.moduleType)
})
.filter(Boolean)
return {
id: asString(source.id),
title: asString(source.title),
description: asString(source.description) || undefined,
subject: asString(source.subject) || undefined,
gradeLevel: asString(source.gradeLevel) || undefined,
moduleTypes: [...new Set(moduleTypes)],
} }
}
function normalizeEnrollment(raw: unknown): LearnerScormEnrollment { function normalizeEnrollment(raw: unknown): LearnerScormEnrollment {
const source = (raw ?? {}) as Record<string, unknown> const source = (raw ?? {}) as Record<string, unknown>
return { return { id: asString(source.id), status: asString(source.status) || undefined, progress: asNumber(source.progress), score: asNumber(source.score) }
id: asString(source.id),
status: asString(source.status) || undefined,
} }
}
function normalizeLaunchState(raw: unknown): LearnerScormLaunchState { function normalizeLaunchState(raw: unknown): LearnerScormLaunchState {
const source = (raw ?? {}) as Record<string, unknown> const source = (raw ?? {}) as Record<string, unknown>
const state = asString(source.state) const state = asString(source.state)
const normalizedState = state === 'ready' || state === 'locked' || state === 'missing_package' || state === 'export_not_ready' const normalizedState = state === 'ready' || state === 'locked' || state === 'missing_package' || state === 'export_not_ready' ? state : 'locked'
? state
: 'locked'
const reason = asString(source.reason) || undefined const reason = asString(source.reason) || undefined
const message = asString(source.message) || (reason === 'missing_entitlement' const message = asString(source.message) || (reason === 'missing_entitlement'
? 'Access is locked because your entitlement is missing or inactive.' ? 'Access is locked because your entitlement is missing or inactive.'
: undefined) : undefined)
return { allowed: Boolean(source.allowed), state: normalizedState, reason, message, launchUrl: asString(source.launchUrl) || undefined, resumeAvailable: Boolean(source.resumeAvailable), progressNote: asString(source.progressNote) || undefined }
}
function normalizeRuntime(raw: unknown): LearnerScormRuntime {
const source = (raw ?? {}) as Record<string, unknown>
return { return {
allowed: Boolean(source.allowed), id: asString(source.id) || undefined,
state: normalizedState, userId: asString(source.userId) || undefined,
reason, courseId: asString(source.courseId),
message, enrollmentId: asString(source.enrollmentId) || undefined,
launchUrl: asString(source.launchUrl) || undefined, scoId: asString(source.scoId),
resumeAvailable: Boolean(source.resumeAvailable), status: asString(source.status) || undefined,
progressNote: asString(source.progressNote) || undefined, location: asString(source.location) || undefined,
suspendData: asString(source.suspendData) || undefined,
lessonStatus: asString(source.lessonStatus) || undefined,
completionStatus: asString(source.completionStatus) || undefined,
successStatus: asString(source.successStatus) || undefined,
score: asNumber(source.score),
progress: asNumber(source.progress),
values: source.values && typeof source.values === 'object' ? source.values as Record<string, unknown> : {},
} }
} }
export async function fetchLearnerScormCourses(): Promise<LearnerScormCourseItem[]> { export async function fetchLearnerScormCourses(): Promise<LearnerScormCourseItem[]> {
const result = await apiRequest<unknown[]>('/api/learner/scorm', { skipAuth: false }) const result = await apiRequest<unknown[]>('/api/learner/scorm', { skipAuth: false })
if (!Array.isArray(result)) return [] if (!Array.isArray(result)) return []
return result.map(raw => {
return result
.map(raw => {
const source = (raw ?? {}) as Record<string, unknown> const source = (raw ?? {}) as Record<string, unknown>
return { return { course: normalizeCourse(source.course), enrollment: normalizeEnrollment(source.enrollment), launchState: normalizeLaunchState(source.launchState) }
course: normalizeCourse(source.course), }).filter(item => Boolean(item.course.id && item.course.title))
enrollment: normalizeEnrollment(source.enrollment),
launchState: normalizeLaunchState(source.launchState),
}
})
.filter(item => Boolean(item.course.id && item.course.title))
} }
export async function fetchLearnerScormLaunch(courseID: string): Promise<LearnerScormLaunchDetail> { export async function fetchLearnerScormLaunch(courseID: string): Promise<LearnerScormLaunchDetail> {
const result = await apiRequest<unknown>(`/api/learner/scorm/${encodeURIComponent(courseID)}/launch`, { skipAuth: false }) const result = await apiRequest<unknown>(`/api/learner/scorm/${encodeURIComponent(courseID)}/launch`, { skipAuth: false })
const source = (result ?? {}) as Record<string, unknown> const source = (result ?? {}) as Record<string, unknown>
return { course: normalizeCourse(source.course), enrollment: normalizeEnrollment(source.enrollment), launchState: normalizeLaunchState(source.launchState) }
}
return { export async function fetchLearnerScormRuntime(courseID: string, scoID: string): Promise<LearnerScormRuntime> {
course: normalizeCourse(source.course), return normalizeRuntime(await apiRequest<unknown>(`/api/learner/scorm/${encodeURIComponent(courseID)}/runtime?scoId=${encodeURIComponent(scoID)}`))
enrollment: normalizeEnrollment(source.enrollment),
launchState: normalizeLaunchState(source.launchState),
} }
export async function commitLearnerScormRuntime(courseID: string, payload: Partial<LearnerScormRuntime>): Promise<{ runtime: LearnerScormRuntime; courseProgress: { percent: number } }> {
const result = await apiRequest<Record<string, unknown>>(`/api/learner/scorm/${encodeURIComponent(courseID)}/runtime`, { method: 'PATCH', body: JSON.stringify(payload) })
const progress = (result.courseProgress ?? {}) as Record<string, unknown>
return { runtime: normalizeRuntime(result.runtime), courseProgress: { percent: Number(progress.percent ?? 0) } }
} }

View File

@ -0,0 +1,68 @@
import { useEffect, useState } from 'react'
import { MessageSquare, Plus, ShieldCheck } from 'lucide-react'
import { createAdminCommunity, createAdminCommunityChannel, fetchAdminCommunities, fetchAdminCommunityChannels, fetchAdminCommunityPosts, patchAdminCommunityPost, type AdminCommunity, type AdminCommunityChannel, type AdminCommunityPost } from '../admin-communities'
export function AdminCommunitiesPage() {
const [communities, setCommunities] = useState<AdminCommunity[]>([])
const [communityID, setCommunityID] = useState('')
const [channels, setChannels] = useState<AdminCommunityChannel[]>([])
const [channelID, setChannelID] = useState('')
const [posts, setPosts] = useState<AdminCommunityPost[]>([])
const [name, setName] = useState('')
const [channelName, setChannelName] = useState('')
const [error, setError] = useState('')
const loadCommunities = async () => {
const items = await fetchAdminCommunities()
setCommunities(items)
if (!communityID && items[0]) setCommunityID(items[0].id)
}
useEffect(() => { void loadCommunities().catch(err => setError(String(err))) }, [])
useEffect(() => {
if (!communityID) return
void fetchAdminCommunityChannels(communityID).then(items => {
setChannels(items)
setChannelID(current => items.some(item => item.id === current) ? current : (items[0]?.id ?? ''))
}).catch(err => setError(String(err)))
}, [communityID])
useEffect(() => {
if (!communityID || !channelID) { setPosts([]); return }
void fetchAdminCommunityPosts(communityID, channelID).then(setPosts).catch(err => setError(String(err)))
}, [communityID, channelID])
return (
<div className="space-y-5 p-5" data-testid="admin-communities-page">
<header><h1 className="text-xl font-semibold text-stone-900">Community moderation</h1><p className="mt-1 text-sm text-stone-600">Create spaces and channels, pin useful discussions, and hide or restore posts.</p></header>
{error && <p className="rounded-lg bg-red-50 p-3 text-sm text-red-700">{error}</p>}
<div className="grid gap-4 lg:grid-cols-[280px_minmax(0,1fr)]">
<aside className="space-y-3 rounded-2xl border border-stone-200 bg-white p-4">
<form className="flex gap-2" onSubmit={event => { event.preventDefault(); if (!name.trim()) return; void createAdminCommunity({ name: name.trim(), visibility: 'private', accessPolicy: 'membership' }).then(() => { setName(''); return loadCommunities() }) }}>
<input value={name} onChange={e => setName(e.target.value)} placeholder="New community" className="min-w-0 flex-1 rounded-lg border border-stone-200 px-3 py-2 text-sm" />
<button type="submit" className="rounded-lg bg-indigo-600 p-2 text-white"><Plus size={16} /></button>
</form>
{communities.map(item => <button key={item.id} type="button" onClick={() => setCommunityID(item.id)} className={`w-full rounded-xl px-3 py-3 text-left ${item.id === communityID ? 'bg-indigo-50' : 'hover:bg-stone-50'}`}><p className="font-semibold text-stone-900">{item.name}</p><p className="mt-1 text-xs text-stone-500">{item.visibility || 'private'} · {item.status || 'active'}</p></button>)}
</aside>
<section className="space-y-4">
<div className="rounded-2xl border border-stone-200 bg-white p-4">
<div className="flex gap-2">
<input value={channelName} onChange={e => setChannelName(e.target.value)} placeholder="Add channel" className="min-w-0 flex-1 rounded-lg border border-stone-200 px-3 py-2 text-sm" />
<button type="button" disabled={!communityID || !channelName.trim()} onClick={() => void createAdminCommunityChannel(communityID, { name: channelName.trim(), topic: '', isPrivate: false }).then(async () => { setChannelName(''); const next = await fetchAdminCommunityChannels(communityID); setChannels(next); setChannelID(next.at(-1)?.id ?? channelID) })} className="rounded-lg border border-stone-200 px-3 py-2 text-sm font-semibold">Add channel</button>
</div>
<div className="mt-3 flex gap-2 overflow-x-auto">{channels.map(channel => <button key={channel.id} type="button" onClick={() => setChannelID(channel.id)} className={`whitespace-nowrap rounded-full px-3 py-1.5 text-xs font-semibold ${channel.id === channelID ? 'bg-indigo-600 text-white' : 'bg-stone-100 text-stone-700'}`}># {channel.name}</button>)}</div>
</div>
<div className="space-y-3">
{posts.map(post => <article key={post.id} className="rounded-2xl border border-stone-200 bg-white p-4">
<div className="flex items-center justify-between gap-3"><p className="text-xs font-semibold text-stone-600">{post.author?.name || 'Member'}</p><span className="text-xs text-stone-500">{post.status || 'active'}</span></div>
<p className="mt-2 whitespace-pre-wrap text-sm text-stone-800">{post.body}</p>
<div className="mt-3 flex flex-wrap gap-2">
<button type="button" onClick={() => void patchAdminCommunityPost(communityID, post.id, { pinned: !post.pinned }).then(updated => setPosts(prev => prev.map(item => item.id === post.id ? updated : item)))} className="rounded-lg border border-stone-200 px-2.5 py-1.5 text-xs font-semibold">{post.pinned ? 'Unpin' : 'Pin'}</button>
<button type="button" onClick={() => void patchAdminCommunityPost(communityID, post.id, { status: post.status === 'hidden' ? 'active' : 'hidden' }).then(updated => setPosts(prev => prev.map(item => item.id === post.id ? updated : item)))} className="rounded-lg border border-stone-200 px-2.5 py-1.5 text-xs font-semibold">{post.status === 'hidden' ? 'Restore' : 'Hide'}</button>
</div>
</article>)}
{channelID && posts.length === 0 && <div className="rounded-2xl border border-dashed border-stone-300 p-8 text-center text-sm text-stone-500"><MessageSquare className="mx-auto mb-2" size={20} />No posts in this channel.</div>}
</div>
</section>
</div>
</div>
)
}

View File

@ -0,0 +1,43 @@
import { useEffect, useState } from 'react'
import { Bell, Mail, Plus } from 'lucide-react'
import { createNotificationDelivery, createNotificationTemplate, fetchNotificationDeliveries, fetchNotificationTemplates, patchNotificationTemplate, type NotificationDelivery, type NotificationTemplate } from '../admin-notifications'
export function AdminNotificationsPage() {
const [templates, setTemplates] = useState<NotificationTemplate[]>([])
const [deliveries, setDeliveries] = useState<NotificationDelivery[]>([])
const [tab, setTab] = useState<'templates' | 'deliveries'>('templates')
const [name, setName] = useState('')
const [subject, setSubject] = useState('')
const [body, setBody] = useState('')
const [testTo, setTestTo] = useState('')
const [error, setError] = useState('')
const load = async () => {
const [t, d] = await Promise.all([fetchNotificationTemplates(), fetchNotificationDeliveries()])
setTemplates(Array.isArray(t) ? t : [])
setDeliveries(Array.isArray(d) ? d : [])
}
useEffect(() => { void load().catch(err => setError(String(err))) }, [])
return (
<div className="space-y-5 p-5" data-testid="admin-notifications-page">
<header><h1 className="text-xl font-semibold text-stone-900">Notification operations</h1><p className="mt-1 text-sm text-stone-600">Manage transactional templates and inspect delivery outcomes from the existing notification queue.</p></header>
{error && <p className="rounded-lg bg-red-50 p-3 text-sm text-red-700">{error}</p>}
<div className="flex gap-2"><button type="button" onClick={() => setTab('templates')} className={`rounded-full px-3 py-1.5 text-sm font-semibold ${tab === 'templates' ? 'bg-indigo-600 text-white' : 'bg-stone-100 text-stone-700'}`}>Templates</button><button type="button" onClick={() => setTab('deliveries')} className={`rounded-full px-3 py-1.5 text-sm font-semibold ${tab === 'deliveries' ? 'bg-indigo-600 text-white' : 'bg-stone-100 text-stone-700'}`}>Deliveries</button></div>
{tab === 'templates' && <div className="grid gap-4 lg:grid-cols-[360px_minmax(0,1fr)]">
<form className="space-y-3 rounded-2xl border border-stone-200 bg-white p-4" onSubmit={event => { event.preventDefault(); if (!name.trim()) return; void createNotificationTemplate({ name: name.trim(), channel: 'email', subject, body, enabled: true }).then(() => { setName(''); setSubject(''); setBody(''); return load() }) }}>
<h2 className="font-semibold text-stone-900">New email template</h2>
<input value={name} onChange={e => setName(e.target.value)} placeholder="Template name" className="w-full rounded-lg border border-stone-200 px-3 py-2 text-sm" />
<input value={subject} onChange={e => setSubject(e.target.value)} placeholder="Subject" className="w-full rounded-lg border border-stone-200 px-3 py-2 text-sm" />
<textarea value={body} onChange={e => setBody(e.target.value)} placeholder="Message body" className="min-h-32 w-full rounded-lg border border-stone-200 p-3 text-sm" />
<button type="submit" className="inline-flex items-center gap-2 rounded-lg bg-indigo-600 px-3 py-2 text-sm font-semibold text-white"><Plus size={15} />Create template</button>
</form>
<div className="space-y-3">{templates.map(template => <article key={template.id} className="rounded-2xl border border-stone-200 bg-white p-4"><div className="flex items-start justify-between gap-3"><div><p className="font-semibold text-stone-900">{template.name}</p><p className="mt-1 text-sm text-stone-600">{template.subject}</p></div><button type="button" onClick={() => void patchNotificationTemplate(template.id, { enabled: !template.enabled }).then(updated => setTemplates(prev => prev.map(item => item.id === updated.id ? updated : item)))} className={`rounded-full px-2 py-1 text-xs font-semibold ${template.enabled === false ? 'bg-stone-100 text-stone-600' : 'bg-emerald-50 text-emerald-700'}`}>{template.enabled === false ? 'Disabled' : 'Enabled'}</button></div><p className="mt-3 whitespace-pre-wrap text-sm text-stone-700">{template.body}</p></article>)}</div>
</div>}
{tab === 'deliveries' && <div className="space-y-4">
<form className="flex flex-col gap-2 rounded-2xl border border-stone-200 bg-white p-4 sm:flex-row" onSubmit={event => { event.preventDefault(); if (!testTo.trim()) return; void createNotificationDelivery({ channel: 'email', to: testTo.trim(), subject: 'Learn platform test notification', body: 'Notification delivery test from the admin workspace.', sync: true }).then(() => { setTestTo(''); return load() }) }}><input value={testTo} onChange={e => setTestTo(e.target.value)} type="email" placeholder="recipient@example.com" className="min-w-0 flex-1 rounded-lg border border-stone-200 px-3 py-2 text-sm" /><button type="submit" className="inline-flex items-center justify-center gap-2 rounded-lg bg-indigo-600 px-3 py-2 text-sm font-semibold text-white"><Mail size={15} />Send test</button></form>
<div className="overflow-hidden rounded-2xl border border-stone-200 bg-white"><table className="responsive-table min-w-full text-sm"><thead className="bg-stone-50 text-left text-xs text-stone-500"><tr><th className="px-3 py-2">Recipient</th><th className="px-3 py-2">Status</th><th className="px-3 py-2">Provider</th><th className="px-3 py-2">Subject</th></tr></thead><tbody>{deliveries.map(item => <tr key={item.id} className="border-t border-stone-100"><td data-primary="true" data-label="Recipient" className="px-3 py-2 font-medium text-stone-900">{item.to || '—'}</td><td data-label="Status" className="px-3 py-2">{item.status || 'unknown'}</td><td data-label="Provider" className="px-3 py-2">{item.provider || '—'}</td><td data-label="Subject" className="px-3 py-2">{item.subject || '—'}</td></tr>)}</tbody></table>{deliveries.length === 0 && <p className="p-6 text-center text-sm text-stone-500"><Bell className="mx-auto mb-2" size={18} />No deliveries recorded yet.</p>}</div>
</div>}
</div>
)
}

View File

@ -1,125 +1,189 @@
import { useEffect, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { MessageSquare, Users } from 'lucide-react' import { MessageSquare, Pin, Send, Users } from 'lucide-react'
import { ApiError } from '../api/client' import { ApiError } from '../api/client'
import { fetchLearnerCommunities, joinLearnerCommunity, type LearnerCommunityItem } from '../learner-communities' import {
createCommunityPost,
createCommunityReply,
fetchCommunityPosts,
fetchLearnerCommunities,
joinLearnerCommunity,
type LearnerCommunityItem,
type LearnerCommunityPost,
} from '../learner-communities'
type LoadState = 'loading' | 'ready' | 'error' type LoadState = 'loading' | 'ready' | 'error'
function formatDate(value?: number) {
if (!value) return ''
return new Date(value).toLocaleString()
}
export function LearnerCommunityPage() { export function LearnerCommunityPage() {
const [items, setItems] = useState<LearnerCommunityItem[]>([]) const [items, setItems] = useState<LearnerCommunityItem[]>([])
const [state, setState] = useState<LoadState>('loading') const [state, setState] = useState<LoadState>('loading')
const [error, setError] = useState('') const [error, setError] = useState('')
const [joiningID, setJoiningID] = useState('') const [joiningID, setJoiningID] = useState('')
const [selectedCommunityID, setSelectedCommunityID] = useState('')
const [selectedChannelID, setSelectedChannelID] = useState('')
const [posts, setPosts] = useState<LearnerCommunityPost[]>([])
const [postBody, setPostBody] = useState('')
const [replyDrafts, setReplyDrafts] = useState<Record<string, string>>({})
const [sending, setSending] = useState(false)
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
void fetchLearnerCommunities().then(result => {
const load = async () => {
try {
setState('loading')
const result = await fetchLearnerCommunities()
if (cancelled) return if (cancelled) return
setItems(result) setItems(result)
const first = result[0]
setSelectedCommunityID(first?.community.id ?? '')
setSelectedChannelID(first?.channels[0]?.id ?? '')
setState('ready') setState('ready')
} catch (err) { }).catch(err => {
if (cancelled) return if (cancelled) return
const message = err instanceof ApiError ? err.message : 'Failed to load communities' setError(err instanceof ApiError ? err.message : 'Failed to load communities')
setError(message)
setState('error') setState('error')
} })
}
void load()
return () => { cancelled = true } return () => { cancelled = true }
}, []) }, [])
const selected = useMemo(() => items.find(item => item.community.id === selectedCommunityID) ?? null, [items, selectedCommunityID])
const joined = selected?.membership?.status?.toLowerCase() === 'active'
useEffect(() => {
if (!selectedCommunityID || !selectedChannelID) {
setPosts([])
return
}
void fetchCommunityPosts(selectedCommunityID, selectedChannelID).then(setPosts).catch(err => {
setError(err instanceof Error ? err.message : 'Unable to load community posts')
})
}, [selectedCommunityID, selectedChannelID])
const handleJoin = async (communityID: string) => { const handleJoin = async (communityID: string) => {
try { try {
setJoiningID(communityID) setJoiningID(communityID)
const membership = await joinLearnerCommunity(communityID) const membership = await joinLearnerCommunity(communityID)
setItems(prev => prev.map(item => ( setItems(prev => prev.map(item => item.community.id === communityID ? { ...item, membership } : item))
item.community.id === communityID
? { ...item, membership }
: item
)))
} catch (err) { } catch (err) {
const message = err instanceof ApiError ? err.message : 'Unable to join community' setError(err instanceof ApiError ? err.message : 'Unable to join community')
setError(message)
} finally { } finally {
setJoiningID('') setJoiningID('')
} }
} }
const submitPost = async () => {
if (!selectedCommunityID || !selectedChannelID || !postBody.trim()) return
try {
setSending(true)
const post = await createCommunityPost(selectedCommunityID, selectedChannelID, postBody.trim())
setPosts(prev => [post, ...prev])
setPostBody('')
} catch (err) {
setError(err instanceof Error ? err.message : 'Unable to create post')
} finally {
setSending(false)
}
}
const submitReply = async (postID: string) => {
const body = replyDrafts[postID]?.trim()
if (!selectedCommunityID || !body) return
try {
setSending(true)
const reply = await createCommunityReply(selectedCommunityID, postID, body)
setPosts(prev => prev.map(post => post.id === postID ? { ...post, replies: [...post.replies, reply] } : post))
setReplyDrafts(prev => ({ ...prev, [postID]: '' }))
} catch (err) {
setError(err instanceof Error ? err.message : 'Unable to reply')
} finally {
setSending(false)
}
}
return ( return (
<div data-testid="learner-community-page"> <div data-testid="learner-community-page" className="space-y-5">
<header className="mb-6"> <header>
<h1 className="text-2xl font-semibold tracking-tight text-slate-900">Community</h1> <h1 className="text-2xl font-semibold tracking-tight text-stone-900">Community</h1>
<p className="mt-2 text-sm text-slate-600"> <p className="mt-2 text-sm text-stone-600">Join your school spaces, follow channels, post questions, and reply to other learners.</p>
Access your learning communities and join channels available to your role or entitlements.
</p>
</header> </header>
{state === 'loading' && <p className="text-sm text-stone-600">Loading your communities...</p>}
{state === 'loading' && <p className="text-sm text-slate-600">Loading your communities...</p>}
{state === 'error' && <p className="text-sm text-red-600">{error}</p>} {state === 'error' && <p className="text-sm text-red-600">{error}</p>}
{state === 'ready' && items.length === 0 && <div className="rounded-2xl border border-stone-200 bg-stone-50 p-8 text-sm text-stone-600">You do not currently have access to any communities.</div>}
{state === 'ready' && ( {state === 'ready' && items.length > 0 && (
<section className="space-y-5"> <div className="grid gap-4 lg:grid-cols-[260px_minmax(0,1fr)]">
{items.length === 0 && ( <aside className="space-y-3 rounded-2xl border border-stone-200 bg-white p-3">
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-8 text-sm text-slate-600"> {items.map(item => (
You do not currently have access to any communities. <button key={item.community.id} type="button" onClick={() => {
</div> setSelectedCommunityID(item.community.id)
)} setSelectedChannelID(item.channels[0]?.id ?? '')
}} className={`w-full rounded-xl px-3 py-3 text-left transition ${item.community.id === selectedCommunityID ? 'bg-indigo-50 text-indigo-950' : 'hover:bg-stone-50'}`}>
{items.map(item => { <p className="font-semibold">{item.community.name}</p>
const membershipStatus = item.membership?.status?.toLowerCase() ?? '' <p className="mt-1 text-xs text-stone-500">{item.channels.length} channels</p>
const isJoined = membershipStatus === 'active'
return (
<article key={item.community.id} className="rounded-2xl border border-slate-200 bg-slate-50 p-6">
<div className="mb-3 flex flex-wrap items-center gap-2 text-xs text-slate-500">
<span className="rounded-full bg-slate-100 px-2.5 py-1 font-medium text-slate-700">{item.community.visibility || 'private'}</span>
<span className="rounded-full bg-sky-100 px-2.5 py-1 font-medium text-sky-800">{item.community.accessPolicy || 'membership'}</span>
{isJoined && <span className="rounded-full bg-emerald-100 px-2.5 py-1 font-medium text-emerald-800">Joined</span>}
</div>
<h2 className="text-2xl font-semibold tracking-tight text-slate-900">{item.community.name}</h2>
{item.community.description && <p className="mt-2 text-sm text-slate-600">{item.community.description}</p>}
<div className="mt-4 flex items-center gap-2 text-xs font-medium text-slate-600">
<Users size={14} />
Access: {item.access.reason || 'membership'}
</div>
<div className="mt-4">
<p className="mb-2 text-xs font-semibold uppercase tracking-[0.08em] text-slate-500">Channels</p>
<ul className="space-y-2 text-sm text-slate-700">
{item.channels.map(channel => (
<li key={channel.id} className="flex items-center gap-2">
<MessageSquare size={14} className="text-slate-500" />
<span>{channel.name}</span>
{channel.topic && <span className="text-slate-500">- {channel.topic}</span>}
</li>
))}
{item.channels.length === 0 && <li className="text-slate-500">No channels configured yet.</li>}
</ul>
</div>
{!isJoined && (
<div className="mt-5">
<button
type="button"
disabled={joiningID === item.community.id}
onClick={() => { void handleJoin(item.community.id) }}
className="rounded-lg border border-sky-300 bg-sky-50 px-3 py-1.5 text-xs font-semibold text-sky-800 hover:border-sky-500 hover:bg-sky-100 disabled:cursor-not-allowed disabled:opacity-70"
>
{joiningID === item.community.id ? 'Joining...' : 'Join Community'}
</button> </button>
))}
</aside>
{selected && (
<section className="min-w-0 space-y-4">
<div className="rounded-2xl border border-stone-200 bg-white p-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="text-xl font-semibold text-stone-900">{selected.community.name}</h2>
<p className="mt-1 text-sm text-stone-600">{selected.community.description}</p>
</div>
{!joined && <button type="button" disabled={joiningID === selected.community.id} onClick={() => void handleJoin(selected.community.id)} className="rounded-lg bg-indigo-600 px-3 py-2 text-sm font-semibold text-white disabled:opacity-60">{joiningID ? 'Joining…' : 'Join community'}</button>}
</div>
<div className="mt-4 flex gap-2 overflow-x-auto pb-1">
{selected.channels.map(channel => (
<button key={channel.id} type="button" onClick={() => setSelectedChannelID(channel.id)} className={`whitespace-nowrap rounded-full border px-3 py-1.5 text-xs font-semibold ${channel.id === selectedChannelID ? 'border-indigo-300 bg-indigo-50 text-indigo-800' : 'border-stone-200 bg-white text-stone-600'}`}>
# {channel.name}
</button>
))}
</div>
</div>
{joined && selectedChannelID && (
<div className="rounded-2xl border border-stone-200 bg-white p-4">
<textarea value={postBody} onChange={event => setPostBody(event.target.value)} placeholder="Start a discussion or ask a question…" className="min-h-24 w-full rounded-xl border border-stone-200 p-3 text-sm outline-none focus:border-indigo-400" />
<div className="mt-2 flex justify-end">
<button type="button" disabled={sending || !postBody.trim()} onClick={() => void submitPost()} className="inline-flex items-center gap-2 rounded-lg bg-indigo-600 px-3 py-2 text-sm font-semibold text-white disabled:opacity-50"><Send size={15} /> Post</button>
</div>
</div> </div>
)} )}
<div className="space-y-3">
{posts.map(post => (
<article key={post.id} className="rounded-2xl border border-stone-200 bg-white p-5" data-testid={`community-post-${post.id}`}>
<div className="flex items-center gap-2 text-xs text-stone-500">
<span className="font-semibold text-stone-700">{post.author.name}</span>
<span>{formatDate(post.createdAt)}</span>
{post.pinned && <span className="inline-flex items-center gap-1 rounded-full bg-amber-50 px-2 py-0.5 text-amber-700"><Pin size={11} /> Pinned</span>}
</div>
<p className="mt-3 whitespace-pre-wrap text-sm leading-6 text-stone-800">{post.body}</p>
<div className="mt-4 space-y-2 border-l-2 border-stone-100 pl-4">
{post.replies.map(reply => (
<div key={reply.id} className="rounded-lg bg-stone-50 p-3">
<p className="text-xs font-semibold text-stone-700">{reply.author.name}</p>
<p className="mt-1 whitespace-pre-wrap text-sm text-stone-700">{reply.body}</p>
</div>
))}
{joined && (
<div className="flex gap-2">
<input value={replyDrafts[post.id] ?? ''} onChange={event => setReplyDrafts(prev => ({ ...prev, [post.id]: event.target.value }))} placeholder="Reply…" className="min-w-0 flex-1 rounded-lg border border-stone-200 px-3 py-2 text-sm" />
<button type="button" disabled={sending || !(replyDrafts[post.id] ?? '').trim()} onClick={() => void submitReply(post.id)} className="rounded-lg border border-stone-200 px-3 text-sm font-semibold text-stone-700 disabled:opacity-50">Reply</button>
</div>
)}
</div>
</article> </article>
) ))}
})} {posts.length === 0 && selectedChannelID && <div className="rounded-2xl border border-dashed border-stone-300 p-8 text-center text-sm text-stone-500"><MessageSquare className="mx-auto mb-2" size={20} />No posts yet. Start the conversation.</div>}
</div>
</section> </section>
)} )}
</div> </div>
)}
</div>
) )
} }

View File

@ -1,8 +1,13 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Link, useParams } from 'react-router' import { Link, useParams } from 'react-router'
import { ArrowLeft, Lock, PlayCircle } from 'lucide-react' import { ArrowLeft, CheckCircle2, Download, ExternalLink, Lock, PlayCircle } from 'lucide-react'
import { ApiError } from '../api/client' import { ApiError } from '../api/client'
import { fetchLearnerCourseDetailBySlug, type LearnerCourseDetail } from '../learner-courses' import {
accessLearnerCourseAsset,
completeLearnerLesson,
fetchLearnerCourseDetailBySlug,
type LearnerCourseDetail,
} from '../learner-courses'
type LoadState = 'loading' | 'ready' | 'error' | 'not-found' type LoadState = 'loading' | 'ready' | 'error' | 'not-found'
@ -11,113 +16,110 @@ export function LearnerCourseDetailPage() {
const [detail, setDetail] = useState<LearnerCourseDetail | null>(null) const [detail, setDetail] = useState<LearnerCourseDetail | null>(null)
const [state, setState] = useState<LoadState>('loading') const [state, setState] = useState<LoadState>('loading')
const [error, setError] = useState('') const [error, setError] = useState('')
const [savingLesson, setSavingLesson] = useState('')
useEffect(() => {
let cancelled = false
const load = async () => { const load = async () => {
try { try {
setState('loading') setState('loading')
const result = await fetchLearnerCourseDetailBySlug(slug) const result = await fetchLearnerCourseDetailBySlug(slug)
if (cancelled) return if (!result) { setState('not-found'); return }
if (!result) { setDetail(result); setState('ready')
setState('not-found')
return
}
setDetail(result)
setState('ready')
} catch (err) { } catch (err) {
if (cancelled) return
setError(err instanceof ApiError ? err.message : 'Failed to load learner course detail') setError(err instanceof ApiError ? err.message : 'Failed to load learner course detail')
setState('error') setState('error')
} }
} }
void load() useEffect(() => { void load() }, [slug])
return () => { cancelled = true }
}, [slug])
if (state === 'loading') { if (state === 'loading') return <p className="text-sm text-stone-600">Loading course details...</p>
return <p className="text-sm text-slate-600">Loading course details...</p> if (state === 'not-found') return <div><h1 className="text-3xl font-semibold text-stone-900">Course not found</h1><p className="mt-2 text-sm text-stone-600">This course may not be available in your learner catalog.</p></div>
if (state === 'error') return <p className="text-sm text-red-600">{error}</p>
if (!detail) return null
const completeLesson = async (lessonID: string) => {
try {
setSavingLesson(lessonID)
await completeLearnerLesson(detail.card.id, lessonID)
await load()
} finally {
setSavingLesson('')
}
}
const openAsset = async (assetID: string) => {
const url = await accessLearnerCourseAsset(detail.card.id, assetID)
if (url) window.open(url, '_blank', 'noopener,noreferrer')
} }
if (state === 'not-found') {
return ( return (
<div> <article data-testid="learner-course-detail-page" className="space-y-5">
<h1 className="text-3xl font-semibold text-slate-900">Course not found</h1> <Link data-testid="learner-course-detail-back" to="/learn/courses" className="inline-flex items-center gap-2 text-sm font-semibold text-stone-600 hover:text-stone-900"><ArrowLeft size={16} />Back to my courses</Link>
<p className="mt-2 text-sm text-slate-600">This course may not be available in your learner catalog.</p>
<header className="rounded-3xl border border-stone-200 bg-stone-50 p-6 md:p-8">
<p className="text-xs font-semibold uppercase tracking-[0.12em] text-stone-500">{detail.card.statusLabel}</p>
<h1 className="mt-2 text-3xl font-semibold leading-tight tracking-tight text-stone-900 md:text-5xl">{detail.card.title}</h1>
<p className="mt-3 text-sm text-stone-600">{detail.card.description}</p>
{detail.progress.total > 0 && (
<div className="mt-5">
<div className="flex justify-between text-xs font-medium text-stone-600"><span>{detail.progress.completed} of {detail.progress.total} lessons complete</span><span>{Math.round(detail.progress.percent)}%</span></div>
<div className="mt-2 h-2 overflow-hidden rounded-full bg-stone-200"><div className="h-full bg-indigo-600 transition-all" style={{ width: `${Math.min(100, detail.progress.percent)}%` }} /></div>
</div> </div>
) )}
}
if (state === 'error') {
return <p className="text-sm text-red-600">{error}</p>
}
return (
<article data-testid="learner-course-detail-page">
<Link data-testid="learner-course-detail-back" to="/learn/courses" className="mb-6 inline-flex items-center gap-2 text-sm font-semibold text-slate-600 hover:text-slate-900">
<ArrowLeft size={16} />
Back to my courses
</Link>
<header className="rounded-3xl border border-slate-200 bg-slate-50 p-6 md:p-8">
<p className="text-xs font-semibold uppercase tracking-[0.12em] text-slate-500">{detail?.card.statusLabel}</p>
<h1 className="mt-2 text-3xl font-semibold leading-tight tracking-tight text-slate-900 md:text-5xl">{detail?.card.title}</h1>
<p className="mt-3 text-sm text-slate-600">{detail?.card.description || 'Course overview details will expand as resource metadata is added.'}</p>
</header> </header>
{!detail?.card.locked && ( {detail.card.locked && <section className="rounded-2xl border border-amber-200 bg-amber-50 p-4"><p className="inline-flex items-center gap-2 text-sm font-semibold text-amber-900"><Lock size={16} />Course Locked</p><p className="mt-2 text-sm text-amber-800">{detail.card.lockMessage}</p></section>}
<div className="mt-6">
<Link {!detail.card.locked && (
data-testid="learner-course-detail-resume" <section className="space-y-4">
to="/learn/live" <h2 className="text-xl font-semibold text-stone-900">Course content</h2>
className="inline-flex items-center gap-2 rounded-lg border border-emerald-300 bg-emerald-50 px-4 py-2 text-sm font-semibold text-emerald-800 hover:border-emerald-500 hover:bg-emerald-100" {detail.modules.map((module, moduleIndex) => (
> <article key={module.id} className="rounded-2xl border border-stone-200 bg-white p-5">
<PlayCircle size={16} /> <p className="text-xs font-semibold uppercase tracking-[0.1em] text-stone-500">Module {moduleIndex + 1}</p>
Resume Course <h3 className="mt-1 text-lg font-semibold text-stone-900">{module.title}</h3>
</Link> {module.description && <p className="mt-1 text-sm text-stone-600">{module.description}</p>}
<div className="mt-4 space-y-3">
{module.lessons.map((lesson, lessonIndex) => (
<div key={lesson.id} className="rounded-xl border border-stone-200 bg-stone-50 p-4" data-testid={`course-lesson-${lesson.id}`}>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className="text-xs text-stone-500">Lesson {lessonIndex + 1}{lesson.durationMinutes ? ` · ${lesson.durationMinutes} min` : ''}</p>
<h4 className="mt-1 font-semibold text-stone-900">{lesson.title}</h4>
{lesson.description && <p className="mt-1 text-sm text-stone-600">{lesson.description}</p>}
</div> </div>
)} {lesson.completed && <span className="inline-flex items-center gap-1 rounded-full bg-emerald-50 px-2 py-1 text-xs font-semibold text-emerald-700"><CheckCircle2 size={13} />Complete</span>}
</div>
{detail?.card.locked && ( {lesson.contentType === 'text' && lesson.content && <div className="mt-4 whitespace-pre-wrap text-sm leading-7 text-stone-700">{lesson.content}</div>}
<section className="mt-6 rounded-2xl border border-amber-200 bg-amber-50 p-4"> {(lesson.contentType === 'video' || lesson.contentType === 'link' || lesson.contentType === 'document') && lesson.sourceUrl && <a href={lesson.sourceUrl} target="_blank" rel="noreferrer" className="mt-4 inline-flex items-center gap-2 text-sm font-semibold text-indigo-700"><ExternalLink size={14} />Open lesson resource</a>}
<p className="inline-flex items-center gap-2 text-sm font-semibold text-amber-900"> {!lesson.completed && <div className="mt-4"><button type="button" disabled={savingLesson === lesson.id} onClick={() => void completeLesson(lesson.id)} className="inline-flex items-center gap-2 rounded-lg bg-indigo-600 px-3 py-2 text-sm font-semibold text-white disabled:opacity-60"><PlayCircle size={14} />{savingLesson === lesson.id ? 'Saving…' : 'Mark complete'}</button></div>}
<Lock size={16} /> </div>
Course Locked ))}
</p> {module.lessons.length === 0 && <p className="text-sm text-stone-500">No published lessons in this module yet.</p>}
<p className="mt-2 text-sm text-amber-800">{detail.card.lockMessage || 'This course is currently locked.'}</p> </div>
</article>
))}
{detail.modules.length === 0 && <div className="rounded-2xl border border-dashed border-stone-300 p-8 text-center text-sm text-stone-500">Course content is being prepared.</div>}
</section> </section>
)} )}
<section className="mt-6 rounded-2xl border border-slate-200 bg-slate-50 p-6"> {detail.assets.length > 0 && (
<h2 className="text-lg font-semibold text-slate-900">Modules</h2> <section className="rounded-2xl border border-stone-200 bg-white p-5">
<p className="mt-2 text-sm text-slate-600"> <h2 className="text-lg font-semibold text-stone-900">Resources</h2>
Module-level learner content is not yet tracked here. This placeholder will be replaced once module/course APIs are integrated. <div className="mt-3 grid gap-3 sm:grid-cols-2">
</p> {detail.assets.map(asset => (
</section> <div key={asset.id} className="rounded-xl border border-stone-200 p-4">
<p className="font-semibold text-stone-900">{asset.title}</p>
<section className="mt-4 rounded-2xl border border-slate-200 bg-slate-50 p-6"> {asset.description && <p className="mt-1 text-sm text-stone-600">{asset.description}</p>}
<h2 className="text-lg font-semibold text-slate-900">Assets and Resources</h2> {asset.allowDownload && <button type="button" onClick={() => void openAsset(asset.id)} className="mt-3 inline-flex items-center gap-2 text-sm font-semibold text-indigo-700"><Download size={14} />Open resource</button>}
<p className="mt-2 text-sm text-slate-600">
Asset-level course resources are not yet available in this view. Follow-up work will connect protected assets and downloads.
</p>
</section>
<section className="mt-4 rounded-2xl border border-slate-200 bg-slate-50 p-6">
<h2 className="text-lg font-semibold text-slate-900">Cohorts</h2>
{detail && detail.cohorts.length > 0 ? (
<div className="mt-3 space-y-2">
{detail.cohorts.map(cohort => (
<div key={cohort.id} className="rounded-lg border border-slate-200 bg-white p-3">
<p className="text-sm font-semibold text-slate-800">{cohort.title}</p>
{cohort.status && <p className="mt-1 text-xs text-slate-600">Status: {cohort.status}</p>}
</div> </div>
))} ))}
</div> </div>
) : ( </section>
<p className="mt-2 text-sm text-slate-600">No learner cohort records are currently available for this course.</p>
)} )}
<section className="rounded-2xl border border-stone-200 bg-stone-50 p-5">
<h2 className="text-lg font-semibold text-stone-900">Cohorts</h2>
{detail.cohorts.length > 0 ? <div className="mt-3 space-y-2">{detail.cohorts.map(cohort => <div key={cohort.id} className="rounded-lg border border-stone-200 bg-white p-3"><p className="text-sm font-semibold text-stone-800">{cohort.title}</p>{cohort.status && <p className="mt-1 text-xs text-stone-600">Status: {cohort.status}</p>}</div>)}</div> : <p className="mt-2 text-sm text-stone-600">No cohort records are currently available.</p>}
</section> </section>
</article> </article>
) )

View File

@ -1,160 +1,139 @@
import { useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Link, useParams } from 'react-router' import { ChevronLeft, ChevronRight, PlayCircle, Save } from 'lucide-react'
import { AlertCircle, ExternalLink, PlayCircle } from 'lucide-react' import { useParams } from 'react-router'
import { ApiError } from '../api/client' import { ApiError } from '../api/client'
import { fetchLearnerScormLaunch, type LearnerScormLaunchDetail } from '../learner-scorm' import { commitLearnerScormRuntime, fetchLearnerScormLaunch, fetchLearnerScormRuntime, type LearnerScormLaunchDetail, type LearnerScormRuntime } from '../learner-scorm'
import { generateSCOHtml } from './scorm-shared'
type LoadState = 'loading' | 'ready' | 'error' type LoadState = 'loading' | 'ready' | 'error'
function launchStateHeadline(state: LearnerScormLaunchDetail['launchState']['state']): string {
if (state === 'missing_package') return 'Package missing'
if (state === 'export_not_ready') return 'Export not ready'
if (state === 'locked') return 'Access restricted'
return 'Ready to launch'
}
export function LearnerScormLaunchPage() { export function LearnerScormLaunchPage() {
const params = useParams<{ id: string }>() const { id = '' } = useParams<{ id: string }>()
const courseID = (params.id ?? '').trim() const courseID = id.trim()
const [detail, setDetail] = useState<LearnerScormLaunchDetail | null>(null) const [detail, setDetail] = useState<LearnerScormLaunchDetail | null>(null)
const [runtime, setRuntime] = useState<LearnerScormRuntime | null>(null)
const [scoIndex, setScoIndex] = useState(0)
const [state, setState] = useState<LoadState>('loading') const [state, setState] = useState<LoadState>('loading')
const [error, setError] = useState('') const [error, setError] = useState('')
const [launchStarted, setLaunchStarted] = useState(false) const [saving, setSaving] = useState(false)
const valuesRef = useRef<Record<string, unknown>>({})
useEffect(() => { useEffect(() => {
if (!courseID) { if (!courseID) { setError('A SCORM course id is required.'); setState('error'); return }
setState('error')
setError('A SCORM course id is required to launch this page.')
return
}
let cancelled = false let cancelled = false
void fetchLearnerScormLaunch(courseID).then(result => {
const load = async () => {
try {
setState('loading')
const result = await fetchLearnerScormLaunch(courseID)
if (cancelled) return if (cancelled) return
setDetail(result) setDetail(result); setState('ready')
setState('ready') }).catch(err => {
} catch (err) {
if (cancelled) return if (cancelled) return
const message = err instanceof ApiError ? err.message : 'Failed to load learner SCORM launch details' setError(err instanceof ApiError ? err.message : 'Failed to load SCORM course'); setState('error')
setError(message) })
setState('error')
}
}
void load()
return () => { cancelled = true } return () => { cancelled = true }
}, [courseID]) }, [courseID])
const launchState = detail?.launchState const selectedSCO = detail?.course.scos[scoIndex] ?? null
const canLaunch = Boolean(launchState?.allowed && launchState.state === 'ready')
const blockedMessage = launchState?.reason === 'missing_entitlement' useEffect(() => {
? 'Launch is blocked until your entitlement is granted or reactivated.' if (!selectedSCO || !courseID) return
: 'Launch is blocked until this SCORM course is ready.' void fetchLearnerScormRuntime(courseID, selectedSCO.id).then(next => {
const title = useMemo(() => { setRuntime(next)
if (!detail?.course.title) return 'SCORM Launch' valuesRef.current = { ...next.values }
return detail.course.title }).catch(err => setError(err instanceof Error ? err.message : 'Unable to load runtime state'))
}, [detail?.course.title]) }, [courseID, selectedSCO?.id])
const commit = useCallback(async () => {
if (!selectedSCO || !courseID) return
setSaving(true)
try {
const values = { ...valuesRef.current }
const lessonStatus = String(values['cmi.core.lesson_status'] ?? runtime?.lessonStatus ?? '')
const completionStatus = String(values['cmi.completion_status'] ?? runtime?.completionStatus ?? '')
const successStatus = String(values['cmi.success_status'] ?? runtime?.successStatus ?? '')
const score = Number(values['cmi.core.score.raw'] ?? values['cmi.score.raw'] ?? runtime?.score ?? 0)
const progress = Number(values['cmi.progress_measure'] ?? runtime?.progress ?? 0)
const result = await commitLearnerScormRuntime(courseID, {
scoId: selectedSCO.id,
status: 'committed',
location: String(values['cmi.core.lesson_location'] ?? values['cmi.location'] ?? ''),
suspendData: String(values['cmi.suspend_data'] ?? ''),
lessonStatus,
completionStatus,
successStatus,
score: Number.isFinite(score) ? score : 0,
progress: Number.isFinite(progress) ? (progress <= 1 ? progress * 100 : progress) : 0,
values,
})
setRuntime(result.runtime)
} finally {
setSaving(false)
}
}, [courseID, runtime, selectedSCO])
useEffect(() => {
if (!selectedSCO) return
const getValue = (key: string) => String(valuesRef.current[key] ?? '')
const setValue = (key: string, value: string) => { valuesRef.current[key] = value; return 'true' }
const api12 = {
LMSInitialize: () => 'true',
LMSFinish: () => { void commit(); return 'true' },
LMSGetValue: getValue,
LMSSetValue: setValue,
LMSCommit: () => { void commit(); return 'true' },
LMSGetLastError: () => '0',
LMSGetErrorString: () => 'No error',
LMSGetDiagnostic: () => '',
}
const api2004 = {
Initialize: () => 'true',
Terminate: () => { void commit(); return 'true' },
GetValue: getValue,
SetValue: setValue,
Commit: () => { void commit(); return 'true' },
GetLastError: () => '0',
GetErrorString: () => 'No error',
GetDiagnostic: () => '',
}
const globalWindow = window as unknown as Record<string, unknown>
globalWindow.API = api12
globalWindow.API_1484_11 = api2004
return () => {
delete globalWindow.API
delete globalWindow.API_1484_11
}
}, [commit, selectedSCO])
const html = useMemo(() => detail && selectedSCO ? generateSCOHtml(detail.course, selectedSCO) : '', [detail, selectedSCO])
const percent = detail?.enrollment.progress ?? 0
return ( return (
<div data-testid="learner-scorm-launch-page"> <div data-testid="learner-scorm-launch-page" className="space-y-4">
<header className="mb-6"> <header>
<h1 className="text-2xl font-semibold tracking-tight text-slate-900">{title}</h1> <h1 className="text-2xl font-semibold tracking-tight text-stone-900">{detail?.course.title ?? 'SCORM Launch'}</h1>
<p className="mt-2 text-sm text-slate-600"> <p className="mt-2 text-sm text-stone-600">SCORM progress, bookmark data, scores, and suspend data are persisted to your learner record.</p>
Launch your SCORM package and use resume placeholders until runtime progress sync is fully integrated.
</p>
</header> </header>
{state === 'loading' && <p className="text-sm text-stone-600">Loading launch details...</p>}
{state === 'loading' && <p className="text-sm text-slate-600">Loading launch details...</p>}
{state === 'error' && <p className="text-sm text-red-600">{error}</p>} {state === 'error' && <p className="text-sm text-red-600">{error}</p>}
{state === 'ready' && detail && !detail.launchState.allowed && <div className="rounded-xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-800">{detail.launchState.message || 'This package is not available yet.'}</div>}
{state === 'ready' && detail && launchState && ( {state === 'ready' && detail && detail.launchState.allowed && selectedSCO && (
<section className="space-y-5"> <>
<article className="rounded-2xl border border-slate-200 bg-slate-50 p-6"> <div className="flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-stone-200 bg-white p-3">
<div className="mb-4 flex flex-wrap items-center gap-2 text-xs text-slate-500"> <div>
<span className="rounded-full bg-slate-100 px-2.5 py-1 font-medium text-slate-700">{detail.enrollment.status || 'enrolled'}</span> <p className="text-xs font-semibold uppercase tracking-wide text-stone-500">Lesson {scoIndex + 1} of {detail.course.scos.length}</p>
<span className="rounded-full bg-sky-100 px-2.5 py-1 font-medium text-sky-800">{launchState.state}</span> <p className="font-semibold text-stone-900">{selectedSCO.title}</p>
{detail.course.subject && (
<span className="rounded-full bg-indigo-100 px-2.5 py-1 font-medium uppercase tracking-wide text-indigo-800">{detail.course.subject}</span>
)}
{detail.course.gradeLevel && (
<span className="rounded-full bg-violet-100 px-2.5 py-1 font-medium uppercase tracking-wide text-violet-800">{detail.course.gradeLevel}</span>
)}
</div> </div>
<div className="flex items-center gap-2">
<h2 className="text-xl font-semibold text-slate-900">{launchStateHeadline(launchState.state)}</h2> <button type="button" disabled={scoIndex === 0} onClick={() => setScoIndex(index => Math.max(0, index - 1))} className="rounded-lg border border-stone-200 p-2 disabled:opacity-40"><ChevronLeft size={16} /></button>
<p className="mt-2 text-sm text-slate-600">{launchState.message || 'Launch details are not available for this course yet.'}</p> <button data-testid="learner-scorm-launch-primary" type="button" onClick={() => void commit()} disabled={saving} className="inline-flex items-center gap-2 rounded-lg bg-indigo-600 px-3 py-2 text-sm font-semibold text-white disabled:opacity-60">{saving ? <Save size={15} /> : <PlayCircle size={15} />}{saving ? 'Saving…' : 'Save progress'}</button>
{detail.course.moduleTypes.length > 0 && ( <button type="button" disabled={scoIndex >= detail.course.scos.length - 1} onClick={() => setScoIndex(index => Math.min(detail.course.scos.length - 1, index + 1))} className="rounded-lg border border-stone-200 p-2 disabled:opacity-40"><ChevronRight size={16} /></button>
<div className="mt-3 flex flex-wrap gap-1.5">
{detail.course.moduleTypes.map(module => (
<span key={module} className="rounded bg-emerald-100 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-emerald-700">
{module.replaceAll('_', ' ')}
</span>
))}
</div> </div>
)}
{launchState.progressNote && (
<div className="mt-4 rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-700">
{launchState.progressNote}
</div> </div>
)} <div className="rounded-2xl border border-stone-200 bg-white p-2">
<iframe title={selectedSCO.title} srcDoc={html} sandbox="allow-scripts allow-same-origin" className="h-[65vh] min-h-[520px] w-full rounded-xl border-0 bg-white" />
{!canLaunch && (
<div className="mt-4 inline-flex items-center gap-2 rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-xs font-medium text-amber-800">
<AlertCircle size={14} />
{blockedMessage}
</div> </div>
)} <div className="rounded-xl border border-stone-200 bg-stone-50 p-3 text-xs text-stone-600">
Course progress: {Math.round(percent)}% · Runtime status: {runtime?.lessonStatus || runtime?.completionStatus || 'incomplete'}
<div className="mt-5 flex flex-wrap items-center gap-3">
<button
data-testid="learner-scorm-launch-primary"
type="button"
disabled={!canLaunch}
onClick={() => setLaunchStarted(true)}
className="inline-flex items-center gap-1 rounded-lg border border-sky-300 bg-sky-50 px-3 py-1.5 text-xs font-semibold text-sky-800 enabled:hover:border-sky-500 enabled:hover:bg-sky-100 disabled:cursor-not-allowed disabled:border-slate-200 disabled:bg-slate-100 disabled:text-slate-500"
>
<PlayCircle size={14} />
{launchState.resumeAvailable ? 'Resume package' : 'Launch package'}
</button>
{launchState.launchUrl && (
<a
data-testid="learner-scorm-open-export-endpoint"
href={launchState.launchUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 rounded-lg border border-slate-300 bg-white px-3 py-1.5 text-xs font-semibold text-slate-700 hover:border-slate-400 hover:bg-slate-100"
>
<ExternalLink size={14} />
Open export endpoint
</a>
)}
<Link
data-testid="learner-scorm-back-to-courses"
to="/learn/scorm"
className="inline-flex items-center rounded-lg border border-slate-300 bg-white px-3 py-1.5 text-xs font-semibold text-slate-700 hover:border-slate-400 hover:bg-slate-100"
>
Back to SCORM courses
</Link>
</div> </div>
</article> </>
{launchStarted && canLaunch && (
<article className="rounded-2xl border border-emerald-200 bg-emerald-50 p-6">
<h3 className="text-lg font-semibold text-emerald-900">SCORM launch started</h3>
<p className="mt-2 text-sm text-emerald-800">
Runtime playback is staged here. Progress and bookmark resume remain placeholder behavior until full SCORM runtime APIs are connected.
</p>
</article>
)}
</section>
)} )}
</div> </div>
) )

View File

@ -9,6 +9,8 @@ import { KeycloakThemeEditorPage } from '../pages/KeycloakThemeEditorPage'
import { FormsBuilderPage } from '../pages/FormsBuilderPage' import { FormsBuilderPage } from '../pages/FormsBuilderPage'
import { SitemapMenuBuilderPage } from '../pages/SitemapMenuBuilderPage' import { SitemapMenuBuilderPage } from '../pages/SitemapMenuBuilderPage'
import { FeatureGuardPage } from '../pages/FeatureGuardPage' import { FeatureGuardPage } from '../pages/FeatureGuardPage'
import { AdminCommunitiesPage } from '../pages/AdminCommunitiesPage'
import { AdminNotificationsPage } from '../pages/AdminNotificationsPage'
import { AdminContentPage } from '../pages/AdminContentPage' import { AdminContentPage } from '../pages/AdminContentPage'
import { AdminLearnersPage } from '../pages/AdminLearnersPage' import { AdminLearnersPage } from '../pages/AdminLearnersPage'
import { AdminSettingsPage } from '../pages/AdminSettingsPage' import { AdminSettingsPage } from '../pages/AdminSettingsPage'
@ -25,18 +27,10 @@ function AdminEmbeddedScormPage() {
return <ScormPage embedded /> return <ScormPage embedded />
} }
function AdminNotificationsNotEnabledPage() {
return <FeatureGuardPage title="Notification Operations" summary="Notification operations are not available in this workspace yet." />
}
function AdminBillingNotEnabledPage() { function AdminBillingNotEnabledPage() {
return <FeatureGuardPage title="Billing Administration" summary="Billing administration is not available in this workspace yet." /> return <FeatureGuardPage title="Billing Administration" summary="Billing administration is not available in this workspace yet." />
} }
function AdminCommunitiesNotEnabledPage() {
return <FeatureGuardPage title="Community Moderation" summary="Community moderation tools are not available in this workspace yet." />
}
export const adminRoutes: RouteObject[] = [ export const adminRoutes: RouteObject[] = [
{ {
path: '/admin', path: '/admin',
@ -53,10 +47,10 @@ export const adminRoutes: RouteObject[] = [
{ path: 'content', Component: AdminContentPage }, { path: 'content', Component: AdminContentPage },
{ path: 'blog', Component: AdminBlogPage, handle: { adminSurface: 'plain' } }, { path: 'blog', Component: AdminBlogPage, handle: { adminSurface: 'plain' } },
{ path: 'learners', Component: AdminLearnersPage }, { path: 'learners', Component: AdminLearnersPage },
{ path: 'notifications', Component: AdminNotificationsNotEnabledPage }, { path: 'notifications', Component: AdminNotificationsPage },
{ path: 'assets', Component: AdminAssetsPage }, { path: 'assets', Component: AdminAssetsPage },
{ path: 'billing', Component: AdminBillingNotEnabledPage }, { path: 'billing', Component: AdminBillingNotEnabledPage },
{ path: 'communities', Component: AdminCommunitiesNotEnabledPage }, { path: 'communities', Component: AdminCommunitiesPage },
{ path: 'audit', Component: AdminAuditPage }, { path: 'audit', Component: AdminAuditPage },
{ path: 'settings', Component: AdminSettingsPage }, { path: 'settings', Component: AdminSettingsPage },
], ],