Compare commits
4 Commits
f1b6172f91
...
b8a73c73db
| Author | SHA1 | Date | |
|---|---|---|---|
| b8a73c73db | |||
| 38550f9519 | |||
| 385f58fd22 | |||
| 2b556d9d09 |
|
|
@ -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),
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -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
6
src/api/personas.api.ts
Normal 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 })
|
||||
|
|
@ -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 } }`.
|
||||
|
|
|
|||
|
|
@ -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`, {})
|
||||
|
|
|
|||
11
src/api/subscription-plans.api.ts
Normal file
11
src/api/subscription-plans.api.ts
Normal 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[]),
|
||||
},
|
||||
}))
|
||||
|
|
@ -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");
|
||||
|
||||
|
|
|
|||
|
|
@ -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 />}
|
||||
|
|
|
|||
272
src/components/analytics/AnalyticsTimeRangeFilter.tsx
Normal file
272
src/components/analytics/AnalyticsTimeRangeFilter.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
593
src/components/content-management/DynamicSchemaSlotField.tsx
Normal file
593
src/components/content-management/DynamicSchemaSlotField.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
130
src/components/dashboard/RevenueTrendCard.tsx
Normal file
130
src/components/dashboard/RevenueTrendCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
228
src/data/userFilterLocations.ts
Normal file
228
src/data/userFilterLocations.ts
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
/**
|
||||
* Static options for GET /users filters (`country`, `region`).
|
||||
* Country: common English short names (ISO-style), sorted A–Z.
|
||||
* 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, A–Z (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]
|
||||
43
src/hooks/useActivePersonas.ts
Normal file
43
src/hooks/useActivePersonas.ts
Normal 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
86
src/lib/analytics.ts
Normal 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"
|
||||
}
|
||||
}
|
||||
175
src/lib/learnEnglishDefinitionQuestion.ts
Normal file
175
src/lib/learnEnglishDefinitionQuestion.ts
Normal 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
|
||||
}
|
||||
145
src/lib/learnEnglishPracticePublish.ts
Normal file
145
src/lib/learnEnglishPracticePublish.ts
Normal 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 }
|
||||
}
|
||||
48
src/lib/parentContextPractice.ts
Normal file
48
src/lib/parentContextPractice.ts
Normal 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
52
src/lib/personaDisplay.ts
Normal 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 : []
|
||||
}
|
||||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
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 */}
|
||||
|
|
|
|||
|
|
@ -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 lesson’s 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>
|
||||
|
|
|
|||
|
|
@ -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&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'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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
379
src/pages/content-management/LessonPracticesPage.tsx
Normal file
379
src/pages/content-management/LessonPracticesPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
31
src/pages/content-management/ReorderContentPage.tsx
Normal file
31
src/pages/content-management/ReorderContentPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
155
src/pages/content-management/components/ModulePracticeCard.tsx
Normal file
155
src/pages/content-management/components/ModulePracticeCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 / True‑False /
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
26
src/types/persona.types.ts
Normal file
26
src/types/persona.types.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
21
src/types/subscription.types.ts
Normal file
21
src/types/subscription.types.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user