Compare commits

..

4 Commits

Author SHA1 Message Date
b8a73c73db feat(content): admin UX for forms, practices, lessons, and content hub
Remove description fields from course, unit, and module create/edit dialogs. Add unit sort order on create, lesson publish status and sort order, video duration on lesson cards, and personas API integration for Learn English practice flows.

Move Manage Question Types to the new content hub, add Reorder Content page with hierarchy drag-and-drop, shared practice review UI, module practice cards, and publish-practice controls on course listings.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 08:00:31 -07:00
38550f9519 UAT fixes stage 2 2026-05-19 04:41:43 -07:00
385f58fd22 UAT fixes stage 1 2026-05-18 08:44:51 -07:00
2b556d9d09 feat(content): lesson practices page, dynamic question schema, and practice flow updates
- Add LessonPracticesPage with GET /lessons/:id/practices and polished UI
- Route and module lesson navigation; view practices icon on VideoCard hover
- Question type definitions API, DynamicSchemaSlotField, definition helpers
- AddPracticeFlow and practice steps; AddQuestionPage and PracticeQuestionEditorFields

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-13 09:30:53 -07:00
68 changed files with 6803 additions and 2142 deletions

View File

@ -1,5 +1,39 @@
import http from "./http";
import type { DashboardResponse } from "../types/analytics.types";
import type { DashboardData, DashboardFilters, DashboardResponse } from "../types/analytics.types";
export const getDashboard = () =>
http.get<DashboardResponse>("/analytics/dashboard");
function buildDashboardQueryParams(filters?: DashboardFilters): Record<string, string | number> {
if (!filters || filters.mode === "all_time") {
return {};
}
if (filters.mode === "year" && filters.year != null) {
return { year: filters.year };
}
if (filters.mode === "year_month" && filters.year != null && filters.month != null) {
return { year: filters.year, month: filters.month };
}
if (filters.mode === "custom" && filters.from && filters.to) {
return { from: filters.from, to: filters.to };
}
return {};
}
function unwrapDashboardResponse(body: DashboardResponse | DashboardData): DashboardData {
if (body && typeof body === "object" && "data" in body && body.data) {
return body.data;
}
return body as DashboardData;
}
export const getDashboard = (filters?: DashboardFilters) =>
http
.get<DashboardResponse | DashboardData>("/analytics/dashboard", {
params: buildDashboardQueryParams(filters),
})
.then((res) => ({
...res,
data: unwrapDashboardResponse(res.data),
}));

View File

@ -104,7 +104,9 @@ import type {
CreateParentLinkedPracticeResponse,
UpdateParentLinkedPracticeRequest,
UpdateParentLinkedPracticeResponse,
PublishParentLinkedPracticeRequest,
UpdateTopLevelModuleLessonRequest,
PublishTopLevelModuleLessonRequest,
CreateTopLevelModuleLessonRequest,
CreateTopLevelModuleLessonResponse,
} from "../types/course.types"
@ -646,6 +648,12 @@ export const updateTopLevelModuleLesson = (
data: UpdateTopLevelModuleLessonRequest,
) => http.put(`/lessons/${lessonId}`, data)
/** PUT /lessons/:id — set publish_status only (draft or published). */
export const publishTopLevelModuleLesson = (
lessonId: number,
data: PublishTopLevelModuleLessonRequest,
) => http.put(`/lessons/${lessonId}`, data)
/** Learn English top-level module lesson — DELETE /lessons/:id */
export const deleteTopLevelModuleLesson = (lessonId: number) =>
http.delete(`/lessons/${lessonId}`)
@ -681,6 +689,12 @@ export const updateParentLinkedPractice = (
data: UpdateParentLinkedPracticeRequest,
) => http.put<UpdateParentLinkedPracticeResponse>(`/practices/${practiceId}`, data)
/** PUT /practices/:id — set publish_status (e.g. publish a draft). */
export const publishParentLinkedPractice = (practiceId: number) =>
http.put<UpdateParentLinkedPracticeResponse>(`/practices/${practiceId}`, {
publish_status: "PUBLISHED",
} satisfies PublishParentLinkedPracticeRequest)
/** DELETE /practices/:id */
export const deleteParentLinkedPractice = (practiceId: number) =>
http.delete<{ message: string; success: boolean; status_code: number; metadata: unknown }>(

6
src/api/personas.api.ts Normal file
View File

@ -0,0 +1,6 @@
import http from "./http"
import type { GetPersonasParams, GetPersonasResponse } from "../types/persona.types"
/** GET /personas — list personas (filter active client-side when needed). */
export const getPersonas = (params?: GetPersonasParams) =>
http.get<GetPersonasResponse>("/personas", { params })

View File

@ -217,7 +217,9 @@ export function normalizeTypeDefinitionFromApi(raw: unknown): QuestionTypeDefini
return {
id,
key: asStr(o.Key ?? o.key),
display_name: asStr(o.DisplayName ?? o.display_name),
display_name: asStr(
o.DisplayName ?? o.display_name ?? o.displayName ?? o.Display_Name,
),
description: (() => {
const d = o.Description ?? o.description
if (d == null) return null
@ -235,6 +237,15 @@ export function normalizeTypeDefinitionFromApi(raw: unknown): QuestionTypeDefini
}
}
/** Label for selects: API `DisplayName` (stored as `display_name`), then key, then id. */
export function questionTypeDefinitionListLabel(def: QuestionTypeDefinition): string {
const name = def.display_name?.trim()
if (name) return name
const k = def.key?.trim()
if (k) return k
return `Type #${def.id}`
}
/**
* Definition id from POST create or PUT update (`data.ID`, `data.id`, or PascalCase `Id`).
* Example update: `{ "data": { "id": 6 } }`.

View File

@ -8,6 +8,8 @@ import type {
DeleteRoleResponse,
SetRolePermissionsRequest,
GetPermissionsResponse,
BulkRoleDeactivateResponse,
BulkRoleReactivateResponse,
} from "../types/rbac.types"
export const getRoles = (params?: GetRolesParams) =>
@ -30,3 +32,11 @@ export const getAllPermissions = () =>
export const deleteRole = (roleId: number) =>
http.delete<DeleteRoleResponse>(`/rbac/roles/${roleId}`)
/** Deactivate all users and team members tied to this role (admin). */
export const bulkDeactivateRole = (roleId: number) =>
http.post<BulkRoleDeactivateResponse>(`/admin/roles/${roleId}/bulk-deactivate`, {})
/** Reactivate users and team members tied to this role (admin). */
export const bulkReactivateRole = (roleId: number) =>
http.post<BulkRoleReactivateResponse>(`/admin/roles/${roleId}/bulk-reactivate`, {})

View File

@ -0,0 +1,11 @@
import http from "./http"
import type { SubscriptionPlansListResponse, SubscriptionPlan } from "../types/subscription.types"
export const getSubscriptionPlans = () =>
http.get<SubscriptionPlansListResponse>("/subscription-plans").then((res) => ({
...res,
data: {
...res.data,
data: Array.isArray(res.data?.data) ? res.data.data : ([] as SubscriptionPlan[]),
},
}))

View File

@ -6,23 +6,46 @@ import {
type UserSummaryResponse,
type GetDeletionRequestsParams,
type GetDeletionRequestsResponse,
type UserRecentActivityResponse,
} from "../types/user.types";
export const getUsers = (
page?: number,
pageSize?: number,
role?: string,
status?: string,
query?: string,
) =>
/** Query params for GET /users (RFC3339 for created_*; subscription_status: ACTIVE | PENDING | Unsubscribed). */
export interface GetUsersParams {
page?: number
page_size?: number
role?: string
status?: string
query?: string
created_before?: string
created_after?: string
country?: string
region?: string
subscription_status?: string
}
function buildGetUsersQuery(params: GetUsersParams): Record<string, string | number> {
const q: Record<string, string | number> = {}
const addString = (key: string, value: string | undefined) => {
const v = value?.trim()
if (!v) return
q[key] = v
}
if (params.page !== undefined) q.page = params.page
if (params.page_size !== undefined) q.page_size = params.page_size
addString("role", params.role)
addString("status", params.status)
addString("query", params.query)
addString("created_before", params.created_before)
addString("created_after", params.created_after)
addString("country", params.country)
addString("region", params.region)
addString("subscription_status", params.subscription_status)
return q
}
export const getUsers = (params: GetUsersParams = {}) =>
http.get<GetUsersResponse>("/users", {
params: {
role,
status,
query,
page,
page_size: pageSize,
},
params: buildGetUsersQuery(params),
});
export type UserStatus = "ACTIVE" | "DEACTIVATED" | "SUSPENDED" | "PENDING";
@ -38,6 +61,9 @@ export const updateUserStatus = (payload: UpdateUserStatusRequest) =>
export const getUserById = (id: number) =>
http.get<UserProfileResponse>(`/user/single/${id}`);
export const getUserRecentActivity = (userId: number) =>
http.get<UserRecentActivityResponse>(`/admin/users/${userId}/recent-activity`);
export const getMyProfile = () =>
http.get<UserProfileResponse>("/team/me");

View File

@ -15,9 +15,11 @@ import { SpeakingPage } from "../pages/content-management/SpeakingPage";
import { AddVideoPage } from "../pages/content-management/AddVideoPage";
import { AddPracticePage } from "../pages/content-management/AddPracticePage";
import { NewContentPage } from "../pages/content-management/NewContentPage";
import { ReorderContentPage } from "../pages/content-management/ReorderContentPage";
import { LearnEnglishPage } from "../pages/content-management/LearnEnglishPage";
import { ProgramCoursesPage } from "../pages/content-management/ProgramCoursesPage";
import { CourseDetailPage } from "../pages/content-management/CourseDetailPage";
import { LessonPracticesPage } from "../pages/content-management/LessonPracticesPage";
import { ModuleDetailPage } from "../pages/content-management/ModuleDetailPage";
import { AddVideoFlow } from "../pages/content-management/AddVideoFlow";
import { AddPracticeFlow } from "../pages/content-management/AddPracticeFlow";
@ -36,7 +38,6 @@ import { CreateNotificationPage } from "../pages/notifications/CreateNotificatio
import { UserDetailPage } from "../pages/user-management/UserDetailPage";
import { UserManagementLayout } from "../pages/user-management/UserManagementLayout";
import { UsersListPage } from "../pages/user-management/UsersListPage";
import { UserManagementDashboard } from "../pages/user-management/UserManagementDashboard";
import { UserGroupsPage } from "../pages/user-management/UserGroupsPage";
import { DeletionRequestsPage } from "../pages/user-management/DeletionRequestsPage";
import { RoleManagementLayout } from "../pages/role-management/RoleManagementLayout";
@ -77,7 +78,7 @@ export function AppRoutes() {
<Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route path="/dashboard" element={<DashboardPage />} />
<Route path="/users" element={<UserManagementLayout />}>
<Route index element={<UserManagementDashboard />} />
<Route index element={<Navigate to="list" replace />} />
<Route path="list" element={<UsersListPage />} />
<Route path="deletion-requests" element={<DeletionRequestsPage />} />
<Route path="groups" element={<UserGroupsPage />} />
@ -162,6 +163,7 @@ export function AppRoutes() {
</Route>
<Route path="/new-content" element={<NewContentPage />} />
<Route path="/new-content/reorder" element={<ReorderContentPage />} />
<Route
path="/new-content/courses"
element={<ProgramTypeSelectionPage />}
@ -222,6 +224,10 @@ export function AppRoutes() {
path="/new-content/learn-english/:level/courses/:courseId/modules/:moduleId/add-video"
element={<AddVideoFlow />}
/>
<Route
path="/new-content/learn-english/:level/courses/:courseId/modules/:moduleId/lessons/:lessonId/practices"
element={<LessonPracticesPage />}
/>
<Route
path="/new-content/learn-english/:level/courses/add-practice"
element={<AddPracticeFlow />}

View File

@ -0,0 +1,272 @@
import { useEffect, useRef, useState } from "react"
import { ChevronDown } from "lucide-react"
import { cn } from "../../lib/utils"
import { Input } from "../ui/input"
import { Button } from "../ui/button"
import type { DashboardFilters } from "../../types/analytics.types"
const MONTH_LABELS = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
] as const
const MIN_SELECTABLE_YEAR = 2000
export function getYearOptions(): number[] {
const currentYear = new Date().getFullYear()
const years: number[] = []
for (let year = currentYear; year >= MIN_SELECTABLE_YEAR; year--) {
years.push(year)
}
return years
}
export function getDashboardFilterLabel(filters: DashboardFilters): string {
if (filters.mode === "year" && filters.year != null) {
return String(filters.year)
}
if (filters.mode === "year_month" && filters.year != null && filters.month != null) {
return `${MONTH_LABELS[filters.month - 1]} ${filters.year}`
}
if (filters.mode === "custom" && filters.from && filters.to) {
const from = new Date(`${filters.from}T00:00:00`)
const to = new Date(`${filters.to}T00:00:00`)
const opts: Intl.DateTimeFormatOptions = { month: "short", day: "numeric", year: "numeric" }
return `${from.toLocaleDateString("en-US", opts)} ${to.toLocaleDateString("en-US", opts)}`
}
return "All Time"
}
type AnalyticsTimeRangeFilterProps = {
value: DashboardFilters
onChange: (filters: DashboardFilters) => void
className?: string
}
export function AnalyticsTimeRangeFilter({ value, onChange, className }: AnalyticsTimeRangeFilterProps) {
const [open, setOpen] = useState(false)
const [yearOpen, setYearOpen] = useState(true)
const [monthOpen, setMonthOpen] = useState(false)
const [customOpen, setCustomOpen] = useState(false)
const [contextYear, setContextYear] = useState(() => value.year ?? new Date().getFullYear())
const [customFrom, setCustomFrom] = useState(value.from ?? "")
const [customTo, setCustomTo] = useState(value.to ?? "")
const containerRef = useRef<HTMLDivElement>(null)
const years = getYearOptions()
useEffect(() => {
if (value.year != null) {
setContextYear(value.year)
}
}, [value.year])
useEffect(() => {
if (value.mode === "custom") {
setCustomFrom(value.from ?? "")
setCustomTo(value.to ?? "")
}
}, [value.from, value.mode, value.to])
useEffect(() => {
if (!open) return
const handlePointerDown = (event: MouseEvent) => {
if (!containerRef.current?.contains(event.target as Node)) {
setOpen(false)
}
}
document.addEventListener("mousedown", handlePointerDown)
return () => document.removeEventListener("mousedown", handlePointerDown)
}, [open])
const selectAllTime = () => {
onChange({ mode: "all_time" })
setOpen(false)
}
const selectYear = (year: number) => {
setContextYear(year)
onChange({ mode: "year", year })
setOpen(false)
}
const selectMonth = (month: number) => {
onChange({ mode: "year_month", year: contextYear, month })
setOpen(false)
}
const applyCustomRange = () => {
if (!customFrom || !customTo) return
onChange({ mode: "custom", from: customFrom, to: customTo })
setOpen(false)
}
return (
<div ref={containerRef} className={cn("relative", className)}>
<button
type="button"
onClick={() => setOpen((prev) => !prev)}
className="inline-flex items-center gap-2 rounded-lg border border-grayScale-200 bg-white px-4 py-2 text-sm font-medium text-grayScale-700 shadow-sm transition-colors hover:bg-grayScale-50"
>
Time Range
<ChevronDown className={cn("h-4 w-4 text-grayScale-400 transition-transform", open && "rotate-180")} />
</button>
{open && (
<div className="absolute right-0 z-50 mt-2 w-[220px] overflow-hidden rounded-xl border border-grayScale-100 bg-white py-2 shadow-lg">
<button
type="button"
onClick={selectAllTime}
className={cn(
"flex w-full px-4 py-2.5 text-left text-sm transition-colors hover:bg-grayScale-50",
value.mode === "all_time" ? "font-semibold text-grayScale-900" : "text-grayScale-700",
)}
>
All Time
</button>
<div className="border-t border-grayScale-100">
<button
type="button"
onClick={() => setYearOpen((prev) => !prev)}
className="flex w-full items-center justify-between px-4 py-2.5 text-left text-sm font-medium text-grayScale-800 hover:bg-grayScale-50"
>
Year
<ChevronDown
className={cn("h-4 w-4 text-grayScale-400 transition-transform", yearOpen && "rotate-180")}
/>
</button>
{yearOpen && (
<div className="max-h-[220px] overflow-y-auto pb-1">
{years.map((year) => (
<button
key={year}
type="button"
onClick={() => selectYear(year)}
className={cn(
"flex w-full px-6 py-2 text-left text-sm transition-colors hover:bg-grayScale-50",
value.mode === "year" && value.year === year
? "font-semibold text-brand-600"
: "text-grayScale-600",
)}
>
{year}
</button>
))}
</div>
)}
</div>
<div className="border-t border-grayScale-100">
<button
type="button"
onClick={() => setMonthOpen((prev) => !prev)}
className="flex w-full items-center justify-between px-4 py-2.5 text-left text-sm font-medium text-grayScale-800 hover:bg-grayScale-50"
>
Month
<ChevronDown
className={cn("h-4 w-4 text-grayScale-400 transition-transform", monthOpen && "rotate-180")}
/>
</button>
{monthOpen && (
<div className="max-h-[260px] overflow-y-auto pb-1">
<div className="flex max-h-[88px] flex-wrap gap-1 overflow-y-auto px-4 pb-2">
{years.map((year) => (
<button
key={year}
type="button"
onClick={() => setContextYear(year)}
className={cn(
"rounded-md px-2 py-0.5 text-[11px] font-medium transition-colors",
contextYear === year
? "bg-brand-100 text-brand-700"
: "text-grayScale-500 hover:bg-grayScale-100",
)}
>
{year}
</button>
))}
</div>
{MONTH_LABELS.map((label, index) => {
const month = index + 1
const isSelected =
value.mode === "year_month" && value.year === contextYear && value.month === month
return (
<button
key={label}
type="button"
onClick={() => selectMonth(month)}
className={cn(
"flex w-full px-6 py-2 text-left text-sm transition-colors hover:bg-grayScale-50",
isSelected ? "font-semibold text-brand-600" : "text-grayScale-600",
)}
>
{label}
</button>
)
})}
</div>
)}
</div>
<div className="border-t border-grayScale-100">
<button
type="button"
onClick={() => setCustomOpen((prev) => !prev)}
className="flex w-full items-center justify-between px-4 py-2.5 text-left text-sm font-medium text-grayScale-800 hover:bg-grayScale-50"
>
Date Range
<ChevronDown
className={cn("h-4 w-4 text-grayScale-400 transition-transform", customOpen && "rotate-180")}
/>
</button>
{customOpen && (
<div className="space-y-2 px-4 pb-3">
<div className="space-y-1">
<label className="text-[11px] font-medium text-grayScale-500">From</label>
<Input
type="date"
value={customFrom}
onChange={(e) => setCustomFrom(e.target.value)}
className="h-8 text-xs"
/>
</div>
<div className="space-y-1">
<label className="text-[11px] font-medium text-grayScale-500">To</label>
<Input
type="date"
value={customTo}
onChange={(e) => setCustomTo(e.target.value)}
className="h-8 text-xs"
/>
</div>
<Button
type="button"
size="sm"
className="h-8 w-full text-xs"
disabled={!customFrom || !customTo}
onClick={applyCustomRange}
>
Apply
</Button>
</div>
)}
</div>
</div>
)}
</div>
)
}

View File

@ -0,0 +1,593 @@
import {
useCallback,
useEffect,
useRef,
useState,
type ChangeEvent,
type DragEvent,
} from "react"
import { CloudUpload, Image as ImageIcon, Mic, Pause, Play, X } from "lucide-react"
import { toast } from "sonner"
import { uploadAudioFile, uploadImageFile } from "../../api/files.api"
import { resolveMediaPreviewUrl } from "../../lib/practiceMedia"
import { Input } from "../ui/input"
import { Textarea } from "../ui/textarea"
import { SpinnerIcon } from "../ui/spinner-icon"
import { cn } from "../../lib/utils"
import { ResolvedImage } from "../media/ResolvedImage"
const MAX_IMAGE_BYTES = 10 * 1024 * 1024
const MAX_AUDIO_BYTES = 50 * 1024 * 1024
const IMAGE_EXT = new Set(["jpg", "jpeg", "png", "webp", "gif"])
const AUDIO_EXT = new Set(["mp3", "wav", "ogg", "m4a", "aac", "webm", "flac"])
export interface DynamicSchemaSlotRow {
id: string
kind: string
label?: string
required?: boolean
}
function slotMediaMode(kind: string): "image" | "audio" | "text" {
const u = kind.trim().toUpperCase()
if (u === "IMAGE") return "image"
if (u.startsWith("AUDIO")) return "audio"
return "text"
}
function isHttpUrl(s: string): boolean {
return /^https?:\/\//i.test(s.trim())
}
function fileLabelFromValue(raw: string): string {
const t = raw.trim()
if (!t) return "No audio"
try {
if (isHttpUrl(t)) {
const path = new URL(t).pathname.split("/").filter(Boolean)
const last = path[path.length - 1]
return last ? decodeURIComponent(last) : "Audio clip"
}
} catch {
/* ignore */
}
const parts = t.split("/").filter(Boolean)
const last = parts[parts.length - 1]
return last ? decodeURIComponent(last) : "Audio clip"
}
function DynamicImageSlot({
value,
onChange,
disabled,
slotLabel,
slotMeta,
}: {
value: string
onChange: (next: string) => void
disabled: boolean
slotLabel: string
slotMeta: string
}) {
const fileInputRef = useRef<HTMLInputElement>(null)
const valueAtFocusRef = useRef("")
const [uploading, setUploading] = useState(false)
const [dragActive, setDragActive] = useState(false)
const processFile = useCallback(
async (file: File) => {
if (disabled || uploading) return
const ext = file.name.split(".").pop()?.toLowerCase() ?? ""
if (!IMAGE_EXT.has(ext)) {
toast.error("Unsupported image format", {
description: "Use JPG, PNG, WEBP, or GIF.",
})
return
}
if (file.size > MAX_IMAGE_BYTES) {
toast.error("Image is too large", { description: "Maximum size is 10 MB." })
return
}
setUploading(true)
try {
const res = await uploadImageFile(file)
const url = res.data?.data?.url?.trim()
if (!url) throw new Error("Upload did not return a URL")
onChange(url)
toast.success("Image uploaded")
} catch (e) {
console.error(e)
toast.error("Failed to upload image")
} finally {
setUploading(false)
}
},
[disabled, uploading, onChange],
)
const importUrl = useCallback(async () => {
const trimmed = value.trim()
if (!trimmed || !isHttpUrl(trimmed)) return
if (trimmed === valueAtFocusRef.current) return
setUploading(true)
try {
const res = await uploadImageFile(trimmed)
const url = res.data?.data?.url?.trim()
if (!url) throw new Error("Import did not return a URL")
onChange(url)
toast.success("Image URL imported to storage")
} catch (e) {
console.error(e)
toast.error("Could not import image from URL")
} finally {
setUploading(false)
}
}, [value, onChange])
const handleFileChange = (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
e.target.value = ""
if (file) void processFile(file)
}
const zoneDisabled = disabled || uploading
const hasImage = Boolean(value.trim())
return (
<div className="space-y-2">
<div className="flex flex-wrap items-end justify-between gap-2">
<label className="text-sm font-medium text-grayScale-700">{slotLabel}</label>
<span className="text-[11px] font-mono text-grayScale-500">{slotMeta}</span>
</div>
<div className="grid grid-cols-1 gap-3 lg:grid-cols-[minmax(0,1fr)_minmax(220px,36%)] lg:items-start lg:gap-4">
<div
className="min-h-[100px] space-y-2 rounded-lg border border-grayScale-200 bg-grayScale-50/40 p-2.5 lg:min-h-[140px]"
onDragOver={
hasImage
? (e: DragEvent) => {
e.preventDefault()
if (!zoneDisabled) setDragActive(true)
}
: undefined
}
onDragLeave={
hasImage
? (e: DragEvent) => {
e.preventDefault()
setDragActive(false)
}
: undefined
}
onDrop={
hasImage
? (e: DragEvent) => {
e.preventDefault()
setDragActive(false)
if (zoneDisabled) return
const file = e.dataTransfer.files?.[0]
if (file) void processFile(file)
}
: undefined
}
>
{hasImage ? (
<div
className={cn(
"relative mx-auto aspect-video max-h-48 w-full max-w-lg overflow-hidden rounded-lg border bg-white shadow-sm transition-colors",
dragActive ? "border-[#9E2891] ring-2 ring-[#9E289133]" : "border-grayScale-200",
)}
>
<ResolvedImage src={value} alt="" className="h-full w-full object-contain" />
<button
type="button"
className="absolute right-1.5 top-1.5 rounded-full bg-white/95 p-1.5 text-[#9E2891] shadow-md hover:bg-white"
onClick={() => onChange("")}
disabled={zoneDisabled}
aria-label="Remove image"
>
<X className="h-4 w-4" />
</button>
</div>
) : (
<div className="flex h-full min-h-[88px] flex-col items-center justify-center rounded-lg border border-dashed border-grayScale-200 bg-white px-3 py-5 text-center text-xs text-grayScale-500 sm:text-sm lg:min-h-[120px]">
<ImageIcon className="mb-2 h-8 w-8 text-grayScale-300" strokeWidth={1.25} aria-hidden />
<p>Preview appears here after you upload or paste a URL on the right.</p>
</div>
)}
</div>
<div className="space-y-2">
<input
ref={fileInputRef}
type="file"
accept=".jpg,.jpeg,.png,.webp,.gif,image/jpeg,image/png,image/webp,image/gif"
className="sr-only"
onChange={handleFileChange}
disabled={zoneDisabled}
/>
<label className="text-sm font-medium text-grayScale-700">Upload file</label>
<button
type="button"
disabled={zoneDisabled}
onClick={() => fileInputRef.current?.click()}
onDragOver={(e: DragEvent) => {
e.preventDefault()
if (!zoneDisabled) setDragActive(true)
}}
onDragLeave={(e: DragEvent) => {
e.preventDefault()
setDragActive(false)
}}
onDrop={(e: DragEvent) => {
e.preventDefault()
setDragActive(false)
if (zoneDisabled) return
const file = e.dataTransfer.files?.[0]
if (file) void processFile(file)
}}
className={cn(
"flex w-full cursor-pointer flex-col items-center justify-center rounded-xl border-2 border-dashed border-[#9E289133] bg-white p-4 text-center transition-colors",
"hover:border-[#9E289180] hover:bg-grayScale-50/30",
dragActive && "border-[#9E2891] bg-[#9E289108]",
zoneDisabled && "cursor-not-allowed opacity-60",
)}
>
{uploading ? (
<p className="flex items-center gap-2 text-sm font-medium text-grayScale-600">
<SpinnerIcon className="h-4 w-4" /> Uploading
</p>
) : (
<>
<CloudUpload className="mb-3 h-9 w-9 text-[#9E2891]" strokeWidth={1.5} aria-hidden />
<p className="text-sm">
<span className="font-bold text-[#9E2891]">Click to upload</span>{" "}
<span className="text-grayScale-500">or paste a URL below</span>
</p>
<p className="mt-1 text-xs font-medium uppercase tracking-wider text-grayScale-400">
JPG, PNG, WebP, GIF (max 10 MB)
</p>
</>
)}
</button>
<Input
value={value}
onChange={(e) => onChange(e.target.value)}
onFocus={(e) => {
valueAtFocusRef.current = e.currentTarget.value.trim()
}}
onBlur={() => void importUrl()}
placeholder="https://…"
title="Leave the field after pasting a public URL to import to storage"
className="h-12 rounded-xl border-grayScale-200 font-mono text-sm"
disabled={disabled || uploading}
autoComplete="off"
/>
</div>
</div>
</div>
)
}
function WaveformDecor({ active }: { active: boolean }) {
const heights = [0.35, 0.55, 0.42, 0.7, 0.5, 0.62, 0.4, 0.58, 0.48, 0.66, 0.44, 0.52, 0.38, 0.6, 0.46, 0.54]
return (
<div className="flex h-7 flex-1 items-end justify-center gap-[2px] overflow-hidden px-0.5">
{heights.map((h, idx) => (
<div
key={idx}
className={cn(
"w-[3px] min-w-[2px] rounded-full transition-colors",
active ? "bg-brand-500/80" : "bg-grayScale-300/90",
)}
style={{ height: `${Math.round(h * 100)}%` }}
/>
))}
</div>
)
}
function DynamicAudioSlot({
value,
onChange,
disabled,
slotLabel,
slotMeta,
}: {
value: string
onChange: (next: string) => void
disabled: boolean
slotLabel: string
slotMeta: string
}) {
const fileInputRef = useRef<HTMLInputElement>(null)
const audioRef = useRef<HTMLAudioElement>(null)
const valueAtFocusRef = useRef("")
const [uploading, setUploading] = useState(false)
const [dragActive, setDragActive] = useState(false)
const [resolvedSrc, setResolvedSrc] = useState("")
const [playing, setPlaying] = useState(false)
useEffect(() => {
let cancelled = false
;(async () => {
const raw = value.trim()
if (!raw) {
setResolvedSrc("")
return
}
try {
const url = await resolveMediaPreviewUrl(raw)
if (!cancelled) setResolvedSrc(url || raw)
} catch {
if (!cancelled) setResolvedSrc(raw)
}
})()
return () => {
cancelled = true
}
}, [value])
const processFile = useCallback(
async (file: File) => {
if (disabled || uploading) return
const ext = file.name.split(".").pop()?.toLowerCase() ?? ""
if (!AUDIO_EXT.has(ext)) {
toast.error("Unsupported audio format")
return
}
if (file.size > MAX_AUDIO_BYTES) {
toast.error("Audio file must be 50MB or less")
return
}
setUploading(true)
try {
const res = await uploadAudioFile(file)
const url = res.data?.data?.url?.trim()
const objectKey = res.data?.data?.object_key?.trim()
const stored = url || objectKey
if (!stored) throw new Error("Upload did not return a URL or key")
onChange(stored)
toast.success("Audio uploaded")
} catch (e) {
console.error(e)
toast.error("Failed to upload audio")
} finally {
setUploading(false)
}
},
[disabled, uploading, onChange],
)
const importUrl = useCallback(async () => {
const trimmed = value.trim()
if (!trimmed || !isHttpUrl(trimmed)) return
if (trimmed === valueAtFocusRef.current) return
setUploading(true)
try {
const res = await uploadAudioFile(trimmed)
const url = res.data?.data?.url?.trim()
const objectKey = res.data?.data?.object_key?.trim()
const stored = url || objectKey
if (!stored) throw new Error("Import did not return a URL or key")
onChange(stored)
toast.success("Audio URL imported to storage")
} catch (e) {
console.error(e)
toast.error("Could not import audio from URL")
} finally {
setUploading(false)
}
}, [value, onChange])
const togglePlay = async () => {
const el = audioRef.current
if (!el || !resolvedSrc) return
if (playing) {
el.pause()
setPlaying(false)
} else {
try {
await el.play()
} catch {
toast.error("Could not play audio")
}
}
}
const handleFileChange = (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
e.target.value = ""
if (file) void processFile(file)
}
const zoneDisabled = disabled || uploading
const hasMedia = Boolean(value.trim() && resolvedSrc)
return (
<div className="space-y-2">
<div className="flex flex-wrap items-end justify-between gap-2">
<label className="text-sm font-medium text-grayScale-700">{slotLabel}</label>
<span className="text-[11px] font-mono text-grayScale-500">{slotMeta}</span>
</div>
<div className="grid grid-cols-1 gap-3 lg:grid-cols-[minmax(0,1fr)_minmax(220px,36%)] lg:items-start lg:gap-4">
<div className="min-h-[100px] space-y-2 rounded-lg border border-grayScale-200 bg-grayScale-50/40 p-2.5 lg:min-h-[140px]">
<input
ref={fileInputRef}
type="file"
accept=".mp3,.wav,.ogg,.m4a,.aac,.webm,.flac,audio/*"
className="sr-only"
onChange={handleFileChange}
disabled={zoneDisabled}
/>
{hasMedia ? (
<div className="flex items-center gap-2 rounded-lg border border-[#9E289140] bg-[#FAF5FF]/80 px-2 py-2 shadow-sm">
<button
type="button"
onClick={() => void togglePlay()}
disabled={!resolvedSrc || zoneDisabled}
className="grid h-9 w-9 shrink-0 place-items-center rounded-full bg-[#9E2891] text-white shadow-sm transition hover:bg-[#8a217d] disabled:opacity-40"
aria-label={playing ? "Pause" : "Play"}
>
{playing ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4 ml-0.5" />}
</button>
<div className="min-w-0 flex-1">
<WaveformDecor active={playing} />
<p className="mt-0.5 truncate text-[11px] font-medium leading-tight text-[#9E2891]">
{fileLabelFromValue(value)}
</p>
</div>
<button
type="button"
className="shrink-0 rounded-md p-1.5 text-[#9E2891] transition hover:bg-white/80"
onClick={() => {
audioRef.current?.pause()
setPlaying(false)
onChange("")
}}
disabled={zoneDisabled}
aria-label="Remove audio"
>
<X className="h-4 w-4" />
</button>
<audio
ref={audioRef}
src={resolvedSrc}
className="hidden"
onPlay={() => setPlaying(true)}
onEnded={() => setPlaying(false)}
onPause={() => setPlaying(false)}
/>
</div>
) : (
<div className="flex h-full min-h-[80px] flex-col items-center justify-center rounded-lg border border-dashed border-grayScale-200 bg-white px-3 py-4 text-center text-xs text-grayScale-500 sm:text-sm">
<Mic className="mb-2 h-8 w-8 text-grayScale-300" strokeWidth={1.25} aria-hidden />
<p>Playback preview appears here after you upload or paste a URL on the right.</p>
</div>
)}
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-grayScale-700">Upload file</label>
<button
type="button"
disabled={zoneDisabled}
onClick={() => fileInputRef.current?.click()}
onDragOver={(e: DragEvent) => {
e.preventDefault()
if (!zoneDisabled) setDragActive(true)
}}
onDragLeave={(e: DragEvent) => {
e.preventDefault()
setDragActive(false)
}}
onDrop={(e: DragEvent) => {
e.preventDefault()
setDragActive(false)
if (zoneDisabled) return
const file = e.dataTransfer.files?.[0]
if (file) void processFile(file)
}}
className={cn(
"flex w-full cursor-pointer flex-col items-center justify-center rounded-xl border-2 border-dashed border-[#9E289133] bg-white p-4 text-center transition-colors",
"hover:border-[#9E289180] hover:bg-grayScale-50/30",
dragActive && "border-[#9E2891] bg-[#9E289108]",
zoneDisabled && "cursor-not-allowed opacity-60",
)}
>
{uploading ? (
<p className="flex items-center gap-2 text-sm font-medium text-grayScale-600">
<SpinnerIcon className="h-4 w-4" /> Uploading
</p>
) : (
<>
<CloudUpload className="mb-3 h-9 w-9 text-[#9E2891]" strokeWidth={1.5} aria-hidden />
<p className="text-sm">
<span className="font-bold text-[#9E2891]">Click to upload</span>{" "}
<span className="text-grayScale-500">or paste a URL below</span>
</p>
<p className="mt-1 text-xs font-medium uppercase tracking-wider text-grayScale-400">
MP3, WAV, OGG, M4A, AAC, WebM, FLAC (max 50 MB)
</p>
</>
)}
</button>
<Input
value={value}
onChange={(e) => onChange(e.target.value)}
onFocus={(e) => {
valueAtFocusRef.current = e.currentTarget.value.trim()
}}
onBlur={() => void importUrl()}
placeholder="https://…"
title="Leave the field after pasting a public URL to import to storage"
className="h-12 rounded-xl border-grayScale-200 font-mono text-sm"
disabled={disabled || uploading}
autoComplete="off"
/>
</div>
</div>
</div>
)
}
export interface DynamicSchemaSlotFieldProps {
row: DynamicSchemaSlotRow
value: string
onChange: (next: string) => void
disabled?: boolean
}
export function DynamicSchemaSlotField({
row,
value,
onChange,
disabled = false,
}: DynamicSchemaSlotFieldProps) {
const mode = slotMediaMode(row.kind)
const baseLabel =
row.label?.trim() ||
(mode === "image" ? "Image" : mode === "audio" ? "Audio" : row.kind)
const slotLabel = `${baseLabel}${row.required ? " *" : ""}`
const slotMeta = `${row.id} · ${row.kind}`
if (mode === "text") {
return (
<div className="space-y-2">
<div className="flex flex-wrap items-end justify-between gap-2">
<label className="text-sm font-medium text-grayScale-700">{slotLabel}</label>
<span className="text-[11px] font-mono text-grayScale-500">{slotMeta}</span>
</div>
<Textarea
rows={3}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="URL, plain text, or JSON object"
className="min-h-[88px] resize-y rounded-lg border-grayScale-200 font-mono text-sm"
disabled={disabled}
/>
</div>
)
}
if (mode === "image") {
return (
<DynamicImageSlot
value={value}
onChange={onChange}
disabled={disabled}
slotLabel={slotLabel}
slotMeta={slotMeta}
/>
)
}
return (
<DynamicAudioSlot
value={value}
onChange={onChange}
disabled={disabled}
slotLabel={slotLabel}
slotMeta={slotMeta}
/>
)
}

View File

@ -12,6 +12,7 @@ import { uploadAudioFile, uploadImageFile } from "../../api/files.api"
import {
getQuestionTypeDefinitionById,
getQuestionTypeDefinitions,
questionTypeDefinitionListLabel,
} from "../../api/questionTypeDefinitions.api"
import type { QuestionTypeDefinition } from "../../types/questionTypeDefinition.types"
import { Input } from "../ui/input"
@ -22,6 +23,7 @@ import { SpinnerIcon } from "../ui/spinner-icon"
import { cn } from "../../lib/utils"
import { ResolvedAudio } from "../media/ResolvedAudio"
import { ResolvedImage } from "../media/ResolvedImage"
import { DynamicSchemaSlotField } from "./DynamicSchemaSlotField"
export type PracticeQuestionEditorType = "MCQ" | "TRUE_FALSE" | "SHORT" | "AUDIO" | "DYNAMIC"
export type PracticeQuestionEditorDifficulty = "EASY" | "MEDIUM" | "HARD"
@ -712,9 +714,9 @@ export function PracticeQuestionEditorFields({
return (
<>
<div className="mt-5 space-y-5">
<div className="space-y-2">
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">Question Text</label>
<div className="mt-3 space-y-3">
<div className="space-y-1.5">
<label className="text-[11px] font-medium uppercase tracking-wide text-grayScale-500">Question Text</label>
<textarea
value={value.questionText}
onChange={(e) => patch({ questionText: e.target.value })}
@ -731,10 +733,10 @@ export function PracticeQuestionEditorFields({
) : null}
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 lg:gap-5">
<div className="space-y-2">
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">Type</label>
<Select value={value.questionType} onChange={(e) => setType(e.target.value as PracticeQuestionEditorType)}>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3 lg:gap-3">
<div className="space-y-1.5">
<label className="text-[11px] font-medium uppercase tracking-wide text-grayScale-500">Type</label>
<Select value={value.questionType} onChange={(e) => setType(e.target.value as PracticeQuestionEditorType)} className="h-9 text-sm">
<option value="MCQ">Multiple Choice</option>
<option value="TRUE_FALSE">True/False</option>
<option value="SHORT">Short Answer</option>
@ -742,25 +744,29 @@ export function PracticeQuestionEditorFields({
<option value="DYNAMIC">Dynamic (schema-driven)</option>
</Select>
</div>
<div className="space-y-2">
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">Difficulty</label>
<div className="space-y-1.5">
<label className="text-[11px] font-medium uppercase tracking-wide text-grayScale-500">Difficulty</label>
<Select
value={value.difficultyLevel}
onChange={(e) => patch({ difficultyLevel: e.target.value as PracticeQuestionEditorDifficulty })}
className="h-9 text-sm"
>
<option value="EASY">Easy</option>
<option value="MEDIUM">Medium</option>
<option value="HARD">Hard</option>
</Select>
</div>
<div className="space-y-2">
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">Points</label>
<div className="space-y-1.5">
<label className="text-[11px] font-medium uppercase tracking-wide text-grayScale-500">Points</label>
<Input
type="number"
value={value.points}
onChange={(e) => patch({ points: Number(e.target.value) || 1 })}
min={1}
className={cn(showFieldErrors && fieldErrors.points ? "border-red-300 ring-1 ring-red-200" : undefined)}
className={cn(
"h-9 text-sm",
showFieldErrors && fieldErrors.points ? "border-red-300 ring-1 ring-red-200" : undefined,
)}
aria-invalid={Boolean(showFieldErrors && fieldErrors.points)}
/>
{showFieldErrors && fieldErrors.points ? (
@ -770,12 +776,11 @@ export function PracticeQuestionEditorFields({
</div>
{value.questionType === "DYNAMIC" && (
<div className="space-y-5 rounded-xl border border-violet-200 bg-violet-50/50 p-4 sm:p-5">
<p className="text-sm leading-relaxed text-grayScale-600">
Pick a question type definition, then fill each stimulus/response slot. Element{" "}
<code className="rounded bg-white px-1 text-xs">id</code> and{" "}
<code className="rounded bg-white px-1 text-xs">kind</code> must match the definition schema. Use JSON
for object values (e.g. <code className="text-xs">{"{\"placeholder\":\"Type here\"}"}</code>).
<div className="space-y-2 rounded-lg border border-violet-200 bg-violet-50/50 p-2.5 sm:p-3">
<p className="text-xs leading-snug text-grayScale-600 sm:text-sm">
<span className="font-medium text-grayScale-800">Image / Audio</span> slots: drop file or paste URL
(imports via <code className="rounded bg-white px-0.5 text-[11px]">POST /files/upload</code>). Other
slots: text or JSON.
</p>
<div className="space-y-2">
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">
@ -785,11 +790,12 @@ export function PracticeQuestionEditorFields({
value={value.questionTypeDefinitionId != null ? String(value.questionTypeDefinitionId) : ""}
onChange={(e) => void handleDynamicDefinitionChange(e.target.value)}
disabled={definitionsLoading || definitionDetailLoading}
className="h-9 text-sm"
>
<option value="">{definitionsLoading ? "Loading definitions…" : "Select definition…"}</option>
{typeDefinitions.map((d) => (
<option key={d.id} value={String(d.id)}>
#{d.id} {d.display_name} ({d.key})
{questionTypeDefinitionListLabel(d)}
</option>
))}
</Select>
@ -798,52 +804,36 @@ export function PracticeQuestionEditorFields({
<p className="text-sm font-medium text-grayScale-500">Loading schema</p>
) : null}
{value.dynamicStimulusRows.length > 0 ? (
<div className="space-y-3">
<p className="text-xs font-bold uppercase tracking-wide text-violet-800">Stimulus</p>
<div className="space-y-2">
<p className="text-[10px] font-bold uppercase tracking-wide text-violet-800">Stimulus</p>
{value.dynamicStimulusRows.map((row) => (
<div
key={`stimulus-${row.id}`}
className="space-y-2 rounded-lg border border-grayScale-200 bg-white p-3 shadow-sm"
className="rounded-lg border border-grayScale-200 bg-white p-2.5 shadow-sm sm:p-3"
>
<div className="flex flex-wrap items-baseline justify-between gap-2">
<span className="text-sm font-semibold text-grayScale-900">{row.label || row.id}</span>
<span className="text-[11px] font-mono text-grayScale-500">
{row.id} · {row.kind}
{row.required ? <span className="text-red-500"> *</span> : null}
</span>
</div>
<Textarea
rows={3}
<DynamicSchemaSlotField
row={row}
value={value.dynamicFieldValues[`stimulus:${row.id}`] ?? ""}
onChange={(e) => setDynamicField(`stimulus:${row.id}`, e.target.value)}
placeholder="URL, plain text, or JSON object"
className="min-h-[72px] resize-y font-mono text-[13px]"
onChange={(next) => setDynamicField(`stimulus:${row.id}`, next)}
disabled={controlsDisabled}
/>
</div>
))}
</div>
) : null}
{value.dynamicResponseRows.length > 0 ? (
<div className="space-y-3">
<p className="text-xs font-bold uppercase tracking-wide text-violet-800">Response</p>
<div className="space-y-2">
<p className="text-[10px] font-bold uppercase tracking-wide text-violet-800">Response</p>
{value.dynamicResponseRows.map((row) => (
<div
key={`response-${row.id}`}
className="space-y-2 rounded-lg border border-grayScale-200 bg-white p-3 shadow-sm"
className="rounded-lg border border-grayScale-200 bg-white p-2.5 shadow-sm sm:p-3"
>
<div className="flex flex-wrap items-baseline justify-between gap-2">
<span className="text-sm font-semibold text-grayScale-900">{row.label || row.id}</span>
<span className="text-[11px] font-mono text-grayScale-500">
{row.id} · {row.kind}
{row.required ? <span className="text-red-500"> *</span> : null}
</span>
</div>
<Textarea
rows={3}
<DynamicSchemaSlotField
row={row}
value={value.dynamicFieldValues[`response:${row.id}`] ?? ""}
onChange={(e) => setDynamicField(`response:${row.id}`, e.target.value)}
placeholder="URL, plain text, or JSON object"
className="min-h-[72px] resize-y font-mono text-[13px]"
onChange={(next) => setDynamicField(`response:${row.id}`, next)}
disabled={controlsDisabled}
/>
</div>
))}
@ -853,7 +843,7 @@ export function PracticeQuestionEditorFields({
)}
{value.questionType === "MCQ" && (
<div className="space-y-3 rounded-lg bg-grayScale-50/50 p-4">
<div className="space-y-2 rounded-lg bg-grayScale-50/50 p-3">
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">Options</label>
<div className="space-y-2.5">
{value.options.map((option, optIdx) => (
@ -966,7 +956,7 @@ export function PracticeQuestionEditorFields({
</div>
)}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2 lg:gap-6">
<div className="grid grid-cols-1 gap-2 lg:grid-cols-2 lg:gap-4">
<div className="space-y-2">
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">Tips (Optional)</label>
<Input

View File

@ -0,0 +1,130 @@
import { useEffect, useMemo, useState } from "react"
import { Bar, CartesianGrid, Cell, ComposedChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"
import { getDashboard } from "../../api/analytics.api"
import { getYearOptions } from "../analytics/AnalyticsTimeRangeFilter"
import { Card, CardContent, CardHeader, CardTitle } from "../ui/card"
import { Select } from "../ui/select"
import { aggregateRevenueByMonth, formatRevenueAxisTick } from "../../lib/analytics"
import type { DateRevenue } from "../../types/analytics.types"
import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg"
const TRACK_COLOR = "#E8E8E8"
const BAR_COLOR = "#9E2891"
export function RevenueTrendCard() {
const currentYear = new Date().getFullYear()
const [year, setYear] = useState(currentYear)
const [totalRevenue, setTotalRevenue] = useState(0)
const [dailyRevenue, setDailyRevenue] = useState<DateRevenue[]>([])
const [loading, setLoading] = useState(true)
const years = useMemo(() => getYearOptions(), [])
useEffect(() => {
let cancelled = false
const fetchRevenueTrend = async () => {
setLoading(true)
try {
const res = await getDashboard({ mode: "year", year })
if (cancelled) return
setTotalRevenue(res.data.payments.total_revenue)
setDailyRevenue(res.data.payments.revenue_last_30_days)
} catch {
if (!cancelled) {
setTotalRevenue(0)
setDailyRevenue([])
}
} finally {
if (!cancelled) setLoading(false)
}
}
fetchRevenueTrend()
return () => {
cancelled = true
}
}, [year])
const chartData = useMemo(() => {
const monthly = aggregateRevenueByMonth(dailyRevenue, year)
const peak = Math.max(...monthly.map((point) => point.revenue), 1)
const trackMax = peak * 1.15
return monthly.map((point) => ({
month: point.month,
revenue: point.revenue,
track: trackMax,
}))
}, [dailyRevenue, year])
return (
<Card className="shadow-none">
<CardHeader className="pb-2">
<div className="flex items-start justify-between gap-3">
<div>
<CardTitle>Revenue Trend</CardTitle>
<div className="mt-2 text-2xl font-semibold tracking-tight">
ETB {totalRevenue.toLocaleString()}
</div>
<div className="text-xs font-medium text-grayScale-500">Monthly · {year} (ETB)</div>
</div>
<Select
value={String(year)}
onChange={(e) => setYear(Number(e.target.value))}
className="h-9 w-[96px] shrink-0 rounded-lg py-1 text-sm font-medium"
aria-label="Revenue trend year"
>
{years.map((optionYear) => (
<option key={optionYear} value={optionYear}>
{optionYear}
</option>
))}
</Select>
</div>
</CardHeader>
<CardContent className="h-[240px] p-6 pt-2">
{loading ? (
<div className="flex h-full items-center justify-center">
<img src={spinnerSrc} alt="" className="h-8 w-8 animate-spin" />
</div>
) : (
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={chartData} margin={{ left: 4, right: 8, top: 8, bottom: 0 }} barGap={-28}>
<CartesianGrid vertical={false} stroke="#E0E0E0" strokeDasharray="4 4" />
<XAxis dataKey="month" tickLine={false} axisLine={false} fontSize={12} />
<YAxis
tickLine={false}
axisLine={false}
fontSize={12}
width={44}
tickFormatter={formatRevenueAxisTick}
/>
<Tooltip
formatter={(value, name) => {
if (name !== "revenue") return null
return [`ETB ${Number(value).toLocaleString()}`, "Revenue"]
}}
contentStyle={{
borderRadius: 12,
border: "1px solid #E0E0E0",
boxShadow: "0 10px 30px rgba(0,0,0,0.08)",
}}
/>
<Bar dataKey="track" barSize={28} radius={[8, 8, 0, 0]} isAnimationActive={false}>
{chartData.map((entry) => (
<Cell key={`track-${entry.month}`} fill={TRACK_COLOR} />
))}
</Bar>
<Bar dataKey="revenue" barSize={28} radius={[8, 8, 0, 0]}>
{chartData.map((entry) => (
<Cell key={`revenue-${entry.month}`} fill={BAR_COLOR} />
))}
</Bar>
</ComposedChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
)
}

View File

@ -0,0 +1,228 @@
/**
* Static options for GET /users filters (`country`, `region`).
* Country: common English short names (ISO-style), sorted AZ.
* Region: Ethiopia federal regions & chartered cities (typical `users.region` values).
*/
const COUNTRY_NAMES_RAW = [
"Afghanistan",
"Albania",
"Algeria",
"Andorra",
"Angola",
"Antigua and Barbuda",
"Argentina",
"Armenia",
"Australia",
"Austria",
"Azerbaijan",
"Bahamas",
"Bahrain",
"Bangladesh",
"Barbados",
"Belarus",
"Belgium",
"Belize",
"Benin",
"Bhutan",
"Bolivia",
"Bosnia and Herzegovina",
"Botswana",
"Brazil",
"Brunei",
"Bulgaria",
"Burkina Faso",
"Burundi",
"Cabo Verde",
"Cambodia",
"Cameroon",
"Canada",
"Central African Republic",
"Chad",
"Chile",
"China",
"Colombia",
"Comoros",
"Congo",
"Costa Rica",
"Croatia",
"Cuba",
"Cyprus",
"Czechia",
"Democratic Republic of the Congo",
"Denmark",
"Djibouti",
"Dominica",
"Dominican Republic",
"Ecuador",
"Egypt",
"El Salvador",
"Equatorial Guinea",
"Eritrea",
"Estonia",
"Eswatini",
"Ethiopia",
"Fiji",
"Finland",
"France",
"Gabon",
"Gambia",
"Georgia",
"Germany",
"Ghana",
"Greece",
"Grenada",
"Guatemala",
"Guinea",
"Guinea-Bissau",
"Guyana",
"Haiti",
"Honduras",
"Hungary",
"Iceland",
"India",
"Indonesia",
"Iran",
"Iraq",
"Ireland",
"Israel",
"Italy",
"Jamaica",
"Japan",
"Jordan",
"Kazakhstan",
"Kenya",
"Kiribati",
"Kuwait",
"Kyrgyzstan",
"Laos",
"Latvia",
"Lebanon",
"Lesotho",
"Liberia",
"Libya",
"Liechtenstein",
"Lithuania",
"Luxembourg",
"Madagascar",
"Malawi",
"Malaysia",
"Maldives",
"Mali",
"Malta",
"Marshall Islands",
"Mauritania",
"Mauritius",
"Mexico",
"Micronesia",
"Moldova",
"Monaco",
"Mongolia",
"Montenegro",
"Morocco",
"Mozambique",
"Myanmar",
"Namibia",
"Nauru",
"Nepal",
"Netherlands",
"New Zealand",
"Nicaragua",
"Niger",
"Nigeria",
"North Korea",
"North Macedonia",
"Norway",
"Oman",
"Pakistan",
"Palau",
"Panama",
"Papua New Guinea",
"Paraguay",
"Peru",
"Philippines",
"Poland",
"Portugal",
"Qatar",
"Romania",
"Russia",
"Rwanda",
"Saint Kitts and Nevis",
"Saint Lucia",
"Saint Vincent and the Grenadines",
"Samoa",
"San Marino",
"Sao Tome and Principe",
"Saudi Arabia",
"Senegal",
"Serbia",
"Seychelles",
"Sierra Leone",
"Singapore",
"Slovakia",
"Slovenia",
"Solomon Islands",
"Somalia",
"South Africa",
"South Korea",
"South Sudan",
"Spain",
"Sri Lanka",
"Sudan",
"Suriname",
"Sweden",
"Switzerland",
"Syria",
"Tajikistan",
"Tanzania",
"Thailand",
"Timor-Leste",
"Togo",
"Tonga",
"Trinidad and Tobago",
"Tunisia",
"Turkey",
"Turkmenistan",
"Tuvalu",
"Uganda",
"Ukraine",
"United Arab Emirates",
"United Kingdom",
"United States",
"Uruguay",
"Uzbekistan",
"Vanuatu",
"Vatican City",
"Venezuela",
"Vietnam",
"Yemen",
"Zambia",
"Zimbabwe",
] as const
/** English short names, AZ (for `<select>` options). */
export const USER_FILTER_COUNTRIES: readonly string[] = [...COUNTRY_NAMES_RAW].sort((a, b) =>
a.localeCompare(b, "en"),
)
/**
* Ethiopia regions & chartered cities (canonical spelling for filters).
* Backend matches case-insensitively; use these labels so UI aligns with stored data.
*/
export const USER_FILTER_ETHIOPIA_REGIONS = [
"Addis Ababa",
"Afar",
"Amhara",
"Benishangul-Gumuz",
"Dire Dawa",
"Gambela Peoples' Region",
"Harari",
"Oromia",
"Sidama",
"Somali",
"Southern Nations, Nationalities, and Peoples' Region",
"South West Ethiopia Peoples' Region",
"Tigray",
] as const satisfies readonly string[]
export type UserFilterEthiopiaRegion = (typeof USER_FILTER_ETHIOPIA_REGIONS)[number]

View File

@ -0,0 +1,43 @@
import { useCallback, useEffect, useState } from "react"
import { getPersonas } from "../api/personas.api"
import {
mapPersonaToCard,
unwrapPersonasList,
type PersonaCardModel,
} from "../lib/personaDisplay"
type UseActivePersonasOptions = {
limit?: number
offset?: number
}
export function useActivePersonas(options: UseActivePersonasOptions = {}) {
const { limit = 50, offset = 0 } = options
const [personas, setPersonas] = useState<PersonaCardModel[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const load = useCallback(async () => {
setLoading(true)
setError(null)
try {
const res = await getPersonas({ limit, offset })
const list = unwrapPersonasList(res).filter((p) => p.is_active)
setPersonas(list.map(mapPersonaToCard))
} catch (e: unknown) {
const msg =
(e as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to load personas"
setError(msg)
setPersonas([])
} finally {
setLoading(false)
}
}, [limit, offset])
useEffect(() => {
void load()
}, [load])
return { personas, loading, error, reload: load }
}

86
src/lib/analytics.ts Normal file
View File

@ -0,0 +1,86 @@
import type { DashboardDateFilter, DateRevenue, LabelCount } from "../types/analytics.types"
const MONTH_SHORT = [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
] as const
function formatShortDate(iso: string) {
const d = new Date(iso)
return d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })
}
function formatBreakdownLabel(label: string) {
return label.replace(/_/g, " ").toLowerCase()
}
export function getPrimaryQuestionTypeSummary(questionsByType: LabelCount[]): string {
if (questionsByType.length === 0) return "No question types"
const top = [...questionsByType].sort((a, b) => b.count - a.count)[0]
return `${top.count.toLocaleString()} ${formatBreakdownLabel(top.label)}`
}
export function getVideoLessonsSummary(lmsLessonsWithVideo = 0, examPrepLessonsWithVideo = 0): string {
return `${lmsLessonsWithVideo.toLocaleString()} LMS · ${examPrepLessonsWithVideo.toLocaleString()} exam prep lessons`
}
export interface MonthlyRevenuePoint {
month: string
monthIndex: number
revenue: number
}
export function aggregateRevenueByMonth(daily: DateRevenue[], year: number): MonthlyRevenuePoint[] {
const monthly = Array.from({ length: 12 }, (_, monthIndex) => ({
month: MONTH_SHORT[monthIndex],
monthIndex,
revenue: 0,
}))
for (const { date, revenue } of daily) {
const parsed = new Date(date)
if (parsed.getUTCFullYear() !== year) continue
monthly[parsed.getUTCMonth()].revenue += revenue
}
return monthly
}
export function formatRevenueAxisTick(value: number): string {
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(0)}M`
if (value >= 1_000) return `${Math.round(value / 1_000)}K`
return String(value)
}
export function getSeriesPeriodLabel(dateFilter?: DashboardDateFilter): string {
if (!dateFilter) return "Last 30 Days"
switch (dateFilter.mode) {
case "all_time":
return "Last 30 Days"
case "year":
return dateFilter.year != null ? String(dateFilter.year) : "Selected year"
case "year_month":
if (dateFilter.year != null && dateFilter.month != null) {
return `${MONTH_SHORT[dateFilter.month - 1]} ${dateFilter.year}`
}
return "Selected month"
case "custom":
if (dateFilter.from && dateFilter.to) {
return `${formatShortDate(dateFilter.from)} ${formatShortDate(dateFilter.to)}`
}
return "Custom range"
default:
return "Selected period"
}
}

View File

@ -0,0 +1,175 @@
import type { CreateQuestionRequest, QuestionOption } from "../types/course.types"
import type { QuestionTypeDefinition } from "../types/questionTypeDefinition.types"
import { buildDynamicQuestionPayload } from "./practiceDynamicQuestionPayload"
export function definitionUsesDynamicPayload(def: QuestionTypeDefinition): boolean {
return def.stimulus_schema.length > 0 || def.response_schema.length > 0
}
export function emptyDynamicFieldValuesForDefinition(
def: QuestionTypeDefinition,
): Record<string, string> {
const o: Record<string, string> = {}
for (const r of def.stimulus_schema) o[`stimulus:${r.id}`] = ""
for (const r of def.response_schema) o[`response:${r.id}`] = ""
return o
}
/**
* System definitions with empty schema map to classic POST /questions types.
* Returns null when the payload must be DYNAMIC (schema-driven or unknown).
*/
export function legacyQuestionTypeFromDefinition(
def: QuestionTypeDefinition,
): "MCQ" | "TRUE_FALSE" | "SHORT_ANSWER" | null {
if (definitionUsesDynamicPayload(def)) return null
const k = def.key.toLowerCase()
if (k === "multiple_choice") return "MCQ"
if (k === "true_false") return "TRUE_FALSE"
if (k === "short_answer" || k === "fill_in_the_blank") return "SHORT_ANSWER"
return null
}
export interface LearnEnglishDefinitionQuestionInput {
questionText: string
questionTypeDefinitionId: number
dynamicFieldValues: Record<string, string>
mcqOptions?: { option_text: string; is_correct: boolean }[]
trueFalseAnswerIsTrue?: boolean
shortAnswers?: string[]
voicePromptUrl?: string
sampleAnswerVoiceUrl?: string
}
export function buildCreateQuestionFromDefinition(
def: QuestionTypeDefinition,
q: LearnEnglishDefinitionQuestionInput,
status: "DRAFT" | "PUBLISHED",
): CreateQuestionRequest {
const difficulty = "EASY"
const points = 1
const question_text = q.questionText.trim()
if (definitionUsesDynamicPayload(def)) {
const payload = buildDynamicQuestionPayload({
stimulusRows: def.stimulus_schema.map((r) => ({ id: r.id, kind: r.kind })),
responseRows: def.response_schema.map((r) => ({ id: r.id, kind: r.kind })),
fieldValues: q.dynamicFieldValues ?? {},
})
return {
question_text,
question_type: "DYNAMIC",
question_type_definition_id: def.id,
difficulty_level: difficulty,
points,
status,
dynamic_payload: payload,
}
}
const legacy = legacyQuestionTypeFromDefinition(def)
if (legacy === "MCQ") {
const options: QuestionOption[] = (q.mcqOptions ?? [])
.filter((o) => o.option_text.trim())
.map((o, idx) => ({
option_order: idx + 1,
option_text: o.option_text.trim(),
is_correct: o.is_correct,
}))
return {
question_text,
question_type: "MCQ",
difficulty_level: difficulty,
points,
status,
options,
}
}
if (legacy === "TRUE_FALSE") {
const trueCorrect = q.trueFalseAnswerIsTrue !== false
const options: QuestionOption[] = [
{ option_order: 1, option_text: "True", is_correct: trueCorrect },
{ option_order: 2, option_text: "False", is_correct: !trueCorrect },
]
return {
question_text,
question_type: "TRUE_FALSE",
difficulty_level: difficulty,
points,
status,
options,
}
}
if (legacy === "SHORT_ANSWER") {
const short_answers = (q.shortAnswers ?? [])
.map((s) => s.trim())
.filter(Boolean)
.map((acceptable_answer) => ({
acceptable_answer,
match_type: "CASE_INSENSITIVE" as const,
}))
return {
question_text,
question_type: "SHORT_ANSWER",
difficulty_level: difficulty,
points,
status,
short_answers,
}
}
// No schema and no legacy key mapping: still create as DYNAMIC with empty payload + definition id
return {
question_text,
question_type: "DYNAMIC",
question_type_definition_id: def.id,
difficulty_level: difficulty,
points,
status,
dynamic_payload: { stimulus: [], response: [] },
}
}
export function validateDefinitionQuestion(
def: QuestionTypeDefinition,
q: LearnEnglishDefinitionQuestionInput,
index1Based: number,
): string | null {
const n = index1Based
if (!q.questionText.trim()) return `Question ${n}: enter question text.`
if (definitionUsesDynamicPayload(def)) {
for (const row of def.stimulus_schema) {
if (!row.required) continue
const v = (q.dynamicFieldValues ?? {})[`stimulus:${row.id}`]?.trim()
if (!v)
return `Question ${n}: fill required stimulus "${row.label || row.id}" (${row.kind}).`
}
for (const row of def.response_schema) {
if (!row.required) continue
const v = (q.dynamicFieldValues ?? {})[`response:${row.id}`]?.trim()
if (!v)
return `Question ${n}: fill required response "${row.label || row.id}" (${row.kind}).`
}
return null
}
const legacy = legacyQuestionTypeFromDefinition(def)
if (legacy === "MCQ") {
const opts = (q.mcqOptions ?? []).filter((o) => o.option_text.trim())
if (opts.length < 2)
return `Question ${n} (${def.display_name}): add at least two choices with text.`
if (!opts.some((o) => o.is_correct))
return `Question ${n} (${def.display_name}): mark one correct choice.`
return null
}
if (legacy === "TRUE_FALSE") return null
if (legacy === "SHORT_ANSWER") {
const answers = (q.shortAnswers ?? []).map((s) => s.trim()).filter(Boolean)
if (answers.length < 1)
return `Question ${n} (${def.display_name}): add at least one acceptable answer.`
return null
}
return null
}

View File

@ -0,0 +1,145 @@
import type { AxiosError } from "axios"
import {
addQuestionToSet,
createParentLinkedPractice,
createQuestion,
createQuestionSet,
} from "../api/courses.api"
import type { PracticeParentKind } from "../types/course.types"
import type { QuestionTypeDefinition } from "../types/questionTypeDefinition.types"
import {
buildCreateQuestionFromDefinition,
validateDefinitionQuestion,
type LearnEnglishDefinitionQuestionInput,
} from "./learnEnglishDefinitionQuestion"
export type { LearnEnglishDefinitionQuestionInput } from "./learnEnglishDefinitionQuestion"
export function learnEnglishPracticeApiErrorMessage(err: unknown): string {
const ax = err as AxiosError<{ message?: string; error?: string }>
const data = ax.response?.data
if (data && typeof data === "object") {
const m = data.message ?? data.error
if (typeof m === "string" && m.trim()) return m.trim()
}
if (err instanceof Error && err.message) return err.message
return "Request failed"
}
export function validateLearnEnglishQuestionsWithDefinitions(
questions: LearnEnglishDefinitionQuestionInput[],
definitions: QuestionTypeDefinition[],
): string | null {
const filled = questions.filter((q) => q.questionText.trim())
if (filled.length === 0) return "Add at least one question with prompt text."
const byId = new Map(definitions.map((d) => [d.id, d]))
for (let i = 0; i < filled.length; i++) {
const q = filled[i]
if (!Number.isFinite(q.questionTypeDefinitionId) || q.questionTypeDefinitionId <= 0) {
return `Question ${i + 1}: select a question type from the list.`
}
const def = byId.get(q.questionTypeDefinitionId)
if (!def) {
return `Question ${i + 1}: type definition #${q.questionTypeDefinitionId} was not found. Refresh and try again.`
}
const err = validateDefinitionQuestion(def, q, i + 1)
if (err) return err
}
return null
}
/**
* Learn English parent-linked practice: create PRACTICE question set,
* create questions from GET /questions/type-definitions entries, attach them, POST /practices.
*/
export async function executeLearnEnglishPracticeCreation(opts: {
parentKind: PracticeParentKind
parentId: number
status: "DRAFT" | "PUBLISHED"
questionSetTitle: string
questionSetDescription?: string | null
shuffleQuestions: boolean
practiceTitle: string
storyDescription: string
storyImage: string
quickTips: string
personaName?: string | null
/** Selected persona from step 2 — sent as `persona_id` on POST /practices. */
personaId: number
questions: LearnEnglishDefinitionQuestionInput[]
definitions: QuestionTypeDefinition[]
}): Promise<{ questionSetId: number; practiceId: number }> {
const err = validateLearnEnglishQuestionsWithDefinitions(
opts.questions,
opts.definitions,
)
if (err) throw new Error(err)
if (!Number.isFinite(opts.personaId) || opts.personaId < 1) {
throw new Error("persona_id is required. Select a persona before saving.")
}
const byId = new Map(opts.definitions.map((d) => [d.id, d]))
const setRes = await createQuestionSet({
title: opts.questionSetTitle.trim() || "Practice question set",
description: opts.questionSetDescription?.trim() || null,
set_type: "PRACTICE",
owner_type: opts.parentKind,
owner_id: opts.parentId,
shuffle_questions: opts.shuffleQuestions,
status: opts.status,
...(opts.personaName?.trim() ? { persona: opts.personaName.trim() } : {}),
})
const setId = setRes.data?.data?.id
if (!setId) {
throw new Error(
(setRes.data as { message?: string } | undefined)?.message ??
"Could not create question set",
)
}
const toCreate = opts.questions.filter((q) => q.questionText.trim())
let displayOrder = 0
for (const q of toCreate) {
const def = byId.get(q.questionTypeDefinitionId)
if (!def) throw new Error(`Missing definition #${q.questionTypeDefinitionId}`)
displayOrder += 1
const payload = buildCreateQuestionFromDefinition(def, q, opts.status)
const qRes = await createQuestion(payload)
const questionId = qRes.data?.data?.id
if (!questionId) {
throw new Error(
(qRes.data as { message?: string } | undefined)?.message ??
"Could not create question",
)
}
await addQuestionToSet(setId, {
question_id: questionId,
display_order: displayOrder,
})
}
const practiceRes = await createParentLinkedPractice({
parent_kind: opts.parentKind,
parent_id: opts.parentId,
title: opts.practiceTitle.trim(),
story_description: opts.storyDescription.trim(),
story_image: opts.storyImage.trim(),
question_set_id: setId,
quick_tips: opts.quickTips.trim(),
publish_status: opts.status,
persona_id: opts.personaId,
})
const practiceId = practiceRes.data?.data?.id
if (!practiceId) {
throw new Error(
(practiceRes.data as { message?: string } | undefined)?.message ??
"Could not create practice",
)
}
return { questionSetId: setId, practiceId }
}

View File

@ -0,0 +1,48 @@
import type {
GetPracticesByParentContextResponse,
ParentContextPractice,
PracticePublishStatus,
} from "../types/course.types"
export function unwrapPracticesList(
res: {
data?: GetPracticesByParentContextResponse & {
Data?: GetPracticesByParentContextResponse["data"]
}
},
): ParentContextPractice[] {
const body = res.data
if (!body) return []
const data = body.data ?? body.Data
const raw = data?.practices
return Array.isArray(raw) ? raw : []
}
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
}
export function isPracticePublished(practice: ParentContextPractice): boolean {
return practicePublishStatus(practice) === "PUBLISHED"
}
export function isPracticeDraft(practice: ParentContextPractice): boolean {
const status = practicePublishStatus(practice)
return status === "DRAFT" || status === null
}
export function draftPracticesForParent(
practices: ParentContextPractice[],
): ParentContextPractice[] {
return practices.filter(isPracticeDraft)
}

52
src/lib/personaDisplay.ts Normal file
View File

@ -0,0 +1,52 @@
import type {
GetPersonasResponse,
PersonaListItem,
} from "../types/persona.types"
export type PersonaCardModel = {
id: string
name: string
description: string
avatar: string
}
/** Soft, professional palette aligned with the admin brand (slate, indigo, violet). */
const PERSONA_FALLBACK_BACKGROUNDS = "f1f5f9,e0e7ff,ede9fe,fdf4ff,ecfeff"
/**
* Default avatar when `profile_picture` is null: professional illustrated portrait
* (DiceBear personas), not casual cartoon avataaars.
*/
export function personaAvatarUrl(
profilePicture: string | null | undefined,
name: string,
personaId?: number | string,
): string {
const url = profilePicture?.trim()
if (url) return url
const params = new URLSearchParams({
seed: personaId != null ? `yimaru-persona-${personaId}` : `yimaru-persona-${name}`,
backgroundColor: PERSONA_FALLBACK_BACKGROUNDS,
radius: "50",
})
return `https://api.dicebear.com/7.x/personas/svg?${params.toString()}`
}
export function mapPersonaToCard(persona: PersonaListItem): PersonaCardModel {
return {
id: String(persona.id),
name: persona.name,
description: persona.description?.trim() ?? "",
avatar: personaAvatarUrl(persona.profile_picture, persona.name, persona.id),
}
}
export function unwrapPersonasList(
res: { data?: GetPersonasResponse & { Data?: GetPersonasResponse["data"] } },
): PersonaListItem[] {
const body = res.data
if (!body) return []
const data = body.data ?? body.Data
const raw = data?.personas
return Array.isArray(raw) ? raw : []
}

View File

@ -88,6 +88,19 @@ export function formatPreviewLength(totalSeconds: number): string {
return `${totalSeconds} seconds`;
}
/** Compact label for thumbnails (e.g. `3:02`, `1:05:07`). */
export function formatVideoDurationLabel(totalSeconds: number): string {
if (!Number.isFinite(totalSeconds) || totalSeconds <= 0) return "";
const s = Math.round(totalSeconds);
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
if (h > 0) {
return `${h}:${String(m).padStart(2, "0")}:${String(sec).padStart(2, "0")}`;
}
return `${m}:${String(sec).padStart(2, "0")}`;
}
/**
* YouTube: `end` = stop after this many seconds from the start of the video.
* Vimeo: time range in URL fragment (supported on many vimeo.com player links).

View File

@ -1,7 +1,7 @@
import {
// Activity,
BadgeCheck,
BookOpen,
Video,
// Coins,
DollarSign,
HelpCircle,
@ -11,14 +11,13 @@ import {
// TrendingUp,
Users,
Bell,
CreditCard,
UsersRound,
} from "lucide-react"
import spinnerSrc from "../assets/Circular-indeterminate progress indicator.svg"
import {
Area,
AreaChart,
Bar,
BarChart,
CartesianGrid,
Cell,
Pie,
@ -28,15 +27,21 @@ import {
XAxis,
YAxis,
} from "recharts"
import { RevenueTrendCard } from "../components/dashboard/RevenueTrendCard"
import { StatCard } from "../components/dashboard/StatCard"
import alertSrc from "../assets/Alert.svg"
import { Badge } from "../components/ui/badge"
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card"
import { cn } from "../lib/utils"
import { getTeamMemberById } from "../api/team.api"
import { getDashboard } from "../api/analytics.api"
import { getSubscriptionPlans } from "../api/subscription-plans.api"
import { getRatings } from "../api/courses.api"
import { useEffect, useState } from "react"
import type { DashboardData } from "../types/analytics.types"
import { AnalyticsTimeRangeFilter } from "../components/analytics/AnalyticsTimeRangeFilter"
import { getPrimaryQuestionTypeSummary, getSeriesPeriodLabel, getVideoLessonsSummary } from "../lib/analytics"
import type { DashboardData, DashboardFilters } from "../types/analytics.types"
import type { SubscriptionPlan } from "../types/subscription.types"
import type { Rating } from "../types/course.types"
const PIE_COLORS = ["#9E2891", "#FFD23F", "#1DE9B6", "#C26FC0", "#6366F1", "#F97316", "#14B8A6", "#EF4444"]
@ -46,6 +51,19 @@ function formatDate(dateStr: string) {
return d.toLocaleDateString("en-US", { month: "short", day: "numeric" })
}
const DEFAULT_FILTERS: DashboardFilters = { mode: "all_time" }
function formatPlanDuration(plan: SubscriptionPlan): string {
const v = plan.duration_value
const u = plan.duration_unit.toUpperCase()
const word =
u === "MONTH" ? "month" : u === "YEAR" ? "year" : u === "WEEK" ? "week" : u === "DAY" ? "day" : plan.duration_unit
if (u === "MONTH" || u === "YEAR" || u === "WEEK" || u === "DAY") {
return `${v} ${v === 1 ? word : `${word}s`}`
}
return `${v} ${word}`
}
export function DashboardPage() {
const [userFirstName, setUserFirstName] = useState<string>("")
const [dashboard, setDashboard] = useState<DashboardData | null>(null)
@ -53,6 +71,9 @@ export function DashboardPage() {
const [activeStatTab, setActiveStatTab] = useState<"primary" | "secondary">("primary")
const [appRatings, setAppRatings] = useState<Rating[]>([])
const [appRatingsLoading, setAppRatingsLoading] = useState(true)
const [filters, setFilters] = useState<DashboardFilters>(DEFAULT_FILTERS)
const [subscriptionPlans, setSubscriptionPlans] = useState<SubscriptionPlan[]>([])
const [subscriptionPlansLoading, setSubscriptionPlansLoading] = useState(true)
useEffect(() => {
const fetchUser = async () => {
@ -70,17 +91,10 @@ export function DashboardPage() {
}
}
const fetchDashboard = async () => {
try {
const res = await getDashboard()
setDashboard(res.data as unknown as DashboardData)
} catch (err) {
console.error(err)
} finally {
setLoading(false)
}
}
fetchUser()
}, [])
useEffect(() => {
const fetchAppRatings = async () => {
try {
const res = await getRatings({ target_type: "app", target_id: 1, limit: 5 })
@ -92,23 +106,49 @@ export function DashboardPage() {
}
}
fetchUser()
fetchDashboard()
fetchAppRatings()
}, [])
useEffect(() => {
const fetchPlans = async () => {
setSubscriptionPlansLoading(true)
try {
const res = await getSubscriptionPlans()
setSubscriptionPlans(res.data.data)
} catch (err) {
console.error(err)
setSubscriptionPlans([])
} finally {
setSubscriptionPlansLoading(false)
}
}
fetchPlans()
}, [])
useEffect(() => {
const fetchDashboard = async () => {
setLoading(true)
try {
const res = await getDashboard(filters)
setDashboard(res.data)
} catch (err) {
console.error(err)
setDashboard(null)
} finally {
setLoading(false)
}
}
fetchDashboard()
}, [filters])
const registrationData =
dashboard?.users.registrations_last_30_days.map((d) => ({
date: formatDate(d.date),
count: d.count,
})) ?? []
const revenueData =
dashboard?.payments.revenue_last_30_days.map((d) => ({
date: formatDate(d.date),
revenue: d.revenue,
})) ?? []
const subscriptionStatusData =
dashboard?.subscriptions.by_status.map((s, i) => ({
name: s.label,
@ -123,9 +163,14 @@ export function DashboardPage() {
color: PIE_COLORS[i % PIE_COLORS.length],
})) ?? []
const seriesPeriodLabel = dashboard ? getSeriesPeriodLabel(dashboard.date_filter) : "Last 30 Days"
return (
<div className="mx-auto w-full max-w-6xl">
<div className="mb-2 text-sm font-semibold text-grayScale-500">Dashboard</div>
<div className="mb-2 flex flex-wrap items-center justify-between gap-3">
<div className="text-sm font-semibold text-grayScale-500">Dashboard</div>
<AnalyticsTimeRangeFilter value={filters} onChange={setFilters} />
</div>
<div className="mb-5 text-2xl font-semibold tracking-tight">
Welcome, {userFirstName || localStorage.getItem("user_first_name")}
</div>
@ -216,18 +261,21 @@ export function DashboardPage() {
{activeStatTab === "secondary" && (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<StatCard
icon={BookOpen}
label="Courses"
value={dashboard.courses.total_courses.toLocaleString()}
deltaLabel={`${dashboard.courses.total_sub_courses} sub-modules, ${dashboard.courses.total_videos} videos`}
deltaPositive
icon={Video}
label="Videos"
value={dashboard.courses.total_videos.toLocaleString()}
deltaLabel={getVideoLessonsSummary(
dashboard.courses.lms?.lessons_with_video,
dashboard.courses.exam_prep?.lessons_with_video,
)}
deltaPositive={dashboard.courses.total_videos > 0}
/>
<StatCard
icon={HelpCircle}
label="Questions"
value={dashboard.content.total_questions.toLocaleString()}
deltaLabel={`${dashboard.content.total_question_sets} question sets`}
deltaPositive
deltaLabel={getPrimaryQuestionTypeSummary(dashboard.content.questions_by_type)}
deltaPositive={dashboard.content.total_questions > 0}
/>
<StatCard
icon={Bell}
@ -261,7 +309,7 @@ export function DashboardPage() {
</div>
</div>
<div className="rounded-full bg-grayScale-100 px-3 py-1 text-xs font-semibold text-grayScale-500">
Last 30 Days
{seriesPeriodLabel}
</div>
</div>
</CardHeader>
@ -357,76 +405,69 @@ export function DashboardPage() {
</CardContent>
</Card>
{/* Revenue Chart */}
<Card className="shadow-none">
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<div>
<CardTitle>Revenue Trend</CardTitle>
<div className="mt-2 text-2xl font-semibold tracking-tight">
ETB {dashboard.payments.total_revenue.toLocaleString()}
</div>
<div className="text-xs font-medium text-grayScale-500">Last 30 Days (ETB)</div>
</div>
</div>
</CardHeader>
<CardContent className="h-[220px] p-6 pt-2">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={revenueData} margin={{ left: 8, right: 8, top: 8 }}>
<CartesianGrid vertical={false} stroke="#E0E0E0" strokeDasharray="4 4" />
<XAxis dataKey="date" tickLine={false} axisLine={false} fontSize={12} />
<YAxis tickLine={false} axisLine={false} fontSize={12} width={42} />
<Tooltip
formatter={(v) => [`${Number(v).toLocaleString()}`, "ETB"]}
contentStyle={{
borderRadius: 12,
border: "1px solid #E0E0E0",
boxShadow: "0 10px 30px rgba(0,0,0,0.08)",
}}
/>
<Bar dataKey="revenue" radius={[10, 10, 0, 0]} fill="#9E2891" />
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
<RevenueTrendCard />
</div>
{/* Users by Role / Region / Knowledge Level */}
<div className="grid gap-4 lg:grid-cols-3">
{[
{ title: "Users by Role", data: dashboard.users.by_role },
{ title: "Users by Region", data: dashboard.users.by_region },
{ title: "Users by Knowledge Level", data: dashboard.users.by_knowledge_level },
].map(({ title, data }) => (
<Card key={title} className="shadow-none">
<CardHeader className="pb-2">
<CardTitle>{title}</CardTitle>
</CardHeader>
<CardContent className="p-6 pt-2">
{data.length > 0 ? (
<div className="space-y-3">
{data.map((item, i) => (
<div key={item.label} className="flex items-center justify-between gap-3 text-sm">
<div className="flex items-center gap-2">
<span
className="h-2.5 w-2.5 rounded-full"
style={{ backgroundColor: PIE_COLORS[i % PIE_COLORS.length] }}
/>
<span className="text-grayScale-600">{item.label}</span>
{/* Subscription plans (from catalog API) */}
<Card className="shadow-none">
<CardHeader className="pb-2">
<div className="flex items-center gap-2">
<CreditCard className="h-5 w-5 text-brand-500" />
<CardTitle>Subscription plans</CardTitle>
</div>
<p className="text-sm text-grayScale-500">Available billing plans for learners.</p>
</CardHeader>
<CardContent className="p-6 pt-2">
{subscriptionPlansLoading ? (
<div className="flex items-center justify-center py-10">
<img src={spinnerSrc} alt="" className="h-8 w-8 animate-spin" />
</div>
) : subscriptionPlans.length === 0 ? (
<div className="flex items-center justify-center py-10 text-sm text-grayScale-400">
No subscription plans found
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
{subscriptionPlans.map((plan) => (
<div
key={plan.id}
className="flex flex-col rounded-xl border border-grayScale-200 bg-grayScale-50/50 p-4"
>
<div className="flex items-start justify-between gap-2">
<h3 className="font-semibold text-grayScale-700">{plan.name}</h3>
<Badge variant={plan.is_active ? "success" : "secondary"}>
{plan.is_active ? "Active" : "Inactive"}
</Badge>
</div>
{plan.description ? (
<p className="mt-2 line-clamp-2 text-sm text-grayScale-500">{plan.description}</p>
) : null}
<div className="mt-4 flex flex-wrap items-end justify-between gap-2 border-t border-grayScale-200 pt-4">
<div>
<div className="text-xs font-medium uppercase tracking-wide text-grayScale-400">Price</div>
<div className="text-lg font-semibold text-brand-600">
{plan.currency}{" "}
{Number.isInteger(plan.price)
? plan.price.toLocaleString()
: plan.price.toLocaleString(undefined, {
minimumFractionDigits: 0,
maximumFractionDigits: 2,
})}
</div>
<span className="font-semibold text-grayScale-600">{item.count.toLocaleString()}</span>
</div>
))}
<div className="text-right">
<div className="text-xs font-medium uppercase tracking-wide text-grayScale-400">
Billing
</div>
<div className="text-sm font-semibold text-grayScale-600">{formatPlanDuration(plan)}</div>
</div>
</div>
</div>
) : (
<div className="flex items-center justify-center py-6 text-sm text-grayScale-400">
No data available
</div>
)}
</CardContent>
</Card>
))}
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* App Ratings */}
<Card className="shadow-none">

View File

@ -39,7 +39,13 @@ import { Badge } from "../../components/ui/badge"
import { Button } from "../../components/ui/button"
import { cn } from "../../lib/utils"
import { getDashboard } from "../../api/analytics.api"
import type { DashboardData, LabelCount } from "../../types/analytics.types"
import { AnalyticsTimeRangeFilter, getDashboardFilterLabel } from "../../components/analytics/AnalyticsTimeRangeFilter"
import {
getPrimaryQuestionTypeSummary,
getSeriesPeriodLabel,
getVideoLessonsSummary,
} from "../../lib/analytics"
import type { DashboardData, DashboardFilters, LabelCount } from "../../types/analytics.types"
const PIE_COLORS = ["#9E2891", "#FFD23F", "#1DE9B6", "#C26FC0", "#6366F1", "#F97316", "#14B8A6", "#EF4444", "#8B5CF6", "#EC4899", "#06B6D4", "#84CC16"]
@ -285,18 +291,21 @@ function Section({
)
}
const DEFAULT_FILTERS: DashboardFilters = { mode: "all_time" }
export function AnalyticsPage() {
const [dashboard, setDashboard] = useState<DashboardData | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(false)
const [activeSummaryTab, setActiveSummaryTab] = useState<"key" | "content" | "operations">("key")
const [filters, setFilters] = useState<DashboardFilters>(DEFAULT_FILTERS)
const fetchData = async () => {
const fetchData = async (nextFilters: DashboardFilters = filters) => {
setLoading(true)
setError(false)
try {
const res = await getDashboard()
setDashboard(res.data as unknown as DashboardData)
const res = await getDashboard(nextFilters)
setDashboard(res.data)
} catch {
setError(true)
} finally {
@ -305,10 +314,11 @@ export function AnalyticsPage() {
}
useEffect(() => {
fetchData()
}, [])
fetchData(filters)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filters])
if (loading) {
if (!dashboard && loading) {
return (
<div className="mx-auto w-full max-w-[1280px] px-2 sm:px-4">
<div className="mb-6 text-xs font-semibold uppercase tracking-wide text-grayScale-400">Analytics</div>
@ -323,11 +333,14 @@ export function AnalyticsPage() {
if (error || !dashboard) {
return (
<div className="mx-auto w-full max-w-[1280px] px-2 sm:px-4">
<div className="mb-6 text-xs font-semibold uppercase tracking-wide text-grayScale-400">Analytics</div>
<div className="mb-6 flex flex-wrap items-center justify-between gap-3">
<div className="text-xs font-semibold uppercase tracking-wide text-grayScale-400">Analytics</div>
<AnalyticsTimeRangeFilter value={filters} onChange={setFilters} />
</div>
<div className="flex flex-col items-center justify-center gap-3 rounded-2xl border border-red-100 bg-red-50/30 py-24">
<img src={alertSrc} alt="" className="h-12 w-12" />
<span className="text-sm text-destructive">Failed to load analytics data.</span>
<Button variant="outline" size="sm" onClick={fetchData}>
<Button variant="outline" size="sm" onClick={() => fetchData(filters)}>
<RefreshCw className="mr-2 h-4 w-4" />
Retry
</Button>
@ -337,6 +350,9 @@ export function AnalyticsPage() {
}
const { users, subscriptions, payments, courses, content, notifications, issues, team } = dashboard
const seriesPeriodLabel = getSeriesPeriodLabel(dashboard.date_filter)
const lms = courses.lms
const examPrep = courses.exam_prep
const registrationData = users.registrations_last_30_days.map((d) => ({
date: formatDate(d.date),
@ -387,15 +403,25 @@ export function AnalyticsPage() {
<div className="mb-1 text-xs font-semibold uppercase tracking-wide text-grayScale-400">Analytics</div>
<h1 className="text-3xl font-semibold tracking-tight text-grayScale-900">Platform Overview</h1>
</div>
<div className="flex items-center gap-3">
<span className="text-xs text-grayScale-400">Generated {generatedAt}</span>
<Button variant="outline" size="sm" onClick={fetchData}>
<RefreshCw className="mr-2 h-3.5 w-3.5" />
<div className="flex flex-wrap items-center gap-3">
<span className="text-xs text-grayScale-400">
{getDashboardFilterLabel(filters)} · Generated {generatedAt}
</span>
<AnalyticsTimeRangeFilter value={filters} onChange={setFilters} />
<Button variant="outline" size="sm" onClick={() => fetchData(filters)} disabled={loading}>
<RefreshCw className={cn("mr-2 h-3.5 w-3.5", loading && "animate-spin")} />
Refresh
</Button>
</div>
</div>
{loading && (
<div className="mb-4 flex items-center gap-2 rounded-lg border border-grayScale-100 bg-grayScale-50 px-3 py-2 text-xs text-grayScale-500">
<img src={spinnerSrc} alt="" className="h-4 w-4 animate-spin" />
Updating analytics for {getDashboardFilterLabel(filters)}
</div>
)}
{/* Summary Tabs */}
<div className="mb-6 rounded-2xl border border-grayScale-100 bg-white px-5 pt-4 shadow-sm">
<div className="-mb-px flex gap-6">
@ -483,7 +509,7 @@ export function AnalyticsPage() {
<Section
title="Content & Platform"
icon={BookOpen}
count={courses.total_courses + content.total_questions}
count={courses.total_videos + content.total_questions}
defaultOpen
>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
@ -491,28 +517,29 @@ export function AnalyticsPage() {
icon={FolderOpen}
label="Categories"
value={courses.total_categories.toLocaleString()}
sub={`${courses.total_courses} courses`}
sub={`${courses.total_courses} courses · ${courses.total_sub_courses} modules`}
trend="neutral"
/>
<KpiCard
icon={BookOpen}
label="Sub-Courses"
value={courses.total_sub_courses.toLocaleString()}
sub={`across ${courses.total_courses} courses`}
label="LMS Programs"
value={(lms?.programs ?? 0).toLocaleString()}
sub={`${lms?.courses ?? 0} courses · ${lms?.practices ?? 0} practices`}
trend="neutral"
/>
<KpiCard
icon={Video}
label="Videos"
value={courses.total_videos.toLocaleString()}
trend="neutral"
sub={getVideoLessonsSummary(lms?.lessons_with_video, examPrep?.lessons_with_video)}
trend={courses.total_videos > 0 ? "up" : "neutral"}
/>
<KpiCard
icon={HelpCircle}
label="Questions"
value={content.total_questions.toLocaleString()}
sub={`${content.total_question_sets} question sets`}
trend="neutral"
sub={getPrimaryQuestionTypeSummary(content.questions_by_type)}
trend={content.total_questions > 0 ? "up" : "neutral"}
/>
</div>
</Section>
@ -573,7 +600,7 @@ export function AnalyticsPage() {
</Badge>
</div>
</div>
<Badge variant="secondary">Last 30 Days</Badge>
<Badge variant="secondary">{seriesPeriodLabel}</Badge>
</div>
</CardHeader>
<CardContent className="h-[280px] p-6 pt-2">
@ -603,10 +630,10 @@ export function AnalyticsPage() {
</Card>
<div className="mt-4 grid items-start gap-4 sm:grid-cols-2 lg:grid-cols-3">
<BreakdownList title="Users by Role" data={users.by_role} total={users.total_users} />
<BreakdownList title="Users by Region" data={users.by_region} total={users.total_users} />
<BreakdownList title="Users by Knowledge Level" data={users.by_knowledge_level} total={users.total_users} />
<BreakdownList title="Users by Status" data={users.by_status} total={users.total_users} />
<BreakdownList title="Users by Age Group" data={users.by_age_group} total={users.total_users} />
<BreakdownList title="Users by Knowledge Level" data={users.by_knowledge_level} total={users.total_users} />
<BreakdownList title="Users by Region" data={users.by_region} total={users.total_users} />
</div>
</Section>
@ -625,7 +652,7 @@ export function AnalyticsPage() {
+{subscriptions.new_today} today · +{subscriptions.new_week} this week
</div>
</div>
<Badge variant="secondary">Last 30 Days</Badge>
<Badge variant="secondary">{seriesPeriodLabel}</Badge>
</div>
</CardHeader>
<CardContent className="h-[240px] p-6 pt-2">
@ -664,7 +691,7 @@ export function AnalyticsPage() {
</div>
<div className="text-xs text-grayScale-400">Daily revenue over last 30 days</div>
</div>
<Badge variant="secondary">Last 30 Days</Badge>
<Badge variant="secondary">{seriesPeriodLabel}</Badge>
</div>
</CardHeader>
<CardContent className="h-[240px] p-6 pt-2">
@ -728,6 +755,43 @@ export function AnalyticsPage() {
</div>
</Section>
{/* ─── Course Management ─── */}
{(lms || examPrep) && (
<Section title="Course Management" icon={BookOpen} count={courses.total_videos} defaultOpen={false}>
<div className="grid items-start gap-4 lg:grid-cols-2">
{lms && (
<BreakdownList
title="LMS"
data={[
{ label: "Programs", count: lms.programs },
{ label: "Courses", count: lms.courses },
{ label: "Modules", count: lms.modules },
{ label: "Lessons", count: lms.lessons },
{ label: "Lessons with video", count: lms.lessons_with_video },
{ label: "Practices", count: lms.practices },
{ label: "Practices at course", count: lms.practices_at_course },
{ label: "Practices at module", count: lms.practices_at_module },
{ label: "Practices at lesson", count: lms.practices_at_lesson },
]}
/>
)}
{examPrep && (
<BreakdownList
title="Exam prep"
data={[
{ label: "Catalog courses", count: examPrep.catalog_courses },
{ label: "Units", count: examPrep.units },
{ label: "Unit modules", count: examPrep.unit_modules },
{ label: "Lessons", count: examPrep.lessons },
{ label: "Lessons with video", count: examPrep.lessons_with_video },
{ label: "Lesson practices", count: examPrep.lesson_practices },
]}
/>
)}
</div>
</Section>
)}
{/* ─── Content Breakdown ─── */}
<Section title="Content Breakdown" icon={HelpCircle} count={content.total_questions} defaultOpen={false}>
<div className="grid items-start gap-4 sm:grid-cols-2">

View File

@ -9,8 +9,6 @@ import {
Plus,
Trash2,
GripVertical,
Edit,
Rocket,
Loader2,
Upload,
} from "lucide-react";
@ -19,6 +17,9 @@ import { Card } from "../../components/ui/card";
import { Button } from "../../components/ui/button";
import { Input } from "../../components/ui/input";
import { PracticeQuestionEditorFields } from "../../components/content-management/PracticeQuestionEditorFields";
import { AddNewPracticeReviewStep } from "./components/AddNewPracticeReviewStep";
import { PersonaStep } from "./components/practice-steps/PersonaStep";
import { useActivePersonas } from "../../hooks/useActivePersonas";
import {
createQuestionSet,
createQuestion,
@ -34,12 +35,6 @@ type ResultStatus = "success" | "error";
type QuestionType = "MCQ" | "TRUE_FALSE" | "SHORT" | "AUDIO" | "DYNAMIC";
type DifficultyLevel = "EASY" | "MEDIUM" | "HARD";
interface Persona {
id: string;
name: string;
avatar: string;
}
interface MCQOption {
text: string;
isCorrect: boolean;
@ -65,49 +60,6 @@ interface Question {
dynamicFieldValues: Record<string, string>;
}
const PERSONAS: Persona[] = [
{
id: "1",
name: "Dawit",
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Dawit",
},
{
id: "2",
name: "Mahlet",
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Mahlet",
},
{
id: "3",
name: "Amanuel",
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Amanuel",
},
{
id: "4",
name: "Bethel",
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Bethel",
},
{
id: "5",
name: "Liya",
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Liya",
},
{
id: "6",
name: "Aseffa",
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Aseffa",
},
{
id: "7",
name: "Hana",
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Hana",
},
{
id: "8",
name: "Nahom",
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Nahom",
},
];
const STEPS = [
{ number: 1, label: "Context" },
{ number: 2, label: "Persona" },
@ -158,64 +110,6 @@ function isDirectVideoFile(url: string): boolean {
return /\.(mp4|webm|ogg|mov|m4v)$/.test(clean);
}
function escapeHtml(raw: string): string {
return raw
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
function sanitizeAdminRichTextHtml(input: string): string {
if (!input.trim()) return "";
try {
const parser = new DOMParser();
const doc = parser.parseFromString(input, "text/html");
const blockedTags = new Set([
"script",
"style",
"iframe",
"object",
"embed",
"link",
"meta",
]);
doc.body.querySelectorAll("*").forEach((el) => {
const tagName = el.tagName.toLowerCase();
if (blockedTags.has(tagName)) {
el.remove();
return;
}
const attrs = [...el.attributes];
attrs.forEach((attr) => {
const name = attr.name.toLowerCase();
const value = attr.value.trim().toLowerCase();
if (name.startsWith("on")) {
el.removeAttribute(attr.name);
return;
}
if (
(name === "href" || name === "src") &&
value.startsWith("javascript:")
) {
el.removeAttribute(attr.name);
}
});
});
return doc.body.innerHTML;
} catch {
return escapeHtml(input).replace(/\r?\n/g, "<br />");
}
}
function formatDescriptionForPreview(raw: string): string {
if (!raw.trim()) return "";
const hasHtml = /<\/?[a-z][\s\S]*>/i.test(raw);
if (hasHtml) return sanitizeAdminRichTextHtml(raw);
return escapeHtml(raw).replace(/\r?\n/g, "<br />");
}
function createEmptyQuestion(id: string): Question {
return {
id,
@ -281,6 +175,12 @@ export function AddNewPracticePage() {
// Step 2: Persona
const [selectedPersona, setSelectedPersona] = useState<string | null>(null);
const {
personas,
loading: personasLoading,
error: personasError,
reload: reloadPersonas,
} = useActivePersonas();
// Step 3: Questions
const [questions, setQuestions] = useState<Question[]>([
@ -373,11 +273,6 @@ export function AddNewPracticePage() {
return null;
}, [introVideoUrl]);
const descriptionPreviewHtml = useMemo(
() => formatDescriptionForPreview(practiceDescription),
[practiceDescription],
);
const addQuestion = () => {
setQuestions([...questions, createEmptyQuestion(String(Date.now()))]);
};
@ -398,7 +293,12 @@ export function AddNewPracticePage() {
setSaving(true);
setSaveError(null);
try {
const persona = PERSONAS.find((p) => p.id === selectedPersona);
if (!selectedPersona) {
toast.error("Select a persona before saving.");
setSaving(false);
return;
}
const persona = personas.find((p) => p.id === selectedPersona);
const setRes = await createQuestionSet({
title: practiceTitle || "Untitled Practice",
set_type: "PRACTICE",
@ -899,66 +799,17 @@ export function AddNewPracticePage() {
practice.
</p>
</div>
<div className="p-5 sm:p-8 lg:p-10">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 sm:gap-4 lg:grid-cols-4 lg:gap-5">
{PERSONAS.map((persona) => (
<button
key={persona.id}
onClick={() => setSelectedPersona(persona.id)}
className={`group relative flex flex-col items-center rounded-xl border-2 p-6 transition-all duration-200 ${
selectedPersona === persona.id
? "border-brand-500 bg-brand-50 shadow-md shadow-brand-100"
: "border-grayScale-200 bg-white hover:border-brand-300 hover:shadow-sm"
}`}
>
{selectedPersona === persona.id && (
<div className="absolute right-2.5 top-2.5 flex h-6 w-6 items-center justify-center rounded-full bg-brand-500 text-white shadow-sm">
<Check className="h-3.5 w-3.5" />
</div>
)}
<div
className={`mb-3 h-20 w-20 overflow-hidden rounded-full bg-grayScale-100 ring-2 transition-all duration-200 ${
selectedPersona === persona.id
? "ring-brand-300 ring-offset-2"
: "ring-transparent group-hover:ring-grayScale-200"
}`}
>
<img
src={persona.avatar}
alt={persona.name}
className="h-full w-full object-cover"
/>
</div>
<span
className={`text-sm font-semibold transition-colors ${
selectedPersona === persona.id
? "text-brand-600"
: "text-grayScale-900"
}`}
>
{persona.name}
</span>
</button>
))}
</div>
</div>
<div className="flex flex-col-reverse items-stretch justify-between gap-3 border-t border-grayScale-100 bg-grayScale-50/30 px-5 py-4 sm:flex-row sm:items-center sm:px-8 sm:py-5">
<Button
variant="outline"
onClick={handleBack}
className="sm:w-auto"
>
Back
</Button>
<Button
className="w-full bg-brand-500 hover:bg-brand-600 sm:w-auto sm:min-w-[180px]"
onClick={handleNext}
>
{getNextButtonLabel()}
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
<PersonaStep
personas={personas}
loading={personasLoading}
error={personasError}
onRetry={() => void reloadPersonas()}
selectedPersona={selectedPersona}
setSelectedPersona={setSelectedPersona}
nextStep={handleNext}
prevStep={handleBack}
/>
</div>
</Card>
)}
@ -1075,261 +926,26 @@ export function AddNewPracticePage() {
)}
{currentStep === 4 && (
<div className="w-full space-y-6">
<div className="rounded-2xl border border-grayScale-200/80 bg-gradient-to-r from-grayScale-50/80 to-white px-5 py-5 shadow-sm sm:px-8 sm:py-6">
<h2 className="text-lg font-semibold tracking-tight text-grayScale-900 sm:text-xl">
Step 4: Review & publish
</h2>
<p className="mt-1.5 max-w-3xl text-sm leading-relaxed text-grayScale-500">
Confirm context, persona, and questions before saving or
publishing.
</p>
</div>
<div className="grid gap-6 lg:grid-cols-2 lg:items-start lg:gap-8">
{/* Basic Information Card */}
<Card className="overflow-hidden border-grayScale-200/80 p-0 shadow-sm">
<div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-4">
<h3 className="font-semibold text-grayScale-900">
Basic Information
</h3>
<button
onClick={() => setCurrentStep(1)}
className="flex items-center gap-1.5 rounded-md px-2.5 py-1 text-sm font-medium text-brand-500 transition-colors hover:bg-brand-50 hover:text-brand-600"
>
<Edit className="h-3.5 w-3.5" />
Edit
</button>
</div>
<div className="divide-y divide-grayScale-100">
<div className="flex justify-between px-6 py-3.5 odd:bg-grayScale-50/50">
<span className="text-sm text-grayScale-500">Title</span>
<span className="text-sm font-medium text-grayScale-900">
{practiceTitle || "Untitled Practice"}
</span>
</div>
<div className="bg-grayScale-50/50 px-6 py-4">
<span className="text-sm text-grayScale-500">
Description
</span>
{descriptionPreviewHtml ? (
<div
className="mt-2 rounded-lg border border-grayScale-200 bg-white px-4 py-3 text-sm leading-relaxed text-grayScale-800 [&_h1]:text-xl [&_h1]:font-semibold [&_h2]:text-lg [&_h2]:font-semibold [&_ol]:list-decimal [&_ol]:pl-6 [&_p]:mb-2 [&_strong]:font-semibold [&_ul]:list-disc [&_ul]:pl-6"
dangerouslySetInnerHTML={{
__html: descriptionPreviewHtml,
}}
/>
) : (
<p className="mt-2 text-sm text-grayScale-400"></p>
)}
</div>
<div className="flex justify-between px-6 py-3.5">
<span className="text-sm text-grayScale-500">
Intro video URL
</span>
<span className="max-w-[min(28rem,55%)] break-all text-right text-sm text-grayScale-700">
{introVideoUrl.trim() || "—"}
</span>
</div>
{introVideoPreview ? (
<div className="bg-grayScale-50/50 px-6 py-4">
<span className="text-sm text-grayScale-500">
Intro video preview
</span>
<div className="mt-2 rounded-lg border border-grayScale-200 bg-white p-3">
{introVideoPreview.kind === "vimeo" ? (
<div className="overflow-hidden rounded-lg border border-grayScale-200 bg-black">
<iframe
src={introVideoPreview.url}
title="Intro video preview"
className="aspect-video w-full"
allow="autoplay; fullscreen; picture-in-picture"
allowFullScreen
/>
</div>
) : (
<video
controls
src={introVideoPreview.url}
className="aspect-video w-full rounded-lg border border-grayScale-200 bg-black"
/>
)}
</div>
</div>
) : null}
<div className="flex justify-between bg-grayScale-50/50 px-6 py-3.5">
<span className="text-sm text-grayScale-500">
Passing Score
</span>
<span className="text-sm font-medium text-grayScale-900">
{passingScore}%
</span>
</div>
<div className="flex justify-between px-6 py-3.5">
<span className="text-sm text-grayScale-500">
Time Limit
</span>
<span className="text-sm font-medium text-grayScale-900">
{timeLimitMinutes} minutes
</span>
</div>
<div className="flex justify-between bg-grayScale-50/50 px-6 py-3.5">
<span className="text-sm text-grayScale-500">
Shuffle Questions
</span>
<span className="text-sm font-medium text-grayScale-900">
{shuffleQuestions ? "Yes" : "No"}
</span>
</div>
<div className="flex justify-between px-6 py-3.5">
<span className="text-sm text-grayScale-500">Persona</span>
<div className="flex items-center gap-2">
{selectedPersona && (
<div className="h-6 w-6 overflow-hidden rounded-full bg-grayScale-100 ring-2 ring-brand-100">
<img
src={
PERSONAS.find((p) => p.id === selectedPersona)
?.avatar
}
alt="Persona"
className="h-full w-full object-cover"
/>
</div>
)}
<span className="text-sm font-medium text-brand-600">
{PERSONAS.find((p) => p.id === selectedPersona)?.name ||
"None selected"}
</span>
</div>
</div>
</div>
</Card>
{/* Questions Review */}
<Card className="overflow-hidden border-grayScale-200/80 p-0 shadow-sm lg:min-h-0">
<div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-4">
<div className="flex items-center gap-2.5">
<h3 className="font-semibold text-grayScale-900">
Questions
</h3>
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-brand-100 text-xs font-semibold text-brand-600">
{questions.length}
</span>
</div>
<button
onClick={() => setCurrentStep(3)}
className="flex items-center gap-1.5 rounded-md px-2.5 py-1 text-sm font-medium text-brand-500 transition-colors hover:bg-brand-50 hover:text-brand-600"
>
<Edit className="h-3.5 w-3.5" />
Edit
</button>
</div>
<div className="max-h-[min(70vh,52rem)] space-y-3 overflow-y-auto px-4 py-4 sm:px-6">
{questions.map((question, index) => (
<div
key={question.id}
className="rounded-xl border border-grayScale-200 bg-grayScale-50/20 p-4 transition-colors hover:border-grayScale-300 sm:p-4"
>
<div className="flex items-start gap-3">
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-brand-100 text-xs font-bold text-brand-600">
{index + 1}
</span>
<div className="flex-1 space-y-2.5">
<p className="text-sm font-medium leading-relaxed text-grayScale-900">
{question.questionText}
</p>
<div className="flex flex-wrap items-center gap-2">
<span className="rounded-md bg-blue-50 px-2 py-0.5 text-xs font-medium text-blue-600">
{question.questionType === "MCQ"
? "Multiple Choice"
: question.questionType === "TRUE_FALSE"
? "True/False"
: question.questionType === "AUDIO"
? "Audio"
: question.questionType === "DYNAMIC"
? "Dynamic"
: "Short Answer"}
</span>
<span className="rounded-md bg-purple-50 px-2 py-0.5 text-xs font-medium text-purple-600">
{question.difficultyLevel}
</span>
<span className="rounded-md bg-grayScale-100 px-2 py-0.5 text-xs font-medium text-grayScale-600">
{question.points} pt
{question.points !== 1 ? "s" : ""}
</span>
</div>
{question.questionType === "MCQ" &&
question.options.length > 0 && (
<div className="mt-2 space-y-1">
{question.options.map((opt, i) => (
<div
key={i}
className={`flex items-center gap-2 rounded-md px-2.5 py-1.5 text-sm ${
opt.isCorrect
? "bg-green-50 font-medium text-green-700"
: "text-grayScale-600"
}`}
>
{opt.isCorrect && (
<Check className="h-3.5 w-3.5" />
)}
{opt.text || `Option ${i + 1}`}
</div>
))}
</div>
)}
{question.tips && (
<p className="rounded-md bg-amber-50 px-2.5 py-1.5 text-xs text-amber-600">
💡 Tip: {question.tips}
</p>
)}
{question.explanation && (
<p className="rounded-md bg-grayScale-50 px-2.5 py-1.5 text-xs text-grayScale-500">
Explanation: {question.explanation}
</p>
)}
</div>
</div>
</div>
))}
</div>
</Card>
</div>
{saveError && (
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-3">
<p className="text-sm font-medium text-red-600">{saveError}</p>
</div>
)}
<div className="flex flex-col-reverse items-stretch justify-between gap-3 rounded-2xl border border-grayScale-200/80 bg-grayScale-50/30 px-4 py-4 sm:flex-row sm:items-center sm:px-6 sm:py-5">
<Button
variant="outline"
onClick={handleBack}
className="sm:w-auto"
>
Back
</Button>
<div className="flex w-full flex-col gap-3 sm:w-auto sm:flex-row sm:justify-end">
<Button
variant="outline"
onClick={handleSaveAsDraft}
disabled={saving}
className="sm:min-w-[140px]"
>
{saving ? "Saving..." : "Save as Draft"}
</Button>
<Button
className="bg-brand-500 hover:bg-brand-600 sm:min-w-[160px]"
onClick={handlePublish}
disabled={saving}
>
<Rocket className="mr-2 h-4 w-4" />
{saving ? "Publishing..." : "Publish Now"}
</Button>
</div>
</div>
</div>
<AddNewPracticeReviewStep
practiceTitle={practiceTitle}
practiceDescription={practiceDescription}
selectedProgram={selectedProgram}
selectedCourse={selectedCourse}
moduleLabel={
subModuleId ? `Module ${subModuleId}` : "Current module"
}
selectedPersona={selectedPersona}
personas={personas}
introVideoPreview={introVideoPreview}
questions={questions}
saving={saving}
saveError={saveError}
onEditContext={() => setCurrentStep(1)}
onEditQuestions={() => setCurrentStep(3)}
onBack={handleBack}
onSaveDraft={handleSaveAsDraft}
onPublish={handlePublish}
/>
)}
{/* Step 5: Result */}

View File

@ -1,4 +1,4 @@
import { useState } from "react";
import { useEffect, useMemo, useState } from "react";
import {
Link,
useNavigate,
@ -6,15 +6,32 @@ import {
useSearchParams,
} from "react-router-dom";
import { ArrowLeft } from "lucide-react";
import { toast } from "sonner";
import { Button } from "../../components/ui/button";
import { Stepper } from "../../components/ui/stepper";
import successIcon from "../../assets/success.svg";
import type { PracticeParentKind } from "../../types/course.types";
import type { QuestionTypeDefinition } from "../../types/questionTypeDefinition.types";
import { getQuestionTypeDefinitions } from "../../api/questionTypeDefinitions.api";
import { emptyDynamicFieldValuesForDefinition } from "../../lib/learnEnglishDefinitionQuestion";
import {
executeLearnEnglishPracticeCreation,
learnEnglishPracticeApiErrorMessage,
validateLearnEnglishQuestionsWithDefinitions,
} from "../../lib/learnEnglishPracticePublish";
import { ContextStep } from "./components/practice-steps/ContextStep";
import { ScenarioStep } from "./components/practice-steps/ScenarioStep";
import { PersonaStep } from "./components/practice-steps/PersonaStep";
import { QuestionsStep } from "./components/practice-steps/QuestionsStep";
import { ReviewStep } from "./components/practice-steps/ReviewStep";
import {
personaFromId,
personaIdNumber,
} from "./components/practice-steps/constants";
import { useActivePersonas } from "../../hooks/useActivePersonas";
const STEP_LABELS = ["Practice", "Persona", "Questions", "Review"] as const;
export function AddPracticeFlow() {
const navigate = useNavigate();
@ -37,6 +54,40 @@ export function AddPracticeFlow() {
const isModuleContext = backTo === "module";
const isCourseContext = backTo === "modules";
const isLessonPractice = useMemo(() => {
const lid = lessonId ? Number(lessonId) : NaN;
return Number.isFinite(lid) && lid > 0;
}, [lessonId]);
const parentContext = useMemo((): {
kind: PracticeParentKind;
id: number;
} | null => {
const lid = lessonId ? Number(lessonId) : NaN;
if (Number.isFinite(lid) && lid > 0) return { kind: "LESSON", id: lid };
const mid = moduleId ? Number(moduleId) : NaN;
if (isModuleContext && Number.isFinite(mid) && mid > 0)
return { kind: "MODULE", id: mid };
const cid = courseId ? Number(courseId) : NaN;
if (isCourseContext && Number.isFinite(cid) && cid > 0)
return { kind: "COURSE", id: cid };
return null;
}, [lessonId, moduleId, courseId, isModuleContext, isCourseContext]);
const parentSummary = useMemo(() => {
if (lessonId)
return `Lesson #${lessonId}${lessonTitleDisplay ? `${lessonTitleDisplay}` : ""}`;
if (isModuleContext && moduleId) return `Module #${moduleId}`;
if (isCourseContext && courseId) return `Course #${courseId}`;
return null;
}, [
lessonId,
lessonTitleDisplay,
isModuleContext,
isCourseContext,
moduleId,
courseId,
]);
const backLabel =
backTo === "module"
@ -51,36 +102,194 @@ export function AddPracticeFlow() {
? `/new-content/learn-english/${level}/courses/${courseId}`
: `/new-content/learn-english/${level}/courses`;
const flowSteps = isModuleContext
? ["Context", "Persona", "Questions", "Review"]
: ["Context", "Scenario", "Persona", "Questions", "Review"];
const [currentStep, setCurrentStep] = useState(1);
const [selectedPersona, setSelectedPersona] = useState<string | null>(
"dawit",
);
const [isPublished, setIsPublished] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [selectedPersona, setSelectedPersona] = useState<string | null>(null);
const {
personas,
loading: personasLoading,
error: personasError,
reload: reloadPersonas,
} = useActivePersonas();
const [formData, setFormData] = useState({
program: "Intermediate",
course: "A2",
title: "",
description: "",
selectedVideo: "",
tips: "Focus on using the present perfect continuous tense to describe an action that started in the past and continues now.",
storyImageUrl: "",
shuffleQuestions: false,
tips: "",
questions: [
{
id: "q1",
text: "How long have you been studying English?",
type: "Speaking",
voicePrompt: "prompt_q1_en.mp3",
sampleAnswer: "prompt_q1_en.mp3",
questionTypeDefinitionId: null as number | null,
text: "",
dynamicFieldValues: {} as Record<string, string>,
mcqOptions: [
{ text: "", isCorrect: true },
{ text: "", isCorrect: false },
{ text: "", isCorrect: false },
{ text: "", isCorrect: false },
],
trueFalseCorrect: true,
shortAnswers: [""],
},
],
});
const [typeDefinitions, setTypeDefinitions] = useState<QuestionTypeDefinition[]>(
[],
);
const [definitionsLoading, setDefinitionsLoading] = useState(true);
const [definitionsError, setDefinitionsError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
(async () => {
setDefinitionsLoading(true);
setDefinitionsError(null);
try {
const list = await getQuestionTypeDefinitions({
include_system: true,
status: "ACTIVE",
});
if (!cancelled) setTypeDefinitions(list);
} catch (e) {
if (!cancelled) {
setDefinitionsError(learnEnglishPracticeApiErrorMessage(e));
setTypeDefinitions([]);
}
} finally {
if (!cancelled) setDefinitionsLoading(false);
}
})();
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
if (typeDefinitions.length === 0) return;
setFormData((fd) => ({
...fd,
questions: fd.questions.map((q) => {
if (q.questionTypeDefinitionId != null) return q;
const def = typeDefinitions[0];
return {
...q,
questionTypeDefinitionId: def.id,
dynamicFieldValues: emptyDynamicFieldValuesForDefinition(def),
};
}),
}));
}, [typeDefinitions]);
const submitPractice = async (status: "DRAFT" | "PUBLISHED") => {
if (!parentContext) {
toast.error("Missing practice parent", {
description:
"Open this screen from a course, module, or lesson so the API receives parent_kind and parent_id.",
});
return;
}
if (
!isLessonPractice &&
(!formData.title.trim() || !formData.description.trim())
) {
toast.error("Title and story description are required", {
description: "Complete the first step before publishing.",
});
return;
}
if (!selectedPersona) {
toast.error("Select a persona", {
description: "Choose a character on the Persona step before publishing.",
});
return;
}
const personaId = personaIdNumber(selectedPersona);
if (!personaId) {
toast.error("Invalid persona", {
description: "Re-select a persona from the list and try again.",
});
return;
}
const persona = personaFromId(selectedPersona, personas);
const mappedQuestions = formData.questions
.filter((q) => String(q.text ?? "").trim())
.map((q) => ({
questionText: String(q.text ?? "").trim(),
questionTypeDefinitionId: Number(q.questionTypeDefinitionId),
dynamicFieldValues: { ...(q.dynamicFieldValues ?? {}) },
mcqOptions: (q.mcqOptions ?? []).map(
(o: { text?: string; isCorrect?: boolean }) => ({
option_text: String(o.text ?? "").trim(),
is_correct: Boolean(o.isCorrect),
}),
),
trueFalseAnswerIsTrue: q.trueFalseCorrect !== false,
shortAnswers: (q.shortAnswers ?? []).map((s: string) => String(s)),
}));
const validationMsg = validateLearnEnglishQuestionsWithDefinitions(
mappedQuestions,
typeDefinitions,
);
if (validationMsg) {
toast.error("Check your questions", { description: validationMsg });
return;
}
const lessonDefaultTitle =
lessonTitleDisplay?.trim() ||
(lessonId ? `Lesson ${lessonId} practice` : "Lesson practice");
setSubmitting(true);
try {
await executeLearnEnglishPracticeCreation({
parentKind: parentContext.kind,
parentId: parentContext.id,
status,
questionSetTitle: isLessonPractice
? lessonDefaultTitle
: formData.title.trim() || "Practice set",
questionSetDescription: isLessonPractice
? null
: formData.description.trim() || null,
shuffleQuestions: formData.shuffleQuestions,
practiceTitle: isLessonPractice
? lessonDefaultTitle
: formData.title.trim() || "Untitled practice",
storyDescription: isLessonPractice
? ""
: formData.description.trim(),
storyImage: isLessonPractice ? "" : formData.storyImageUrl.trim(),
quickTips: formData.tips.trim(),
personaName: persona?.name ?? null,
personaId,
questions: mappedQuestions,
definitions: typeDefinitions,
});
toast.success(
status === "PUBLISHED" ? "Practice published" : "Draft saved",
{
description:
"Question set, questions, and parent-linked practice were created.",
},
);
setIsPublished(true);
} catch (e) {
toast.error("Could not save practice", {
description: learnEnglishPracticeApiErrorMessage(e),
});
} finally {
setSubmitting(false);
}
};
const nextStep = () =>
setCurrentStep((prev) => Math.min(prev + 1, flowSteps.length));
setCurrentStep((prev) => Math.min(prev + 1, STEP_LABELS.length));
const prevStep = () => setCurrentStep((prev) => Math.max(prev - 1, 1));
if (isPublished) {
@ -98,23 +307,47 @@ export function AddPracticeFlow() {
Practice Published Successfully!
</h1>
<p className="text-grayScale-600 text-md mb-14 max-w-lg font-medium leading-relaxed">
Your speaking practice is now active and available inside the module.
{lessonId
? "Your speaking practice is saved and linked to this lessons question set."
: "Your speaking practice is saved for the linked course or module."}
</p>
<div className="flex flex-col gap-4 w-full max-w-[400px]">
<Button
onClick={() => navigate(backPath)}
className="h-14 rounded-[6px] bg-[#9E2891] font-bold shadow-xl shadow-brand-500/20 text-[16px] text-white "
>
Go back to Module
{backLabel}
</Button>
<Button
onClick={() => {
setIsPublished(false);
setCurrentStep(1);
setSelectedPersona(null);
setFormData({
...formData,
title: "",
description: "",
storyImageUrl: "",
shuffleQuestions: false,
tips: "",
questions: [
{
id: "q1",
questionTypeDefinitionId:
typeDefinitions[0]?.id ?? (null as number | null),
text: "",
dynamicFieldValues: typeDefinitions[0]
? emptyDynamicFieldValuesForDefinition(typeDefinitions[0])
: {},
mcqOptions: [
{ text: "", isCorrect: true },
{ text: "", isCorrect: false },
{ text: "", isCorrect: false },
{ text: "", isCorrect: false },
],
trueFalseCorrect: true,
shortAnswers: [""],
},
],
});
}}
variant="outline"
@ -127,9 +360,8 @@ export function AddPracticeFlow() {
);
}
// Helper to map currentStep to the actual component for the module flow
const renderStep = () => {
if (!isModuleContext) {
if (isModuleContext) {
switch (currentStep) {
case 1:
return (
@ -137,70 +369,18 @@ export function AddPracticeFlow() {
formData={formData}
setFormData={setFormData}
nextStep={nextStep}
navigate={navigate}
level={level!}
isModuleContext={isModuleContext}
isCourseContext={isCourseContext}
/>
);
case 2:
return (
<ScenarioStep
formData={formData}
setFormData={setFormData}
nextStep={nextStep}
prevStep={prevStep}
/>
);
case 3:
return (
<PersonaStep
selectedPersona={selectedPersona}
setSelectedPersona={setSelectedPersona}
nextStep={nextStep}
prevStep={prevStep}
/>
);
case 4:
return (
<QuestionsStep
formData={formData}
setFormData={setFormData}
nextStep={nextStep}
prevStep={prevStep}
/>
);
case 5:
return (
<ReviewStep
formData={formData}
selectedPersona={selectedPersona}
prevStep={prevStep}
setIsPublished={setIsPublished}
isModuleContext={isModuleContext}
/>
);
default:
return null;
}
} else {
// Module Context Flow (Skips Scenario)
switch (currentStep) {
case 1:
return (
<ContextStep
formData={formData}
setFormData={setFormData}
nextStep={nextStep}
navigate={navigate}
level={level!}
isModuleContext={isModuleContext}
isCourseContext={isCourseContext}
onCancel={() => navigate(backPath)}
isLessonPractice={isLessonPractice}
lessonTitle={lessonTitleDisplay}
/>
);
case 2:
return (
<PersonaStep
personas={personas}
loading={personasLoading}
error={personasError}
onRetry={() => void reloadPersonas()}
selectedPersona={selectedPersona}
setSelectedPersona={setSelectedPersona}
nextStep={nextStep}
@ -214,6 +394,9 @@ export function AddPracticeFlow() {
setFormData={setFormData}
nextStep={nextStep}
prevStep={prevStep}
typeDefinitions={typeDefinitions}
definitionsLoading={definitionsLoading}
definitionsError={definitionsError}
/>
);
case 4:
@ -221,20 +404,92 @@ export function AddPracticeFlow() {
<ReviewStep
formData={formData}
selectedPersona={selectedPersona}
personas={personas}
isLessonPractice={isLessonPractice}
lessonTitle={lessonTitleDisplay}
programLabel={level ? `Program ${level}` : null}
courseLabel={courseId ? `Course ${courseId}` : null}
moduleLabel={moduleId ? `Module ${moduleId}` : null}
prevStep={prevStep}
setIsPublished={setIsPublished}
isModuleContext={isModuleContext}
onEditContext={() => setCurrentStep(1)}
onEditQuestions={() => setCurrentStep(3)}
parentSummary={parentSummary}
typeDefinitions={typeDefinitions}
canPublish={parentContext !== null}
submitting={submitting}
onSaveDraft={() => void submitPractice("DRAFT")}
onPublish={() => void submitPractice("PUBLISHED")}
/>
);
default:
return null;
}
}
switch (currentStep) {
case 1:
return (
<ScenarioStep
formData={formData}
setFormData={setFormData}
nextStep={nextStep}
cancelHref={backPath}
/>
);
case 2:
return (
<PersonaStep
personas={personas}
loading={personasLoading}
error={personasError}
onRetry={() => void reloadPersonas()}
selectedPersona={selectedPersona}
setSelectedPersona={setSelectedPersona}
nextStep={nextStep}
prevStep={prevStep}
/>
);
case 3:
return (
<QuestionsStep
formData={formData}
setFormData={setFormData}
nextStep={nextStep}
prevStep={prevStep}
typeDefinitions={typeDefinitions}
definitionsLoading={definitionsLoading}
definitionsError={definitionsError}
/>
);
case 4:
return (
<ReviewStep
formData={formData}
selectedPersona={selectedPersona}
personas={personas}
isLessonPractice={isLessonPractice}
lessonTitle={lessonTitleDisplay}
programLabel={level ? `Program ${level}` : null}
courseLabel={courseId ? `Course ${courseId}` : null}
moduleLabel={moduleId ? `Module ${moduleId}` : null}
prevStep={prevStep}
onEditContext={() => setCurrentStep(1)}
onEditQuestions={() => setCurrentStep(3)}
parentSummary={parentSummary}
typeDefinitions={typeDefinitions}
canPublish={parentContext !== null}
submitting={submitting}
onSaveDraft={() => void submitPractice("DRAFT")}
onPublish={() => void submitPractice("PUBLISHED")}
/>
);
default:
return null;
}
};
return (
<div className="space-y-8 pb-32 px-6 pt-6 min-h-screen ">
{/* Header */}
<div className="mx-auto max-w-7xl w-full">
<div className="flex items-center justify-between mb-8">
<Link
@ -260,33 +515,36 @@ export function AddPracticeFlow() {
</Button>
</div>
<p className="text-grayScale-400 text-base">
Create a new immersive practice session for students.
Create a practice: question types from{" "}
<code className="text-xs">GET /questions/type-definitions</code>, then
question set and POST /practices.
</p>
{lessonId ? (
<div className="mt-4 rounded-xl border border-violet-200 bg-violet-50/80 px-4 py-3 text-sm text-violet-950">
<p className="font-semibold text-violet-900">Practice for this lesson</p>
<p className="font-semibold text-violet-900">Lesson practice</p>
<p className="mt-1 text-violet-800/90">
This session will be associated with lesson{" "}
<span className="font-mono font-bold text-violet-950">#{lessonId}</span>
Linked to lesson{" "}
<span className="font-mono font-bold text-violet-950">
#{lessonId}
</span>
{lessonTitleDisplay ? (
<>
{" "}
<span className="font-medium">{lessonTitleDisplay}</span>
</>
) : null}
. The module-level flow still uses the same steps; use this context when naming and
configuring the practice.
.
</p>
</div>
) : null}
</div>
<div className="mx-auto w-[70%] mb-12">
<Stepper steps={flowSteps} currentStep={currentStep} />
<Stepper steps={[...STEP_LABELS]} currentStep={currentStep} />
</div>
<div
className={`mx-auto ${(!isModuleContext && currentStep === 3) || (isModuleContext && currentStep === 2) || currentStep === 5 ? "max-w-6xl" : "max-w-4xl"}`}
className={`mx-auto ${currentStep === 3 || currentStep === 4 ? "max-w-6xl" : "max-w-4xl"}`}
>
{renderStep()}
</div>

View File

@ -8,7 +8,10 @@ import { Input } from "../../components/ui/input"
import { Textarea } from "../../components/ui/textarea"
import { Select } from "../../components/ui/select"
import { createQuestion, getQuestionById, updateQuestion } from "../../api/courses.api"
import { getQuestionTypeDefinitions } from "../../api/questionTypeDefinitions.api"
import {
getQuestionTypeDefinitions,
questionTypeDefinitionListLabel,
} from "../../api/questionTypeDefinitions.api"
import type { QuestionTypeDefinition } from "../../types/questionTypeDefinition.types"
type QuestionType = "MCQ" | "TRUE_FALSE" | "SHORT_ANSWER" | "AUDIO" | "DYNAMIC"
@ -343,47 +346,46 @@ export function AddQuestionPage() {
}
return (
<div className="space-y-8">
{/* Page Header */}
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-4">
<div className="space-y-4 pb-6">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
<Button
variant="ghost"
size="icon"
onClick={() => navigate("/content/questions")}
className="rounded-lg bg-grayScale-50 hover:bg-brand-500/10 hover:text-brand-500 transition-colors"
className="h-9 w-9 shrink-0 rounded-lg bg-grayScale-50 hover:bg-brand-500/10 hover:text-brand-500"
>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-2xl font-bold tracking-tight text-grayScale-600">
<div className="min-w-0">
<h1 className="text-lg font-bold tracking-tight text-grayScale-800 sm:text-xl">
{isEditing ? "Edit Question" : "Add New Question"}
</h1>
<p className="mt-1 text-sm text-grayScale-400">
{isEditing ? "Update the question details below" : "Fill in the details to create a new question"}
<p className="mt-0.5 text-xs text-grayScale-500 sm:text-sm">
{isEditing ? "Update fields below" : "Create a bank question"}
</p>
</div>
</div>
<div className="max-w-3xl mx-auto">
<div className="mx-auto max-w-2xl">
{loading && (
<Card className="mb-4 border border-grayScale-200">
<CardContent className="py-4 text-sm text-grayScale-500">Loading question details...</CardContent>
<Card className="mb-2 border border-grayScale-200">
<CardContent className="py-2.5 text-xs text-grayScale-500">Loading</CardContent>
</Card>
)}
<form onSubmit={handleSubmit}>
<Card className="shadow-sm border border-grayScale-100 rounded-xl">
<CardHeader className="pb-2">
<CardTitle className="text-lg font-semibold text-grayScale-600">Question Details</CardTitle>
<Card className="rounded-lg border border-grayScale-100 shadow-sm">
<CardHeader className="space-y-0 px-4 py-3 sm:px-5">
<CardTitle className="text-base font-semibold text-grayScale-700">Question details</CardTitle>
</CardHeader>
<CardContent className="space-y-7">
{/* Question Type */}
<CardContent className="space-y-3 px-4 pb-4 pt-0 sm:px-5 sm:pb-5">
<div>
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
<label className="mb-1 block text-xs font-medium text-grayScale-600">
Question Type
</label>
<Select
value={formData.type}
onChange={(e) => handleTypeChange(e.target.value as QuestionType)}
className="h-9 text-sm"
>
<option value="MCQ">Multiple Choice</option>
<option value="TRUE_FALSE">True/False</option>
@ -396,7 +398,7 @@ export function AddQuestionPage() {
{formData.type === "DYNAMIC" && (
<>
<div>
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
<label className="mb-1 block text-xs font-medium text-grayScale-600">
Question type definition <span className="text-red-500">*</span>
</label>
<Select
@ -405,49 +407,44 @@ export function AddQuestionPage() {
setFormData((prev) => ({ ...prev, questionTypeDefinitionId: e.target.value }))
}
required
className="h-9 text-sm"
>
<option value="">Select definition</option>
{typeDefinitions.map((d) => (
<option key={d.id} value={String(d.id)}>
{d.display_name} ({d.key})
{questionTypeDefinitionListLabel(d)}
</option>
))}
</Select>
<p className="mt-1 text-xs text-grayScale-400">
Loaded from GET /questions/type-definitions?include_system=true&amp;status=ACTIVE
</p>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
<label className="mb-1 block text-xs font-medium text-grayScale-600">
dynamic_payload (JSON) <span className="text-red-500">*</span>
</label>
<Textarea
value={formData.dynamicPayloadJson}
onChange={(e) => setFormData((prev) => ({ ...prev, dynamicPayloadJson: e.target.value }))}
rows={12}
className="font-mono text-xs"
rows={7}
className="min-h-0 font-mono text-[11px] leading-snug"
spellCheck={false}
/>
<p className="mt-1 text-xs text-grayScale-400">
Must match the selected definition&apos;s stimulus/response schema (see integration guide).
</p>
</div>
</>
)}
<hr className="border-grayScale-100" />
{/* Question Text */}
<div>
<label htmlFor="question" className="mb-1.5 block text-sm font-medium text-grayScale-500">
{formData.type === "DYNAMIC" ? "Question title / stem" : "Question"}
<label htmlFor="question" className="mb-1 block text-xs font-medium text-grayScale-600">
{formData.type === "DYNAMIC" ? "Title / stem" : "Question"}
</label>
<Textarea
id="question"
placeholder="Enter your question here..."
value={formData.question}
onChange={(e) => setFormData((prev) => ({ ...prev, question: e.target.value }))}
rows={3}
rows={2}
className="min-h-[72px] text-sm"
required
/>
</div>
@ -455,13 +452,11 @@ export function AddQuestionPage() {
{/* Options for Multiple Choice */}
{(formData.type === "MCQ" || formData.type === "TRUE_FALSE") && (
<div>
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
Options
</label>
<div className="space-y-3">
<label className="mb-1 block text-xs font-medium text-grayScale-600">Options</label>
<div className="space-y-1.5">
{formData.options.map((option, index) => (
<div key={index} className="flex items-center gap-2 group">
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-grayScale-50 text-grayScale-400 text-xs font-medium flex items-center justify-center">
<div key={index} className="group flex items-center gap-1.5">
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-grayScale-100 text-[10px] font-medium text-grayScale-500">
{index + 1}
</span>
<Input
@ -469,6 +464,7 @@ export function AddQuestionPage() {
onChange={(e) => handleOptionChange(index, e.target.value)}
placeholder={`Option ${index + 1}`}
disabled={formData.type === "TRUE_FALSE"}
className="h-9 text-sm"
required
/>
{formData.type === "MCQ" && formData.options.length > 2 && (
@ -477,17 +473,22 @@ export function AddQuestionPage() {
variant="ghost"
size="icon"
onClick={() => removeOption(index)}
className="opacity-0 group-hover:opacity-100 hover:bg-red-50 hover:text-red-500 transition-all"
className="h-8 w-8 shrink-0 opacity-0 transition-all group-hover:opacity-100 hover:bg-red-50 hover:text-red-500"
>
<X className="h-4 w-4" />
<X className="h-3.5 w-3.5" />
</Button>
)}
</div>
))}
{formData.type === "MCQ" && (
<Button type="button" variant="outline" onClick={addOption} className="w-full mt-1 border-dashed border-grayScale-200 text-grayScale-400 hover:text-brand-500 hover:border-brand-500/30">
<Plus className="h-4 w-4" />
Add Option
<Button
type="button"
variant="outline"
onClick={addOption}
className="mt-0.5 h-9 w-full border-dashed border-grayScale-200 text-xs text-grayScale-500 hover:border-brand-500/30 hover:text-brand-500"
>
<Plus className="mr-1 h-3.5 w-3.5" />
Add option
</Button>
)}
</div>
@ -499,8 +500,8 @@ export function AddQuestionPage() {
{/* Correct Answer */}
{formData.type !== "DYNAMIC" && (
<div>
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
{formData.type === "AUDIO" ? "Audio Correct Answer Text" : "Correct Answer"}
<label className="mb-1 block text-xs font-medium text-grayScale-600">
{formData.type === "AUDIO" ? "Audio correct answer" : "Correct answer"}
</label>
{formData.type === "MCQ" || formData.type === "TRUE_FALSE" ? (
<Select
@ -508,6 +509,7 @@ export function AddQuestionPage() {
onChange={(e) =>
setFormData((prev) => ({ ...prev, correctAnswer: e.target.value }))
}
className="h-9 text-sm"
required
>
<option value="">Select correct answer</option>
@ -519,7 +521,7 @@ export function AddQuestionPage() {
</Select>
) : (
<Textarea
placeholder={formData.type === "AUDIO" ? "Enter audio correct answer text..." : "Enter the correct answer..."}
placeholder={formData.type === "AUDIO" ? "Expected spoken answer…" : "Correct answer…"}
value={formData.type === "AUDIO" ? formData.audioCorrectAnswerText : formData.correctAnswer}
onChange={(e) =>
setFormData((prev) =>
@ -529,6 +531,7 @@ export function AddQuestionPage() {
)
}
rows={2}
className="min-h-[60px] text-sm"
required
/>
)}
@ -538,16 +541,16 @@ export function AddQuestionPage() {
<hr className="border-grayScale-100" />
{/* Points and Difficulty side by side */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
{/* Points */}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-4">
<div>
<label htmlFor="points" className="mb-1.5 block text-sm font-medium text-grayScale-500">
<label htmlFor="points" className="mb-1 block text-xs font-medium text-grayScale-600">
Points
</label>
<Input
id="points"
type="number"
min="1"
className="h-9 text-sm"
value={formData.points}
onChange={(e) =>
setFormData((prev) => ({ ...prev, points: parseInt(e.target.value) || 1 }))
@ -556,14 +559,12 @@ export function AddQuestionPage() {
/>
</div>
{/* Difficulty */}
<div>
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
Difficulty (Optional)
</label>
<label className="mb-1 block text-xs font-medium text-grayScale-600">Difficulty</label>
<Select
value={formData.difficulty}
onChange={(e) => setFormData((prev) => ({ ...prev, difficulty: e.target.value as Difficulty }))}
className="h-9 text-sm"
>
<option value="EASY">Easy</option>
<option value="MEDIUM">Medium</option>
@ -574,12 +575,11 @@ export function AddQuestionPage() {
{/* Status */}
<div>
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
Status
</label>
<label className="mb-1 block text-xs font-medium text-grayScale-600">Status</label>
<Select
value={formData.status}
onChange={(e) => setFormData((prev) => ({ ...prev, status: e.target.value as QuestionStatus }))}
className="h-9 text-sm"
>
<option value="DRAFT">Draft</option>
<option value="PUBLISHED">Published</option>
@ -588,58 +588,71 @@ export function AddQuestionPage() {
</div>
{(formData.type === "AUDIO" || formData.type === "SHORT_ANSWER") && (
<>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
<div>
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
Voice Prompt{formData.type === "AUDIO" ? "" : " (Optional)"}
<label className="mb-1 block text-xs font-medium text-grayScale-600">
Voice prompt{formData.type === "AUDIO" ? "" : " (opt.)"}
</label>
<Textarea
value={formData.voicePrompt}
onChange={(e) => setFormData((prev) => ({ ...prev, voicePrompt: e.target.value }))}
rows={2}
placeholder="Please say your answer..."
placeholder="URL or key…"
className="min-h-[60px] text-sm"
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
Sample Answer Voice Prompt{formData.type === "AUDIO" ? "" : " (Optional)"}
<label className="mb-1 block text-xs font-medium text-grayScale-600">
Sample answer (voice){formData.type === "AUDIO" ? "" : " (opt.)"}
</label>
<Textarea
value={formData.sampleAnswerVoicePrompt}
onChange={(e) => setFormData((prev) => ({ ...prev, sampleAnswerVoicePrompt: e.target.value }))}
rows={2}
placeholder="Sample spoken answer..."
placeholder="URL or key…"
className="min-h-[60px] text-sm"
/>
</div>
</>
</div>
)}
<div>
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">Tips (Optional)</label>
<Input
value={formData.tips}
onChange={(e) => setFormData((prev) => ({ ...prev, tips: e.target.value }))}
placeholder="Helpful tip for learners"
/>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
<div>
<label className="mb-1 block text-xs font-medium text-grayScale-600">Tips (opt.)</label>
<Input
value={formData.tips}
onChange={(e) => setFormData((prev) => ({ ...prev, tips: e.target.value }))}
placeholder="Short tip"
className="h-9 text-sm"
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-grayScale-600">Explanation (opt.)</label>
<Textarea
value={formData.explanation}
onChange={(e) => setFormData((prev) => ({ ...prev, explanation: e.target.value }))}
rows={2}
placeholder="Why this answer"
className="min-h-[60px] text-sm"
/>
</div>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">Explanation (Optional)</label>
<Textarea
value={formData.explanation}
onChange={(e) => setFormData((prev) => ({ ...prev, explanation: e.target.value }))}
rows={2}
placeholder="Explain why the answer is correct"
/>
</div>
{/* Actions */}
<div className="flex flex-col-reverse sm:flex-row justify-end gap-3 pt-6 border-t border-grayScale-100">
<Button type="button" variant="outline" onClick={() => navigate("/content/questions")} className="w-full sm:w-auto hover:bg-grayScale-50">
<div className="flex flex-col-reverse gap-2 border-t border-grayScale-100 pt-3 sm:flex-row sm:justify-end">
<Button
type="button"
variant="outline"
onClick={() => navigate("/content/questions")}
className="h-9 w-full text-sm sm:w-auto"
>
Cancel
</Button>
<Button type="submit" disabled={submitting || loading} className="bg-brand-500 hover:bg-brand-600 text-white w-full sm:w-auto shadow-sm hover:shadow-md transition-all">
{isEditing ? "Update Question" : "Create Question"}
<Button
type="submit"
disabled={submitting || loading}
className="h-9 w-full bg-brand-500 text-sm text-white hover:bg-brand-600 sm:w-auto"
>
{isEditing ? "Update" : "Create"}
</Button>
</div>
</CardContent>

View File

@ -5,6 +5,7 @@ import { ArrowLeft } from "lucide-react";
import { Button } from "../../components/ui/button";
import { Stepper } from "../../components/ui/stepper";
import { createModuleLesson } from "../../api/courses.api";
import type { PracticePublishStatus } from "../../types/course.types";
import { VideoDetailStep } from "./components/video-steps/VideoDetailStep";
import { ReviewPublishStep } from "./components/video-steps/ReviewPublishStep";
@ -17,7 +18,7 @@ const STEPS = [
export type AddLessonFormData = {
title: string;
order: string;
sortOrder: string;
description: string;
videoUrl: string;
thumbnailUrl: string;
@ -25,7 +26,7 @@ export type AddLessonFormData = {
const emptyForm = (): AddLessonFormData => ({
title: "",
order: "1",
sortOrder: "0",
description: "",
videoUrl: "",
thumbnailUrl: "",
@ -51,6 +52,8 @@ export function AddVideoFlow() {
}>();
const [currentStep, setCurrentStep] = useState(1);
const [isPublished, setIsPublished] = useState(false);
const [lastCreatedPublishStatus, setLastCreatedPublishStatus] =
useState<PracticePublishStatus>("PUBLISHED");
const [formData, setFormData] = useState<AddLessonFormData>(emptyForm);
const [publishing, setPublishing] = useState(false);
const [formResetKey, setFormResetKey] = useState(0);
@ -60,7 +63,7 @@ export function AddVideoFlow() {
const backPath = `/new-content/learn-english/${level}/courses/${courseId}/modules/${moduleId}`;
const handlePublish = async () => {
const handleCreateLesson = async (publishStatus: PracticePublishStatus) => {
const mid = Number(moduleId);
if (!Number.isFinite(mid) || mid < 1) {
toast.error("Invalid module");
@ -86,6 +89,16 @@ export function AddVideoFlow() {
toast.error("Description is required");
return;
}
const sortOrderRaw = formData.sortOrder.trim();
if (sortOrderRaw === "") {
toast.error("Sort order is required");
return;
}
const sort_order = Number(sortOrderRaw);
if (!Number.isInteger(sort_order) || sort_order < 0) {
toast.error("Sort order must be a whole number of 0 or greater");
return;
}
setPublishing(true);
try {
await createModuleLesson(mid, {
@ -93,8 +106,15 @@ export function AddVideoFlow() {
video_url: videoUrl,
thumbnail,
description,
sort_order,
publish_status: publishStatus,
});
toast.success("Lesson created");
setLastCreatedPublishStatus(publishStatus);
toast.success(
publishStatus === "DRAFT"
? "Lesson saved as draft"
: "Lesson published",
);
setIsPublished(true);
} catch (e: unknown) {
console.error(e);
@ -123,10 +143,14 @@ export function AddVideoFlow() {
</div>
<h1 className="text-[26px] font-bold text-grayScale-900 mb-4">
Lesson created successfully
{lastCreatedPublishStatus === "DRAFT"
? "Lesson saved as draft"
: "Lesson published successfully"}
</h1>
<p className="text-grayScale-600 text-base mb-14 max-w-lg font-medium leading-relaxed">
Your lesson is now available in this module.
{lastCreatedPublishStatus === "DRAFT"
? "You can finish editing and publish it later from the module."
: "Your lesson is now available in this module."}
</p>
<div className="flex flex-col gap-4 w-full max-w-[400px]">
@ -140,6 +164,7 @@ export function AddVideoFlow() {
onClick={() => {
setFormData(emptyForm());
setFormResetKey((k) => k + 1);
setLastCreatedPublishStatus("PUBLISHED");
setIsPublished(false);
setCurrentStep(1);
}}
@ -205,7 +230,7 @@ export function AddVideoFlow() {
<ReviewPublishStep
formData={formData}
prevStep={prevStep}
onPublish={() => void handlePublish()}
onCreateLesson={(status) => void handleCreateLesson(status)}
publishing={publishing}
/>
)}

View File

@ -21,7 +21,6 @@ import {
DialogTitle,
} from "../../components/ui/dialog";
import { Input } from "../../components/ui/input";
import { Textarea } from "../../components/ui/textarea";
import { cn } from "../../lib/utils";
import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg";
import alertSrc from "../../assets/Alert.svg";
@ -38,6 +37,7 @@ import type {
} from "../../types/course.types";
import { AddModuleModal } from "./components/AddModuleModal";
import { ModuleIconUploadField } from "./components/ModuleIconUploadField";
import { PublishPracticeButton } from "./components/PublishPracticeButton";
const MODULE_CARD_GRADIENT = "from-[#8E44AD] to-[#C39BD3]" as const;
@ -145,7 +145,7 @@ export function CourseDetailPage() {
const [editingModule, setEditingModule] =
useState<TopLevelCourseModuleItem | null>(null);
const [editModuleName, setEditModuleName] = useState("");
const [editModuleDescription, setEditModuleDescription] = useState("");
const [editModuleSortOrder, setEditModuleSortOrder] = useState("");
const [editModuleIcon, setEditModuleIcon] = useState("");
const [editModuleIconUploadBusy, setEditModuleIconUploadBusy] =
useState(false);
@ -158,7 +158,7 @@ export function CourseDetailPage() {
const openEditModule = (module: TopLevelCourseModuleItem) => {
setEditingModule(module);
setEditModuleName(module.name ?? "");
setEditModuleDescription(module.description ?? "");
setEditModuleSortOrder(String(module.sort_order ?? 0));
setEditModuleIcon(module.icon?.trim() ?? "");
setEditModuleIconUploadBusy(false);
};
@ -267,12 +267,23 @@ export function CourseDetailPage() {
toast.error("Module name is required");
return;
}
const sortOrderRaw = editModuleSortOrder.trim();
if (!sortOrderRaw) {
toast.error("Sort order is required");
return;
}
const sort_order = Number(sortOrderRaw);
if (!Number.isInteger(sort_order) || sort_order < 0) {
toast.error("Sort order must be a whole number of 0 or greater");
return;
}
setSavingModuleEdit(true);
try {
await updateTopLevelCourseModule(editingModule.id, {
name,
description: editModuleDescription.trim(),
description: editingModule.description?.trim() ?? "",
icon: editModuleIcon.trim(),
sort_order,
});
toast.success("Module updated");
setEditModuleIconUploadBusy(false);
@ -412,18 +423,19 @@ export function CourseDetailPage() {
if (!open) closeEditModule();
}}
>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogContent className="flex max-h-[min(90vh,calc(100dvh-2rem))] max-w-lg flex-col gap-0 overflow-hidden p-0">
<DialogHeader className="shrink-0 space-y-1.5 border-b border-grayScale-100 px-6 pb-4 pt-6 pr-12">
<DialogTitle>Edit module</DialogTitle>
<DialogDescription>
Update name, description, and icon (upload or URL). Saved with{" "}
Update name, sort order, and icon (upload or URL). Saved with{" "}
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px]">
PUT /modules/:id
</code>
.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-2">
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-6 py-4">
<div className="grid gap-4">
<div className="space-y-2">
<label className="text-sm font-medium text-grayScale-700">
Name
@ -437,17 +449,27 @@ export function CourseDetailPage() {
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-grayScale-700">
Description
<label
htmlFor="edit-module-sort-order"
className="text-sm font-medium text-grayScale-700"
>
Sort Order
</label>
<Textarea
value={editModuleDescription}
onChange={(e) => setEditModuleDescription(e.target.value)}
rows={4}
className="min-h-[100px] resize-y rounded-xl"
placeholder="Optional short description."
disabled={savingModuleEdit}
<Input
id="edit-module-sort-order"
type="number"
min={0}
step={1}
inputMode="numeric"
value={editModuleSortOrder}
onChange={(e) => setEditModuleSortOrder(e.target.value)}
className="rounded-xl"
placeholder="e.g. 5"
disabled={savingModuleEdit || editModuleIconUploadBusy}
/>
<p className="text-xs text-grayScale-500">
Lower numbers appear first when modules are listed.
</p>
</div>
<ModuleIconUploadField
value={editModuleIcon}
@ -456,7 +478,8 @@ export function CourseDetailPage() {
onUploadBusyChange={setEditModuleIconUploadBusy}
/>
</div>
<DialogFooter className="gap-2 sm:gap-0">
</div>
<DialogFooter className="shrink-0 gap-2 border-t border-grayScale-100 bg-white px-6 py-4 sm:gap-0">
<Button
type="button"
variant="outline"
@ -560,9 +583,11 @@ export function CourseDetailPage() {
>
View Detail
</Button>
<Button className="h-10 flex-1 rounded-[6px] bg-brand-500 text-sm text-white shadow-md shadow-brand-500/10">
Publish Practice
</Button>
<PublishPracticeButton
parentKind="MODULE"
parentId={module.id}
className="h-10 flex-1 rounded-[6px] bg-brand-500 text-sm text-white shadow-md shadow-brand-500/10 hover:bg-brand-600 disabled:opacity-60"
/>
</div>
</div>
</Card>

View File

@ -15,7 +15,6 @@ import {
} from "lucide-react";
import { Button } from "../../components/ui/button";
import { Card } from "../../components/ui/card";
import { Textarea } from "../../components/ui/textarea";
import {
Dialog,
DialogContent,
@ -45,7 +44,7 @@ export function CourseManagementPage() {
const catalogCourseId = Number(courseId);
const [addUnitOpen, setAddUnitOpen] = useState(false);
const [createName, setCreateName] = useState("");
const [createDescription, setCreateDescription] = useState("");
const [createSortOrder, setCreateSortOrder] = useState("");
const [createThumbnail, setCreateThumbnail] = useState("");
const [creating, setCreating] = useState(false);
const [uploadingThumbnail, setUploadingThumbnail] = useState(false);
@ -66,7 +65,6 @@ export function CourseManagementPage() {
const [unitsLoading, setUnitsLoading] = useState(false);
const [editingUnitId, setEditingUnitId] = useState<number | null>(null);
const [editName, setEditName] = useState("");
const [editDescription, setEditDescription] = useState("");
const [editThumbnail, setEditThumbnail] = useState("");
const [editSortOrder, setEditSortOrder] = useState("1");
const [savingEdit, setSavingEdit] = useState(false);
@ -152,7 +150,7 @@ export function CourseManagementPage() {
const clearCreateUnitForm = () => {
setCreateName("");
setCreateDescription("");
setCreateSortOrder("");
setCreateThumbnail("");
if (createThumbnailFileInputRef.current) {
createThumbnailFileInputRef.current.value = "";
@ -202,13 +200,24 @@ export function CourseManagementPage() {
toast.error("Unit name is required");
return;
}
const sortOrderRaw = createSortOrder.trim();
if (!sortOrderRaw) {
toast.error("Sort order is required");
return;
}
const sort_order = Number(sortOrderRaw);
if (!Number.isInteger(sort_order) || sort_order < 0) {
toast.error("Sort order must be a whole number of 0 or greater");
return;
}
setCreating(true);
try {
const minioThumbnail = await resolveThumbnailToMinioUrl(createThumbnail);
const response = await createExamPrepCatalogUnit(catalogCourseId, {
name,
description: createDescription.trim() || null,
description: null,
thumbnail: minioThumbnail || null,
sort_order,
});
void response;
await loadUnits();
@ -271,18 +280,16 @@ export function CourseManagementPage() {
const openEditUnit = (unit: (typeof units)[number]) => {
setEditingUnitId(unit.id);
setEditName(unit.name ?? "");
setEditDescription(unit.description ?? "");
setEditThumbnail(unit.thumbnail ?? "");
setEditSortOrder(String(unit.sortOrder ?? 1));
setEditSortOrder(String(unit.sortOrder ?? 0));
};
const closeEditUnit = () => {
if (savingEdit || uploadingEditThumbnail) return;
setEditingUnitId(null);
setEditName("");
setEditDescription("");
setEditThumbnail("");
setEditSortOrder("1");
setEditSortOrder("");
};
const handleEditUnitThumbnailFile = async (
@ -320,20 +327,30 @@ export function CourseManagementPage() {
toast.error("Unit name is required");
return;
}
const sortOrderNum = Number(editSortOrder);
if (!Number.isFinite(sortOrderNum) || sortOrderNum < 0) {
toast.error("Sort order must be a valid number");
const sortOrderRaw = editSortOrder.trim();
if (!sortOrderRaw) {
toast.error("Sort order is required");
return;
}
const sort_order = Number(sortOrderRaw);
if (!Number.isInteger(sort_order) || sort_order < 0) {
toast.error("Sort order must be a whole number of 0 or greater");
return;
}
setSavingEdit(true);
try {
const existing = units.find((u) => u.id === editingUnitId);
const preservedDescription =
existing?.description && existing.description !== "—"
? existing.description
: null;
const minioThumbnail = await resolveThumbnailToMinioUrl(editThumbnail);
await updateExamPrepCatalogUnit(editingUnitId, {
name,
description: editDescription.trim() || null,
description: preservedDescription,
thumbnail: minioThumbnail || null,
sort_order: sortOrderNum,
sort_order,
});
await loadUnits();
toast.success("Unit updated");
@ -425,18 +442,29 @@ export function CourseManagementPage() {
disabled={creating || uploadingThumbnail}
/>
</div>
<div className="space-y-3">
<label className="text-[15px] text-grayScale-800">
Description
<label
htmlFor="create-unit-sort-order"
className="text-[15px] text-grayScale-800"
>
Sort Order
</label>
<Textarea
value={createDescription}
onChange={(e) => setCreateDescription(e.target.value)}
placeholder="Short unit description"
rows={4}
className="min-h-[96px] rounded-[8px] border-grayScale-400"
<Input
id="create-unit-sort-order"
type="number"
min={0}
step={1}
inputMode="numeric"
value={createSortOrder}
onChange={(e) => setCreateSortOrder(e.target.value)}
placeholder="e.g. 0"
className="h-12 border-grayScale-400 rounded-[8px] px-4 placeholder:text-grayScale-400 text-[15px] focus:ring-brand-500/20"
disabled={creating || uploadingThumbnail}
/>
<p className="text-xs text-grayScale-500">
Lower numbers appear first when units are listed.
</p>
</div>
<div className="space-y-3">
@ -690,25 +718,27 @@ export function CourseManagementPage() {
/>
</div>
<div className="space-y-3">
<label className="text-[15px] text-grayScale-800">Description</label>
<Textarea
value={editDescription}
onChange={(e) => setEditDescription(e.target.value)}
rows={4}
className="min-h-[96px] rounded-[8px] border-grayScale-400"
disabled={savingEdit || uploadingEditThumbnail}
/>
</div>
<div className="space-y-3">
<label className="text-[15px] text-grayScale-800">Sort Order</label>
<label
htmlFor="edit-unit-sort-order"
className="text-[15px] text-grayScale-800"
>
Sort Order
</label>
<Input
id="edit-unit-sort-order"
type="number"
min={0}
step={1}
inputMode="numeric"
value={editSortOrder}
onChange={(e) => setEditSortOrder(e.target.value)}
className="h-12 border-grayScale-400 rounded-[8px] px-4"
placeholder="e.g. 0"
className="h-12 border-grayScale-400 rounded-[8px] px-4 placeholder:text-grayScale-400 text-[15px] focus:ring-brand-500/20"
disabled={savingEdit || uploadingEditThumbnail}
/>
<p className="text-xs text-grayScale-500">
Lower numbers appear first when units are listed.
</p>
</div>
<div className="space-y-3">
<label className="text-[15px] text-grayScale-800">Thumbnail</label>

View File

@ -25,6 +25,7 @@ import {
getExamPrepModuleLessons,
} from "../../api/courses.api";
import { uploadImageFile, uploadVideoFile } from "../../api/files.api";
import type { PracticePublishStatus } from "../../types/course.types";
const MOCK_PRACTICES = [
{
@ -64,6 +65,7 @@ export function CourseModuleDetailPage() {
thumbnail: string;
sortOrder: number;
gradient: string;
durationSeconds: number | null;
}>
>([]);
const [createLessonOpen, setCreateLessonOpen] = useState(false);
@ -129,20 +131,28 @@ export function CourseModuleDetailPage() {
const rows = response.data?.data?.lessons;
const list = Array.isArray(rows) ? rows : [];
setLessons(
list.map((row, index) => ({
list.map((row, index) => {
const raw = row.duration_seconds ?? row.duration ?? null;
const n =
raw == null ? NaN : typeof raw === "number" ? raw : Number(raw);
const durationSeconds =
Number.isFinite(n) && n > 0 ? n : null;
return {
id: Number(row.id),
title: row.title?.trim() || `Lesson ${row.id}`,
videoUrl: row.video_url?.trim() || "",
description: row.description?.trim() || "—",
thumbnail: row.thumbnail?.trim() || "",
sortOrder: Number(row.sort_order ?? 0),
durationSeconds,
gradient:
index % 3 === 1
? "linear-gradient(135deg, rgba(79, 70, 229, 0.35) 0%, rgba(79, 70, 229, 0.6) 100%)"
: index % 3 === 2
? "linear-gradient(135deg, rgba(124, 58, 237, 0.35) 0%, rgba(124, 58, 237, 0.6) 100%)"
: "linear-gradient(135deg, rgba(158, 40, 145, 0.35) 0%, rgba(158, 40, 145, 0.6) 100%)",
})),
};
}),
);
} catch (error) {
console.error(error);
@ -252,7 +262,7 @@ export function CourseModuleDetailPage() {
}
};
const handleCreateLesson = async () => {
const handleCreateLesson = async (publishStatus: PracticePublishStatus) => {
if (!Number.isFinite(parsedModuleId) || parsedModuleId < 1) {
toast.error("Invalid module");
return;
@ -276,9 +286,14 @@ export function CourseModuleDetailPage() {
video_url: videoUrl,
thumbnail: minioThumbnail || null,
description: createDescription.trim() || null,
publish_status: publishStatus,
});
await loadLessons();
toast.success("Lesson created");
toast.success(
publishStatus === "DRAFT"
? "Lesson saved as draft"
: "Lesson created",
);
clearCreateLessonForm();
setCreateLessonOpen(false);
} catch (error: unknown) {
@ -641,7 +656,7 @@ export function CourseModuleDetailPage() {
/>
</div>
</div>
<div className="shrink-0 px-8 py-6 bg-grayScale-50/30 border-t border-grayScale-50 flex justify-end gap-3">
<div className="shrink-0 px-8 py-6 bg-grayScale-50/30 border-t border-grayScale-50 flex flex-wrap justify-end gap-3">
<DialogClose asChild>
<Button
type="button"
@ -653,13 +668,22 @@ export function CourseModuleDetailPage() {
Cancel
</Button>
</DialogClose>
<Button
type="button"
variant="outline"
className="h-11 px-8 rounded-[8px] border-grayScale-200 text-grayScale-700 font-bold hover:bg-grayScale-50"
disabled={creatingLesson || uploadingThumbnail || uploadingVideo}
onClick={() => void handleCreateLesson("DRAFT")}
>
{creatingLesson ? "Saving…" : "Save as draft"}
</Button>
<Button
type="button"
className="h-11 px-8 rounded-[8px] bg-brand-500 text-white font-bold hover:bg-brand-600"
disabled={creatingLesson || uploadingThumbnail || uploadingVideo}
onClick={() => void handleCreateLesson()}
onClick={() => void handleCreateLesson("PUBLISHED")}
>
{creatingLesson ? "Creating..." : "Create Lesson"}
{creatingLesson ? "Creating..." : "Publish lesson"}
</Button>
</div>
</div>
@ -716,6 +740,7 @@ export function CourseModuleDetailPage() {
thumbnailUrl={lesson.thumbnail}
videoUrl={lesson.videoUrl}
thumbnailGradient={lesson.gradient}
durationSeconds={lesson.durationSeconds}
hoverModuleActions
onEdit={() => openEditLesson(lesson)}
onDelete={() => setDeletingLessonId(lesson.id)}

View File

@ -9,7 +9,6 @@ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, D
import { Input } from "../../components/ui/input"
import { Select } from "../../components/ui/select"
import { SpinnerIcon } from "../../components/ui/spinner-icon"
import { Textarea } from "../../components/ui/textarea"
import {
createModule,
deleteModule,
@ -241,7 +240,6 @@ export function HumanLanguageHierarchyPage() {
const [createModuleTitle, setCreateModuleTitle] = useState("")
const [createModuleUseDefaultNaming, setCreateModuleUseDefaultNaming] = useState(false)
const [createModuleDefaultTitle, setCreateModuleDefaultTitle] = useState("")
const [createModuleDescription, setCreateModuleDescription] = useState("")
const [createModuleIconSource, setCreateModuleIconSource] = useState<"url" | "file">("url")
const [createModuleIconUrl, setCreateModuleIconUrl] = useState("")
const [createModuleIconFile, setCreateModuleIconFile] = useState<File | null>(null)
@ -253,7 +251,6 @@ export function HumanLanguageHierarchyPage() {
const [editModuleSaving, setEditModuleSaving] = useState(false)
const [editModuleTarget, setEditModuleTarget] = useState<EditModuleTarget | null>(null)
const [editModuleTitle, setEditModuleTitle] = useState("")
const [editModuleDescription, setEditModuleDescription] = useState("")
const [editModuleDisplayOrder, setEditModuleDisplayOrder] = useState(0)
const [editModuleIconSource, setEditModuleIconSource] = useState<"url" | "file">("url")
const [editModuleIconUrl, setEditModuleIconUrl] = useState("")
@ -467,7 +464,6 @@ export function HumanLanguageHierarchyPage() {
setCreateModuleUseDefaultNaming(false)
setCreateModuleDefaultTitle(getNextDefaultModuleName(level))
setCreateModuleTitle("")
setCreateModuleDescription("")
setCreateModuleIconSource("url")
setCreateModuleIconUrl("")
setCreateModuleIconFile(null)
@ -503,7 +499,6 @@ export function HumanLanguageHierarchyPage() {
await createModule({
level_id: createModuleLevelId,
title,
description: createModuleDescription.trim() || undefined,
icon_url: uploadedIconUrl,
display_order: createModuleDisplayOrder,
is_active: true,
@ -553,7 +548,6 @@ export function HumanLanguageHierarchyPage() {
levelKey,
})
setEditModuleTitle(module.title)
setEditModuleDescription("")
setEditModuleDisplayOrder(moduleDisplayOrder)
setEditModuleIconSource("url")
setEditModuleIconUrl(existingIconUrl)
@ -594,7 +588,6 @@ export function HumanLanguageHierarchyPage() {
await updateModule(editModuleTarget.moduleId, {
title,
description: editModuleDescription.trim() || undefined,
icon_url: uploadedIconUrl,
display_order: editModuleDisplayOrder,
is_active: true,
@ -1068,17 +1061,6 @@ export function HumanLanguageHierarchyPage() {
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-grayScale-600">Description (optional)</label>
<Textarea
rows={3}
value={createModuleDescription}
onChange={(event) => setCreateModuleDescription(event.target.value)}
placeholder="Optional description"
disabled={createModuleSaving}
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-grayScale-600">Icon URL (optional)</label>
<div className="mb-2 grid grid-cols-2 gap-2">
@ -1173,17 +1155,6 @@ export function HumanLanguageHierarchyPage() {
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-grayScale-600">Description</label>
<Textarea
rows={3}
value={editModuleDescription}
onChange={(event) => setEditModuleDescription(event.target.value)}
placeholder="New description"
disabled={editModuleSaving}
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-grayScale-600">Display order</label>
<Input

View File

@ -24,9 +24,26 @@ import {
updateLearningProgram,
deleteLearningProgram,
} from "../../api/courses.api";
import { uploadImageFile } from "../../api/files.api";
import { refreshFileUrl, uploadImageFile } from "../../api/files.api";
import type { LearningProgramListItem } from "../../types/course.types";
/** Presigned MinIO/S3 URLs and our storage hosts — safe to send to POST /files/refresh-url. */
function looksLikeRefreshableFileUrl(url: string): boolean {
const trimmed = url.trim();
if (!trimmed.startsWith("http://") && !trimmed.startsWith("https://")) return false;
try {
const u = new URL(trimmed);
const q = u.search.toLowerCase();
if (q.includes("x-amz-")) return true;
const h = u.hostname.toLowerCase();
if (h.includes("yimaruacademy.com")) return true;
if (h.includes("minio")) return true;
return false;
} catch {
return false;
}
}
export function LearnEnglishPage() {
const [programs, setPrograms] = useState<LearningProgramListItem[]>([]);
const [loading, setLoading] = useState(true);
@ -36,6 +53,7 @@ export function LearnEnglishPage() {
useState<LearningProgramListItem | null>(null);
const [editName, setEditName] = useState("");
const [editDescription, setEditDescription] = useState("");
const [editSortOrder, setEditSortOrder] = useState("");
const [editThumbnail, setEditThumbnail] = useState("");
const [savingEdit, setSavingEdit] = useState(false);
const [uploadingEditThumbnail, setUploadingEditThumbnail] = useState(false);
@ -44,6 +62,7 @@ export function LearnEnglishPage() {
const [createOpen, setCreateOpen] = useState(false);
const [createName, setCreateName] = useState("");
const [createDescription, setCreateDescription] = useState("");
const [createSortOrder, setCreateSortOrder] = useState("");
const [createThumbnail, setCreateThumbnail] = useState("");
const [createSaving, setCreateSaving] = useState(false);
const [createUploadingThumbnail, setCreateUploadingThumbnail] = useState(false);
@ -57,6 +76,7 @@ export function LearnEnglishPage() {
setEditingProgram(program);
setEditName(program.name ?? "");
setEditDescription(program.description?.trim() ?? "");
setEditSortOrder(String(program.sort_order ?? 0));
setEditThumbnail(program.thumbnail?.trim() ?? "");
};
@ -64,6 +84,7 @@ export function LearnEnglishPage() {
setEditingProgram(null);
setEditName("");
setEditDescription("");
setEditSortOrder("");
setEditThumbnail("");
setUploadingEditThumbnail(false);
if (editThumbnailFileInputRef.current) editThumbnailFileInputRef.current.value = "";
@ -107,6 +128,7 @@ export function LearnEnglishPage() {
const clearCreateFormFields = () => {
setCreateName("");
setCreateDescription("");
setCreateSortOrder("");
setCreateThumbnail("");
if (createThumbnailFileInputRef.current) {
createThumbnailFileInputRef.current.value = "";
@ -160,12 +182,23 @@ export function LearnEnglishPage() {
toast.error("Program name is required");
return;
}
const sortOrderRaw = createSortOrder.trim();
if (!sortOrderRaw) {
toast.error("Sort order is required");
return;
}
const sort_order = Number(sortOrderRaw);
if (!Number.isInteger(sort_order) || sort_order < 0) {
toast.error("Sort order must be a whole number of 0 or greater");
return;
}
setCreateSaving(true);
try {
await createLearningProgram({
name,
description: createDescription.trim(),
thumbnail: createThumbnail.trim(),
sort_order,
});
toast.success("Program created");
clearCreateFormFields();
@ -189,12 +222,23 @@ export function LearnEnglishPage() {
toast.error("Program name is required");
return;
}
const sortOrderRaw = editSortOrder.trim();
if (!sortOrderRaw) {
toast.error("Sort order is required");
return;
}
const sort_order = Number(sortOrderRaw);
if (!Number.isInteger(sort_order) || sort_order < 0) {
toast.error("Sort order must be a whole number of 0 or greater");
return;
}
setSavingEdit(true);
try {
await updateLearningProgram(editingProgram.id, {
name,
description: editDescription.trim(),
thumbnail: editThumbnail.trim(),
sort_order,
});
toast.success("Program updated");
closeEdit();
@ -240,6 +284,35 @@ export function LearnEnglishPage() {
(a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0),
);
setPrograms(sorted);
void (async () => {
const results = await Promise.all(
sorted.map(async (p) => {
const ref = p.thumbnail?.trim();
if (!ref || !looksLikeRefreshableFileUrl(ref)) return null;
try {
const res = await refreshFileUrl(ref);
const url = res.data?.data?.url?.trim();
if (!url) return null;
return { id: p.id, url };
} catch {
return null;
}
}),
);
const map = new Map(
results
.filter((r): r is { id: number; url: string } => r != null)
.map((r) => [r.id, r.url] as const),
);
if (map.size === 0) return;
setPrograms((prev) =>
prev.map((prog) => {
const next = map.get(prog.id);
return next ? { ...prog, thumbnail: next } : prog;
}),
);
})();
} catch (e) {
console.error(e);
setError("Failed to load programs");
@ -348,6 +421,27 @@ export function LearnEnglishPage() {
/>
</div>
<div className="space-y-2">
<label htmlFor="create-program-sort-order" className="text-[15px] text-grayScale-700">
Sort Order
</label>
<Input
id="create-program-sort-order"
type="number"
min={0}
step={1}
inputMode="numeric"
value={createSortOrder}
onChange={(e) => setCreateSortOrder(e.target.value)}
placeholder="e.g. 5"
className="h-12 rounded-xl ring-0"
disabled={createSaving || createUploadingThumbnail}
/>
<p className="text-xs text-grayScale-500">
Lower numbers appear first when programs are listed.
</p>
</div>
<div className="space-y-2">
<label className="text-[15px] text-grayScale-700">
Thumbnail
@ -549,16 +643,17 @@ export function LearnEnglishPage() {
if (!open) closeEdit();
}}
>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogContent className="flex max-h-[min(90vh,calc(100dvh-2rem))] max-w-lg flex-col gap-0 overflow-hidden p-0">
<DialogHeader className="shrink-0 space-y-1.5 border-b border-grayScale-100 px-6 pb-4 pt-6 pr-12">
<DialogTitle>Edit program</DialogTitle>
<DialogDescription>
Update name, description, and thumbnail. Upload an image from your
computer (via file storage) or paste a URL. Changes are saved to the
server.
Update name, description, sort order, and thumbnail. Upload an image
from your computer (via file storage) or paste a URL. Changes are
saved to the server.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-2">
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-6 py-4">
<div className="grid gap-4">
<div className="space-y-2">
<label className="text-sm font-medium text-grayScale-700">
Name
@ -584,6 +679,26 @@ export function LearnEnglishPage() {
disabled={savingEdit || uploadingEditThumbnail}
/>
</div>
<div className="space-y-2">
<label htmlFor="edit-program-sort-order" className="text-sm font-medium text-grayScale-700">
Sort Order
</label>
<Input
id="edit-program-sort-order"
type="number"
min={0}
step={1}
inputMode="numeric"
value={editSortOrder}
onChange={(e) => setEditSortOrder(e.target.value)}
className="rounded-xl"
placeholder="e.g. 5"
disabled={savingEdit || uploadingEditThumbnail}
/>
<p className="text-xs text-grayScale-500">
Lower numbers appear first when programs are listed.
</p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-grayScale-700">
Thumbnail
@ -632,7 +747,8 @@ export function LearnEnglishPage() {
</p>
</div>
</div>
<DialogFooter className="gap-2 sm:gap-0">
</div>
<DialogFooter className="shrink-0 gap-2 border-t border-grayScale-100 bg-white px-6 py-4 sm:gap-0">
<Button
type="button"
variant="outline"

View File

@ -0,0 +1,379 @@
import { useCallback, useEffect, useState } from "react";
import {
AlertCircle,
ArrowLeft,
BookOpen,
Calendar,
Clock,
Hash,
Loader2,
RefreshCw,
Sparkles,
} from "lucide-react";
import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom";
import { toast } from "sonner";
import { getPracticesByParentLesson } from "../../api/courses.api";
import { Badge } from "../../components/ui/badge";
import { Button } from "../../components/ui/button";
import { Card, CardContent } from "../../components/ui/card";
import type {
GetPracticesByParentContextResponse,
ParentContextPractice,
} from "../../types/course.types";
import { resolveThumbnailForPreview } from "../../lib/videoPreview";
import { cn } from "../../lib/utils";
function unwrapPracticesEnvelope(
res: { data?: GetPracticesByParentContextResponse & { Data?: GetPracticesByParentContextResponse["data"] } },
): GetPracticesByParentContextResponse["data"] | null {
const b = res.data;
if (!b) return null;
return b.data ?? b.Data ?? null;
}
function formatPracticeDate(iso: string): string {
const d = new Date(iso);
return Number.isNaN(d.getTime())
? iso
: d.toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" });
}
function PracticeCard({
practice,
index,
total,
}: {
practice: ParentContextPractice;
index: number;
total: number;
}) {
const [imgFailed, setImgFailed] = useState(false);
const thumb = resolveThumbnailForPreview(practice.story_image);
const showThumb = Boolean(thumb) && !imgFailed;
return (
<Card
className={cn(
"overflow-hidden border-grayScale-100/90 bg-white shadow-sm transition-all duration-300",
"hover:border-brand-200/60 hover:shadow-md hover:shadow-brand-500/5",
)}
>
<CardContent className="p-0">
<div className="flex flex-col lg:flex-row lg:items-stretch">
<div className="relative shrink-0 lg:w-[280px]">
<div
className={cn(
"relative aspect-[16/10] w-full overflow-hidden bg-gradient-to-br from-grayScale-100 to-grayScale-50 lg:aspect-auto lg:h-full lg:min-h-[220px]",
!showThumb && "grid min-h-[180px] place-items-center lg:min-h-[220px]",
)}
>
{showThumb ? (
<>
<img
src={thumb!}
alt=""
className="h-full w-full object-cover"
onError={() => setImgFailed(true)}
/>
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/25 via-transparent to-black/10" />
</>
) : (
<div className="flex flex-col items-center gap-2 text-grayScale-400">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-white/80 shadow-inner ring-1 ring-grayScale-200/80">
<BookOpen className="h-7 w-7" />
</div>
<span className="text-[11px] font-semibold uppercase tracking-wider">
No cover image
</span>
</div>
)}
</div>
</div>
<div className="flex min-w-0 flex-1 flex-col p-6 sm:p-7">
<div className="mb-3 flex flex-wrap items-center gap-2">
<span className="text-[11px] font-bold uppercase tracking-[0.14em] text-brand-500">
Practice {index + 1} of {total}
</span>
<Badge variant="secondary" className="font-mono text-[10px] font-semibold">
ID {practice.id}
</Badge>
</div>
<h2 className="text-xl font-semibold leading-snug tracking-tight text-grayScale-900 sm:text-[1.35rem]">
{practice.title}
</h2>
{practice.story_description?.trim() ? (
<div className="mt-4 rounded-xl border border-grayScale-100 bg-grayScale-50/80 px-4 py-3.5">
<p className="text-[10px] font-bold uppercase tracking-wider text-grayScale-400">
Story & instructions
</p>
<p className="mt-2 whitespace-pre-line text-[14px] leading-relaxed text-grayScale-700">
{practice.story_description}
</p>
</div>
) : null}
{practice.quick_tips?.trim() ? (
<div className="mt-4 border-l-[3px] border-amber-400 bg-gradient-to-r from-amber-50/90 to-amber-50/30 py-3 pl-4 pr-3">
<p className="text-[10px] font-bold uppercase tracking-wider text-amber-900/75">
Quick tips
</p>
<p className="mt-1.5 whitespace-pre-line text-[13px] leading-relaxed text-grayScale-800">
{practice.quick_tips}
</p>
</div>
) : null}
<div className="mt-6 flex flex-wrap gap-2 border-t border-grayScale-100 pt-5">
<Badge variant="secondary" className="gap-1.5 pl-2 pr-2.5 py-1 font-medium normal-case">
<Hash className="h-3 w-3 opacity-70" aria-hidden />
Question set {practice.question_set_id}
</Badge>
<Badge variant="secondary" className="gap-1.5 pl-2 pr-2.5 py-1 font-medium normal-case">
<Clock className="h-3 w-3 opacity-70" aria-hidden />
{formatPracticeDate(practice.created_at)}
</Badge>
</div>
</div>
</div>
</CardContent>
</Card>
);
}
export function LessonPracticesPage() {
const navigate = useNavigate();
const { level, courseId, moduleId, lessonId } = useParams<{
level: string;
courseId: string;
moduleId: string;
lessonId: string;
}>();
const [searchParams] = useSearchParams();
const lessonTitle = searchParams.get("lessonTitle")?.trim() || "";
const backHref = `/new-content/learn-english/${level}/courses/${courseId}/modules/${moduleId}`;
const [practices, setPractices] = useState<ParentContextPractice[]>([]);
const [totalCount, setTotalCount] = useState(0);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
const lid = lessonId ? Number(lessonId) : NaN;
const validLesson = Number.isFinite(lid) && lid > 0;
const load = useCallback(async () => {
if (!validLesson) {
setLoading(false);
setLoadError("Invalid lesson.");
setPractices([]);
return;
}
setLoading(true);
setLoadError(null);
try {
const res = await getPracticesByParentLesson(lid, { limit: 100, offset: 0 });
const envelope = unwrapPracticesEnvelope(res);
const list = Array.isArray(envelope?.practices) ? envelope.practices : [];
setPractices(list);
setTotalCount(
typeof envelope?.total_count === "number"
? envelope.total_count
: list.length,
);
} catch {
setPractices([]);
setTotalCount(0);
setLoadError("Could not load practices for this lesson.");
toast.error("Failed to load practices");
} finally {
setLoading(false);
}
}, [lid, validLesson]);
useEffect(() => {
void load();
}, [load]);
const displayTitle =
lessonTitle || (validLesson ? `Lesson #${lid}` : "Lesson practices");
const addPracticeHref = `/new-content/learn-english/${level}/courses/add-practice?backTo=module&courseId=${courseId}&moduleId=${moduleId}&lessonId=${lid}&lessonTitle=${encodeURIComponent(lessonTitle || displayTitle)}`;
return (
<div className="min-h-screen bg-gradient-to-b from-[#F4F6FB] via-white to-[#F8FAFC]">
<div className="pointer-events-none fixed inset-0 -z-10 overflow-hidden">
<div className="absolute -right-24 -top-24 h-72 w-72 rounded-full bg-brand-500/[0.06] blur-3xl" />
<div className="absolute -bottom-32 -left-20 h-80 w-80 rounded-full bg-violet-500/[0.05] blur-3xl" />
</div>
<div className="mx-auto max-w-4xl px-4 pb-24 pt-8 sm:px-6 lg:px-8">
<div className="animate-in fade-in slide-in-from-bottom-2 duration-500">
<Link
to={backHref}
className="group mb-6 inline-flex items-center gap-2 rounded-full border border-transparent px-1 py-1 text-[14px] font-medium text-grayScale-600 transition-colors hover:border-grayScale-200 hover:bg-white/80 hover:text-brand-600"
>
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-white shadow-sm ring-1 ring-grayScale-100 transition-transform group-hover:-translate-x-0.5">
<ArrowLeft className="h-4 w-4" />
</span>
Back to module
</Link>
<Card className="mb-10 border-grayScale-100/80 bg-white/90 shadow-md shadow-grayScale-200/40 backdrop-blur-sm">
<CardContent className="p-6 sm:p-8">
<div className="flex flex-col gap-6 lg:flex-row lg:items-start lg:justify-between">
<div className="flex min-w-0 gap-4">
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl bg-gradient-to-br from-brand-500 to-brand-600 text-white shadow-lg shadow-brand-500/25">
<BookOpen className="h-7 w-7" strokeWidth={1.75} />
</div>
<div className="min-w-0">
<p className="text-[11px] font-bold uppercase tracking-[0.2em] text-brand-500/90">
Lesson practices
</p>
<h1 className="mt-1.5 text-2xl font-semibold tracking-tight text-grayScale-900 sm:text-3xl">
{displayTitle}
</h1>
<p className="mt-2 max-w-xl text-[15px] leading-relaxed text-grayScale-500">
Review speaking practices linked to this lesson. Thumbnails
and copy come from your published practice content.
</p>
{!loading && !loadError ? (
<div className="mt-4 flex flex-wrap items-center gap-2">
<Badge variant="default" className="px-3 py-1 text-xs font-semibold">
{practices.length}{" "}
{practices.length === 1 ? "practice" : "practices"}
</Badge>
{totalCount > practices.length ? (
<span className="text-[12px] text-grayScale-500">
Showing {practices.length} of {totalCount}
</span>
) : null}
</div>
) : null}
</div>
</div>
<div className="flex shrink-0 flex-col gap-2 sm:flex-row lg:flex-col">
<Button
type="button"
className="h-11 rounded-xl bg-brand-500 px-6 font-semibold shadow-md shadow-brand-500/20 hover:bg-brand-600"
onClick={() => void navigate(addPracticeHref)}
>
<Calendar className="mr-2 h-4 w-4" />
Add practice
</Button>
<Button
type="button"
variant="outline"
className="h-11 rounded-xl border-grayScale-200 font-semibold text-grayScale-700 hover:bg-grayScale-50"
disabled={loading}
onClick={() => void load()}
>
<RefreshCw
className={cn("mr-2 h-4 w-4", loading && "animate-spin")}
/>
Refresh
</Button>
</div>
</div>
</CardContent>
</Card>
{loading ? (
<Card className="border-grayScale-100 bg-white/95 py-20 shadow-sm">
<CardContent className="flex flex-col items-center justify-center gap-4 pt-6">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-brand-50 ring-1 ring-brand-100">
<Loader2 className="h-8 w-8 animate-spin text-brand-500" />
</div>
<div className="text-center">
<p className="text-[16px] font-semibold text-grayScale-800">
Loading practices
</p>
<p className="mt-1 text-[14px] text-grayScale-500">
Fetching content for this lesson
</p>
</div>
</CardContent>
</Card>
) : loadError ? (
<Card className="border-red-100 bg-gradient-to-br from-red-50/90 to-white shadow-sm">
<CardContent className="flex flex-col items-center gap-5 py-14 text-center sm:py-16">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-red-100 text-red-600">
<AlertCircle className="h-8 w-8" />
</div>
<div>
<p className="text-lg font-semibold text-grayScale-900">
Something went wrong
</p>
<p className="mx-auto mt-2 max-w-md text-[14px] leading-relaxed text-grayScale-600">
{loadError}
</p>
</div>
<Button
type="button"
variant="outline"
className="rounded-xl border-grayScale-300 font-semibold"
onClick={() => void load()}
>
Try again
</Button>
</CardContent>
</Card>
) : practices.length === 0 ? (
<Card className="border-dashed border-grayScale-200 bg-white/90 shadow-sm">
<CardContent className="flex flex-col items-center px-6 py-16 text-center sm:py-20">
<div className="mb-6 flex h-20 w-20 items-center justify-center rounded-3xl bg-gradient-to-br from-violet-50 to-brand-50 ring-1 ring-brand-100/60">
<Sparkles className="h-9 w-9 text-brand-500" strokeWidth={1.5} />
</div>
<p className="text-xl font-semibold text-grayScale-900">
No practices yet
</p>
<p className="mx-auto mt-3 max-w-md text-[15px] leading-relaxed text-grayScale-500">
This lesson does not have any linked practices. Create one to
give learners a structured speaking activity after the video.
</p>
<div className="mt-8 flex flex-col gap-3 sm:flex-row">
<Button
type="button"
className="h-11 rounded-xl bg-brand-500 px-8 font-semibold shadow-md shadow-brand-500/15 hover:bg-brand-600"
onClick={() => void navigate(addPracticeHref)}
>
<Calendar className="mr-2 h-4 w-4" />
Create practice
</Button>
<Button
type="button"
variant="outline"
className="h-11 rounded-xl border-grayScale-200 px-8 font-semibold"
asChild
>
<Link to={backHref}>Return to module</Link>
</Button>
</div>
</CardContent>
</Card>
) : (
<div className="space-y-5">
{practices.map((p, i) => (
<PracticeCard
key={p.id}
practice={p}
index={i}
total={practices.length}
/>
))}
<p className="px-1 text-center text-[11px] text-grayScale-400">
Source:{" "}
<code className="rounded-md bg-grayScale-100 px-1.5 py-0.5 font-mono text-[10px] text-grayScale-500">
GET /lessons/{lid}/practices
</code>
</p>
</div>
)}
</div>
</div>
</div>
);
}

View File

@ -1,23 +1,27 @@
import { useCallback, useEffect, useState } from "react";
import {
ArrowLeft,
Video,
Calendar,
Mic,
Layers,
Edit2,
Trash2,
X,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { ArrowLeft, Video, Calendar, Trash2, X } from "lucide-react";
import { Link, useLocation, useNavigate, useParams } from "react-router-dom";
import { toast } from "sonner";
import {
deleteTopLevelModuleLesson,
getModuleLessons,
getPracticesByParentModule,
getTopLevelCourseModules,
publishParentLinkedPractice,
publishTopLevelModuleLesson,
updateParentLinkedPractice,
updateTopLevelModuleLesson,
} from "../../api/courses.api";
import type { TopLevelModuleLessonItem } from "../../types/course.types";
import type {
ParentContextPractice,
PracticePublishStatus,
TopLevelModuleLessonItem,
} from "../../types/course.types";
import {
isPracticeDraft,
isPracticePublished,
unwrapPracticesList,
} from "../../lib/parentContextPractice";
import { Button } from "../../components/ui/button";
import {
Dialog,
@ -32,6 +36,7 @@ import { Textarea } from "../../components/ui/textarea";
import { resolveThumbnailForPreview } from "../../lib/videoPreview";
import { cn } from "../../lib/utils";
import { LessonMediaUploadField } from "./components/LessonMediaUploadField";
import { ModulePracticeCard } from "./components/ModulePracticeCard";
import { VideoCard } from "./components/VideoCard";
const LESSON_THUMB_GRADIENTS = [
@ -41,37 +46,6 @@ const LESSON_THUMB_GRADIENTS = [
"from-[#FCE7F3] to-[#F9A8D4]",
] as const;
const MOCK_PRACTICES = [
{
id: "p1",
title: "Describe a Photo",
level: "IELTS",
variations: 12,
status: "Draft",
},
{
id: "p2",
title: "Describe a Photo",
level: "IELTS",
variations: 12,
status: "Draft",
},
{
id: "p3",
title: "Describe a Photo",
level: "IELTS",
variations: 12,
status: "Draft",
},
{
id: "p4",
title: "Describe a Photo",
level: "IELTS",
variations: 12,
status: "Draft",
},
];
type ModuleDetailState = {
moduleName?: string;
moduleDescription?: string;
@ -87,13 +61,14 @@ export function ModuleDetailPage() {
moduleId: string;
}>();
const [activeTab, setActiveTab] = useState<"video" | "practice">("video");
const [activeFilter, setActiveFilter] = useState("Draft");
const [activeFilter, setActiveFilter] = useState("All");
const [lessons, setLessons] = useState<TopLevelModuleLessonItem[]>([]);
const [lessonsLoading, setLessonsLoading] = useState(true);
const [lessonsLoadError, setLessonsLoadError] = useState<string | null>(null);
const [editingLesson, setEditingLesson] =
useState<TopLevelModuleLessonItem | null>(null);
const [editLessonTitle, setEditLessonTitle] = useState("");
const [editLessonSortOrder, setEditLessonSortOrder] = useState("");
const [editLessonVideoUrl, setEditLessonVideoUrl] = useState("");
const [editLessonThumbnail, setEditLessonThumbnail] = useState("");
const [editLessonDescription, setEditLessonDescription] = useState("");
@ -104,7 +79,17 @@ export function ModuleDetailPage() {
const [deletingLesson, setDeletingLesson] =
useState<TopLevelModuleLessonItem | null>(null);
const [deletingLessonInFlight, setDeletingLessonInFlight] = useState(false);
const [practices] = useState(MOCK_PRACTICES);
const [publishStatusLessonId, setPublishStatusLessonId] = useState<
number | null
>(null);
const [practices, setPractices] = useState<ParentContextPractice[]>([]);
const [practicesLoading, setPracticesLoading] = useState(false);
const [practicesLoadError, setPracticesLoadError] = useState<string | null>(
null,
);
const [publishStatusPracticeId, setPublishStatusPracticeId] = useState<
number | null
>(null);
const [loadedModuleName, setLoadedModuleName] = useState<string | null>(null);
const [loadedModuleDescription, setLoadedModuleDescription] = useState<
string | null
@ -233,9 +218,96 @@ export function ModuleDetailPage() {
void loadModuleLessons({ showPageLoading: true });
}, [loadModuleLessons]);
const loadModulePractices = useCallback(async () => {
const mid = Number(moduleId);
if (!Number.isFinite(mid) || mid < 1) {
setPractices([]);
setPracticesLoadError(null);
setPracticesLoading(false);
return;
}
setPracticesLoading(true);
setPracticesLoadError(null);
try {
const res = await getPracticesByParentModule(mid, {
limit: 100,
offset: 0,
});
setPractices(unwrapPracticesList(res));
} catch {
setPractices([]);
setPracticesLoadError("Failed to load practices. Please try again.");
} finally {
setPracticesLoading(false);
}
}, [moduleId]);
useEffect(() => {
if (activeTab !== "practice") return;
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 handlePublishPractice = async (practiceId: number) => {
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",
});
setPractices((prev) =>
prev.map((p) =>
p.id === practiceId ? { ...p, publish_status: "DRAFT" } : p,
),
);
toast.success("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";
toast.error(msg);
} finally {
setPublishStatusPracticeId(null);
}
};
const openEditLesson = (lesson: TopLevelModuleLessonItem) => {
setEditingLesson(lesson);
setEditLessonTitle(lesson.title ?? "");
setEditLessonSortOrder(String(lesson.sort_order ?? 0));
setEditLessonVideoUrl(lesson.video_url ?? "");
setEditLessonThumbnail(lesson.thumbnail ?? "");
setEditLessonDescription(lesson.description ?? "");
@ -253,6 +325,16 @@ export function ModuleDetailPage() {
toast.error("Title is required");
return;
}
const sortOrderRaw = editLessonSortOrder.trim();
if (sortOrderRaw === "") {
toast.error("Sort order is required");
return;
}
const sort_order = Number(sortOrderRaw);
if (!Number.isInteger(sort_order) || sort_order < 0) {
toast.error("Sort order must be a whole number of 0 or greater");
return;
}
setSavingLessonEdit(true);
try {
await updateTopLevelModuleLesson(editingLesson.id, {
@ -260,6 +342,7 @@ export function ModuleDetailPage() {
video_url: editLessonVideoUrl.trim(),
thumbnail: editLessonThumbnail.trim(),
description: editLessonDescription.trim(),
sort_order,
});
toast.success("Lesson updated");
setEditingLesson(null);
@ -275,6 +358,39 @@ export function ModuleDetailPage() {
}
};
const handleToggleLessonPublishStatus = async (
lessonId: number,
nextStatus: PracticePublishStatus,
) => {
setPublishStatusLessonId(lessonId);
try {
await publishTopLevelModuleLesson(lessonId, {
publish_status: nextStatus,
});
setLessons((prev) =>
prev.map((l) =>
l.id === lessonId ? { ...l, publish_status: nextStatus } : l,
),
);
toast.success(
nextStatus === "PUBLISHED"
? "Lesson published"
: "Lesson saved as draft",
);
} catch (e: unknown) {
console.error(e);
const msg =
(e as { response?: { data?: { message?: string } } })?.response?.data
?.message ??
(nextStatus === "PUBLISHED"
? "Failed to publish lesson"
: "Failed to save lesson as draft");
toast.error(msg);
} finally {
setPublishStatusLessonId(null);
}
};
const handleConfirmDeleteLesson = async () => {
if (!deletingLesson) return;
setDeletingLessonInFlight(true);
@ -393,9 +509,17 @@ export function ModuleDetailPage() {
id={lesson.id}
title={lesson.title}
videoUrl={lesson.video_url}
publishStatus={lesson.publish_status}
hoverModuleActions
thumbnailUrl={resolveThumbnailForPreview(lesson.thumbnail)}
thumbnailGradient={LESSON_THUMB_GRADIENTS[i % LESSON_THUMB_GRADIENTS.length]}
durationSeconds={(() => {
const raw =
lesson.duration_seconds ?? lesson.duration ?? null;
if (raw == null) return null;
const n = typeof raw === "number" ? raw : Number(raw);
return Number.isFinite(n) && n > 0 ? n : null;
})()}
onEdit={() => openEditLesson(lesson)}
onDelete={() => setDeletingLesson(lesson)}
description={lesson.description}
@ -404,6 +528,15 @@ export function ModuleDetailPage() {
`/new-content/learn-english/${level}/courses/add-practice?backTo=module&courseId=${courseId}&moduleId=${moduleId}&lessonId=${lesson.id}&lessonTitle=${encodeURIComponent(lesson.title)}`,
)
}
onViewPractices={() =>
navigate(
`/new-content/learn-english/${level}/courses/${courseId}/modules/${moduleId}/lessons/${lesson.id}/practices?lessonTitle=${encodeURIComponent(lesson.title ?? "")}`,
)
}
onTogglePublishStatus={(nextStatus) =>
void handleToggleLessonPublishStatus(lesson.id, nextStatus)
}
publishStatusUpdating={publishStatusLessonId === lesson.id}
/>
))}
</div>
@ -460,12 +593,66 @@ export function ModuleDetailPage() {
</div>
</div>
{/* Practice Cards Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{practices.map((practice) => (
<PracticeCard key={practice.id} {...practice} />
))}
</div>
{practicesLoading ? (
<div className="flex flex-col items-center justify-center py-24 text-grayScale-500 text-[15px] font-medium">
Loading practices
</div>
) : practicesLoadError ? (
<div className="rounded-2xl border border-amber-100 bg-amber-50/80 px-6 py-8 text-center text-sm text-amber-900 max-w-lg mx-auto">
{practicesLoadError}
</div>
) : filteredPractices.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{filteredPractices.map((practice) => (
<ModulePracticeCard
key={practice.id}
practice={practice}
statusUpdating={publishStatusPracticeId === practice.id}
onEdit={() =>
navigate(
`/content/practices?type=module&id=${moduleId}`,
)
}
onPublish={() => void handlePublishPractice(practice.id)}
onSaveAsDraft={() =>
void handleSavePracticeAsDraft(practice.id)
}
/>
))}
</div>
) : (
<div className="flex flex-col items-center justify-center py-32 px-4 rounded-[40px] border-2 border-dashed border-[#F1F5F9] bg-white max-w-4xl mx-auto shadow-sm">
<div className="h-20 w-20 rounded-full bg-[#FAF5FF] flex items-center justify-center mb-6">
<div className="h-14 w-14 rounded-full bg-[#F5EBFF] flex items-center justify-center">
<Calendar className="h-7 w-7 text-brand-500" />
</div>
</div>
<h2 className="text-2xl font-extrabold text-grayScale-900 mb-3">
{practices.length === 0
? "No practices in this module yet"
: "No practices match this filter"}
</h2>
<p className="text-grayScale-400 font-medium text-[15px] text-center max-w-sm mb-10 leading-relaxed">
{practices.length === 0
? "Add a practice to give learners speaking exercises for this module."
: "Try another status filter or add a new practice."}
</p>
{practices.length === 0 ? (
<Button
variant="outline"
className="h-12 px-8 rounded-xl border-brand-500 text-brand-500 font-bold hover:bg-brand-50 transition-all flex items-center gap-2"
onClick={() =>
navigate(
`/new-content/learn-english/${level}/courses/add-practice?backTo=module&courseId=${courseId}&moduleId=${moduleId}`,
)
}
>
<Calendar className="h-5 w-5" />
Add Practice
</Button>
) : null}
</div>
)}
</div>
)}
</div>
@ -507,6 +694,28 @@ export function ModuleDetailPage() {
disabled={savingLessonEdit}
/>
</div>
<div className="space-y-2">
<label
className="text-sm font-medium text-grayScale-700"
htmlFor="edit-lesson-sort-order"
>
Sort order
</label>
<Input
id="edit-lesson-sort-order"
type="number"
inputMode="numeric"
min={0}
step={1}
value={editLessonSortOrder}
onChange={(e) => setEditLessonSortOrder(e.target.value)}
disabled={savingLessonEdit}
className="max-w-[200px]"
/>
<p className="text-xs text-grayScale-500">
Whole number, 0 or greater.
</p>
</div>
<LessonMediaUploadField
kind="video"
value={editLessonVideoUrl}
@ -615,68 +824,3 @@ export function ModuleDetailPage() {
</div>
);
}
function PracticeCard({
title,
level,
variations,
status,
}: {
title: string;
level: string;
variations: number;
status: string;
}) {
return (
<div className="bg-white rounded-[24px] border border-grayScale-50 shadow-sm overflow-hidden hover:shadow-xl hover:shadow-grayScale-400/5 transition-all group p-6 flex flex-col h-full min-h-[340px]">
<div className="flex-1 space-y-6">
<div className="flex items-center justify-between">
<h3 className="text-[18px] font-bold text-grayScale-900 line-clamp-1">
{title}
</h3>
</div>
<div className="flex items-center justify-between gap-3">
<span className="bg-[#22C55E] text-white text-[11px] font-bold px-2 py-1 rounded-[4px]">
{level}
</span>
<div className="flex items-center gap-1.5 text-grayScale-500">
<Mic className="h-4 w-4" />
<span className="text-[13px] font-bold">Speaking</span>
</div>
</div>
<div className="flex items-center gap-2.5 text-brand-400 w-fit py-2 rounded-xl">
<Layers className="h-4 w-4" />
<span className="text-[14px] font-bold">{variations} Variations</span>
</div>
<div className="flex border-t border-grayScale-200 items-center justify-between pt-2">
<div className="bg-grayScale-100 text-grayScale-400 text-[11px] font-bold px-3 py-1.5 rounded-[6px] tracking-wide uppercase">
{status}
</div>
<div className="flex items-center gap-3">
<button className="h-8 w-8 rounded-lg flex items-center justify-center text-grayScale-400 hover:text-brand-500 hover:border-brand-100 transition-all">
<Edit2 className="h-5 w-5" />
</button>
<button className="h-8 w-8 rounded-lg flex items-center justify-center text-grayScale-400 hover:text-red-500 hover:border-red-100 transition-all">
<Trash2 className="h-5 w-5" />
</button>
</div>
</div>
</div>
<div className="mt-8 grid grid-cols-2 gap-3">
<Button className="bg-brand-500 text-white rounded-xl h-11 text-[13px] font-bold shadow-md shadow-brand-500/10 hover:bg-brand-600 transition-all px-0">
Publish Practice
</Button>
<Button
variant="outline"
className="border-brand-500 text-brand-500 rounded-xl h-11 text-[13px] font-bold bg-white hover:bg-brand-50 transition-all px-0"
>
Publish Video
</Button>
</div>
</div>
);
}

View File

@ -7,14 +7,31 @@ export function NewContentPage() {
return (
<div className="space-y-8">
{/* Header section */}
<div>
<h1 className="text-2xl font-bold tracking-tight text-grayScale-700">
Content Management
</h1>
<p className="mt-1 text-sm text-grayScale-500">
Upload, organize, and manage learning content across programs and
courses
</p>
<div className="flex items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-bold tracking-tight text-grayScale-700">
Content Management
</h1>
<p className="mt-1 text-sm text-grayScale-500">
Upload, organize, and manage learning content across programs and
courses
</p>
</div>
<div className="flex shrink-0 flex-wrap items-center justify-end gap-3">
<Link to="/new-content/question-types">
<Button className="h-10 px-6 rounded-[6px] bg-brand-500 font-bold text-white shadow-sm hover:bg-brand-600 transition-all">
Manage Question Types
</Button>
</Link>
<Link to="/new-content/reorder">
<Button
variant="outline"
className="h-10 px-6 rounded-[6px] border-brand-500 font-bold text-brand-500 hover:bg-brand-50 transition-all"
>
Reorder Content
</Button>
</Link>
</div>
</div>
{/* Gradient Divider */}

View File

@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { ArrowLeft, Plus, FileText, Pencil, Trash2, X } from "lucide-react";
import { ArrowLeft, Plus, Pencil, Trash2, X } from "lucide-react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { toast } from "sonner";
import { Card, CardContent } from "../../components/ui/card";
@ -14,7 +14,6 @@ import {
DialogTrigger,
} from "../../components/ui/dialog";
import { Input } from "../../components/ui/input";
import { Textarea } from "../../components/ui/textarea";
import uploadIcon from "../../assets/icons/upload.png";
import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg";
import alertSrc from "../../assets/Alert.svg";
@ -30,6 +29,7 @@ import type {
LearningProgramListItem,
ProgramCourseListItem,
} from "../../types/course.types";
import { PublishPracticeButton } from "./components/PublishPracticeButton";
export function ProgramCoursesPage() {
const navigate = useNavigate();
@ -51,7 +51,7 @@ export function ProgramCoursesPage() {
null,
);
const [editName, setEditName] = useState("");
const [editDescription, setEditDescription] = useState("");
const [editSortOrder, setEditSortOrder] = useState("");
const [editThumbnail, setEditThumbnail] = useState("");
const [savingEdit, setSavingEdit] = useState(false);
const [uploadingEditThumbnail, setUploadingEditThumbnail] = useState(false);
@ -59,7 +59,7 @@ export function ProgramCoursesPage() {
const [createCourseOpen, setCreateCourseOpen] = useState(false);
const [createName, setCreateName] = useState("");
const [createDescription, setCreateDescription] = useState("");
const [createSortOrder, setCreateSortOrder] = useState("");
const [createThumbnail, setCreateThumbnail] = useState("");
const [createSaving, setCreateSaving] = useState(false);
const [createUploadingThumbnail, setCreateUploadingThumbnail] = useState(false);
@ -133,16 +133,16 @@ export function ProgramCoursesPage() {
const openEditCourse = (course: ProgramCourseListItem) => {
setEditingCourse(course);
setEditName(course.name ?? "");
setEditDescription(course.description?.trim() ?? "");
setEditThumbnail(
course.thumbnail?.trim() || course.thumbnail_url?.trim() || "",
);
setEditSortOrder(String(course.sort_order ?? 0));
};
const closeEditCourse = () => {
setEditingCourse(null);
setEditName("");
setEditDescription("");
setEditSortOrder("");
setEditThumbnail("");
setUploadingEditThumbnail(false);
if (editThumbnailFileInputRef.current) {
@ -192,12 +192,23 @@ export function ProgramCoursesPage() {
toast.error("Course name is required");
return;
}
const sortOrderRaw = editSortOrder.trim();
if (!sortOrderRaw) {
toast.error("Sort order is required");
return;
}
const sort_order = Number(sortOrderRaw);
if (!Number.isInteger(sort_order) || sort_order < 0) {
toast.error("Sort order must be a whole number of 0 or greater");
return;
}
setSavingEdit(true);
try {
await updateTopLevelCourse(editingCourse.id, {
name,
description: editDescription.trim(),
description: editingCourse.description?.trim() ?? "",
thumbnail: editThumbnail.trim(),
sort_order,
});
toast.success("Course updated");
closeEditCourse();
@ -215,7 +226,7 @@ export function ProgramCoursesPage() {
const clearCreateCourseForm = () => {
setCreateName("");
setCreateDescription("");
setCreateSortOrder("");
setCreateThumbnail("");
setCreateUploadingThumbnail(false);
if (createThumbnailFileInputRef.current) {
@ -271,12 +282,23 @@ export function ProgramCoursesPage() {
toast.error("Course name is required");
return;
}
const sortOrderRaw = createSortOrder.trim();
if (!sortOrderRaw) {
toast.error("Sort order is required");
return;
}
const sort_order = Number(sortOrderRaw);
if (!Number.isInteger(sort_order) || sort_order < 0) {
toast.error("Sort order must be a whole number of 0 or greater");
return;
}
setCreateSaving(true);
try {
await createProgramCourse(programId, {
name,
description: createDescription.trim(),
description: "",
thumbnail: createThumbnail.trim(),
sort_order,
});
toast.success("Course created");
clearCreateCourseForm();
@ -337,18 +359,6 @@ export function ProgramCoursesPage() {
<div className="flex gap-3">
{programIdValid ? (
<>
<Link
to={`/new-content/learn-english/${programIdParam}/courses/add-practice`}
>
<Button
variant="outline"
className="rounded-[6px] border-brand-500 text-brand-500 "
>
<FileText className="mr-2 h-4 w-4" />
Add Practice
</Button>
</Link>
<Dialog
open={createCourseOpen}
onOpenChange={handleCreateCourseDialogOpenChange}
@ -422,17 +432,27 @@ export function ProgramCoursesPage() {
</div>
<div className="space-y-2">
<label className="text-[15px] font-medium text-grayScale-700">
Description
<label
htmlFor="create-course-sort-order"
className="text-[15px] font-medium text-grayScale-700"
>
Sort Order
</label>
<Textarea
value={createDescription}
onChange={(e) => setCreateDescription(e.target.value)}
placeholder="Short summary of the course"
rows={3}
className="min-h-[88px] resize-y rounded-xl"
<Input
id="create-course-sort-order"
type="number"
min={0}
step={1}
inputMode="numeric"
value={createSortOrder}
onChange={(e) => setCreateSortOrder(e.target.value)}
placeholder="e.g. 5"
className="h-12 rounded-xl"
disabled={createSaving || createUploadingThumbnail}
/>
<p className="text-xs text-grayScale-500">
Lower numbers appear first when courses are listed.
</p>
</div>
<div className="space-y-2">
@ -664,9 +684,11 @@ export function ProgramCoursesPage() {
>
View Detail
</Button>
<Button className="h-10 flex-1 rounded-[6px] bg-brand-500 text-[13px] font-semibold ">
Publish Practice
</Button>
<PublishPracticeButton
parentKind="COURSE"
parentId={course.id}
className="h-10 flex-1 rounded-[6px] bg-brand-500 text-[13px] font-semibold hover:bg-brand-600 disabled:opacity-60"
/>
</div>
</CardContent>
</Card>
@ -682,18 +704,19 @@ export function ProgramCoursesPage() {
if (!open) closeEditCourse();
}}
>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogContent className="flex max-h-[min(90vh,calc(100dvh-2rem))] max-w-lg flex-col gap-0 overflow-hidden p-0">
<DialogHeader className="shrink-0 space-y-1.5 border-b border-grayScale-100 px-6 pb-4 pt-6 pr-12">
<DialogTitle>Edit course</DialogTitle>
<DialogDescription>
Update name, description, and thumbnail. Saved with{" "}
Update name, sort order, and thumbnail. Saved with{" "}
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px]">
PUT /courses/:id
</code>
.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-2">
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-6 py-4">
<div className="grid gap-4">
<div className="space-y-2">
<label className="text-sm font-medium text-grayScale-700">
Name
@ -707,17 +730,27 @@ export function ProgramCoursesPage() {
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-grayScale-700">
Description
<label
htmlFor="edit-course-sort-order"
className="text-sm font-medium text-grayScale-700"
>
Sort Order
</label>
<Textarea
value={editDescription}
onChange={(e) => setEditDescription(e.target.value)}
rows={4}
className="min-h-[100px] resize-y rounded-xl"
placeholder="Short summary"
<Input
id="edit-course-sort-order"
type="number"
min={0}
step={1}
inputMode="numeric"
value={editSortOrder}
onChange={(e) => setEditSortOrder(e.target.value)}
className="rounded-xl"
placeholder="e.g. 5"
disabled={savingEdit || uploadingEditThumbnail}
/>
<p className="text-xs text-grayScale-500">
Lower numbers appear first when courses are listed.
</p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-grayScale-700">
@ -760,7 +793,8 @@ export function ProgramCoursesPage() {
/>
</div>
</div>
<DialogFooter className="gap-2 sm:gap-0">
</div>
<DialogFooter className="shrink-0 gap-2 border-t border-grayScale-100 bg-white px-6 py-4 sm:gap-0">
<Button
type="button"
variant="outline"

View File

@ -13,7 +13,6 @@ import {
} from "lucide-react";
import { Button } from "../../components/ui/button";
import { Card } from "../../components/ui/card";
import { Textarea } from "../../components/ui/textarea";
import {
Dialog,
DialogContent,
@ -39,7 +38,6 @@ export function ProgramDetailPage() {
const { programType } = useParams<{ programType: string }>();
const [createOpen, setCreateOpen] = useState(false);
const [createName, setCreateName] = useState("");
const [createDescription, setCreateDescription] = useState("");
const [createThumbnail, setCreateThumbnail] = useState("");
const [createThumbnailFromUpload, setCreateThumbnailFromUpload] = useState(false);
const [creating, setCreating] = useState(false);
@ -60,7 +58,6 @@ export function ProgramDetailPage() {
const [catalogLoading, setCatalogLoading] = useState(false);
const [editingCourseId, setEditingCourseId] = useState<number | null>(null);
const [editName, setEditName] = useState("");
const [editDescription, setEditDescription] = useState("");
const [editThumbnail, setEditThumbnail] = useState("");
const [editSortOrder, setEditSortOrder] = useState("1");
const [savingEdit, setSavingEdit] = useState(false);
@ -216,7 +213,7 @@ export function ProgramDetailPage() {
const response = await createExamPrepCatalogCourse({
name,
description: createDescription.trim() || null,
description: null,
thumbnail: thumbnailToSend,
});
const row = response.data?.data;
@ -227,7 +224,7 @@ export function ProgramDetailPage() {
{
id: row.id,
name: row.name ?? name,
description: row.description?.trim() || createDescription.trim() || "—",
description: row.description?.trim() || "—",
thumbnail: row.thumbnail?.trim() || null,
sortOrder: Number(row.sort_order ?? 0),
unitsCount: Number(row.units_count ?? 0),
@ -239,7 +236,6 @@ export function ProgramDetailPage() {
await loadCatalogCourses();
toast.success("Course created");
setCreateName("");
setCreateDescription("");
setCreateThumbnail("");
setCreateThumbnailFromUpload(false);
setCreateOpen(false);
@ -259,7 +255,6 @@ export function ProgramDetailPage() {
if (!Number.isFinite(idNum)) return;
setEditingCourseId(idNum);
setEditName(String(course.name ?? ""));
setEditDescription(String(course.description ?? ""));
setEditThumbnail(String(course.thumbnail ?? ""));
setEditSortOrder(String(course.sort_order ?? 1));
};
@ -268,7 +263,6 @@ export function ProgramDetailPage() {
if (savingEdit || uploadingEditThumbnail) return;
setEditingCourseId(null);
setEditName("");
setEditDescription("");
setEditThumbnail("");
setEditSortOrder("1");
};
@ -317,9 +311,14 @@ export function ProgramDetailPage() {
setSavingEdit(true);
try {
const minioThumbnail = await resolveThumbnailToMinioUrl(editThumbnail);
const existing = createdCourses.find((c) => c.id === editingCourseId);
const preservedDescription =
existing?.description && existing.description !== "—"
? existing.description
: null;
const response = await updateExamPrepCatalogCourse(editingCourseId, {
name,
description: editDescription.trim() || null,
description: preservedDescription,
thumbnail: minioThumbnail || null,
sort_order: sortOrderNum,
});
@ -330,7 +329,7 @@ export function ProgramDetailPage() {
? {
...course,
name: row?.name ?? name,
description: row?.description?.trim() || editDescription.trim() || "—",
description: row?.description?.trim() || preservedDescription || "—",
thumbnail: row?.thumbnail?.trim() || null,
sortOrder: Number(row?.sort_order ?? sortOrderNum),
unitsCount: Number(row?.units_count ?? course.unitsCount ?? 0),
@ -467,20 +466,6 @@ export function ProgramDetailPage() {
/>
</div>
<div className="space-y-3">
<label className="text-[15px] text-grayScale-800">
Description
</label>
<Textarea
value={createDescription}
onChange={(e) => setCreateDescription(e.target.value)}
placeholder="Optional description"
rows={4}
className="min-h-[96px] rounded-[8px] border-grayScale-400"
disabled={creating}
/>
</div>
<div className="space-y-3">
<label className="text-[15px] text-grayScale-800">
Thumbnail
@ -735,17 +720,6 @@ export function ProgramDetailPage() {
/>
</div>
<div className="space-y-3">
<label className="text-[15px] text-grayScale-800">Description</label>
<Textarea
value={editDescription}
onChange={(e) => setEditDescription(e.target.value)}
rows={4}
className="min-h-[96px] rounded-[8px] border-grayScale-400"
disabled={savingEdit || uploadingEditThumbnail}
/>
</div>
<div className="space-y-3">
<label className="text-[15px] text-grayScale-800">Sort Order</label>
<Input

View File

@ -1,26 +1,18 @@
import { Link } from "react-router-dom";
import { GraduationCap, Brain } from "lucide-react";
import { Button } from "../../components/ui/button";
export function ProgramTypeSelectionPage() {
return (
<div className="space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
{/* Header section */}
<div className="flex items-start justify-between">
<div className="space-y-1.5 pt-2">
<h1 className="text-[28px] font-bold tracking-tight text-grayScale-900">
Courses
</h1>
<p className="max-w-2xl text-[15px] font-medium text-grayScale-500">
Organize courses under skill-based learning or English proficiency
exams. Select a program type to manage curriculum and modules.
</p>
</div>
<Link to="/new-content/question-types">
<Button className="h-10 px-6 rounded-[6px] bg-brand-500 font-bold text-white shadow-sm hover:bg-brand-600 transition-all flex items-center gap-2 mt-4">
Manage Question Types
</Button>
</Link>
<div className="space-y-1.5 pt-2">
<h1 className="text-[28px] font-bold tracking-tight text-grayScale-900">
Courses
</h1>
<p className="max-w-2xl text-[15px] font-medium text-grayScale-500">
Organize courses under skill-based learning or English proficiency
exams. Select a program type to manage curriculum and modules.
</p>
</div>
{/* Gradient Divider */}

View File

@ -113,11 +113,11 @@ export function QuestionTypeLibraryPage() {
<div className="space-y-8 animate-in fade-in duration-500 pb-20">
<div className="space-y-6">
<Link
to="/new-content/courses"
to="/new-content"
className="flex items-center gap-2 text-[15px] font-bold text-grayScale-600 transition-colors hover:text-brand-500 group w-fit"
>
<ArrowLeft className="h-5 w-5 transition-transform group-hover:-translate-x-1" />
Back to Courses
Back to Content Management
</Link>
<div className="flex items-start justify-between gap-4 flex-wrap">

View File

@ -0,0 +1,31 @@
import { Link } from "react-router-dom";
import { ArrowLeft } from "lucide-react";
import { ContentHierarchyList } from "./components/ContentHierarchyList";
export function ReorderContentPage() {
return (
<div className="space-y-8 animate-in fade-in duration-500 pb-20">
<div className="space-y-6">
<Link
to="/new-content"
className="flex items-center gap-2 text-[15px] font-bold text-grayScale-600 transition-colors hover:text-brand-500 group w-fit"
>
<ArrowLeft className="h-5 w-5 transition-transform group-hover:-translate-x-1" />
Back to Content Management
</Link>
<div className="space-y-1">
<h1 className="text-2xl font-bold tracking-tight text-grayScale-700">
Reorder Content
</h1>
<p className="max-w-2xl text-sm text-grayScale-500">
Drag and drop programs, courses, modules, and lessons to change
their display order.
</p>
</div>
</div>
<ContentHierarchyList />
</div>
);
}

View File

@ -13,7 +13,6 @@ import {
} from "lucide-react";
import { Button } from "../../components/ui/button";
import { Card } from "../../components/ui/card";
import { Textarea } from "../../components/ui/textarea";
import {
Dialog,
DialogContent,
@ -55,7 +54,6 @@ export function UnitManagementPage() {
const parsedUnitId = Number(unitId);
const [addModuleOpen, setAddModuleOpen] = useState(false);
const [createName, setCreateName] = useState("");
const [createDescription, setCreateDescription] = useState("");
const [createThumbnail, setCreateThumbnail] = useState("");
const [createIcon, setCreateIcon] = useState("");
const [creating, setCreating] = useState(false);
@ -79,7 +77,6 @@ export function UnitManagementPage() {
>([]);
const [editingModuleId, setEditingModuleId] = useState<number | null>(null);
const [editName, setEditName] = useState("");
const [editDescription, setEditDescription] = useState("");
const [editThumbnail, setEditThumbnail] = useState("");
const [editIcon, setEditIcon] = useState("");
const [editSortOrder, setEditSortOrder] = useState("1");
@ -159,7 +156,6 @@ export function UnitManagementPage() {
const clearCreateModuleForm = () => {
setCreateName("");
setCreateDescription("");
setCreateThumbnail("");
setCreateIcon("");
if (createThumbnailFileInputRef.current) {
@ -264,7 +260,7 @@ export function UnitManagementPage() {
const minioIcon = await resolveToMinioUrl(createIcon);
await createExamPrepUnitModule(parsedUnitId, {
name,
description: createDescription.trim() || null,
description: null,
thumbnail: minioThumbnail || null,
icon: minioIcon || null,
});
@ -286,7 +282,6 @@ export function UnitManagementPage() {
const openEditModule = (module: (typeof modules)[number]) => {
setEditingModuleId(module.id);
setEditName(module.name ?? "");
setEditDescription(module.description ?? "");
setEditThumbnail(module.thumbnail ?? "");
setEditIcon(module.icon ?? "");
setEditSortOrder(String(module.sortOrder ?? 1));
@ -296,7 +291,6 @@ export function UnitManagementPage() {
if (savingEdit || uploadingEditThumbnail || uploadingEditIcon) return;
setEditingModuleId(null);
setEditName("");
setEditDescription("");
setEditThumbnail("");
setEditIcon("");
setEditSortOrder("1");
@ -391,11 +385,16 @@ export function UnitManagementPage() {
setSavingEdit(true);
try {
const existing = modules.find((m) => m.id === editingModuleId);
const preservedDescription =
existing?.description && existing.description !== "—"
? existing.description
: null;
const minioThumbnail = await resolveToMinioUrl(editThumbnail);
const minioIcon = await resolveToMinioUrl(editIcon);
await updateExamPrepUnitModule(editingModuleId, {
name,
description: editDescription.trim() || null,
description: preservedDescription,
thumbnail: minioThumbnail || null,
icon: minioIcon || null,
sort_order: sortOrderNum,
@ -489,20 +488,6 @@ export function UnitManagementPage() {
/>
</div>
<div className="space-y-3">
<label className="text-[15px] text-grayScale-800">
Description
</label>
<Textarea
value={createDescription}
onChange={(e) => setCreateDescription(e.target.value)}
placeholder="Optional module description"
rows={4}
className="min-h-[96px] rounded-[8px] border-grayScale-400"
disabled={creating || uploadingThumbnail || uploadingIcon}
/>
</div>
<div className="space-y-3">
<label className="text-[15px] text-grayScale-800">Thumbnail</label>
<input
@ -812,16 +797,6 @@ export function UnitManagementPage() {
disabled={savingEdit || uploadingEditThumbnail || uploadingEditIcon}
/>
</div>
<div className="space-y-3">
<label className="text-[15px] text-grayScale-800">Description</label>
<Textarea
value={editDescription}
onChange={(e) => setEditDescription(e.target.value)}
rows={4}
className="min-h-[96px] rounded-[8px] border-grayScale-400"
disabled={savingEdit || uploadingEditThumbnail || uploadingEditIcon}
/>
</div>
<div className="space-y-3">
<label className="text-[15px] text-grayScale-800">Sort Order</label>
<Input

View File

@ -9,7 +9,6 @@ import {
DialogClose,
} from "../../../components/ui/dialog";
import { Input } from "../../../components/ui/input";
import { Textarea } from "../../../components/ui/textarea";
import { toast } from "sonner";
import { createTopLevelCourseModule } from "../../../api/courses.api";
import { ModuleIconUploadField } from "./ModuleIconUploadField";
@ -28,7 +27,7 @@ export function AddModuleModal({
onCreated,
}: AddModuleModalProps) {
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [sortOrder, setSortOrder] = useState("");
const [icon, setIcon] = useState("");
const [submitting, setSubmitting] = useState(false);
const [iconUploadBusy, setIconUploadBusy] = useState(false);
@ -36,7 +35,7 @@ export function AddModuleModal({
useEffect(() => {
if (isOpen) {
setName("");
setDescription("");
setSortOrder("");
setIcon("");
setSubmitting(false);
setIconUploadBusy(false);
@ -45,7 +44,7 @@ export function AddModuleModal({
const resetAndClose = () => {
setName("");
setDescription("");
setSortOrder("");
setIcon("");
setIconUploadBusy(false);
onClose();
@ -69,12 +68,23 @@ export function AddModuleModal({
toast.error("Invalid course");
return;
}
const sortOrderRaw = sortOrder.trim();
if (!sortOrderRaw) {
toast.error("Sort order is required");
return;
}
const sort_order = Number(sortOrderRaw);
if (!Number.isInteger(sort_order) || sort_order < 0) {
toast.error("Sort order must be a whole number of 0 or greater");
return;
}
setSubmitting(true);
try {
await createTopLevelCourseModule(courseId, {
name: trimmedName,
description: description.trim(),
description: "",
icon: icon.trim(),
sort_order,
});
toast.success("Module created");
if (onCreated) {
@ -144,17 +154,27 @@ export function AddModuleModal({
</div>
<div className="space-y-2">
<label className="text-[15px] font-medium text-grayScale-700">
Description
<label
htmlFor="create-module-sort-order"
className="text-[15px] font-medium text-grayScale-700"
>
Sort Order
</label>
<Textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Learn to introduce yourself and talk about your life."
className="min-h-[88px] resize-y rounded-xl"
disabled={submitting}
rows={3}
<Input
id="create-module-sort-order"
type="number"
min={0}
step={1}
inputMode="numeric"
value={sortOrder}
onChange={(e) => setSortOrder(e.target.value)}
placeholder="e.g. 5"
className="h-12 rounded-xl"
disabled={submitting || iconUploadBusy}
/>
<p className="text-xs text-grayScale-500">
Lower numbers appear first when modules are listed.
</p>
</div>
<ModuleIconUploadField

View File

@ -0,0 +1,104 @@
import { useMemo } from "react";
import {
PracticeSequentialReview,
type PracticeReviewQuestion,
} from "./practice-steps/PracticeSequentialReview";
import type { PersonaCardModel } from "../../../lib/personaDisplay";
type IntroVideoPreview =
| { kind: "vimeo"; url: string }
| { kind: "video"; url: string }
| null;
function plainTextFromHtml(raw: string): string {
if (!raw.trim()) return "";
if (!/<\/?[a-z][\s\S]*>/i.test(raw)) return raw.trim();
try {
const doc = new DOMParser().parseFromString(raw, "text/html");
return doc.body.textContent?.replace(/\s+/g, " ").trim() ?? "";
} catch {
return raw.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
}
}
export type AddNewPracticeReviewStepProps = {
practiceTitle: string;
practiceDescription: string;
selectedProgram: string;
selectedCourse: string;
moduleLabel: string;
selectedPersona: string | null;
personas: PersonaCardModel[];
introVideoPreview: IntroVideoPreview;
questions: PracticeReviewQuestion[];
saving: boolean;
saveError: string | null;
onEditContext: () => void;
onEditQuestions: () => void;
onBack: () => void;
onSaveDraft: () => void;
onPublish: () => void;
};
export function AddNewPracticeReviewStep({
practiceTitle,
practiceDescription,
selectedProgram,
selectedCourse,
moduleLabel,
selectedPersona,
personas,
introVideoPreview,
questions,
saving,
saveError,
onEditContext,
onEditQuestions,
onBack,
onSaveDraft,
onPublish,
}: AddNewPracticeReviewStepProps) {
const persona = personas.find((p) => p.id === selectedPersona);
const guidanceText = useMemo(() => {
const fromDescription = plainTextFromHtml(practiceDescription);
if (fromDescription) return fromDescription;
const tips = questions
.map((q) => q.tips?.trim() ?? "")
.filter(Boolean)
.join(" ");
return tips || "—";
}, [practiceDescription, questions]);
const thumbnailKind =
introVideoPreview?.kind === "video"
? "video"
: introVideoPreview?.kind === "vimeo"
? "vimeo"
: "gradient";
return (
<PracticeSequentialReview
practiceTitle={practiceTitle}
thumbnailUrl={
introVideoPreview?.kind === "video" ? introVideoPreview.url : null
}
thumbnailKind={thumbnailKind}
persona={persona ?? null}
metadata={[
{ label: "Program", value: selectedProgram },
{ label: "Course", value: selectedCourse },
{ label: "Module", value: moduleLabel },
]}
guidanceText={guidanceText}
questions={questions}
saving={saving}
saveError={saveError}
onEditContext={onEditContext}
onEditQuestions={onEditQuestions}
onBack={onBack}
onSaveDraft={onSaveDraft}
onPublish={onPublish}
/>
);
}

View File

@ -11,7 +11,11 @@ import {
createQuestion,
createQuestionSet,
} from "../../../api/courses.api"
import type { CreateQuestionRequest, PracticeParentKind } from "../../../types/course.types"
import type {
CreateQuestionRequest,
PracticeParentKind,
PracticePublishStatus,
} from "../../../types/course.types"
import { cn } from "../../../lib/utils"
import { SpinnerIcon } from "../../../components/ui/spinner-icon"
@ -60,6 +64,8 @@ export function CreatePracticeWizard({ parent, onCreated }: Props) {
const [storyDescription, setStoryDescription] = useState("")
const [storyImage, setStoryImage] = useState("")
const [quickTips, setQuickTips] = useState("")
const [pendingSaveStatus, setPendingSaveStatus] =
useState<PracticePublishStatus | null>(null)
const canUseWizard = parent != null
@ -79,6 +85,7 @@ export function CreatePracticeWizard({ parent, onCreated }: Props) {
setStoryDescription("")
setStoryImage("")
setQuickTips("")
setPendingSaveStatus(null)
}, [])
const handleStep1 = async () => {
@ -173,12 +180,13 @@ export function CreatePracticeWizard({ parent, onCreated }: Props) {
}
}
const handleStep4 = async () => {
const handleStep4 = async (status: PracticePublishStatus) => {
if (!parent || questionSetId == null) return
if (!practiceTitle.trim() || !storyDescription.trim() || !storyImage.trim()) {
toast.error("Title, story description, and story image are required")
return
}
setPendingSaveStatus(status)
setSaving(true)
try {
await createParentLinkedPractice({
@ -189,8 +197,11 @@ export function CreatePracticeWizard({ parent, onCreated }: Props) {
story_image: storyImage.trim(),
question_set_id: questionSetId,
quick_tips: quickTips.trim(),
publish_status: status,
})
toast.success("Practice created successfully")
toast.success(
status === "PUBLISHED" ? "Practice published" : "Practice saved as draft",
)
resetAll()
onCreated?.()
} catch (e: unknown) {
@ -198,6 +209,7 @@ export function CreatePracticeWizard({ parent, onCreated }: Props) {
toast.error(err.response?.data?.message || err.message || "Failed to create practice")
} finally {
setSaving(false)
setPendingSaveStatus(null)
}
}
@ -468,9 +480,26 @@ export function CreatePracticeWizard({ parent, onCreated }: Props) {
<ChevronLeft className="mr-1 h-4 w-4" />
Back
</Button>
<Button type="button" onClick={handleStep4} disabled={saving}>
{saving ? <SpinnerIcon className="h-4 w-4" /> : <ChevronRight className="mr-1.5 h-4 w-4" />}
Create practice
<Button
type="button"
variant="outline"
disabled={saving}
onClick={() => void handleStep4("DRAFT")}
>
{saving && pendingSaveStatus === "DRAFT" ? (
<SpinnerIcon className="h-4 w-4" />
) : null}
Save as draft
</Button>
<Button
type="button"
disabled={saving}
onClick={() => void handleStep4("PUBLISHED")}
>
{saving && pendingSaveStatus === "PUBLISHED" ? (
<SpinnerIcon className="h-4 w-4" />
) : null}
Publish practice
</Button>
</div>
</div>

View File

@ -0,0 +1,155 @@
import { useEffect, useMemo, useState } from "react";
import { Edit2, Loader2, MoreVertical } from "lucide-react";
import { Button } from "../../../components/ui/button";
import { Card } from "../../../components/ui/card";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "../../../components/ui/dropdown-menu";
import { ResolvedImage } from "../../../components/media/ResolvedImage";
import type { ParentContextPractice } from "../../../types/course.types";
import {
isPracticePublished,
practicePublishStatus,
} from "../../../lib/parentContextPractice";
import { resolveThumbnailForPreview } from "../../../lib/videoPreview";
import { cn } from "../../../lib/utils";
type ModulePracticeCardProps = {
practice: ParentContextPractice;
statusUpdating?: boolean;
onEdit?: () => void;
onPublish?: () => void;
onSaveAsDraft?: () => void;
};
export function ModulePracticeCard({
practice,
statusUpdating = false,
onEdit,
onPublish,
onSaveAsDraft,
}: ModulePracticeCardProps) {
const isPublished = isPracticePublished(practice);
const statusLabel = practicePublishStatus(practice) ?? "DRAFT";
const thumbnailSrc = useMemo(
() => resolveThumbnailForPreview(practice.story_image),
[practice.story_image],
);
const [thumbFailed, setThumbFailed] = useState(false);
useEffect(() => {
setThumbFailed(false);
}, [thumbnailSrc]);
return (
<Card className="group flex flex-col overflow-hidden rounded-[20px] border border-grayScale-50 bg-white shadow-sm transition-all hover:shadow-xl hover:shadow-grayScale-400/5">
<div className="relative h-44 w-full overflow-hidden bg-gradient-to-br from-[#E0F2FE] to-[#BFDBFE]">
{thumbnailSrc && !thumbFailed ? (
<ResolvedImage
src={thumbnailSrc}
alt=""
className="absolute inset-0 h-full w-full object-cover"
onError={() => setThumbFailed(true)}
/>
) : null}
</div>
<div className="flex flex-1 flex-col space-y-5 p-5">
<div className="flex items-center justify-between gap-2">
<div
className={cn(
"flex min-w-0 items-center gap-1.5 rounded-full border px-3 py-1 text-[10px] font-bold uppercase tracking-wider",
isPublished
? "border-[#DCFCE7] bg-[#F0FDF4] text-[#16A34A]"
: "border-grayScale-100 bg-grayScale-50 text-grayScale-400",
)}
>
<div
className={cn(
"h-1.5 w-1.5 flex-shrink-0 rounded-full",
isPublished ? "bg-[#16A34A]" : "bg-grayScale-300",
)}
/>
{statusLabel}
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
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={statusUpdating}
aria-label={`Practice options: ${practice.title}`}
onClick={(e) => e.stopPropagation()}
>
{statusUpdating ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<MoreVertical className="h-4 w-4" />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem
disabled={statusUpdating}
onClick={(e) => {
e.stopPropagation();
if (isPublished) {
onSaveAsDraft?.();
} else {
onPublish?.();
}
}}
>
{isPublished ? "Save as draft" : "Publish practice"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<h3 className="line-clamp-3 min-h-[2.75rem] text-[14px] font-bold leading-snug text-[#0F172A]">
{practice.title}
</h3>
<div className="mt-auto grid grid-cols-1 gap-2 pt-2">
<Button
type="button"
variant="outline"
className="h-10 w-full rounded-[10px] border-brand-500 text-[12px] font-bold text-brand-500 hover:bg-brand-50"
onClick={(e) => {
e.stopPropagation();
onEdit?.();
}}
>
<Edit2 className="mr-1.5 h-3.5 w-3.5" />
Edit
</Button>
<Button
type="button"
disabled={isPublished || statusUpdating}
className={cn(
"h-10 w-full rounded-[10px] text-[12px] font-bold shadow-sm transition-all",
isPublished
? "cursor-default bg-[#ECD5E9] text-[#9E2891] hover:bg-[#ECD5E9]"
: "bg-brand-500 text-white hover:bg-brand-600",
)}
onClick={(e) => {
e.stopPropagation();
if (!isPublished) onPublish?.();
}}
>
{statusUpdating
? "Updating…"
: isPublished
? "Published"
: "Publish"}
</Button>
</div>
</div>
</Card>
);
}

View File

@ -0,0 +1,185 @@
import { useCallback, useEffect, useState } from "react"
import { Loader2 } from "lucide-react"
import { toast } from "sonner"
import {
getPracticesByParentCourse,
getPracticesByParentModule,
publishParentLinkedPractice,
updateParentLinkedPractice,
} from "../../../api/courses.api"
import type { PracticeParentKind } from "../../../types/course.types"
import { Button } from "../../../components/ui/button"
import { cn } from "../../../lib/utils"
import {
draftPracticesForParent,
isPracticePublished,
unwrapPracticesList,
} from "../../../lib/parentContextPractice"
type Props = {
parentKind: Extract<PracticeParentKind, "COURSE" | "MODULE">
parentId: number
className?: string
onPublished?: () => void
}
export function PublishPracticeButton({
parentKind,
parentId,
className,
onPublished,
}: Props) {
const [loading, setLoading] = useState(true)
const [acting, setActing] = useState(false)
const [hasDraft, setHasDraft] = useState(false)
const [allPublished, setAllPublished] = useState(false)
const [hasPractice, setHasPractice] = useState(false)
const loadPractices = useCallback(async () => {
if (!Number.isFinite(parentId) || parentId < 1) {
setHasPractice(false)
setHasDraft(false)
setAllPublished(false)
setLoading(false)
return
}
setLoading(true)
try {
const res =
parentKind === "COURSE"
? await getPracticesByParentCourse(parentId, { limit: 50, offset: 0 })
: await getPracticesByParentModule(parentId, { limit: 50, offset: 0 })
const list = unwrapPracticesList(res)
const drafts = draftPracticesForParent(list)
setHasPractice(list.length > 0)
setHasDraft(drafts.length > 0)
setAllPublished(
list.length > 0 && list.every((p) => isPracticePublished(p)),
)
} catch {
setHasPractice(false)
setHasDraft(false)
setAllPublished(false)
} finally {
setLoading(false)
}
}, [parentKind, parentId])
useEffect(() => {
void loadPractices()
}, [loadPractices])
const isDraftMode = allPublished
const handlePublish = async () => {
if (!Number.isFinite(parentId) || parentId < 1) return
setActing(true)
try {
const res =
parentKind === "COURSE"
? await getPracticesByParentCourse(parentId, { limit: 50, offset: 0 })
: await getPracticesByParentModule(parentId, { limit: 50, offset: 0 })
const drafts = draftPracticesForParent(unwrapPracticesList(res))
if (drafts.length === 0) {
toast.info("No draft practice to publish")
await loadPractices()
return
}
for (const practice of drafts) {
await publishParentLinkedPractice(practice.id)
}
toast.success(
drafts.length === 1
? "Practice published"
: `${drafts.length} practices published`,
)
await loadPractices()
onPublished?.()
} catch (e: unknown) {
const msg =
(e as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to publish practice"
toast.error(msg)
} finally {
setActing(false)
}
}
const handleSaveAsDraft = async () => {
if (!Number.isFinite(parentId) || parentId < 1) return
setActing(true)
try {
const res =
parentKind === "COURSE"
? await getPracticesByParentCourse(parentId, { limit: 50, offset: 0 })
: await getPracticesByParentModule(parentId, { limit: 50, offset: 0 })
const toDraft = unwrapPracticesList(res).filter(isPracticePublished)
if (toDraft.length === 0) {
toast.info("No published practice to save as draft")
await loadPractices()
return
}
for (const practice of toDraft) {
await updateParentLinkedPractice(practice.id, {
publish_status: "DRAFT",
})
}
toast.success(
toDraft.length === 1
? "Practice saved as draft"
: `${toDraft.length} practices saved as draft`,
)
await loadPractices()
onPublished?.()
} catch (e: unknown) {
const msg =
(e as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to save practice as draft"
toast.error(msg)
} finally {
setActing(false)
}
}
const disabled =
loading ||
acting ||
!hasPractice ||
(!hasDraft && !allPublished)
let label = "Publish Practice"
if (loading) label = "Loading…"
else if (acting) label = isDraftMode ? "Saving…" : "Publishing…"
else if (allPublished) label = "Save as Draft"
const handleClick = () => {
if (isDraftMode) {
void handleSaveAsDraft()
return
}
void handlePublish()
}
return (
<Button
type="button"
className={cn(className)}
disabled={disabled}
onClick={handleClick}
title={
!hasPractice
? "No practice linked to this course yet"
: allPublished
? "Move published practice back to draft"
: hasDraft
? "Publish draft practice"
: undefined
}
>
{(loading || acting) && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
{label}
</Button>
)
}

View File

@ -1,6 +1,21 @@
import { useEffect, useMemo, useState } from "react";
import { MoreVertical, Edit2, Play, Pencil, Trash2, Calendar } from "lucide-react";
import {
BookOpen,
Calendar,
Edit2,
Loader2,
MoreVertical,
Pencil,
Play,
Trash2,
} from "lucide-react";
import { Button } from "../../../components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "../../../components/ui/dropdown-menu";
import {
Dialog,
DialogContent,
@ -13,17 +28,46 @@ import {
applyShortPreviewToEmbedUrl,
DEFAULT_PREVIEW_MAX_SECONDS,
formatPreviewLength,
formatVideoDurationLabel,
getVideoPreview,
isDirectVideoFileUrl,
} from "../../../lib/videoPreview";
import { PreviewLimitedFileVideo } from "./PreviewLimitedFileVideo";
import type { PracticePublishStatus } from "../../../types/course.types";
function resolvePublishBadge(
publishStatus?: PracticePublishStatus | string | null,
status?: "Draft" | "Published",
hoverModuleActions?: boolean,
): { label: string; isPublished: boolean } | null {
const raw =
publishStatus ??
(status === "Published"
? "PUBLISHED"
: status === "Draft"
? "DRAFT"
: null);
if (raw) {
const label = String(raw).toUpperCase();
return { label, isPublished: label === "PUBLISHED" };
}
if (hoverModuleActions) {
return { label: "DRAFT", isPublished: false };
}
return null;
}
interface VideoCardProps {
id?: string | number;
title: string;
/** Omits the duration chip when not provided (e.g. API has no length yet). */
duration?: string;
/** Total seconds; shown when `duration` string is omitted. Direct file URLs may still be probed in-browser. */
durationSeconds?: number | null;
/** When omitted, shows a neutral "Lesson" chip and no Publish button. */
status?: "Draft" | "Published";
/** From GET lesson list — preferred for module lesson cards (`PUBLISHED` / `DRAFT`). */
publishStatus?: PracticePublishStatus | string | null;
thumbnailGradient?: string;
thumbnailUrl?: string | null;
/**
@ -32,15 +76,20 @@ interface VideoCardProps {
*/
videoUrl?: string;
/**
* When true, shows edit/delete in the top-right of the thumbnail (same
* hover pattern as module cards) and removes the footer + overflow menu.
* When true, shows edit/delete (and optional view practices) in the top-right
* of the thumbnail on hover, and removes the footer + overflow menu.
*/
hoverModuleActions?: boolean;
onEdit?: () => void;
onDelete?: () => void;
/** When set (e.g. on module lesson cards), shows an "Add practice" control scoped to this lesson. */
onAddPractice?: () => void;
/** When set with hoverModuleActions, shows a book icon next to edit/delete on thumbnail hover. */
onViewPractices?: () => void;
onPublish?: () => void;
/** Toggle draft ↔ published via PUT /lessons/:id (module lesson cards). */
onTogglePublishStatus?: (nextStatus: PracticePublishStatus) => void;
publishStatusUpdating?: boolean;
/** Shown under title on module lesson cards; reserved height keeps grid rows even. */
description?: string | null;
}
@ -48,7 +97,9 @@ interface VideoCardProps {
export function VideoCard({
title,
duration,
durationSeconds,
status,
publishStatus,
thumbnailGradient = "from-[#CBD5E1] to-[#94A3B8]",
thumbnailUrl,
videoUrl,
@ -56,10 +107,16 @@ export function VideoCard({
onDelete,
onPublish,
onAddPractice,
onViewPractices,
onTogglePublishStatus,
publishStatusUpdating = false,
hoverModuleActions = false,
description,
}: VideoCardProps) {
const [thumbFailed, setThumbFailed] = useState(false);
const [probedDurationSeconds, setProbedDurationSeconds] = useState<
number | null
>(null);
const [previewOpen, setPreviewOpen] = useState(false);
/** Iframe players ignore URL limits in many cases — unmount after real time. */
const [iframeSessionDone, setIframeSessionDone] = useState(false);
@ -81,6 +138,77 @@ export function VideoCard({
const previewLengthLabel = formatPreviewLength(
DEFAULT_PREVIEW_MAX_SECONDS,
);
const publishBadge = resolvePublishBadge(
publishStatus,
status,
hoverModuleActions,
);
useEffect(() => {
if (duration?.trim()) {
setProbedDurationSeconds(null);
return;
}
if (
typeof durationSeconds === "number" &&
Number.isFinite(durationSeconds) &&
durationSeconds > 0
) {
setProbedDurationSeconds(null);
return;
}
const url = videoUrl?.trim() ?? "";
if (!isDirectVideoFileUrl(url)) {
setProbedDurationSeconds(null);
return;
}
let cancelled = false;
const video = document.createElement("video");
video.preload = "metadata";
video.muted = true;
const onLoaded = () => {
if (cancelled) return;
const d = video.duration;
if (Number.isFinite(d) && d > 0 && !Number.isNaN(d)) {
setProbedDurationSeconds(d);
} else {
setProbedDurationSeconds(null);
}
};
const onError = () => {
if (!cancelled) setProbedDurationSeconds(null);
};
video.addEventListener("loadedmetadata", onLoaded);
video.addEventListener("error", onError);
video.src = url;
return () => {
cancelled = true;
video.removeEventListener("loadedmetadata", onLoaded);
video.removeEventListener("error", onError);
video.removeAttribute("src");
video.load();
};
}, [duration, durationSeconds, videoUrl]);
const durationLabel = (() => {
const trimmed = duration?.trim();
if (trimmed) return trimmed;
const fromApi =
typeof durationSeconds === "number" &&
Number.isFinite(durationSeconds) &&
durationSeconds > 0
? durationSeconds
: null;
if (fromApi != null) return formatVideoDurationLabel(fromApi);
if (
probedDurationSeconds != null &&
Number.isFinite(probedDurationSeconds) &&
probedDurationSeconds > 0
) {
return formatVideoDurationLabel(probedDurationSeconds);
}
return null;
})();
useEffect(() => {
if (!previewOpen) {
@ -128,10 +256,25 @@ export function VideoCard({
!useGradient && "bg-grayScale-100",
)}
>
{hoverModuleActions && (onEdit || onDelete) ? (
{hoverModuleActions && (onEdit || onDelete || onViewPractices) ? (
<div
className="absolute right-2 top-2 z-20 flex translate-y-1 gap-1 opacity-0 pointer-events-none transition-all duration-300 ease-out group-hover:translate-y-0 group-hover:opacity-100 group-hover:pointer-events-auto"
>
{onViewPractices ? (
<Button
type="button"
variant="secondary"
size="icon"
className="h-8 w-8 rounded-md bg-white/95 text-brand-600 shadow-sm transition-colors hover:bg-brand-50"
aria-label={`View practices for ${title}`}
onClick={(e) => {
e.stopPropagation();
onViewPractices();
}}
>
<BookOpen className="h-3.5 w-3.5" />
</Button>
) : null}
{onEdit ? (
<Button
type="button"
@ -172,10 +315,10 @@ export function VideoCard({
onError={() => setThumbFailed(true)}
/>
) : null}
{/* Duration Badge */}
{duration ? (
<div className="absolute bottom-3 right-3 z-10 bg-black/70 text-white text-[11px] font-bold px-2 py-1 rounded-md backdrop-blur-sm">
{duration}
{/* Duration — bottom-right on thumbnail */}
{durationLabel ? (
<div className="pointer-events-none absolute bottom-2 right-2 z-[12] rounded bg-black/75 px-1.5 py-0.5 text-[11px] font-semibold tabular-nums text-white shadow-sm backdrop-blur-sm">
{durationLabel}
</div>
) : null}
{/* Play: opens preview dialog when videoUrl is set */}
@ -307,34 +450,69 @@ export function VideoCard({
<div
className={cn(
"mb-4 flex shrink-0 items-center gap-2",
hoverModuleActions ? "justify-start" : "justify-between",
"justify-between",
)}
>
{/* Status Badge */}
{status ? (
{/* Publish status badge */}
{publishBadge ? (
<div
className={cn(
"flex items-center gap-1.5 px-3 py-1 rounded-full text-[11px] font-bold uppercase tracking-wider border min-w-0",
status === "Published"
? "bg-[#ECFDF5] text-[#059669] border-[#D1FAE5]"
: "bg-[#F3F4F6] text-[#6B7280] border-[#E5E7EB]",
"flex min-w-0 items-center gap-1.5 rounded-full border px-3 py-1 text-[11px] font-bold uppercase tracking-wider",
publishBadge.isPublished
? "border-[#D1FAE5] bg-[#ECFDF5] text-[#059669]"
: "border-[#E5E7EB] bg-grayScale-50 text-grayScale-500",
)}
>
<div
className={cn(
"h-1.5 w-1.5 rounded-full flex-shrink-0",
status === "Published" ? "bg-[#10B981]" : "bg-[#9CA3AF]",
"h-1.5 w-1.5 flex-shrink-0 rounded-full",
publishBadge.isPublished ? "bg-[#10B981]" : "bg-[#9CA3AF]",
)}
/>
{status}
{publishBadge.label}
</div>
) : (
<div className="flex min-w-0 items-center gap-1.5 px-3 py-1 rounded-full text-[11px] font-bold uppercase tracking-wider border border-[#E5E7EB] bg-grayScale-50 text-grayScale-500">
<div className="h-1.5 w-1.5 rounded-full flex-shrink-0 bg-[#9CA3AF]" />
<div className="flex min-w-0 items-center gap-1.5 rounded-full border border-[#E5E7EB] bg-grayScale-50 px-3 py-1 text-[11px] font-bold uppercase tracking-wider text-grayScale-500">
<div className="h-1.5 w-1.5 flex-shrink-0 rounded-full bg-[#9CA3AF]" />
Lesson
</div>
)}
{!hoverModuleActions ? (
{hoverModuleActions && onTogglePublishStatus ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
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}
aria-label={`Lesson options: ${title}`}
onClick={(e) => e.stopPropagation()}
>
{publishStatusUpdating ? (
<Loader2 className="h-5 w-5 animate-spin" />
) : (
<MoreVertical className="h-5 w-5" />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem
disabled={publishStatusUpdating}
onClick={(e) => {
e.stopPropagation();
onTogglePublishStatus(
publishBadge?.isPublished ? "DRAFT" : "PUBLISHED",
);
}}
>
{publishBadge?.isPublished
? "Save as draft"
: "Publish lesson"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : !hoverModuleActions ? (
<button
type="button"
className="h-8 w-8 flex flex-shrink-0 items-center justify-center rounded-full hover:bg-grayScale-50 transition-colors text-grayScale-400"

View File

@ -1,35 +1,79 @@
import { GraduationCap, ArrowRight, LayoutGrid, Monitor } from "lucide-react";
import { useRef, useState, type ChangeEvent } from "react";
import { ArrowRight, Loader2, Upload } from "lucide-react";
import { Button } from "../../../../components/ui/button";
import { Card } from "../../../../components/ui/card";
import { Select } from "../../../../components/ui/select";
import { Input } from "../../../../components/ui/input";
import { Textarea } from "../../../../components/ui/textarea";
import { toast } from "sonner";
import { uploadImageFile } from "../../../../api/files.api";
interface ContextStepProps {
formData: any;
setFormData: (data: any) => void;
nextStep: () => void;
navigate: (path: string) => void;
level: string;
isModuleContext?: boolean;
isCourseContext?: boolean;
onCancel: () => void;
/** Lesson-linked practice: no title, story description, or story image on step 1. */
isLessonPractice?: boolean;
lessonTitle?: string | null;
}
/**
* Module / lesson entry: fields that map to POST /practices and POST /question-sets.
*/
export function ContextStep({
formData,
setFormData,
nextStep,
navigate,
level,
isModuleContext,
isCourseContext,
onCancel,
isLessonPractice = false,
lessonTitle = null,
}: ContextStepProps) {
const storyFileRef = useRef<HTMLInputElement>(null);
const [uploadingStory, setUploadingStory] = useState(false);
const handleStoryImageFile = async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
event.target.value = "";
if (!file) return;
setUploadingStory(true);
try {
const res = await uploadImageFile(file);
const url = res.data?.data?.url?.trim();
if (!url) throw new Error("Missing image URL from upload");
setFormData({ ...formData, storyImageUrl: url });
toast.success("Story image uploaded");
} catch {
toast.error("Could not upload story image");
} finally {
setUploadingStory(false);
}
};
const canContinue = isLessonPractice
? true
: Boolean(formData.title?.trim()) && Boolean(formData.description?.trim());
return (
<Card className="overflow-hidden border-grayScale-300 rounded-2xl bg-white animate-in fade-in duration-500">
<Card className="overflow-hidden border-grayScale-300 rounded-2xl bg-white animate-in fade-in duration-500">
<div className="border-b border-grayScale-50 px-8 pt-8 pb-4">
<h2 className="text-xl font-bold text-grayScale-900 leading-none">
Step 1: Context Definition
{isLessonPractice ? "Practice options" : "Practice details"}
</h2>
<p className="text-grayScale-600 text-base mt-3">
Define the educational level and curriculum module for this practice.
{isLessonPractice ? (
<>
This practice is linked to{" "}
<span className="font-medium text-grayScale-800">
{lessonTitle?.trim() || "the selected lesson"}
</span>
. Set optional quick tips and question order below.
</>
) : (
<>
Title, story, optional image, shuffle, and quick tips match the create
practice and question set APIs.
</>
)}
</p>
</div>
@ -40,123 +84,129 @@ export function ContextStep({
<div className="relative flex justify-center">
<div
className="h-[0.5px] w-full opacity-20 rounded-full"
style={{
background: "gray",
}}
style={{ background: "gray" }}
/>
</div>
</div>
<div className="space-y-10 p-10">
{/* Program Field */}
<div className="space-y-3">
<label className="text-[16px] text-grayScale-700 ml-1">
Program{" "}
<span className="text-grayScale-300 font-medium">
(Auto-selected)
</span>
</label>
<div className="relative">
<div className="absolute left-6 top-1/2 -translate-y-1/2">
<GraduationCap className="h-6 w-6 text-grayScale-600" />
</div>
<Select
className="h-12 w-full rounded-[6px] border-grayScale-400 bg-[#fff] pl-16 text-grayScale-800 font-bold focus:border-brand-500 focus:ring-0 transition-all cursor-default"
disabled
>
<option>{formData.program || "Intermediate"}</option>
</Select>
</div>
</div>
{/* Course Field */}
<div className="space-y-3">
<label className="text-[16px] font-bold text-grayScale-700 ml-1">
Course{" "}
<span className="text-grayScale-300 font-medium">
(Auto-selected)
</span>
</label>
<div className="relative">
<div className="absolute left-6 top-1/2 -translate-y-1/2">
<GraduationCap className="h-6 w-6 text-grayScale-600" />
</div>
<Select
className="h-12 w-full rounded-[6px] border-grayScale-400 bg-[#fff] pl-16 text-grayScale-800 font-bold focus:border-brand-500 focus:ring-0 transition-all cursor-default"
disabled
>
<option>{formData.course || "B2"}</option>
</Select>
</div>
</div>
{/* Select Module Field */}
{(isModuleContext || isCourseContext) && (
<div className="space-y-3">
<label className="text-[16px] font-bold text-grayScale-700 ml-1">
Select Module
</label>
<div className="relative">
<div className="absolute left-6 top-1/2 -translate-y-1/2">
<LayoutGrid className="h-6 w-6 text-grayScale-400" />
</div>
<Select className="h-12 w-full rounded-[6px] border-grayScale-300 bg-[#fff] pl-16 text-grayScale-800 font-bold focus:border-brand-500 focus:ring-0 transition-all">
<option value="">Choose a module...</option>
<option value="m1">Introduction Basics</option>
<option value="m2">Daily Routines</option>
<option value="m3">Travel Essentials</option>
</Select>
</div>
<p className="text-[13px] text-grayScale-400 font-medium px-2">
Select the specific learning module this practice will reinforce.
</p>
</div>
)}
{/* Select Video Field (Conditional) */}
{isModuleContext && (
<div className="space-y-3 animate-in fade-in slide-in-from-top-2 duration-300">
<label className="text-[16px] font-bold text-grayScale-700 ml-1">
Select Video
</label>
<div className="relative">
<div className="absolute left-6 top-1/2 -translate-y-1/2">
<Monitor className="h-6 w-6 text-grayScale-400" />
</div>
<Select
className="h-12 w-full rounded-[6px] border-grayScale-300 bg-[#fff] pl-16 text-grayScale-800 font-bold focus:border-brand-500 focus:ring-0 transition-all"
value={formData.selectedVideo}
<div className="space-y-8 p-10">
{!isLessonPractice ? (
<>
<div className="space-y-2">
<label className="text-sm font-medium text-grayScale-700">
Practice title <span className="text-red-500">*</span>
</label>
<Input
value={formData.title ?? ""}
onChange={(e) =>
setFormData({ ...formData, selectedVideo: e.target.value })
setFormData({ ...formData, title: e.target.value })
}
>
<option value="">Choose a video</option>
<option value="v1">Intro to Greetings</option>
<option value="v2">Advanced Grammar</option>
</Select>
placeholder="e.g. Module conversation drill"
className="h-11 rounded-xl border-grayScale-200"
/>
</div>
<p className="text-[13px] text-grayScale-400 font-medium px-2">
Select the specific learning module this practice will reinforce.
</p>
<div className="space-y-2">
<label className="text-sm font-medium text-grayScale-700">
Story description <span className="text-red-500">*</span>
</label>
<Textarea
value={formData.description ?? ""}
onChange={(e) =>
setFormData({ ...formData, description: e.target.value })
}
placeholder="Short scenario for learners…"
className="min-h-[120px] rounded-xl border-grayScale-200"
maxLength={2000}
/>
</div>
</>
) : null}
<div className="space-y-2">
<label className="text-sm font-medium text-grayScale-700">
Quick tips <span className="text-grayScale-400">(optional)</span>
</label>
<Textarea
value={formData.tips ?? ""}
onChange={(e) =>
setFormData({ ...formData, tips: e.target.value })
}
placeholder="Learner-facing tips (quick_tips on POST /practices)"
className="min-h-[80px] rounded-xl border-grayScale-200"
maxLength={1000}
/>
</div>
{!isLessonPractice ? (
<div className="space-y-2">
<label className="text-sm font-medium text-grayScale-700">
Story image <span className="text-grayScale-400">(optional)</span>
</label>
<Input
value={formData.storyImageUrl ?? ""}
onChange={(e) =>
setFormData({ ...formData, storyImageUrl: e.target.value })
}
placeholder="https://… or upload"
className="h-11 rounded-xl border-grayScale-200 font-mono text-[13px]"
/>
<input
ref={storyFileRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleStoryImageFile}
/>
<Button
type="button"
variant="outline"
size="sm"
disabled={uploadingStory}
onClick={() => storyFileRef.current?.click()}
className="gap-2"
>
{uploadingStory ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Upload className="h-4 w-4" />
)}
Upload image
</Button>
</div>
)}
) : null}
<label className="flex cursor-pointer items-center gap-3 text-sm text-grayScale-700">
<input
type="checkbox"
className="h-4 w-4 rounded border-grayScale-300 text-brand-600 focus:ring-brand-500"
checked={Boolean(formData.shuffleQuestions)}
onChange={(e) =>
setFormData({
...formData,
shuffleQuestions: e.target.checked,
})
}
/>
<span>Shuffle questions in the set</span>
</label>
</div>
<div className="flex items-center justify-between border-t border-grayScale-100 bg-[#F8FAFC] p-4 px-12">
<button
type="button"
className="text-[14px] font-bold text-grayScale-500 transition-colors hover:text-grayScale-700"
onClick={() =>
navigate(`/new-content/learn-english/${level}/courses`)
}
onClick={onCancel}
>
Cancel
</button>
<Button
type="button"
onClick={nextStep}
className="h-10 px-10 rounded-[6px] bg-brand-500 text-[14px] font-bold text-white transition-all active:scale-95 flex items-center gap-2"
disabled={!canContinue}
className="h-10 px-10 rounded-[6px] bg-brand-500 text-[14px] font-bold text-white transition-all active:scale-95 flex items-center gap-2 disabled:opacity-50"
>
Next: {isModuleContext ? "Persona" : "Scenario"}{" "}
<ArrowRight className="h-5 w-5" />
Next: Persona <ArrowRight className="h-5 w-5" />
</Button>
</div>
</Card>

View File

@ -1,4 +1,4 @@
import { Check, ArrowRight } from "lucide-react";
import { Check, ArrowRight, Loader2 } from "lucide-react";
import { Button } from "../../../../components/ui/button";
import {
Avatar,
@ -6,9 +6,13 @@ import {
AvatarImage,
} from "../../../../components/ui/avatar";
import { cn } from "../../../../lib/utils";
import { PERSONAS } from "./constants";
import type { PersonaCardModel } from "../../../../lib/personaDisplay";
interface PersonaStepProps {
personas: PersonaCardModel[];
loading?: boolean;
error?: string | null;
onRetry?: () => void;
selectedPersona: string | null;
setSelectedPersona: (id: string) => void;
nextStep: () => void;
@ -16,6 +20,10 @@ interface PersonaStepProps {
}
export function PersonaStep({
personas,
loading = false,
error = null,
onRetry,
selectedPersona,
setSelectedPersona,
nextStep,
@ -25,66 +33,109 @@ export function PersonaStep({
<div className="space-y-8">
<div className="space-y-1 px-2">
<h2 className="text-2xl font-extrabold text-grayScale-700">
Select Personas
Select Persona
</h2>
<p className="text-grayScale-400 text-lg">
Choose the characters that will participate in this practice scenario.
<p className="text-lg text-grayScale-400">
Choose the character that will guide this practice scenario.
</p>
</div>
<div className="grid grid-cols-2 gap-6 sm:grid-cols-4">
{PERSONAS.map((persona) => {
const isSelected = selectedPersona === persona.id;
return (
<div
key={persona.id}
onClick={() => setSelectedPersona(persona.id)}
className={cn(
"group relative w-[260px] cursor-pointer rounded-2xl border-2 bg-white p-6 transition-all duration-300",
isSelected
? "border-brand-500"
: "border-grayScale-100 hover:border-brand-200",
)}
{loading ? (
<div className="flex flex-col items-center justify-center py-16">
<Loader2 className="h-10 w-10 animate-spin text-brand-500" />
<p className="mt-4 text-sm font-medium text-grayScale-500">
Loading personas
</p>
</div>
) : error ? (
<div className="rounded-xl border border-red-200 bg-red-50 px-6 py-8 text-center">
<p className="text-sm font-medium text-red-700">{error}</p>
{onRetry ? (
<Button
type="button"
variant="outline"
className="mt-4"
onClick={onRetry}
>
{/* Top-right checkmark badge */}
{isSelected && (
<div className="absolute right-2.5 top-2.5 grid h-6 w-6 place-items-center rounded-full bg-brand-500 text-white z-10">
<Check className="h-4 w-4 stroke-[3]" />
Try again
</Button>
) : null}
</div>
) : personas.length === 0 ? (
<div className="rounded-xl border border-dashed border-grayScale-200 bg-grayScale-50/50 px-6 py-12 text-center">
<p className="text-sm font-medium text-grayScale-600">
No active personas available.
</p>
<p className="mt-1 text-sm text-grayScale-400">
Add personas in the admin panel, then return here.
</p>
</div>
) : (
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{personas.map((persona) => {
const isSelected = selectedPersona === persona.id;
return (
<button
key={persona.id}
type="button"
onClick={() => setSelectedPersona(persona.id)}
className={cn(
"group relative w-full cursor-pointer rounded-2xl border-2 bg-white p-6 text-left transition-all duration-300",
isSelected
? "border-brand-500 shadow-md shadow-brand-100/50"
: "border-grayScale-100 hover:border-brand-200",
)}
>
{isSelected && (
<div className="absolute right-2.5 top-2.5 z-10 grid h-6 w-6 place-items-center rounded-full bg-brand-500 text-white">
<Check className="h-4 w-4 stroke-[3]" />
</div>
)}
<div className="flex flex-col items-center gap-4">
<div
className={cn(
"rounded-full p-[3px] transition-all duration-300",
isSelected ? "bg-brand-500" : "bg-transparent",
)}
>
<Avatar className="h-24 w-24 border-2 border-white">
<AvatarImage src={persona.avatar} alt={persona.name} />
<AvatarFallback>
{persona.name.substring(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
</div>
<div className="space-y-1 text-center">
<span className="block text-lg font-bold text-grayScale-700">
{persona.name}
</span>
{persona.description ? (
<span className="block text-xs leading-relaxed text-grayScale-500 line-clamp-3">
{persona.description}
</span>
) : null}
</div>
</div>
)}
<div className="flex flex-col items-center gap-4">
{/* Avatar with conditional purple ring */}
<div
className={cn(
"rounded-full p-[3px] transition-all duration-300",
isSelected ? "bg-brand-500" : "bg-transparent",
)}
>
<Avatar className="h-24 w-24 border-2 border-white">
<AvatarImage src={persona.avatar} />
<AvatarFallback>
{persona.name.substring(0, 2)}
</AvatarFallback>
</Avatar>
</div>
<span className="text-lg font-bold text-grayScale-700">
{persona.name}
</span>
</div>
</div>
);
})}
</div>
</button>
);
})}
</div>
)}
<div className="flex items-center justify-between pt-8">
<Button
type="button"
onClick={prevStep}
variant="outline"
className="h-10 w-20 rounded-[6px] border-grayScale-200 text-grayScale-600"
className="h-10 w-20 rounded-[6px] border-grayScale-200 text-grayScale-600"
>
Back
</Button>
<Button
type="button"
onClick={nextStep}
className="h-10 rounded-[6px] bg-brand-500 px-8 hover:bg-brand-600 shadow-md shadow-brand-500/20"
disabled={!selectedPersona || loading || personas.length === 0}
className="h-10 rounded-[6px] bg-brand-500 px-8 shadow-md shadow-brand-500/20 hover:bg-brand-600 disabled:opacity-50"
>
Next: Questions <ArrowRight className="ml-2 h-4 w-4" />
</Button>

View File

@ -0,0 +1,407 @@
import { useState } from "react";
import { Edit, Info, Loader2, Play, Rocket } from "lucide-react";
import { Button } from "../../../../components/ui/button";
import { Card } from "../../../../components/ui/card";
import { cn } from "../../../../lib/utils";
export type PracticeReviewQuestion = {
id: string;
questionText: string;
voicePrompt: string;
sampleAnswerVoicePrompt: string;
tips?: string;
};
export type PracticeReviewMetadataItem = {
label: string;
value: string;
};
export type PracticeSequentialReviewProps = {
practiceTitle: string;
thumbnailUrl?: string | null;
thumbnailKind?: "image" | "video" | "vimeo" | "gradient";
persona?: { name: string; avatar: string } | null;
metadata?: PracticeReviewMetadataItem[];
parentLink?: string | null;
guidanceText: string;
questions: PracticeReviewQuestion[];
saving?: boolean;
saveError?: string | null;
canPublish?: boolean;
showMissingParentWarning?: boolean;
onEditContext?: () => void;
onEditQuestions?: () => void;
onBack: () => void;
onSaveDraft: () => void;
onPublish: () => void;
sectionTitle?: string;
sectionSubtitle?: string;
};
function audioFileLabel(url: string, fallback: string): string {
const trimmed = url.trim();
if (!trimmed) return fallback;
try {
const path = new URL(trimmed).pathname.split("/").filter(Boolean).pop();
if (path) return decodeURIComponent(path);
} catch {
// fall through
}
const seg = trimmed.split("/").filter(Boolean).pop();
return seg && seg.length < 64 ? seg : fallback;
}
export function ReviewAudioPlayer({
src,
label,
className,
}: {
src: string;
label: string;
className?: string;
}) {
const [playing, setPlaying] = useState(false);
const fileName = audioFileLabel(src, label);
if (!src.trim()) {
return (
<p className="text-xs italic text-grayScale-400">No audio URL provided</p>
);
}
return (
<div
className={cn(
"flex items-center gap-2 rounded-lg border border-brand-100 bg-brand-50/40 px-2 py-1.5",
className,
)}
>
<button
type="button"
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-brand-500 text-white shadow-sm transition-colors hover:bg-brand-600"
aria-label={`Play ${fileName}`}
onClick={() => {
const audio = new Audio(src);
setPlaying(true);
void audio.play().finally(() => setPlaying(false));
}}
>
<Play
className={cn("h-3.5 w-3.5", playing && "opacity-80")}
fill="currentColor"
/>
</button>
<div className="flex min-w-0 flex-1 items-center gap-2">
<div
className="flex h-6 flex-1 items-end gap-0.5 px-1 opacity-70"
aria-hidden
>
{[3, 5, 4, 7, 5, 8, 4, 6, 5, 4, 6, 4].map((h, i) => (
<span
key={i}
className="w-0.5 shrink-0 rounded-full bg-brand-400"
style={{ height: `${h + 4}px` }}
/>
))}
</div>
<span className="max-w-[8rem] truncate text-[11px] font-medium text-brand-700 sm:max-w-[10rem]">
{fileName}
</span>
</div>
<span className="sr-only">{src}</span>
</div>
);
}
function formatQuestionIndex(index: number): string {
return String(index + 1).padStart(2, "0");
}
export function PracticeSequentialReview({
practiceTitle,
thumbnailUrl,
thumbnailKind = "gradient",
persona = null,
metadata = [],
parentLink = null,
guidanceText,
questions,
saving = false,
saveError = null,
canPublish = true,
showMissingParentWarning = false,
onEditContext,
onEditQuestions,
onBack,
onSaveDraft,
onPublish,
sectionTitle = "Create Practice Questions",
sectionSubtitle = "Define the dialogue flow and interactions for this scenario.",
}: PracticeSequentialReviewProps) {
const filledQuestions = questions.filter((q) => q.questionText.trim());
return (
<div className="w-full space-y-6">
{sectionTitle ? (
<div className="space-y-1 px-0.5">
<h2 className="text-xl font-bold tracking-tight text-grayScale-900 sm:text-2xl">
{sectionTitle}
</h2>
{sectionSubtitle ? (
<p className="text-sm text-grayScale-500 sm:text-[15px]">
{sectionSubtitle}
</p>
) : null}
</div>
) : null}
{showMissingParentWarning && !canPublish ? (
<div className="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-950">
<p className="font-semibold">Missing parent for the API</p>
<p className="mt-1 text-amber-900/90">
Open Add Practice from a course, module, or lesson so parent IDs are
in the URL.
</p>
</div>
) : null}
<Card className="overflow-hidden border-grayScale-200/80 p-0 shadow-sm">
<div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-4">
<h3 className="font-semibold text-grayScale-900">Basic Information</h3>
{onEditContext ? (
<button
type="button"
onClick={onEditContext}
className="flex items-center gap-1.5 rounded-md px-2 py-1 text-sm font-medium text-brand-500 transition-colors hover:bg-brand-50 hover:text-brand-600"
>
<Edit className="h-3.5 w-3.5" />
Edit
</button>
) : null}
</div>
<div className="flex flex-col gap-6 p-6 sm:flex-row sm:items-start">
<div className="h-[70px] w-[85px] shrink-0 overflow-hidden rounded-xl bg-grayScale-100 shadow-inner sm:h-20 sm:w-24">
{thumbnailKind === "video" && thumbnailUrl ? (
<video
src={thumbnailUrl}
className="h-full w-full object-cover"
muted
playsInline
/>
) : thumbnailKind === "vimeo" ? (
<div className="flex h-full w-full items-center justify-center bg-gradient-to-br from-grayScale-200 to-grayScale-300">
<Play className="h-6 w-6 text-brand-500" fill="currentColor" />
</div>
) : thumbnailUrl?.trim() ? (
<img
src={thumbnailUrl}
alt=""
className="h-full w-full object-cover"
/>
) : persona?.avatar ? (
<img
src={persona.avatar}
alt=""
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full w-full items-center justify-center bg-gradient-to-br from-[#E0F2FE] to-[#BFDBFE]" />
)}
</div>
<div className="min-w-0 flex-1 space-y-3">
<h4 className="text-lg font-bold leading-tight text-grayScale-900 sm:text-xl">
{practiceTitle.trim() || "Untitled Practice"}
</h4>
{metadata.length > 0 ? (
<dl className="flex flex-wrap gap-x-6 gap-y-1 text-sm">
{metadata.map((item) => (
<div key={item.label}>
<dt className="inline text-grayScale-900">{item.label}: </dt>
<dd className="inline font-medium text-brand-600">
{item.value}
</dd>
</div>
))}
</dl>
) : parentLink ? (
<p className="text-sm text-grayScale-600">
<span className="font-medium text-grayScale-800">Link:</span>{" "}
{parentLink}
</p>
) : null}
</div>
<div className="flex shrink-0 flex-col items-center gap-2 sm:items-end">
<span className="text-[10px] font-bold uppercase tracking-wider text-grayScale-500">
Persona
</span>
{persona ? (
<div className="flex flex-col items-center gap-1.5">
<div className="h-12 w-12 overflow-hidden rounded-full bg-grayScale-100 ring-2 ring-brand-100">
<img
src={persona.avatar}
alt=""
className="h-full w-full object-cover"
/>
</div>
<span className="text-sm font-semibold text-grayScale-900">
{persona.name}
</span>
</div>
) : (
<span className="text-sm text-grayScale-400">None selected</span>
)}
</div>
</div>
</Card>
<div className="space-y-3 px-0.5">
<div className="flex items-center gap-2">
<span className="text-[11px] font-bold uppercase tracking-[0.14em] text-grayScale-900">
Tips / Guidance
</span>
<Info className="h-4 w-4 text-brand-500" />
</div>
<div className="rounded-xl border border-grayScale-200 bg-white px-5 py-4 shadow-sm">
<p className="text-sm leading-relaxed text-grayScale-600">
{guidanceText.trim() || "—"}
</p>
</div>
</div>
<Card className="overflow-hidden border-grayScale-200/80 p-0 shadow-sm">
<div className="grid md:grid-cols-2 md:divide-x md:divide-grayScale-100">
<div className="flex flex-col">
<div className="flex items-center gap-2.5 border-b border-grayScale-100 px-6 py-4">
<h3 className="font-semibold text-grayScale-900">Questions</h3>
<span className="flex h-6 min-w-6 items-center justify-center rounded-full bg-grayScale-100 px-2 text-xs font-semibold text-grayScale-500">
{filledQuestions.length}
</span>
</div>
<div className="max-h-[min(70vh,40rem)] space-y-6 overflow-y-auto px-6 py-5">
{filledQuestions.map((question, index) => (
<div key={question.id} className="space-y-3">
<span className="text-sm font-bold text-grayScale-400">
{formatQuestionIndex(index)}
</span>
<div className="space-y-2">
<p className="text-[10px] font-bold uppercase tracking-wider text-grayScale-500">
Text prompt
</p>
<p className="text-sm leading-relaxed text-grayScale-800">
{question.questionText.trim() || "—"}
</p>
</div>
{question.voicePrompt.trim() ? (
<div className="space-y-2">
<p className="text-[10px] font-bold uppercase tracking-wider text-grayScale-500">
Voice prompt
</p>
<ReviewAudioPlayer
src={question.voicePrompt}
label={`prompt_q${index + 1}.mp3`}
/>
</div>
) : null}
</div>
))}
</div>
</div>
<div className="flex flex-col border-t border-grayScale-100 md:border-t-0">
<div className="flex items-center justify-between gap-2 border-b border-grayScale-100 px-6 py-4">
<div className="flex items-center gap-2.5">
<h3 className="font-semibold text-grayScale-900">Answers</h3>
<span className="flex h-6 min-w-6 items-center justify-center rounded-full bg-grayScale-100 px-2 text-xs font-semibold text-grayScale-500">
{filledQuestions.length}
</span>
</div>
{onEditQuestions ? (
<button
type="button"
onClick={onEditQuestions}
className="flex items-center gap-1.5 rounded-md px-2 py-1 text-sm font-medium text-brand-500 transition-colors hover:bg-brand-50 hover:text-brand-600"
>
<Edit className="h-3.5 w-3.5" />
Edit
</button>
) : null}
</div>
<div className="max-h-[min(70vh,40rem)] space-y-6 overflow-y-auto px-6 py-5">
{filledQuestions.map((question, index) => (
<div key={question.id} className="space-y-3">
<span className="text-sm font-bold text-grayScale-400">
{formatQuestionIndex(index)}
</span>
{question.sampleAnswerVoicePrompt.trim() ? (
<div className="space-y-2">
<p className="text-[10px] font-bold uppercase tracking-wider text-grayScale-500">
Voice prompt
</p>
<ReviewAudioPlayer
src={question.sampleAnswerVoicePrompt}
label={`answer_q${index + 1}.mp3`}
/>
</div>
) : (
<p className="text-xs text-grayScale-400"></p>
)}
</div>
))}
</div>
</div>
</div>
</Card>
{saveError ? (
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-3">
<p className="text-sm font-medium text-red-600">{saveError}</p>
</div>
) : null}
<div className="flex flex-col-reverse items-stretch justify-between gap-3 pt-2 sm:flex-row sm:items-center">
<Button
type="button"
variant="outline"
onClick={onBack}
className="h-10 rounded-[6px] border-grayScale-200 bg-white px-8 text-sm font-bold text-grayScale-600 shadow-sm hover:bg-grayScale-50"
>
Back
</Button>
<div className="flex w-full flex-col gap-3 sm:w-auto sm:flex-row sm:justify-end">
<Button
type="button"
variant="outline"
onClick={onSaveDraft}
disabled={saving || !canPublish}
className="h-10 rounded-[6px] border-brand-500 bg-white px-8 text-sm font-bold text-brand-500 hover:bg-brand-50 disabled:opacity-50"
>
{saving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Saving
</>
) : (
"Save as Draft"
)}
</Button>
<Button
type="button"
onClick={onPublish}
disabled={saving || !canPublish}
className="h-10 gap-2 rounded-[6px] bg-brand-500 px-8 text-sm font-bold text-white shadow-md shadow-brand-500/20 hover:bg-brand-600 disabled:opacity-50"
>
{saving ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Rocket className="h-4 w-4" />
)}
{saving ? "Publishing…" : "Publish Now"}
</Button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,59 @@
import type { PracticePublishStatus } from "../../../../types/course.types"
import { cn } from "../../../../lib/utils"
type Props = {
value: PracticePublishStatus
onChange: (value: PracticePublishStatus) => void
disabled?: boolean
className?: string
}
export function PublishStatusField({ value, onChange, disabled, className }: Props) {
return (
<div className={cn("space-y-2", className)}>
<p className="text-sm font-medium text-grayScale-700">
Publish status <span className="text-red-500">*</span>
</p>
<p className="text-xs text-grayScale-500">
Sent as <code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px]">publish_status</code> on{" "}
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px]">POST /practices</code>.
</p>
<div className="flex flex-wrap gap-3" role="radiogroup" aria-label="Publish status">
{(
[
{ id: "DRAFT" as const, label: "Draft", hint: "Save without publishing to learners" },
{ id: "PUBLISHED" as const, label: "Published", hint: "Make the practice available" },
] as const
).map((opt) => {
const selected = value === opt.id
return (
<label
key={opt.id}
className={cn(
"flex min-w-[140px] flex-1 cursor-pointer flex-col rounded-xl border px-4 py-3 transition-colors",
selected
? "border-brand-500 bg-brand-50/60 ring-1 ring-brand-500/30"
: "border-grayScale-200 bg-white hover:border-grayScale-300",
disabled && "cursor-not-allowed opacity-60",
)}
>
<span className="flex items-center gap-2">
<input
type="radio"
name="publish_status"
value={opt.id}
checked={selected}
disabled={disabled}
onChange={() => onChange(opt.id)}
className="h-4 w-4 border-grayScale-300 text-brand-600 focus:ring-brand-500"
/>
<span className="text-sm font-semibold text-grayScale-800">{opt.label}</span>
</span>
<span className="mt-1 pl-6 text-xs text-grayScale-500">{opt.hint}</span>
</label>
)
})}
</div>
</div>
)
}

View File

@ -1,14 +1,45 @@
import { GripVertical, Trash2, Plus, ArrowRight } from "lucide-react";
import { Trash2, Plus, ArrowRight } from "lucide-react";
import { Button } from "../../../../components/ui/button";
import { Card } from "../../../../components/ui/card";
import { Input } from "../../../../components/ui/input";
import { VoicePrompt } from "./VoicePrompt";
import { DynamicSchemaSlotField } from "../../../../components/content-management/DynamicSchemaSlotField";
import type { QuestionTypeDefinition } from "../../../../types/questionTypeDefinition.types";
import { questionTypeDefinitionListLabel } from "../../../../api/questionTypeDefinitions.api";
import {
definitionUsesDynamicPayload,
emptyDynamicFieldValuesForDefinition,
legacyQuestionTypeFromDefinition,
} from "../../../../lib/learnEnglishDefinitionQuestion";
function defaultMcqOptions() {
return [
{ text: "", isCorrect: true },
{ text: "", isCorrect: false },
{ text: "", isCorrect: false },
{ text: "", isCorrect: false },
];
}
function createEmptyQuestionRow(id: string) {
return {
id,
questionTypeDefinitionId: null as number | null,
text: "",
dynamicFieldValues: {} as Record<string, string>,
mcqOptions: defaultMcqOptions(),
trueFalseCorrect: true,
shortAnswers: [""],
};
}
interface QuestionsStepProps {
formData: any;
setFormData: (data: any) => void;
nextStep: () => void;
prevStep: () => void;
typeDefinitions: QuestionTypeDefinition[];
definitionsLoading: boolean;
definitionsError: string | null;
}
export function QuestionsStep({
@ -16,64 +47,365 @@ export function QuestionsStep({
setFormData,
nextStep,
prevStep,
typeDefinitions,
definitionsLoading,
definitionsError,
}: QuestionsStepProps) {
const addQuestion = () => {
const newQuestion = {
id: `q${formData.questions.length + 1}`,
text: "",
type: "Speaking",
voicePrompt: "upload_audio.mp3",
sampleAnswer: "upload_audio.mp3",
const applyDefinitionToQuestion = (
index: number,
definitionId: number,
defs: QuestionTypeDefinition[],
) => {
const def = defs.find((d) => d.id === definitionId);
const newQuestions = [...formData.questions];
const row = { ...newQuestions[index], questionTypeDefinitionId: definitionId };
if (def) {
row.dynamicFieldValues = emptyDynamicFieldValuesForDefinition(def);
}
newQuestions[index] = row;
setFormData({ ...formData, questions: newQuestions });
};
const setDynamicValue = (qIndex: number, key: string, value: string) => {
const newQuestions = [...formData.questions];
newQuestions[qIndex] = {
...newQuestions[qIndex],
dynamicFieldValues: {
...(newQuestions[qIndex].dynamicFieldValues ?? {}),
[key]: value,
},
};
setFormData({ ...formData, questions: newQuestions });
};
const addQuestion = () => {
const id = `q${Date.now()}`;
const row = createEmptyQuestionRow(id);
if (typeDefinitions[0]) {
row.questionTypeDefinitionId = typeDefinitions[0].id;
row.dynamicFieldValues = emptyDynamicFieldValuesForDefinition(
typeDefinitions[0],
);
}
setFormData({
...formData,
questions: [...formData.questions, newQuestion],
questions: [...formData.questions, row],
});
};
const renderTypeSpecificFields = (q: any, i: number, def: QuestionTypeDefinition) => {
if (definitionUsesDynamicPayload(def)) {
return (
<div className="space-y-3 rounded-lg border border-violet-200 bg-violet-50/40 p-3">
<p className="text-xs leading-snug text-grayScale-600">
<span className="font-medium text-grayScale-800">Image / Audio</span> slots use upload or URL import (
<code className="rounded bg-white px-0.5 text-[11px]">POST /files/upload</code>
). Others: URL, text, or JSON.
</p>
{def.stimulus_schema.length > 0 ? (
<div className="space-y-2">
<p className="text-[10px] font-bold uppercase tracking-wide text-violet-800">Stimulus</p>
{def.stimulus_schema.map((row) => (
<div
key={`stimulus-${row.id}`}
className="rounded-lg border border-grayScale-200 bg-white p-2.5"
>
<DynamicSchemaSlotField
row={row}
value={q.dynamicFieldValues?.[`stimulus:${row.id}`] ?? ""}
onChange={(next) =>
setDynamicValue(i, `stimulus:${row.id}`, next)
}
/>
</div>
))}
</div>
) : null}
{def.response_schema.length > 0 ? (
<div className="space-y-2">
<p className="text-[10px] font-bold uppercase tracking-wide text-violet-800">Response</p>
{def.response_schema.map((row) => (
<div
key={`response-${row.id}`}
className="rounded-lg border border-grayScale-200 bg-white p-2.5"
>
<DynamicSchemaSlotField
row={row}
value={q.dynamicFieldValues?.[`response:${row.id}`] ?? ""}
onChange={(next) =>
setDynamicValue(i, `response:${row.id}`, next)
}
/>
</div>
))}
</div>
) : null}
</div>
);
}
const legacy = legacyQuestionTypeFromDefinition(def);
if (legacy === "MCQ") {
return (
<div className="space-y-3">
<label className="text-[10px] font-bold uppercase tracking-widest text-grayScale-700">
Choices (mark one correct)
</label>
<div className="space-y-2">
{(q.mcqOptions ?? defaultMcqOptions()).map(
(opt: { text: string; isCorrect: boolean }, j: number) => (
<div
key={j}
className="flex flex-wrap items-center gap-2 sm:flex-nowrap"
>
<Input
value={opt.text}
onChange={(e) => {
const newQuestions = [...formData.questions];
const opts = [
...(newQuestions[i].mcqOptions ?? defaultMcqOptions()),
];
opts[j] = { ...opts[j], text: e.target.value };
newQuestions[i].mcqOptions = opts;
setFormData({ ...formData, questions: newQuestions });
}}
className="min-w-0 flex-1 rounded-lg border-grayScale-200"
placeholder={`Option ${j + 1}`}
/>
<label className="flex shrink-0 items-center gap-2 text-sm text-grayScale-600">
<input
type="radio"
name={`mcq-correct-${q.id}`}
checked={opt.isCorrect}
onChange={() => {
const newQuestions = [...formData.questions];
const opts = (
newQuestions[i].mcqOptions ?? defaultMcqOptions()
).map((o: { text: string; isCorrect: boolean }, k: number) => ({
...o,
isCorrect: k === j,
}));
newQuestions[i].mcqOptions = opts;
setFormData({ ...formData, questions: newQuestions });
}}
/>
Correct
</label>
</div>
),
)}
</div>
</div>
);
}
if (legacy === "TRUE_FALSE") {
return (
<div className="space-y-2">
<span className="text-[10px] font-bold uppercase tracking-widest text-grayScale-700">
Correct answer
</span>
<div className="flex flex-wrap gap-4">
<label className="flex cursor-pointer items-center gap-2 text-sm text-grayScale-700">
<input
type="radio"
name={`tf-${q.id}`}
checked={q.trueFalseCorrect !== false}
onChange={() => {
const newQuestions = [...formData.questions];
newQuestions[i].trueFalseCorrect = true;
setFormData({ ...formData, questions: newQuestions });
}}
/>
True
</label>
<label className="flex cursor-pointer items-center gap-2 text-sm text-grayScale-700">
<input
type="radio"
name={`tf-${q.id}`}
checked={q.trueFalseCorrect === false}
onChange={() => {
const newQuestions = [...formData.questions];
newQuestions[i].trueFalseCorrect = false;
setFormData({ ...formData, questions: newQuestions });
}}
/>
False
</label>
</div>
</div>
);
}
if (legacy === "SHORT_ANSWER") {
return (
<div className="space-y-3">
<label className="text-[10px] font-bold uppercase tracking-widest text-grayScale-700">
Acceptable answers
</label>
{(q.shortAnswers ?? [""]).map((line: string, j: number) => (
<div key={j} className="flex gap-2">
<Input
value={line}
onChange={(e) => {
const newQuestions = [...formData.questions];
const lines = [...(newQuestions[i].shortAnswers ?? [""])];
lines[j] = e.target.value;
newQuestions[i].shortAnswers = lines;
setFormData({ ...formData, questions: newQuestions });
}}
className="rounded-lg border-grayScale-200"
placeholder="Acceptable wording"
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
const newQuestions = [...formData.questions];
const lines = [...(newQuestions[i].shortAnswers ?? [""])];
lines.splice(j, 1);
newQuestions[i].shortAnswers =
lines.length > 0 ? lines : [""];
setFormData({ ...formData, questions: newQuestions });
}}
>
Remove
</Button>
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
const newQuestions = [...formData.questions];
newQuestions[i].shortAnswers = [
...(newQuestions[i].shortAnswers ?? [""]),
"",
];
setFormData({ ...formData, questions: newQuestions });
}}
>
Add acceptable answer
</Button>
</div>
);
}
return (
<p className="rounded-lg border border-amber-200 bg-amber-50/80 px-3 py-2 text-sm text-amber-900">
This definition has no schema rows and is not mapped to MCQ / TrueFalse /
Short answer. It will be submitted as{" "}
<span className="font-mono">DYNAMIC</span> with an empty payload.
</p>
);
};
return (
<div className="space-y-6">
<div className="space-y-1 px-2">
<h2 className="text-2xl font-bold text-grayScale-700">
Create Practice Questions
</h2>
<h2 className="text-2xl font-bold text-grayScale-700">Questions</h2>
<p className="text-grayScale-400 text-lg">
Define the dialogue flow and interactions for this scenario.
Question types are loaded from{" "}
<code className="rounded bg-grayScale-100 px-1 text-sm">
GET /questions/type-definitions
</code>
. Pick a type per row, then fill the fields required for that definition.
</p>
</div>
{definitionsError ? (
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{definitionsError}
</div>
) : null}
{definitionsLoading ? (
<p className="px-2 text-sm text-grayScale-500">Loading question types</p>
) : null}
<div className="space-y-6">
{formData.questions.map((q: any, i: number) => (
<Card
key={q.id}
className="overflow-hidden border-grayScale-50 shadow-soft rounded-2xl bg-white relative"
>
<div className="absolute left-0 top-0 bottom-0 w-[5px] bg-brand-500" />
<div className="px-5 pb-7 pt-2 space-y-6">
<div className="flex items-center justify-between border-b border-grayScale-50 pb-4 mb-4">
<div className="flex items-center gap-3">
<GripVertical className="h-5 w-5 text-brand-500 cursor-grab" />
<span className="font-bold text-grayScale-500 text-base">
{formData.questions.map((q: any, i: number) => {
const def = typeDefinitions.find(
(d) => d.id === q.questionTypeDefinitionId,
);
return (
<Card
key={q.id}
className="relative overflow-hidden rounded-2xl border border-grayScale-50 bg-white shadow-soft"
>
<div className="absolute bottom-0 left-0 top-0 w-[5px] bg-brand-500" />
<div className="space-y-6 px-5 pb-7 pt-4 pl-7">
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-grayScale-50 pb-4">
<span className="text-base font-bold text-grayScale-500">
Question {i + 1}
</span>
<Button
variant="ghost"
size="icon"
type="button"
className="text-brand-500 hover:bg-brand-50 rounded-lg"
onClick={() => {
const newQuestions = formData.questions.filter(
(item: any) => item.id !== q.id,
);
if (newQuestions.length > 0) {
setFormData({ ...formData, questions: newQuestions });
return;
}
const row = createEmptyQuestionRow("q1");
if (typeDefinitions[0]) {
row.questionTypeDefinitionId = typeDefinitions[0].id;
row.dynamicFieldValues =
emptyDynamicFieldValuesForDefinition(
typeDefinitions[0],
);
}
setFormData({ ...formData, questions: [row] });
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<Button
variant="ghost"
size="icon"
className="text-brand-500 hover:bg-brand-50 rounded-lg"
onClick={() => {
const newQuestions = formData.questions.filter(
(item: any) => item.id !== q.id,
);
setFormData({ ...formData, questions: newQuestions });
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<div className="grid grid-cols-1 md:grid-cols-12 gap-8">
<div className="md:col-span-8 space-y-3">
<label className="text-[10px] font-bold text-grayScale-700 uppercase tracking-widest">
QUESTION PROMPT
<div className="space-y-2">
<label className="text-[10px] font-bold uppercase tracking-widest text-grayScale-700">
Question type
</label>
<select
className="h-11 w-full max-w-xl rounded-lg border border-grayScale-200 bg-white px-3 text-sm font-medium text-grayScale-800"
disabled={definitionsLoading || typeDefinitions.length === 0}
value={
q.questionTypeDefinitionId != null
? String(q.questionTypeDefinitionId)
: ""
}
onChange={(e) => {
const v = e.target.value;
if (!v) return;
applyDefinitionToQuestion(i, Number(v), typeDefinitions);
}}
>
<option value="">
{definitionsLoading
? "Loading…"
: "Select question type…"}
</option>
{typeDefinitions.map((d) => (
<option key={d.id} value={String(d.id)}>
{questionTypeDefinitionListLabel(d)}
</option>
))}
</select>
{def?.description ? (
<p className="text-xs text-grayScale-500">{def.description}</p>
) : null}
</div>
<div className="space-y-3">
<label className="text-[10px] font-bold uppercase tracking-widest text-grayScale-700">
Question text
</label>
<Input
value={q.text}
@ -82,62 +414,35 @@ export function QuestionsStep({
newQuestions[i].text = e.target.value;
setFormData({ ...formData, questions: newQuestions });
}}
className="h-16 rounded-xl border-grayScale-200 font-medium px-6 text-base placeholder:text-grayScale-400 bg-white text-grayScale-700"
placeholder="e.g. How long have you been studying English?"
/>
</div>
<div className="md:col-span-4 space-y-3">
<label className="text-[10px] font-bold text-grayScale-700 uppercase tracking-widest">
VOICE PROMPT
</label>
<VoicePrompt
src={q.voicePrompt}
filename={q.voicePrompt}
onRemove={() => {
const newQuestions = [...formData.questions];
newQuestions[i].voicePrompt = "";
setFormData({ ...formData, questions: newQuestions });
}}
className="min-h-[52px] rounded-xl border-grayScale-200 px-4 py-3 text-base font-medium text-grayScale-700"
placeholder="Question prompt for learners"
/>
</div>
{def ? renderTypeSpecificFields(q, i, def) : null}
</div>
<div className="md:w-1/3 space-y-3">
<label className="text-[10px] font-bold text-grayScale-700 uppercase tracking-widest">
SAMPLE ANSWER PROMPT
</label>
<VoicePrompt
src={q.sampleAnswer}
filename={q.sampleAnswer}
onRemove={() => {
const newQuestions = [...formData.questions];
newQuestions[i].sampleAnswer = "";
setFormData({ ...formData, questions: newQuestions });
}}
/>
</div>
</div>
</Card>
))}
</Card>
);
})}
<div className="flex items-center gap-8 pt-4">
<button
type="button"
onClick={addQuestion}
className="flex items-center gap-3 text-brand-500 font-bold text-base hover:opacity-80 transition-all"
disabled={definitionsLoading || typeDefinitions.length === 0}
className="flex items-center gap-3 text-base font-bold text-brand-500 transition-all hover:opacity-80 disabled:opacity-40"
>
<div className="flex h-5 w-5 items-center justify-center rounded-full border-2 border-brand-500">
<Plus className="h-3 w-3 stroke-[4]" />
</div>{" "}
Add New Question
</button>
<button className="flex items-center gap-3 text-brand-500 font-bold text-base hover:opacity-80 transition-all">
<div className="flex h-5 w-5 items-center justify-center rounded-full border-2 border-brand-500">
<Plus className="h-3 w-3 stroke-[4]" />
</div>{" "}
Add Tips
</div>
Add question
</button>
</div>
</div>
<div className="flex items-center justify-between pt-8">
<Button
type="button"
onClick={prevStep}
variant="outline"
className="h-10 w-20 rounded-[6px] border-grayScale-200 font-bold text-grayScale-600 shadow-sm"
@ -145,8 +450,10 @@ export function QuestionsStep({
Back
</Button>
<Button
type="button"
onClick={nextStep}
className="h-10 rounded-[6px] bg-brand-500 px-8 font-bold "
disabled={definitionsLoading || !!definitionsError || typeDefinitions.length === 0}
className="h-10 rounded-[6px] bg-brand-500 px-8 font-bold disabled:opacity-50"
>
Next: Review <ArrowRight className="ml-2 h-4 w-4" />
</Button>

View File

@ -1,305 +1,119 @@
import { Edit2, GripVertical, Trash2, Rocket, Info } from "lucide-react";
import { Button } from "../../../../components/ui/button";
import { Card } from "../../../../components/ui/card";
import { Input } from "../../../../components/ui/input";
import { useMemo } from "react";
import type { QuestionTypeDefinition } from "../../../../types/questionTypeDefinition.types";
import { personaFromId } from "./constants";
import type { PersonaCardModel } from "../../../../lib/personaDisplay";
import { mapFormQuestionsForPracticeReview } from "./mapQuestionsForPracticeReview";
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "../../../../components/ui/avatar";
import { PERSONAS } from "./constants";
import { VoicePrompt } from "./VoicePrompt";
PracticeSequentialReview,
type PracticeReviewMetadataItem,
} from "./PracticeSequentialReview";
interface ReviewStepProps {
formData: any;
selectedPersona: string | null;
formData: {
title?: string;
description?: string;
storyImageUrl?: string;
tips?: string;
shuffleQuestions?: boolean;
questions: {
id: string;
text?: string;
dynamicFieldValues?: Record<string, string>;
questionTypeDefinitionId?: number | null;
}[];
};
selectedPersona?: string | null;
personas?: PersonaCardModel[];
isLessonPractice?: boolean;
lessonTitle?: string | null;
programLabel?: string | null;
courseLabel?: string | null;
moduleLabel?: string | null;
prevStep: () => void;
setIsPublished: (val: boolean) => void;
isModuleContext?: boolean;
onEditContext?: () => void;
onEditQuestions?: () => void;
parentSummary: string | null;
typeDefinitions: QuestionTypeDefinition[];
canPublish: boolean;
submitting: boolean;
onSaveDraft: () => void;
onPublish: () => void;
}
export function ReviewStep({
formData,
selectedPersona,
selectedPersona = null,
personas = [],
isLessonPractice = false,
lessonTitle = null,
programLabel = null,
courseLabel = null,
moduleLabel = null,
prevStep,
setIsPublished,
isModuleContext,
onEditContext,
onEditQuestions,
parentSummary,
typeDefinitions,
canPublish,
submitting,
onSaveDraft,
onPublish,
}: ReviewStepProps) {
const persona = PERSONAS.find((p) => p.id === selectedPersona);
const persona = personaFromId(selectedPersona, personas);
const reviewQuestions = useMemo(
() => mapFormQuestionsForPracticeReview(formData.questions, typeDefinitions),
[formData.questions, typeDefinitions],
);
const metadata = useMemo((): PracticeReviewMetadataItem[] => {
const items: PracticeReviewMetadataItem[] = [];
if (programLabel?.trim()) {
items.push({ label: "Program", value: programLabel.trim() });
}
if (courseLabel?.trim()) {
items.push({ label: "Course", value: courseLabel.trim() });
}
if (moduleLabel?.trim()) {
items.push({ label: "Module", value: moduleLabel.trim() });
}
if (isLessonPractice && lessonTitle?.trim()) {
items.push({ label: "Lesson", value: lessonTitle.trim() });
}
return items;
}, [programLabel, courseLabel, moduleLabel, isLessonPractice, lessonTitle]);
const practiceTitle = isLessonPractice
? lessonTitle?.trim() || parentSummary || "Lesson practice"
: formData.title?.trim() || "Untitled Practice";
const guidanceText =
formData.tips?.trim() ||
(!isLessonPractice ? formData.description?.trim() : "") ||
"—";
const thumbnailUrl = isLessonPractice
? null
: formData.storyImageUrl?.trim() || null;
return (
<div className="space-y-10 animate-in fade-in duration-700">
<div className="flex items-center justify-between px-2">
<h2 className="text-2xl font-bold text-grayScale-900 tracking-tight">
Review Practice Questions
</h2>
</div>
{/* 1. Basic Info Card (Image 1436.1) */}
<Card className="overflow-hidden border border-grayScale-200 rounded-2xl bg-white ">
<div className="border-b border-grayScale-50 p-4 px-5 flex justify-between items-center bg-white">
<h3 className="text-[17px] font-extrabold text-grayScale-900">
Basic Information
</h3>
<Button
variant="ghost"
size="sm"
className="text-brand-500 font-bold hover:bg-brand-50 gap-2 h-9"
>
<Edit2 className="h-4 w-4" />
Edit
</Button>
</div>
{/* Gradient Divider */}
<div className="relative">
<div
className="absolute inset-0 flex items-center"
aria-hidden="true"
>
<div className="w-full border-t border-grayScale-100" />
</div>
<div className="relative flex justify-center">
<div
className="h-[0.5px] w-full opacity-20 rounded-full"
style={{
background: "gray",
}}
/>
</div>
</div>
<div className="p-8 px-5 flex items-center justify-between ">
<div className="flex items-center gap-6">
<div className="h-[70px] w-[85px] rounded-xl bg-grayScale-100 overflow-hidden shadow-inner flex-shrink-0">
<img
src="https://images.unsplash.com/photo-1558403194-611308249627?auto=format&fit=crop&q=80&w=200"
alt="Banner"
className="w-full h-full object-cover opacity-80"
/>
</div>
<div className="space-y-2">
<h4 className="text-[22px] font-bold text-grayScale-900 leading-tight">
{formData.title || "Business English 101: Communication"}
</h4>
<div className="flex items-center gap-6 text-[14px]">
<span className="text-grayScale-900 ">
Program:{" "}
<span className="text-brand-500 ">{formData.program}</span>
</span>
<span className="text-grayScale-900 ">
Course:{" "}
<span className="text-brand-500 ">{formData.course}</span>
</span>
<span className="text-grayScale-900 font-bold">
Module:{" "}
<span className="text-brand-500 font-extrabold">
Module 101
</span>
</span>
</div>
</div>
</div>
<div className="flex flex-col items-center gap-2">
<span className="text-[11px] text-left font-medium text-grayScale-900 ">
Persona
</span>
<div className="flex items-center gap-2 bg-[#FAF5FF] py-1 pl-2.5 pr-4 rounded-full border border-brand-100/30">
<Avatar className="h-8 w-8 border-2 border-white shadow-sm font-bold">
<AvatarImage src={persona?.avatar} />
<AvatarFallback>P</AvatarFallback>
</Avatar>
<span className="text-[14px] text-brand-500 capitalize">
{persona?.name || "Alex Johnson"}
</span>
</div>
</div>
</div>
</Card>
{/* 2. Tips Section (Image 1436.1) */}
<div className="space-y-4 px-2">
<div className="flex items-center gap-2">
<label className="text-[12px] font-bold text-grayScale-900 uppercase tracking-widest leading-none">
TIPS / GUIDANCE
</label>
<Info className="h-4 w-4 text-brand-500" />
</div>
<div className="px-5 pt-2 pb-8 bg-white border border-[#E2E8F0] shadow-sm rounded-xl">
<p className="text-[14px] text-grayScale-500 font-medium leading-relaxed">
{formData.tips ||
"Focus on using the present perfect continuous tense to describe an action that started in the past and continues now."}
</p>
</div>
</div>
{isModuleContext ? (
/* 3. Split Questions & Answers Layout (Image 1413.1) */
<div className="grid grid-cols-1 md:grid-cols-2 bg-white rounded-[12px] border border-grayScale-50 shadow-sm overflow-hidden min-h-[600px]">
{/* Left Column: Questions */}
<div className="border-r border-grayScale-200 flex flex-col">
<div className="p-4 border-b border-grayScale-50 flex items-center gap-3 bg-white">
<h3 className="text-[16px] font-extrabold text-[#0F172A]">
Questions
</h3>
<span className="h-6 w-6 rounded-full bg-grayScale-100 flex items-center justify-center text-[12px] font-extrabold text-grayScale-500">
{formData.questions.length}
</span>
</div>
<div className="p-4 space-y-14">
{formData.questions.map((q: any, i: number) => (
<div key={q.id} className="relative pl-12">
<span className="absolute left-0 top-0 text-[18px] font-bold text-grayScale-400 tracking-tighter opacity-70">
{(i + 1).toString().padStart(2, "0")}
</span>
<div className="space-y-8">
<div className="space-y-4">
<span className="text-[11px] font-extrabold text-grayScale-600 uppercase tracking-[0.1em] block">
TEXT PROMPT
</span>
<p className="text-[16px] font-medium text-grayScale-600 leading-relaxed max-w-[90%]">
{q.text}
</p>
</div>
<div className="space-y-4">
<span className="text-[11px] font-extrabold text-grayScale-300 uppercase tracking-[0.1em] block">
VOICE PROMPT
</span>
<VoicePrompt
filename={q.voicePrompt}
className="bg-[#FAF5FF]/60 border-[#F3E8FF] h-[72px]"
/>
</div>
</div>
</div>
))}
</div>
</div>
{/* Right Column: Answers */}
<div className="flex flex-col">
<div className="p-4 border-b border-grayScale-50 flex items-center justify-between bg-white">
<div className="flex items-center gap-3">
<h3 className="text-[16px] font-extrabold ">Answers</h3>
<span className="h-6 w-6 rounded-full bg-grayScale-100 flex items-center justify-center text-[12px] font-extrabold text-grayScale-500">
{formData.questions.length}
</span>
</div>
<button className="flex items-center gap-2 text-brand-500 font-bold text-[15px] hover:opacity-80 transition-opacity">
<Edit2 className="h-3 w-3" />
Edit
</button>
</div>
<div className="p-4 space-y-14">
{formData.questions.map((q: any, i: number) => (
<div key={q.id + "_ans"} className="relative pl-12">
<span className="absolute left-0 top-0 text-[18px] font-bold text-grayScale-400 tracking-tighter opacity-70">
{(i + 1).toString().padStart(2, "0")}
</span>
<div className="space-y-4">
<span className="text-[11px] font-extrabold text-grayScale-600 uppercase tracking-[0.1em] block">
VOICE PROMPT
</span>
<VoicePrompt
filename={q.sampleAnswer}
className="bg-[#FAF5FF]/60 border-[#F3E8FF] h-[60px]"
/>
</div>
</div>
))}
</div>
</div>
</div>
) : (
/* Original Non-Module View */
<div className="space-y-6">
{formData.questions.map((q: any, i: number) => (
<ReviewItem key={q.id} q={q} index={i} />
))}
</div>
)}
{/* Action Footer */}
<div className="flex items-center justify-between pt-12">
<Button
onClick={prevStep}
variant="outline"
className="h-10 px-10 rounded-[6px] border-grayScale-200 font-bold text-grayScale-600 bg-white shadow-sm hover:bg-grayScale-50 transition-all text-sm"
>
Back
</Button>
<div className="flex gap-4">
<Button
variant="outline"
className="h-10 px-8 rounded-[6px] border-grayScale-100 font-bold text-grayScale-600 bg-white shadow-sm hover:bg-grayScale-50 transition-all text-sm"
>
Save as Draft
</Button>
<Button
onClick={() => setIsPublished(true)}
className="h-10 px-10 rounded-[6px] bg-brand-500 font-bold hover:bg-brand-600 shadow-xl shadow-brand-500/20 gap-3 active:scale-95 transition-all text-white text-sm"
>
<Rocket className="h-4 w-4" />
Publish Now
</Button>
</div>
</div>
</div>
);
}
function ReviewItem({ q, index }: { q: any; index: number }) {
return (
<Card className="overflow-hidden border-grayScale-50 shadow-soft rounded-2xl bg-white relative">
<div className="absolute left-0 top-0 bottom-0 w-[5px] bg-brand-500" />
<div className="px-5 pb-7 pt-2 space-y-6">
<div className="flex items-center justify-between border-b border-grayScale-50 pb-4 mb-4">
<div className="flex items-center gap-3">
<GripVertical className="h-5 w-5 text-brand-500 cursor-grab" />
<span className="font-bold text-grayScale-500 text-base">
Question {index + 1}
</span>
</div>
<Button
variant="ghost"
size="icon"
className="text-brand-500 hover:bg-brand-50 rounded-lg"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<div className="grid grid-cols-1 md:grid-cols-12 gap-8">
<div className="md:col-span-8 space-y-3">
<label className="text-[10px] font-bold text-grayScale-700 uppercase tracking-widest">
QUESTION PROMPT
</label>
<Input
value={q.text}
readOnly
className="h-16 rounded-xl border-grayScale-200 font-medium px-6 text-base placeholder:text-grayScale-400 bg-white text-grayScale-700"
placeholder="e.g. How long have you been studying English?"
/>
</div>
<div className="md:col-span-4 space-y-3">
<label className="text-[10px] font-bold text-grayScale-700 uppercase tracking-widest">
VOICE PROMPT
</label>
<VoicePrompt
src={q.voicePrompt}
filename={q.voicePrompt}
onRemove={() => {}}
/>
</div>
</div>
<div className="md:w-1/3 space-y-3">
<label className="text-[10px] font-bold text-grayScale-700 uppercase tracking-widest">
SAMPLE ANSWER PROMPT
</label>
<VoicePrompt
src={q.sampleAnswer}
filename={q.sampleAnswer}
onRemove={() => {}}
/>
</div>
</div>
</Card>
<PracticeSequentialReview
practiceTitle={practiceTitle}
thumbnailUrl={thumbnailUrl}
thumbnailKind={thumbnailUrl ? "image" : "gradient"}
persona={persona ?? null}
metadata={metadata}
parentLink={metadata.length === 0 ? parentSummary : null}
guidanceText={guidanceText}
questions={reviewQuestions}
saving={submitting}
canPublish={canPublish}
showMissingParentWarning
onEditContext={onEditContext}
onEditQuestions={onEditQuestions}
onBack={prevStep}
onSaveDraft={onSaveDraft}
onPublish={onPublish}
/>
);
}

View File

@ -1,69 +1,120 @@
import { Upload, ArrowRight } from "lucide-react";
import { useRef, useState, type ChangeEvent } from "react";
import { Link } from "react-router-dom";
import { Upload, ArrowRight, Loader2 } from "lucide-react";
import { Button } from "../../../../components/ui/button";
import { Card } from "../../../../components/ui/card";
import { Input } from "../../../../components/ui/input";
import { Textarea } from "../../../../components/ui/textarea";
import { toast } from "sonner";
import { uploadImageFile } from "../../../../api/files.api";
interface ScenarioStepProps {
formData: any;
setFormData: (data: any) => void;
nextStep: () => void;
prevStep: () => void;
cancelHref: string;
}
export function ScenarioStep({
formData,
setFormData,
nextStep,
prevStep,
cancelHref,
}: ScenarioStepProps) {
const fileRef = useRef<HTMLInputElement>(null);
const [uploadingBanner, setUploadingBanner] = useState(false);
const onBannerFile = async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
event.target.value = "";
if (!file) return;
setUploadingBanner(true);
try {
const res = await uploadImageFile(file);
const url = res.data?.data?.url?.trim();
if (!url) throw new Error("Missing URL");
setFormData({ ...formData, storyImageUrl: url });
toast.success("Story image uploaded");
} catch {
toast.error("Could not upload image");
} finally {
setUploadingBanner(false);
}
};
const canContinue =
Boolean(formData.title?.trim()) && Boolean(formData.description?.trim());
return (
<div className="space-y-6">
<div className="space-y-1 px-2">
<h2 className="text-2xl font-extrabold text-grayScale-700">
Define Scenario Details
Practice details
</h2>
<p className="text-grayScale-400 text-lg">
Set the scene and context for this English practice session.
Story fields and question set options used when saving the practice.
</p>
</div>
<Card className="p-8 space-y-6 border-grayScale-200 rounded-2xl bg-white">
<div className="space-y-1">
<label className="text-sm text-grayScale-700">
Practice Banner Image
</label>
<p className="text-xs pb-2 text-grayScale-400">
This image will appear as the background for the scenario.
</p>
<div className="mt-4 flex flex-col items-center justify-center rounded-2xl border-2 border-dashed border-grayScale-200 bg-[#F8F9FA] p-12 hover:bg-grayScale-50 transition-all">
<div className="mb-4 rounded-xl border border-grayScale-100 bg-white p-3 text-brand-500 shadow-sm">
<Upload className="h-6 w-6" />
</div>
<p className="text-sm">
<span className="text-grayScale-700">
Click to upload or drag and drop
</span>
</p>
<p className="mt-1 text-xs text-grayScale-400 uppercase tracking-wide ">
SVG, PNG, JPG (MAX 5MB)
</p>
<Button
variant="outline"
className="mt-6 h-10 rounded-[6px] border-grayScale-200 bg-white px-8 font-bold text-brand-500 shadow-sm hover:bg-grayScale-50"
>
Browse Files
</Button>
</div>
</div>
</Card>
<Card className="p-8 space-y-6 border-grayScale-200 rounded-2xl bg-white">
<div className="space-y-2">
<label className="text-sm font-medium text-grayScale-700">
Practice Title <span className="text-red-500">*</span>
Story image <span className="text-grayScale-400">(optional)</span>
</label>
<Input
placeholder="e.g., Ordering Coffee at a Cafe"
className="h-12 rounded-xl border-grayScale-200 focus:border-brand-500 placeholder:text-grayScale-500 bg-white"
value={formData.storyImageUrl ?? ""}
onChange={(e) =>
setFormData({ ...formData, storyImageUrl: e.target.value })
}
placeholder="Image URL"
className="h-10 rounded-lg border-grayScale-200 font-mono text-xs"
/>
<input
ref={fileRef}
type="file"
accept="image/*"
className="hidden"
onChange={onBannerFile}
/>
<Button
type="button"
variant="outline"
disabled={uploadingBanner}
onClick={() => fileRef.current?.click()}
className="gap-2"
>
{uploadingBanner ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Upload className="h-4 w-4" />
)}
Upload image
</Button>
</div>
<label className="flex cursor-pointer items-center gap-3 text-sm text-grayScale-700">
<input
type="checkbox"
className="h-4 w-4 rounded border-grayScale-300 text-brand-600 focus:ring-brand-500"
checked={Boolean(formData.shuffleQuestions)}
onChange={(e) =>
setFormData({
...formData,
shuffleQuestions: e.target.checked,
})
}
/>
<span>Shuffle questions in the set</span>
</label>
</Card>
<Card className="p-8 space-y-6 border-grayScale-200 rounded-2xl bg-white">
<div className="space-y-2">
<label className="text-sm font-medium text-grayScale-700">
Practice title <span className="text-red-500">*</span>
</label>
<Input
placeholder="e.g. Ordering coffee at a cafe"
className="h-12 rounded-xl border-grayScale-200 focus:border-brand-500 placeholder:text-grayScale-500 bg-white"
value={formData.title}
onChange={(e) =>
setFormData({ ...formData, title: e.target.value })
@ -72,11 +123,11 @@ export function ScenarioStep({
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-grayScale-700">
Scenario Description <span className="text-red-500">*</span>
Story description <span className="text-red-500">*</span>
</label>
<div className="relative">
<Textarea
placeholder="Describe the setting..."
placeholder="Describe the scenario…"
className="min-h-[160px] rounded-xl resize-none p-4 border-grayScale-200 focus:border-brand-500 leading-relaxed placeholder:text-grayScale-500 bg-white"
maxLength={1000}
value={formData.description}
@ -91,24 +142,32 @@ export function ScenarioStep({
{formData.description.length} / 1000
</div>
</div>
<span className="text-xs text-grayScale-500">
Provide context for the AI and the student. Be specific about the
location and the goal.
</span>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-grayScale-700">
Quick tips <span className="text-grayScale-400">(optional)</span>
</label>
<Textarea
value={formData.tips ?? ""}
onChange={(e) =>
setFormData({ ...formData, tips: e.target.value })
}
placeholder="Learner-facing tips (quick_tips)"
className="min-h-[80px] rounded-xl border-grayScale-200"
maxLength={1000}
/>
</div>
</Card>
<div className="flex items-center justify-between pt-4">
<Button
onClick={prevStep}
variant="outline"
className="h-10 w-20 rounded-[6px] border-grayScale-200 text-grayScale-600 shadow-sm"
>
Back
<Button variant="outline" className="h-10 px-6" asChild>
<Link to={cancelHref}>Cancel</Link>
</Button>
<Button
type="button"
onClick={nextStep}
disabled={!formData.title || !formData.description}
className="h-10 rounded-[6px] bg-brand-500 px-8 "
disabled={!canContinue}
className="h-10 rounded-[6px] bg-brand-500 px-8 disabled:opacity-50"
>
Next: Persona <ArrowRight className="ml-2 h-4 w-4" />
</Button>

View File

@ -1,44 +1,16 @@
export const PERSONAS = [
{
id: "dawit",
name: "Dawit",
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Dawit",
},
{
id: "mahlet",
name: "Mahlet",
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Mahlet",
},
{
id: "amanuel",
name: "Amanuel",
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Amanuel",
},
{
id: "bethel",
name: "Bethel",
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Bethel",
},
{
id: "liya",
name: "Liya",
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Liya",
},
{
id: "aseffa",
name: "Aseffa",
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Aseffa",
},
{
id: "hana",
name: "Hana",
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Hana",
},
{
id: "nahom",
name: "Nahom",
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Nahom",
},
];
import type { PersonaCardModel } from "../../../../lib/personaDisplay"
export const STEPS = ["Context", "Scenario", "Persona", "Questions", "Review"];
export const STEPS = ["Context", "Scenario", "Persona", "Questions", "Review"]
export function personaFromId(
selectedPersona: string | null,
personas: PersonaCardModel[],
): PersonaCardModel | undefined {
if (!selectedPersona) return undefined
return personas.find((p) => p.id === selectedPersona)
}
export function personaIdNumber(selectedPersona: string | null): number | undefined {
const n = Number(selectedPersona)
return Number.isFinite(n) && n > 0 ? n : undefined
}

View File

@ -0,0 +1,83 @@
import type { QuestionTypeDefinition } from "../../../../types/questionTypeDefinition.types";
import {
definitionUsesDynamicPayload,
legacyQuestionTypeFromDefinition,
} from "../../../../lib/learnEnglishDefinitionQuestion";
import type { PracticeReviewQuestion } from "./PracticeSequentialReview";
function isAudioLikeKind(kind: string): boolean {
const k = kind.toLowerCase();
return (
k.includes("audio") ||
k.includes("voice") ||
k === "url" ||
k === "file" ||
k === "media"
);
}
function firstUrlFromSchema(
schema: { id: number; kind: string }[],
prefix: "stimulus" | "response",
values: Record<string, string>,
): string {
for (const row of schema) {
if (!isAudioLikeKind(row.kind)) continue;
const v = values[`${prefix}:${row.id}`]?.trim();
if (v) return v;
}
for (const row of schema) {
const v = values[`${prefix}:${row.id}`]?.trim();
if (v && /^https?:\/\//i.test(v)) return v;
}
return "";
}
export function mapFormQuestionsForPracticeReview(
questions: {
id: string;
text?: string;
dynamicFieldValues?: Record<string, string>;
questionTypeDefinitionId?: number | null;
}[],
typeDefinitions: QuestionTypeDefinition[],
): PracticeReviewQuestion[] {
return questions.map((q) => {
const def = typeDefinitions.find(
(d) => d.id === q.questionTypeDefinitionId,
);
const values = q.dynamicFieldValues ?? {};
let voicePrompt = "";
let sampleAnswerVoicePrompt = "";
if (def && definitionUsesDynamicPayload(def)) {
voicePrompt = firstUrlFromSchema(def.stimulus_schema, "stimulus", values);
sampleAnswerVoicePrompt = firstUrlFromSchema(
def.response_schema,
"response",
values,
);
} else if (def) {
const legacy = legacyQuestionTypeFromDefinition(def);
const key = def.key.toLowerCase();
if (legacy === null && key.includes("audio")) {
voicePrompt = Object.entries(values)
.filter(([k]) => k.startsWith("stimulus:"))
.map(([, v]) => v?.trim())
.find(Boolean) ?? "";
sampleAnswerVoicePrompt =
Object.entries(values)
.filter(([k]) => k.startsWith("response:"))
.map(([, v]) => v?.trim())
.find(Boolean) ?? "";
}
}
return {
id: q.id,
questionText: String(q.text ?? "").trim(),
voicePrompt,
sampleAnswerVoicePrompt,
};
});
}

View File

@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from "react";
import { Rocket, Edit2, Link2, Video } from "lucide-react";
import { Button } from "../../../../components/ui/button";
import { toast } from "sonner";
import type { PracticePublishStatus } from "../../../../types/course.types";
import type { AddLessonFormData } from "../../AddVideoFlow";
import {
applyShortPreviewToEmbedUrl,
@ -15,7 +15,7 @@ import { PreviewLimitedFileVideo } from "../PreviewLimitedFileVideo";
interface ReviewPublishStepProps {
formData: AddLessonFormData;
prevStep: () => void;
onPublish: () => void;
onCreateLesson: (publishStatus: PracticePublishStatus) => void;
publishing: boolean;
}
@ -27,7 +27,7 @@ function truncate(s: string, max: number): string {
export function ReviewPublishStep({
formData,
prevStep,
onPublish,
onCreateLesson,
publishing,
}: ReviewPublishStepProps) {
const [thumbBroken, setThumbBroken] = useState(false);
@ -180,6 +180,17 @@ export function ReviewPublishStep({
</p>
</div>
<div className="space-y-2">
<span className="text-[11px] font-bold text-grayScale-500 uppercase tracking-widest block">
Sort order
</span>
<p className="text-[15px] font-medium text-grayScale-900">
{formData.sortOrder.trim() !== ""
? formData.sortOrder.trim()
: "—"}
</p>
</div>
<div className="space-y-3">
<span className="text-[11px] font-bold text-grayScale-500 uppercase tracking-widest block">
Description
@ -226,20 +237,18 @@ export function ReviewPublishStep({
variant="outline"
className="h-12 px-8 rounded-[6px] border-grayScale-100 font-bold text-grayScale-600 hover:bg-grayScale-50 transition-all shadow-sm"
disabled={publishing}
onClick={() =>
toast.info("Drafts are not supported yet. Use Create lesson.")
}
onClick={() => onCreateLesson("DRAFT")}
>
Save as draft
</Button>
<Button
type="button"
onClick={onPublish}
onClick={() => onCreateLesson("PUBLISHED")}
disabled={publishing}
className="h-10 px-10 rounded-[6px] bg-brand-500 font-bold text-white shadow-brand-500/20 transition-all flex items-center gap-2.5 disabled:opacity-60"
>
<Rocket className="h-4 w-4" />
{publishing ? "Creating…" : "Create lesson"}
{publishing ? "Creating…" : "Publish lesson"}
</Button>
</div>
</div>

View File

@ -70,6 +70,16 @@ export function VideoDetailStep({
toast.error("Title is required");
return;
}
const sortOrderRaw = formData.sortOrder.trim();
if (sortOrderRaw === "") {
toast.error("Sort order is required");
return;
}
const sortOrderNum = Number(sortOrderRaw);
if (!Number.isInteger(sortOrderNum) || sortOrderNum < 0) {
toast.error("Sort order must be a whole number of 0 or greater");
return;
}
if (!formData.videoUrl.trim()) {
toast.error("Add a video URL or upload a video");
return;
@ -141,6 +151,35 @@ export function VideoDetailStep({
/>
</div>
<div className="space-y-3">
<label
className="text-[14px] font-medium text-grayScale-900 ml-1"
htmlFor="lesson-sort-order"
>
Sort order
</label>
<Input
id="lesson-sort-order"
type="number"
inputMode="numeric"
min={0}
step={1}
placeholder="0"
className="h-12 max-w-[200px] rounded-xl border-grayScale-200 bg-white px-6 text-[15px] text-grayScale-800 placeholder:text-grayScale-500 focus:border-brand-500 font-medium transition-all shadow-sm"
value={formData.sortOrder}
onChange={(e) =>
setFormData((prev) => ({
...prev,
sortOrder: e.target.value,
}))
}
/>
<p className="text-xs text-grayScale-500 ml-1">
Whole number, 0 or greater. Lower numbers appear first in the
module.
</p>
</div>
<div className="space-y-3">
<label className="text-[14px] font-medium text-grayScale-900 ml-1">
Description

View File

@ -363,7 +363,7 @@ export function NotificationsPage() {
if (needsUsers) {
tasks.push(
getUsers(1, 20)
getUsers({ page: 1, page_size: 20 })
.then(async (res) => {
const firstBatch = res.data?.data?.users ?? []
const total = res.data?.data?.total ?? firstBatch.length
@ -376,7 +376,7 @@ export function NotificationsPage() {
const remainingRequests: Array<ReturnType<typeof getUsers>> = []
for (let page = 2; page <= totalPages; page += 1) {
remainingRequests.push(getUsers(page, pageSize))
remainingRequests.push(getUsers({ page, page_size: pageSize }))
}
try {

View File

@ -13,6 +13,8 @@ import {
Pencil,
Check,
Trash2,
UserX,
UserCheck,
} from "lucide-react"
import { Button } from "../../components/ui/button"
import { Card, CardContent } from "../../components/ui/card"
@ -29,6 +31,8 @@ import {
setRolePermissions,
updateRole,
deleteRole,
bulkDeactivateRole,
bulkReactivateRole,
} from "../../api/rbac.api"
import type { Role, RoleDetail, RolePermission } from "../../types/rbac.types"
import { cn } from "../../lib/utils"
@ -58,6 +62,13 @@ export function RolesListPage() {
const [roleToDelete, setRoleToDelete] = useState<Role | null>(null)
const [deleteLoading, setDeleteLoading] = useState(false)
/** Bulk deactivate / reactivate (users + team members for this role). */
const [bulkDialog, setBulkDialog] = useState<{
type: "deactivate" | "reactivate"
role: Role
} | null>(null)
const [bulkActionLoading, setBulkActionLoading] = useState(false)
// Role info editing state
const [editingRole, setEditingRole] = useState(false)
const [editName, setEditName] = useState("")
@ -130,6 +141,39 @@ export function RolesListPage() {
setRoleToDelete(null)
}
const handleCancelBulkDialog = () => {
setBulkDialog(null)
}
const handleConfirmBulkAction = async () => {
if (!bulkDialog) return
const { type, role } = bulkDialog
setBulkActionLoading(true)
try {
if (type === "deactivate") {
const res = await bulkDeactivateRole(role.id)
const d = res.data.data
toast.success(res.data.message ?? "Bulk deactivation completed", {
description: `${d.role}: ${d.users_deactivated} user(s), ${d.team_members_deactivated} team member(s) deactivated.`,
})
} else {
const res = await bulkReactivateRole(role.id)
const d = res.data.data
toast.success(res.data.message ?? "Bulk reactivation completed", {
description: `${d.role}: ${d.users_reactivated} user(s), ${d.team_members_reactivated} team member(s) reactivated.`,
})
}
setBulkDialog(null)
} catch (err: unknown) {
const message =
(err as { response?: { data?: { message?: string } } })?.response?.data?.message ??
(type === "deactivate" ? "Bulk deactivation failed." : "Bulk reactivation failed.")
toast.error(message)
} finally {
setBulkActionLoading(false)
}
}
const handleConfirmDeleteRole = async () => {
if (!roleToDelete) return
setDeleteLoading(true)
@ -421,6 +465,31 @@ export function RolesListPage() {
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<Button
type="button"
variant="outline"
size="sm"
className="h-8 gap-1.5 text-xs text-grayScale-700"
onClick={() => setBulkDialog({ type: "deactivate", role })}
disabled={deleteLoading || bulkActionLoading}
>
<UserX className="h-3.5 w-3.5 shrink-0" />
Deactivate all
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="h-8 gap-1.5 text-xs text-grayScale-700"
onClick={() => setBulkDialog({ type: "reactivate", role })}
disabled={deleteLoading || bulkActionLoading}
>
<UserCheck className="h-3.5 w-3.5 shrink-0" />
Reactivate all
</Button>
</div>
<div className="flex items-center justify-between">
<span className="text-[11px] text-grayScale-400">
Open details to view permissions
@ -783,6 +852,72 @@ export function RolesListPage() {
</DialogContent>
</Dialog>
{/* Bulk deactivate / reactivate confirmation */}
<Dialog
open={bulkDialog != null}
onOpenChange={(open) => {
if (!open) handleCancelBulkDialog()
}}
>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{bulkDialog?.type === "deactivate" ? (
<>
<UserX className="h-5 w-5 text-amber-600" />
Deactivate all for this role?
</>
) : (
<>
<UserCheck className="h-5 w-5 text-brand-600" />
Reactivate all for this role?
</>
)}
</DialogTitle>
<DialogDescription>
{bulkDialog?.type === "deactivate"
? "This deactivates every user and team member currently assigned to this role. They can be reactivated later with Reactivate all."
: "This reactivates users and team members tied to this role who were deactivated in bulk for this role."}
</DialogDescription>
</DialogHeader>
{bulkDialog && (
<div className="rounded-lg border border-grayScale-100 bg-grayScale-50/80 p-3">
<p className="text-sm font-semibold text-grayScale-700">{bulkDialog.role.name}</p>
<p className="text-xs text-grayScale-500">Role #{bulkDialog.role.id}</p>
</div>
)}
<div className="flex justify-end gap-2 pt-2">
<Button
variant="outline"
size="sm"
onClick={handleCancelBulkDialog}
disabled={bulkActionLoading}
>
Cancel
</Button>
<Button
size="sm"
className={
bulkDialog?.type === "deactivate"
? "gap-1.5 bg-amber-600 hover:bg-amber-700"
: "gap-1.5 bg-brand-500 hover:bg-brand-600"
}
disabled={bulkActionLoading || !bulkDialog}
onClick={handleConfirmBulkAction}
>
{bulkActionLoading && <SpinnerIcon className="h-3.5 w-3.5" />}
{bulkActionLoading
? "Working…"
: bulkDialog?.type === "deactivate"
? "Deactivate all"
: "Reactivate all"}
</Button>
</div>
</DialogContent>
</Dialog>
{/* Delete role dialog */}
<Dialog
open={deleteDialogOpen}

View File

@ -24,7 +24,7 @@ import { Separator } from "../../components/ui/separator";
import { Avatar, AvatarFallback, AvatarImage } from "../../components/ui/avatar";
import { cn } from "../../lib/utils";
import { useUsersStore } from "../../zustand/userStore";
import { getUserById } from "../../api/users.api";
import { getUserById, getUserRecentActivity } from "../../api/users.api";
import { getCourseCategories, getCoursesByCategory } from "../../api/courses.api";
import {
getAdminLearnerCourseProgress,
@ -42,12 +42,48 @@ import { Select } from "../../components/ui/select";
import { SpinnerIcon } from "../../components/ui/spinner-icon";
import type { LearnerCourseProgressItem, LearnerCourseProgressSummary } from "../../types/progress.types";
import type { Course } from "../../types/course.types";
import type { UserRecentActivityItem } from "../../types/user.types";
const activityIcons: Record<string, typeof CheckCircle2> = {
const activityIcons = {
completed: CheckCircle2,
started: PlayCircle,
joined: UserPlus,
};
default: BookOpen,
} as const;
function visualActivityKind(kind: string): keyof typeof activityIcons {
const k = kind.toLowerCase();
if (k === "completed" || k === "complete") return "completed";
if (k === "started" || k === "start") return "started";
if (k === "joined" || k === "join") return "joined";
return "default";
}
/** Matches Recent Activity mock: "Today, 10:27 AM" / "Yesterday, 3:45 PM" / "Jan 10, 2025". */
function formatActivityOccurredAt(iso: string): string {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "—";
const now = new Date();
const startToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
const startThat = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
const dayDiff = Math.round((startToday - startThat) / 86_400_000);
const timePart = d.toLocaleTimeString("en-US", {
hour: "numeric",
minute: "2-digit",
hour12: true,
});
if (dayDiff === 0) return `Today, ${timePart}`;
if (dayDiff === 1) return `Yesterday, ${timePart}`;
return d.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
}
type CourseOption = Course & { category_name: string };
@ -62,6 +98,8 @@ export function UserDetailPage() {
const [progressSummary, setProgressSummary] = useState<LearnerCourseProgressSummary | null>(null);
const [loadingProgress, setLoadingProgress] = useState(false);
const [progressError, setProgressError] = useState<string | null>(null);
const [recentActivityItems, setRecentActivityItems] = useState<UserRecentActivityItem[]>([]);
const [recentActivityLoading, setRecentActivityLoading] = useState(false);
useEffect(() => {
if (!id) return;
@ -149,6 +187,28 @@ export function UserDetailPage() {
loadProgress();
}, [id, selectedProgressCourseId]);
useEffect(() => {
if (!id) return;
const userId = Number(id);
if (Number.isNaN(userId)) return;
const loadRecent = async () => {
setRecentActivityLoading(true);
try {
const res = await getUserRecentActivity(userId);
const items = res.data?.data?.items ?? [];
setRecentActivityItems(items);
} catch (err) {
console.error("Failed to load recent activity", err);
setRecentActivityItems([]);
} finally {
setRecentActivityLoading(false);
}
};
loadRecent();
}, [id]);
const progressMetrics = useMemo(() => {
if (progressSummary) {
return {
@ -198,13 +258,6 @@ export function UserDetailPage() {
const fullName = `${user.first_name} ${user.last_name}`;
const initials = `${user.first_name?.[0] ?? ""}${user.last_name?.[0] ?? ""}`.toUpperCase();
const recentActivities = [
{ type: "completed", text: "Completed Unit 4: Business Emails", time: "Today, 10:27 AM" },
{ type: "completed", text: "Completed Unit 3: Formal Writing", time: "Yesterday, 3:45 PM" },
{ type: "started", text: "Started Learning Path: Business English", time: "Jan 15, 2025" },
{ type: "joined", text: "Joined Yimaru", time: "Jan 10, 2025" },
];
const infoFields = [
{ icon: Phone, label: "Phone", value: user.phone_number },
{ icon: Mail, label: "Email", value: user.email },
@ -566,37 +619,49 @@ export function UserDetailPage() {
</div>
</CardHeader>
<CardContent>
<div className="relative space-y-0">
{recentActivities.map((activity, index) => {
const Icon = activityIcons[activity.type] ?? CheckCircle2;
const isLast = index === recentActivities.length - 1;
return (
<div key={index} className="relative flex gap-4 pb-5 last:pb-0">
{!isLast && (
<div className="absolute left-[15px] top-8 bottom-0 w-px bg-grayScale-200" />
)}
<div
className={cn(
"relative z-10 flex h-8 w-8 shrink-0 items-center justify-center rounded-full",
activity.type === "completed"
? "bg-mint-100 text-mint-500"
: activity.type === "started"
? "bg-brand-100/50 text-brand-500"
: "bg-grayScale-100 text-grayScale-400"
{recentActivityLoading ? (
<div className="flex items-center justify-center gap-2 py-10 text-sm text-grayScale-400">
<SpinnerIcon className="h-5 w-5" />
Loading activity
</div>
) : recentActivityItems.length === 0 ? (
<p className="py-8 text-center text-sm text-grayScale-400">No recent activity yet.</p>
) : (
<div className="relative space-y-0">
{recentActivityItems.map((item, index) => {
const vk = visualActivityKind(item.kind);
const Icon = activityIcons[vk];
const isLast = index === recentActivityItems.length - 1;
return (
<div key={item.id} className="relative flex gap-4 pb-5 last:pb-0">
{!isLast && (
<div className="absolute bottom-0 left-[15px] top-8 w-px bg-grayScale-200" />
)}
>
<Icon className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1 pt-1">
<div className="text-sm font-medium text-grayScale-600">
{activity.text}
<div
className={cn(
"relative z-10 flex h-8 w-8 shrink-0 items-center justify-center rounded-full",
vk === "completed"
? "bg-mint-100 text-mint-500"
: vk === "started"
? "bg-brand-100/50 text-brand-500"
: vk === "joined"
? "bg-grayScale-100 text-grayScale-400"
: "bg-grayScale-100 text-grayScale-500",
)}
>
<Icon className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1 pt-1">
<div className="text-sm font-medium text-grayScale-600">{item.headline}</div>
<div className="mt-0.5 text-xs text-grayScale-400">
{formatActivityOccurredAt(item.occurred_at)}
</div>
</div>
<div className="mt-0.5 text-xs text-grayScale-400">{activity.time}</div>
</div>
</div>
);
})}
</div>
);
})}
</div>
)}
</CardContent>
</Card>
</div>

View File

@ -1,180 +0,0 @@
import { useEffect, useState } from "react"
import { Link } from "react-router-dom"
import {
Users,
UserX,
UserCheck,
TrendingUp,
ArrowRight,
List,
UsersRound,
} from "lucide-react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../../components/ui/card"
import { SpinnerIcon } from "../../components/ui/spinner-icon"
import { getDashboard } from "../../api/analytics.api"
import type { DashboardUsers } from "../../types/analytics.types"
export function UserManagementDashboard() {
const [stats, setStats] = useState<DashboardUsers | null>(null)
const [statsLoading, setStatsLoading] = useState(true)
useEffect(() => {
const fetchStats = async () => {
try {
const res = await getDashboard()
const usersData = (res.data as any)?.users ?? (res.data as any)?.data?.users ?? null
setStats(usersData)
} catch {
// silently fail — cards will show "—"
} finally {
setStatsLoading(false)
}
}
fetchStats()
}, [])
const formatNum = (n: number) => n.toLocaleString()
const activeUsers =
stats?.by_status?.find((item) => item.label?.toUpperCase() === "ACTIVE")?.count ?? null
return (
<div className="space-y-8">
{/* Page Header */}
<div>
<h1 className="text-2xl font-bold text-grayScale-600">User Management</h1>
<p className="mt-1 text-sm text-grayScale-400">
Manage users, groups, and registrations.
</p>
</div>
{/* Stat Cards */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Card className="border-none bg-brand-50 shadow-sm">
<CardContent className="flex items-center gap-4 p-5">
<div className="grid h-12 w-12 shrink-0 place-items-center rounded-xl bg-brand-100 text-brand-600">
<Users className="h-6 w-6" />
</div>
<div className="min-w-0">
<p className="text-sm font-medium text-white/80">Total Users</p>
<p className="text-2xl font-bold text-white">
{statsLoading ? <SpinnerIcon className="h-5 w-5" /> : stats ? formatNum(stats.total_users) : "—"}
</p>
</div>
</CardContent>
</Card>
<Card className="border-none bg-brand-50 shadow-sm">
<CardContent className="flex items-center gap-4 p-5">
<div className="grid h-12 w-12 shrink-0 place-items-center rounded-xl bg-brand-100 text-brand-600">
<UserCheck className="h-6 w-6" />
</div>
<div className="min-w-0">
<p className="text-sm font-medium text-white/80">Active Users</p>
<p className="text-2xl font-bold text-white">
{statsLoading ? (
<SpinnerIcon className="h-5 w-5" />
) : activeUsers !== null ? (
formatNum(activeUsers)
) : (
"—"
)}
</p>
</div>
</CardContent>
</Card>
<Card className="border-none bg-brand-50 shadow-sm sm:col-span-2 lg:col-span-1">
<CardContent className="flex items-center gap-4 p-5">
<div className="grid h-12 w-12 shrink-0 place-items-center rounded-xl bg-brand-100 text-brand-600">
<TrendingUp className="h-6 w-6" />
</div>
<div className="min-w-0">
<p className="text-sm font-medium text-white/80">New This Month</p>
<p className="text-2xl font-bold text-white">
{statsLoading ? (
<SpinnerIcon className="h-5 w-5" />
) : stats ? (
formatNum(stats.new_month)
) : (
"—"
)}
</p>
</div>
</CardContent>
</Card>
</div>
{/* Action Cards */}
<div>
<h2 className="mb-4 text-lg font-semibold text-grayScale-600">Quick Actions</h2>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Link to="/users/deletion-requests" className="group">
<Card className="h-full border border-grayScale-100 shadow-sm transition-all duration-200 group-hover:border-brand-200 group-hover:shadow-md">
<CardHeader className="pb-3">
<div className="mb-3 grid h-11 w-11 place-items-center rounded-lg bg-brand-100 text-brand-600 transition-colors group-hover:bg-brand-500 group-hover:text-white">
<UserX className="h-5 w-5" />
</div>
<CardTitle className="text-base font-semibold text-grayScale-600">
Deletion Requests
</CardTitle>
<CardDescription className="text-sm text-grayScale-400">
Review account deletion requests and user deletion states.
</CardDescription>
</CardHeader>
<CardContent>
<span className="inline-flex items-center gap-1.5 text-sm font-medium text-brand-500 transition-colors group-hover:text-brand-600">
View requests
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
</span>
</CardContent>
</Card>
</Link>
<Link to="/users/groups" className="group">
<Card className="h-full border border-grayScale-100 shadow-sm transition-all duration-200 group-hover:border-brand-200 group-hover:shadow-md">
<CardHeader className="pb-3">
<div className="mb-3 grid h-11 w-11 place-items-center rounded-lg bg-brand-100 text-brand-600 transition-colors group-hover:bg-brand-500 group-hover:text-white">
<UsersRound className="h-5 w-5" />
</div>
<CardTitle className="text-base font-semibold text-grayScale-600">
User Groups
</CardTitle>
<CardDescription className="text-sm text-grayScale-400">
Manage groups, roles, and permission settings.
</CardDescription>
</CardHeader>
<CardContent>
<span className="inline-flex items-center gap-1.5 text-sm font-medium text-brand-500 transition-colors group-hover:text-brand-600">
Manage groups
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
</span>
</CardContent>
</Card>
</Link>
<Link to="/users/list" className="group sm:col-span-2 lg:col-span-1">
<Card className="h-full border border-grayScale-100 shadow-sm transition-all duration-200 group-hover:border-brand-200 group-hover:shadow-md">
<CardHeader className="pb-3">
<div className="mb-3 grid h-11 w-11 place-items-center rounded-lg bg-brand-100 text-brand-600 transition-colors group-hover:bg-brand-500 group-hover:text-white">
<List className="h-5 w-5" />
</div>
<CardTitle className="text-base font-semibold text-grayScale-600">
User List
</CardTitle>
<CardDescription className="text-sm text-grayScale-400">
Browse, search, and manage all registered users.
</CardDescription>
</CardHeader>
<CardContent>
<span className="inline-flex items-center gap-1.5 text-sm font-medium text-brand-500 transition-colors group-hover:text-brand-600">
View all users
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
</span>
</CardContent>
</Card>
</Link>
</div>
</div>
</div>
)
}

View File

@ -1,15 +1,110 @@
import { ChevronDown, ChevronLeft, ChevronRight, Search, UserCheck, Users, X } from "lucide-react"
import { ChevronDown, ChevronLeft, ChevronRight, Search, TrendingUp, UserCheck, Users, X } from "lucide-react"
import { useEffect, useState } from "react"
import { useNavigate } from "react-router-dom"
import * as DropdownMenu from "@radix-ui/react-dropdown-menu"
import { Input } from "../../components/ui/input"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../../components/ui/table"
import { Avatar, AvatarFallback, AvatarImage } from "../../components/ui/avatar"
import { Button } from "../../components/ui/button"
import { Card, CardContent } from "../../components/ui/card"
import { SpinnerIcon } from "../../components/ui/spinner-icon"
import { cn } from "../../lib/utils"
import { getDashboard } from "../../api/analytics.api"
import { getUsers, updateUserStatus, type UserStatus } from "../../api/users.api"
import type { DashboardUsers } from "../../types/analytics.types"
import { mapUserApiToUser } from "../../types/user.types"
import { useUsersStore } from "../../zustand/userStore"
import { toast } from "sonner"
import axios from "axios"
import { USER_FILTER_COUNTRIES, USER_FILTER_ETHIOPIA_REGIONS } from "../../data/userFilterLocations"
function formatJoinedAt(iso: string): string {
if (!iso?.trim()) return "—"
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return "—"
return d.toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" })
}
/** Convert `<input type="datetime-local" />` value to RFC3339 for GET /users. */
function toRfc3339FromDatetimeLocal(value: string): string | undefined {
const t = value?.trim()
if (!t) return undefined
const d = new Date(t)
if (Number.isNaN(d.getTime())) return undefined
return d.toISOString()
}
/** Portaled menu — native `<select>` lists break inside `overflow-y-auto` shells (e.g. app main). */
function UserListFilterDropdown({
id,
label,
value,
allLabel,
options,
onSelect,
}: {
id: string
label: string
value: string
allLabel: string
options: readonly string[]
onSelect: (next: string) => void
}) {
return (
<div className="flex flex-col gap-1">
<label htmlFor={id} className="text-xs font-medium text-grayScale-500">
{label}
</label>
<DropdownMenu.Root modal={false}>
<DropdownMenu.Trigger asChild>
<button
type="button"
id={id}
className={cn(
"flex h-9 w-full items-center justify-between gap-2 rounded-md border border-grayScale-200 bg-white px-3 text-left text-sm text-grayScale-600",
"outline-none focus-visible:ring-1 focus-visible:ring-brand-500",
)}
>
<span className="min-w-0 truncate">{value || allLabel}</span>
<ChevronDown className="h-4 w-4 shrink-0 text-grayScale-400" />
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
side="bottom"
align="start"
sideOffset={4}
collisionPadding={12}
className="z-[200] max-h-60 min-w-[var(--radix-dropdown-menu-trigger-width)] overflow-y-auto rounded-md border border-grayScale-200 bg-white p-1 shadow-lg"
onCloseAutoFocus={(e) => e.preventDefault()}
>
<DropdownMenu.Item
className={cn(
"cursor-pointer rounded px-2 py-2 text-sm text-grayScale-700 outline-none data-[highlighted]:bg-grayScale-100",
!value && "bg-grayScale-50 font-medium",
)}
onSelect={() => onSelect("")}
>
{allLabel}
</DropdownMenu.Item>
{options.map((opt) => (
<DropdownMenu.Item
key={opt}
className={cn(
"cursor-pointer rounded px-2 py-2 text-sm text-grayScale-700 outline-none data-[highlighted]:bg-grayScale-100",
value === opt && "bg-brand-50 font-medium text-brand-700",
)}
onSelect={() => onSelect(opt)}
>
{opt}
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
</div>
)
}
export function UsersListPage() {
const navigate = useNavigate()
@ -36,19 +131,45 @@ export function UsersListPage() {
} | null>(null)
const [roleFilter, setRoleFilter] = useState("")
const [statusFilter, setStatusFilter] = useState("")
const [createdAfterLocal, setCreatedAfterLocal] = useState("")
const [createdBeforeLocal, setCreatedBeforeLocal] = useState("")
const [countryFilter, setCountryFilter] = useState("")
const [regionFilter, setRegionFilter] = useState("")
const [subscriptionStatusFilter, setSubscriptionStatusFilter] = useState("")
const [loading, setLoading] = useState(false)
const [userSummary, setUserSummary] = useState<DashboardUsers | null>(null)
const [userSummaryLoading, setUserSummaryLoading] = useState(true)
useEffect(() => {
const fetchSummary = async () => {
try {
const res = await getDashboard()
setUserSummary(res.data.users ?? null)
} catch {
setUserSummary(null)
} finally {
setUserSummaryLoading(false)
}
}
fetchSummary()
}, [])
useEffect(() => {
const fetchUsers = async () => {
setLoading(true)
try {
const res = await getUsers(
const res = await getUsers({
page,
pageSize,
roleFilter || undefined,
statusFilter || undefined,
search || undefined,
)
page_size: pageSize,
role: roleFilter || undefined,
status: statusFilter || undefined,
query: search || undefined,
created_after: toRfc3339FromDatetimeLocal(createdAfterLocal),
created_before: toRfc3339FromDatetimeLocal(createdBeforeLocal),
country: countryFilter.trim() || undefined,
region: regionFilter.trim() || undefined,
subscription_status: subscriptionStatusFilter || undefined,
})
const apiUsers = res.data.data.users
const mapped = apiUsers.map(mapUserApiToUser)
@ -64,13 +185,30 @@ export function UsersListPage() {
console.error("Failed to fetch users:", error)
setUsers([])
setTotal(0)
const msg = axios.isAxiosError(error)
? (error.response?.data as { message?: string } | undefined)?.message
: undefined
toast.error(typeof msg === "string" && msg.trim() ? msg : "Failed to fetch users")
} finally {
setLoading(false)
}
}
fetchUsers()
}, [page, pageSize, roleFilter, statusFilter, search, setUsers, setTotal])
}, [
page,
pageSize,
roleFilter,
statusFilter,
search,
createdAfterLocal,
createdBeforeLocal,
countryFilter,
regionFilter,
subscriptionStatusFilter,
setUsers,
setTotal,
])
const pageCount = Math.max(1, Math.ceil(total / pageSize))
const safePage = Math.min(page, pageCount)
@ -99,7 +237,10 @@ export function UsersListPage() {
const allSelected = users.length > 0 && selectedIds.size === users.length
const startEntry = total === 0 ? 0 : (safePage - 1) * pageSize + 1
const endEntry = Math.min(safePage * pageSize, total)
const activeUsersOnPage = users.filter((u) => (u.status || "").toUpperCase() === "ACTIVE").length
const formatSummaryNum = (n: number) => n.toLocaleString()
const activeUsersTotal =
userSummary?.by_status?.find((item) => item.label?.toUpperCase() === "ACTIVE")?.count ?? null
const getPageNumbers = () => {
const pages: (number | string)[] = []
@ -168,6 +309,29 @@ export function UsersListPage() {
navigate(`/users/${userId}`)
}
const clearExtraFilters = () => {
setCreatedAfterLocal("")
setCreatedBeforeLocal("")
setCountryFilter("")
setRegionFilter("")
setSubscriptionStatusFilter("")
setPage(1)
}
const renderContactDetails = (phone: string | undefined, email: string | undefined) => {
const hasPhone = Boolean(phone?.trim())
const hasEmail = Boolean(email?.trim())
if (!hasPhone && !hasEmail) {
return <span className="text-grayScale-400"></span>
}
return (
<div className="space-y-1 text-sm text-grayScale-600">
{hasPhone ? <div className="tabular-nums">{phone!.trim()}</div> : null}
{hasEmail ? <div className="break-all text-grayScale-500">{email!.trim()}</div> : null}
</div>
)
}
return (
<div className="space-y-4">
{/* Header */}
@ -176,35 +340,67 @@ export function UsersListPage() {
<p className="text-sm text-grayScale-400">View and manage all registered users.</p>
</div>
{/* Stats cards (match UserLogPage approach) */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div className="flex items-center gap-4 rounded-xl border bg-white p-4">
<div className="grid h-10 w-10 place-items-center rounded-lg bg-brand-100 text-brand-600">
<Users className="h-5 w-5" />
</div>
<div>
<p className="text-2xl font-bold text-grayScale-600">{total}</p>
<p className="text-xs text-grayScale-400">Total Users</p>
</div>
</div>
<div className="flex items-center gap-4 rounded-xl border bg-white p-4">
<div className="grid h-10 w-10 place-items-center rounded-lg bg-emerald-100 text-emerald-600">
<UserCheck className="h-5 w-5" />
</div>
<div>
<p className="text-2xl font-bold text-grayScale-600">{activeUsersOnPage}</p>
<p className="text-xs text-grayScale-400">Active In Current Page</p>
</div>
</div>
<div className="flex items-center gap-4 rounded-xl border bg-white p-4">
<div className="grid h-10 w-10 place-items-center rounded-lg bg-amber-100 text-amber-600">
<Search className="h-5 w-5" />
</div>
<div>
<p className="text-2xl font-bold text-grayScale-600">{users.length}</p>
<p className="text-xs text-grayScale-400">Showing Results</p>
</div>
</div>
{/* Platform-wide user summary (same metrics as former User Management dashboard) */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Card className="border-none bg-brand-50 shadow-sm">
<CardContent className="flex items-center gap-4 p-5">
<div className="grid h-12 w-12 shrink-0 place-items-center rounded-xl bg-brand-100 text-brand-600">
<Users className="h-6 w-6" />
</div>
<div className="min-w-0">
<p className="text-sm font-medium text-white/80">Total Users</p>
<p className="text-2xl font-bold text-white">
{userSummaryLoading ? (
<SpinnerIcon className="h-5 w-5" />
) : userSummary ? (
formatSummaryNum(userSummary.total_users)
) : (
"—"
)}
</p>
</div>
</CardContent>
</Card>
<Card className="border-none bg-brand-50 shadow-sm">
<CardContent className="flex items-center gap-4 p-5">
<div className="grid h-12 w-12 shrink-0 place-items-center rounded-xl bg-brand-100 text-brand-600">
<UserCheck className="h-6 w-6" />
</div>
<div className="min-w-0">
<p className="text-sm font-medium text-white/80">Active Users</p>
<p className="text-2xl font-bold text-white">
{userSummaryLoading ? (
<SpinnerIcon className="h-5 w-5" />
) : activeUsersTotal !== null ? (
formatSummaryNum(activeUsersTotal)
) : (
"—"
)}
</p>
</div>
</CardContent>
</Card>
<Card className="border-none bg-brand-50 shadow-sm sm:col-span-2 lg:col-span-1">
<CardContent className="flex items-center gap-4 p-5">
<div className="grid h-12 w-12 shrink-0 place-items-center rounded-xl bg-brand-100 text-brand-600">
<TrendingUp className="h-6 w-6" />
</div>
<div className="min-w-0">
<p className="text-sm font-medium text-white/80">New This Month</p>
<p className="text-2xl font-bold text-white">
{userSummaryLoading ? (
<SpinnerIcon className="h-5 w-5" />
) : userSummary ? (
formatSummaryNum(userSummary.new_month)
) : (
"—"
)}
</p>
</div>
</CardContent>
</Card>
</div>
<div className="bg-white rounded-xl border">
@ -225,7 +421,10 @@ export function UsersListPage() {
<div className="relative w-full sm:w-auto">
<select
value={roleFilter}
onChange={(e) => setRoleFilter(e.target.value)}
onChange={(e) => {
setRoleFilter(e.target.value)
setPage(1)
}}
className="h-9 w-full sm:w-auto appearance-none rounded-md border bg-white pl-3 pr-8 text-sm text-grayScale-600 focus:outline-none focus:ring-1 focus:ring-brand-500"
>
<option value="">All roles</option>
@ -239,7 +438,10 @@ export function UsersListPage() {
<div className="relative w-full sm:w-auto">
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
onChange={(e) => {
setStatusFilter(e.target.value)
setPage(1)
}}
className="h-9 w-full sm:w-auto appearance-none rounded-md border bg-white pl-3 pr-8 text-sm text-grayScale-600 focus:outline-none focus:ring-1 focus:ring-brand-500"
>
<option value="">All statuses</option>
@ -252,6 +454,96 @@ export function UsersListPage() {
</div>
</div>
</div>
<div className="mt-4 grid gap-3 border-t border-grayScale-100 pt-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5">
<div className="flex flex-col gap-1">
<label htmlFor="filter-created-after" className="text-xs font-medium text-grayScale-500">
Created on or after
</label>
<input
id="filter-created-after"
type="datetime-local"
value={createdAfterLocal}
onChange={(e) => {
setCreatedAfterLocal(e.target.value)
setPage(1)
}}
className="h-9 w-full rounded-md border border-grayScale-200 bg-white px-2 text-sm text-grayScale-700 focus:outline-none focus:ring-1 focus:ring-brand-500"
/>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="filter-created-before" className="text-xs font-medium text-grayScale-500">
Created on or before
</label>
<input
id="filter-created-before"
type="datetime-local"
value={createdBeforeLocal}
onChange={(e) => {
setCreatedBeforeLocal(e.target.value)
setPage(1)
}}
className="h-9 w-full rounded-md border border-grayScale-200 bg-white px-2 text-sm text-grayScale-700 focus:outline-none focus:ring-1 focus:ring-brand-500"
/>
</div>
<UserListFilterDropdown
id="filter-country"
label="Country"
value={countryFilter}
allLabel="All countries"
options={USER_FILTER_COUNTRIES}
onSelect={(next) => {
setCountryFilter(next)
setPage(1)
}}
/>
<UserListFilterDropdown
id="filter-region"
label="Region (Ethiopia)"
value={regionFilter}
allLabel="All regions"
options={USER_FILTER_ETHIOPIA_REGIONS}
onSelect={(next) => {
setRegionFilter(next)
setPage(1)
}}
/>
<div className="flex flex-col gap-1">
<label htmlFor="filter-subscription-status" className="text-xs font-medium text-grayScale-500">
Subscription status
</label>
<div className="relative w-full">
<select
id="filter-subscription-status"
value={subscriptionStatusFilter}
onChange={(e) => {
setSubscriptionStatusFilter(e.target.value)
setPage(1)
}}
className="h-9 w-full appearance-none rounded-md border border-grayScale-200 bg-white pl-3 pr-8 text-sm text-grayScale-600 focus:outline-none focus:ring-1 focus:ring-brand-500"
>
<option value="">All</option>
<option value="ACTIVE">Active</option>
<option value="PENDING">Pending</option>
<option value="Unsubscribed">Unsubscribed</option>
</select>
<ChevronDown className="pointer-events-none absolute right-2 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-400" />
</div>
</div>
</div>
<div className="mt-3 flex flex-wrap items-center justify-between gap-2">
<p className="text-xs text-grayScale-400">
Dates are sent as RFC3339 (UTC). Country and region filters use the lists above; the API matches
case-insensitively.
</p>
<button
type="button"
onClick={clearExtraFilters}
className="shrink-0 text-sm font-medium text-brand-600 hover:text-brand-700"
>
Clear date, location & subscription filters
</button>
</div>
</div>
{/* Table */}
@ -267,10 +559,11 @@ export function UsersListPage() {
/>
</TableHead>
<TableHead>USER</TableHead>
<TableHead className="hidden md:table-cell">Role</TableHead>
<TableHead className="hidden md:table-cell">Phone</TableHead>
<TableHead className="hidden md:table-cell min-w-[10rem]">Contact details</TableHead>
<TableHead className="hidden md:table-cell">Country</TableHead>
<TableHead className="hidden md:table-cell">Region</TableHead>
<TableHead className="hidden md:table-cell whitespace-nowrap">Joined at</TableHead>
<TableHead className="hidden lg:table-cell max-w-[12rem]">Subscription status</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
@ -278,13 +571,13 @@ export function UsersListPage() {
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={7} className="py-12 text-center">
<TableCell colSpan={8} className="py-12 text-center">
<p className="text-sm text-grayScale-400">Loading users...</p>
</TableCell>
</TableRow>
) : users.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="py-16 text-center">
<TableCell colSpan={8} className="py-16 text-center">
<div className="flex flex-col items-center gap-3">
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-grayScale-100">
<Users className="h-7 w-7 text-grayScale-400" />
@ -322,16 +615,31 @@ export function UsersListPage() {
{`${u.firstName?.[0] ?? ""}${u.lastName?.[0] ?? ""}`.toUpperCase()}
</AvatarFallback>
</Avatar>
<div>
<div className="font-medium text-grayScale-600">{u.firstName} {u.lastName}</div>
<div className="text-xs text-grayScale-400">{u.email || u.phoneNumber || "-"}</div>
<div className="font-medium text-grayScale-600">
{u.firstName} {u.lastName}
</div>
</div>
</TableCell>
<TableCell className="hidden md:table-cell text-grayScale-500">{u.role || "-"}</TableCell>
<TableCell className="hidden md:table-cell text-grayScale-500">{u.phoneNumber || "-"}</TableCell>
<TableCell className="hidden md:table-cell align-top">
{renderContactDetails(u.phoneNumber, u.email)}
</TableCell>
<TableCell className="hidden md:table-cell text-grayScale-500">{u.country || "-"}</TableCell>
<TableCell className="hidden md:table-cell text-grayScale-500">{u.region || "-"}</TableCell>
<TableCell className="hidden md:table-cell text-sm text-grayScale-500 whitespace-nowrap">
{formatJoinedAt(u.createdAt)}
</TableCell>
<TableCell className="hidden lg:table-cell align-top text-sm text-grayScale-600">
<span
className={cn(
u.subscriptionStatus === "—" ||
u.subscriptionStatus.toLowerCase() === "unsubscribed"
? "text-grayScale-400"
: undefined,
)}
>
{u.subscriptionStatus}
</span>
</TableCell>
<TableCell onClick={(e) => e.stopPropagation()}>
<button
type="button"

View File

@ -52,11 +52,34 @@ export interface DashboardPayments {
revenue_last_30_days: DateRevenue[]
}
export interface DashboardCoursesLms {
programs: number
courses: number
modules: number
lessons: number
lessons_with_video: number
practices: number
practices_at_course: number
practices_at_module: number
practices_at_lesson: number
}
export interface DashboardCoursesExamPrep {
catalog_courses: number
units: number
unit_modules: number
lessons: number
lessons_with_video: number
lesson_practices: number
}
export interface DashboardCourses {
total_categories: number
total_courses: number
total_sub_courses: number
total_videos: number
lms?: DashboardCoursesLms
exam_prep?: DashboardCoursesExamPrep
}
export interface DashboardContent {
@ -88,8 +111,34 @@ export interface DashboardTeam {
by_status: LabelCount[]
}
export type DashboardDateFilterMode = "all_time" | "year" | "year_month" | "custom"
export interface DashboardDateFilter {
mode: DashboardDateFilterMode
year?: number
month?: number
from?: string
to?: string
range_start?: string
range_end?: string
series_start?: string
series_end?: string
ref_date?: string
}
export type DashboardFilterMode = DashboardDateFilterMode
export interface DashboardFilters {
mode: DashboardFilterMode
year?: number
month?: number
from?: string
to?: string
}
export interface DashboardData {
generated_at: string
date_filter?: DashboardDateFilter
users: DashboardUsers
subscriptions: DashboardSubscriptions
payments: DashboardPayments

View File

@ -73,12 +73,14 @@ export interface UpdateLearningProgramRequest {
name: string
description: string
thumbnail: string
sort_order: number
}
export interface CreateLearningProgramRequest {
name: string
description: string
thumbnail: string
sort_order: number
}
export interface CreateLearningProgramResponse {
@ -128,6 +130,7 @@ export interface UpdateTopLevelCourseRequest {
name: string
description: string
thumbnail: string
sort_order: number
}
/** Body for POST /programs/:program_id/courses */
@ -135,6 +138,7 @@ export interface CreateProgramCourseRequest {
name: string
description: string
thumbnail: string
sort_order: number
}
export interface CreateProgramCourseResponse {
@ -220,6 +224,7 @@ export interface CreateExamPrepCatalogUnitRequest {
name: string
description?: string | null
thumbnail?: string | null
sort_order: number
}
export interface CreateExamPrepCatalogUnitResponse {
@ -325,6 +330,9 @@ export interface ExamPrepModuleLessonItem {
thumbnail?: string | null
description?: string | null
sort_order?: number
/** Total length in seconds when the API provides it. */
duration?: number | null
duration_seconds?: number | null
created_at?: string
updated_at?: string
}
@ -334,6 +342,7 @@ export interface CreateExamPrepModuleLessonRequest {
video_url: string
thumbnail?: string | null
description?: string | null
publish_status: PracticePublishStatus
}
export interface CreateExamPrepModuleLessonResponse {
@ -416,6 +425,7 @@ export interface UpdateTopLevelCourseModuleRequest {
name: string
description: string
icon: string
sort_order: number
}
/** Body for POST /courses/:courseId/modules */
@ -423,6 +433,7 @@ export interface CreateTopLevelCourseModuleRequest {
name: string
description: string
icon: string
sort_order: number
}
export interface CreateTopLevelCourseModuleResponse {
@ -442,6 +453,11 @@ export interface TopLevelModuleLessonItem {
thumbnail: string
description: string
sort_order: number
publish_status?: PracticePublishStatus | string | null
has_practice?: boolean
/** Total length in seconds when the API provides it. */
duration?: number | null
duration_seconds?: number | null
created_at: string
}
@ -468,6 +484,7 @@ export interface ParentContextPractice {
story_image: string
question_set_id: number
quick_tips: string
publish_status?: PracticePublishStatus | string | null
persona_id?: number | null
created_at: string
}
@ -487,6 +504,8 @@ export interface GetPracticesByParentContextResponse {
export type PracticeParentKind = "COURSE" | "MODULE" | "LESSON"
export type PracticePublishStatus = "DRAFT" | "PUBLISHED"
/** POST /practices — create practice linked to a course, module, or lesson (Learn English). */
export interface CreateParentLinkedPracticeRequest {
parent_kind: PracticeParentKind
@ -496,6 +515,7 @@ export interface CreateParentLinkedPracticeRequest {
story_image: string
question_set_id: number
quick_tips: string
publish_status: PracticePublishStatus
persona_id?: number
}
@ -509,14 +529,20 @@ export interface CreateParentLinkedPracticeResponse {
/** Body for PUT /practices/:id (Learn English parent-linked practice). */
export interface UpdateParentLinkedPracticeRequest {
title: string
story_description: string
story_image: string
question_set_id: number
quick_tips: string
title?: string
story_description?: string
story_image?: string
question_set_id?: number
quick_tips?: string
publish_status?: PracticePublishStatus
persona_id?: number | null
}
/** Publish-only patch: PUT /practices/:id with { publish_status: "PUBLISHED" }. */
export interface PublishParentLinkedPracticeRequest {
publish_status: PracticePublishStatus
}
export interface UpdateParentLinkedPracticeResponse {
message: string
data: ParentContextPractice
@ -531,6 +557,12 @@ export interface UpdateTopLevelModuleLessonRequest {
video_url: string
thumbnail: string
description: string
sort_order: number
}
/** Publish-only patch: PUT /lessons/:id with { publish_status }. */
export interface PublishTopLevelModuleLessonRequest {
publish_status: PracticePublishStatus
}
/** Body for POST /modules/:moduleId/lessons. */
@ -539,6 +571,8 @@ export interface CreateTopLevelModuleLessonRequest {
video_url: string
thumbnail: string
description: string
sort_order: number
publish_status: PracticePublishStatus
}
export interface CreateTopLevelModuleLessonResponse {

View File

@ -0,0 +1,26 @@
export interface PersonaListItem {
id: number
name: string
description: string
profile_picture: string | null
is_active: boolean
created_at: string
}
export interface GetPersonasParams {
limit?: number
offset?: number
}
export interface GetPersonasResponse {
message: string
data: {
personas: PersonaListItem[]
total_count: number
limit: number
offset: number
}
success: boolean
status_code: number
metadata: unknown | null
}

View File

@ -79,3 +79,31 @@ export interface GetPermissionsResponse {
status_code: number
metadata: unknown
}
export interface BulkRoleDeactivateData {
role: string
users_deactivated: number
team_members_deactivated: number
}
export interface BulkRoleReactivateData {
role: string
users_reactivated: number
team_members_reactivated: number
}
export interface BulkRoleDeactivateResponse {
message: string
data: BulkRoleDeactivateData
success: boolean
status_code: number
metadata: unknown | null
}
export interface BulkRoleReactivateResponse {
message: string
data: BulkRoleReactivateData
success: boolean
status_code: number
metadata: unknown | null
}

View File

@ -0,0 +1,21 @@
export type SubscriptionPlanDurationUnit = "MONTH" | "YEAR" | "WEEK" | "DAY" | string
export interface SubscriptionPlan {
id: number
name: string
description: string
duration_value: number
duration_unit: SubscriptionPlanDurationUnit
price: number
currency: string
is_active: boolean
created_at: string
}
export interface SubscriptionPlansListResponse {
message?: string
data: SubscriptionPlan[]
success?: boolean
status_code?: number
metadata?: unknown
}

View File

@ -1,36 +1,40 @@
// This matches the API response 1:1
// This matches the API response 1:1 (GET /users); many fields are optional on partial profiles.
export interface UserApiDTO {
id: number
first_name: string
last_name: string
gender: string
birth_day: string | null
first_name?: string
last_name?: string
gender?: string
birth_day?: string | null
email: string
email?: string
phone_number?: string
role: string
age_group: string
education_level: string
country: string
region: string
age_group?: string
education_level?: string
country?: string
region?: string
nick_name: string
occupation: string
learning_goal: string
language_goal: string
language_challange: string
favoutite_topic: string
nick_name?: string
occupation?: string
learning_goal?: string
language_goal?: string
language_challange?: string
favoutite_topic?: string
email_verified: boolean
phone_verified: boolean
email_verified?: boolean
phone_verified?: boolean
status: string
profile_completed: boolean
profile_picture_url: string
preferred_language: string
profile_completed?: boolean
profile_picture_url?: string
preferred_language?: string
profile_completion_percentage?: number
created_at: string
updated_at?: string
/** Billing / plan state for list UI (e.g. "Unsubscribed", "Active"). */
subscription_status?: string
}
export interface GetUsersResponse {
@ -55,20 +59,26 @@ export interface User {
country: string
lastLogin: string | null
status: string
/** From API `subscription_status` (e.g. "Unsubscribed"). */
subscriptionStatus: string
/** ISO 8601 from API `created_at`. */
createdAt: string
}
export const mapUserApiToUser = (u: UserApiDTO): User => ({
id: u.id,
firstName: u.first_name,
lastName: u.last_name,
nickName: u.nick_name,
email: u.email,
firstName: u.first_name ?? "",
lastName: u.last_name ?? "",
nickName: u.nick_name ?? "",
email: u.email ?? "",
phoneNumber: u.phone_number ?? "",
role: u.role,
region: u.region,
country: u.country,
region: u.region ?? "",
country: u.country ?? "",
lastLogin: null,
status: u.status,
subscriptionStatus: u.subscription_status?.trim() ? u.subscription_status.trim() : "—",
createdAt: u.created_at ?? "",
})
export interface UserProfileData {
@ -115,6 +125,27 @@ export interface UserProfileResponse {
timestamp: string
}
/** GET /admin/users/:user_id/recent-activity */
export interface UserRecentActivityItem {
id: string
kind: string
occurred_at: string
headline: string
}
export interface UserRecentActivityData {
user_id: number
items: UserRecentActivityItem[]
}
export interface UserRecentActivityResponse {
message?: string
data?: UserRecentActivityData
success?: boolean
status_code?: number
metadata?: unknown
}
export interface UserSummary {
total_users: number
active_users: number