Compare commits

..

No commits in common. "main" and "el-ui" have entirely different histories.
main ... el-ui

168 changed files with 4668 additions and 22320 deletions

View File

@ -5,27 +5,6 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>yimaru-admin</title>
<script>
(function () {
var key = "yimaru-admin-theme";
var stored = localStorage.getItem(key);
var root = document.documentElement;
var systemDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
var resolved =
stored === "dark"
? "dark"
: stored === "system"
? systemDark
? "dark"
: "light"
: "light";
root.classList.remove("dark");
if (resolved === "dark") root.classList.add("dark");
root.dataset.theme = resolved;
root.dataset.themePreference = stored || "light";
root.style.colorScheme = resolved;
})();
</script>
</head>
<body>
<div id="root"></div>

82
package-lock.json generated
View File

@ -26,7 +26,6 @@
"react-is": "^19.2.5",
"react-router-dom": "^7.10.1",
"recharts": "^3.6.0",
"resend": "^6.12.3",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"zustand": "^5.0.9"
@ -93,7 +92,6 @@
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
@ -362,7 +360,6 @@
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
@ -2673,12 +2670,6 @@
"win32"
]
},
"node_modules/@stablelib/base64": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz",
"integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
"license": "MIT"
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
@ -2819,7 +2810,6 @@
"integrity": "sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~7.16.0"
}
@ -2830,7 +2820,6 @@
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@ -2841,7 +2830,6 @@
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"devOptional": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@ -2897,7 +2885,6 @@
"integrity": "sha512-6/cmF2piao+f6wSxUsJLZjck7OQsYyRtcOZS02k7XINSNlz93v6emM8WutDQSXnroG2xwYlEVHJI+cPA7CPM3Q==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.50.0",
"@typescript-eslint/types": "8.50.0",
@ -3149,7 +3136,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@ -3388,7 +3374,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@ -3965,7 +3950,6 @@
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@ -4201,12 +4185,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/fast-sha256": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
"integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==",
"license": "Unlicense"
},
"node_modules/fastq": {
"version": "1.19.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz",
@ -5086,7 +5064,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@ -5114,12 +5091,6 @@
"node": ">= 6"
}
},
"node_modules/postal-mime": {
"version": "2.7.4",
"resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-2.7.4.tgz",
"integrity": "sha512-0WdnFQYUrPGGTFu1uOqD2s7omwua8xaeYGdO6rb88oD5yJ/4pPHDA4sdWqfD8wQVfCny563n/HQS7zTFft+f/g==",
"license": "MIT-0"
},
"node_modules/postcss": {
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
@ -5140,7 +5111,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@ -5329,7 +5299,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@ -5339,7 +5308,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@ -5351,15 +5319,13 @@
"version": "19.2.5",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.5.tgz",
"integrity": "sha512-Dn0t8IQhCmeIT3wu+Apm1/YVsJXsGWi6k4sPdnBIdqMVtHtv0IGi6dcpNpNkNac0zB2uUAqNX3MHzN8c+z2rwQ==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/react-redux": {
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
@ -5565,8 +5531,7 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/redux-thunk": {
"version": "3.1.0",
@ -5583,27 +5548,6 @@
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
"license": "MIT"
},
"node_modules/resend": {
"version": "6.12.3",
"resolved": "https://registry.npmjs.org/resend/-/resend-6.12.3.tgz",
"integrity": "sha512-FkEi6YPnVL96/LvH8+QP7NaeaBy5brYXwlRqUCqZZeNL0/iyKij18IPmyPXYauT/2ODn1JG04qKz+qlJfzqzTw==",
"license": "MIT",
"dependencies": {
"postal-mime": "2.7.4",
"svix": "1.92.2"
},
"engines": {
"node": ">=20"
},
"peerDependencies": {
"@react-email/render": "*"
},
"peerDependenciesMeta": {
"@react-email/render": {
"optional": true
}
}
},
"node_modules/resolve": {
"version": "1.22.11",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
@ -5777,16 +5721,6 @@
"node": ">=0.10.0"
}
},
"node_modules/standardwebhooks": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz",
"integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==",
"license": "MIT",
"dependencies": {
"@stablelib/base64": "^1.0.0",
"fast-sha256": "^1.3.0"
}
},
"node_modules/strip-json-comments": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
@ -5849,15 +5783,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/svix": {
"version": "1.92.2",
"resolved": "https://registry.npmjs.org/svix/-/svix-1.92.2.tgz",
"integrity": "sha512-ZmuA3UVvlnF9EgxlzmPtF7CKjQb64Z6OFlyfdDfU0sdcC7dJa+3aOYX5B9mA+RS6ch1AxBa4UP/l6KmqfGtWBQ==",
"license": "MIT",
"dependencies": {
"standardwebhooks": "1.0.0"
}
},
"node_modules/tailwind-merge": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz",
@ -6010,7 +5935,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@ -6178,7 +6102,6 @@
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@ -6316,7 +6239,6 @@
"integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==",
"dev": true,
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}

View File

@ -28,7 +28,6 @@
"react-is": "^19.2.5",
"react-router-dom": "^7.10.1",
"recharts": "^3.6.0",
"resend": "^6.12.3",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"zustand": "^5.0.9"

View File

@ -1,29 +1,9 @@
import { useEffect } from 'react'
import { Toaster } from 'sonner'
import { AppRoutes } from './app/AppRoutes'
import { useTheme } from './contexts/ThemeContext'
const SESSION_KEY = 'yimaru_session_active'
function AppToaster() {
const { resolvedTheme } = useTheme()
return (
<Toaster
position="top-center"
theme={resolvedTheme}
toastOptions={{
className: 'font-sans',
style: {
padding: '14px 20px',
borderRadius: '12px',
fontSize: '14px',
},
}}
richColors
/>
)
}
export default function App() {
useEffect(() => {
if (!sessionStorage.getItem(SESSION_KEY)) {
@ -38,7 +18,18 @@ export default function App() {
return (
<>
<AppRoutes />
<AppToaster />
<Toaster
position="top-center"
toastOptions={{
className: 'font-sans',
style: {
padding: '14px 20px',
borderRadius: '12px',
fontSize: '14px',
},
}}
richColors
/>
</>
)
}

View File

@ -1,177 +1,5 @@
import http from "./http";
import type {
DashboardData,
DashboardFilters,
DashboardUsers,
DateCount,
LabelCount,
} from "../types/analytics.types";
import type { DashboardResponse } from "../types/analytics.types";
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 isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function pickField(record: Record<string, unknown>, ...keys: string[]): unknown {
for (const key of keys) {
if (key in record && record[key] != null) return record[key];
}
return undefined;
}
function asLabelCounts(value: unknown): LabelCount[] {
if (!Array.isArray(value)) return [];
return value
.map((item) => {
if (!isRecord(item)) return null;
const label = String(pickField(item, "label", "Label") ?? "").trim();
const count = Number(pickField(item, "count", "Count") ?? 0);
if (!label) return null;
return { label, count: Number.isFinite(count) ? count : 0 };
})
.filter((row): row is LabelCount => row !== null);
}
function asDateCounts(value: unknown): DateCount[] {
if (!Array.isArray(value)) return [];
return value
.map((item) => {
if (!isRecord(item)) return null;
const date = String(pickField(item, "date", "Date") ?? "");
const count = Number(pickField(item, "count", "Count") ?? 0);
if (!date) return null;
return { date, count: Number.isFinite(count) ? count : 0 };
})
.filter((row): row is DateCount => row !== null);
}
function isDashboardPayload(value: unknown): value is Record<string, unknown> {
if (!isRecord(value)) return false;
return (
"generated_at" in value ||
"generatedAt" in value ||
"users" in value ||
"Users" in value
);
}
/** Unwrap `{ data }` / `{ Data }` envelopes until the dashboard object is found. */
function unwrapDashboardPayload(body: unknown): Record<string, unknown> {
let current: unknown = body;
for (let depth = 0; depth < 5; depth++) {
if (!isRecord(current)) break;
if (isDashboardPayload(current)) {
const nested = pickField(current, "data", "Data");
if (isRecord(nested) && isDashboardPayload(nested)) {
current = nested;
continue;
}
return current;
}
const inner = pickField(current, "data", "Data");
if (inner != null) {
current = inner;
continue;
}
break;
}
return isRecord(current) ? current : {};
}
const EDUCATION_LEVEL_KEYS = ["by_education_level", "byEducationLevel", "ByEducationLevel"] as const;
const OCCUPATION_KEYS = ["by_occupation", "byOccupation", "ByOccupation"] as const;
const LEARNING_GOAL_KEYS = ["by_learning_goal", "byLearningGoal", "ByLearningGoal"] as const;
const LANGUAGE_CHALLENGE_KEYS = [
"by_language_challange",
"by_language_challenge",
"byLanguageChallange",
"byLanguageChallenge",
"ByLanguageChallange",
"ByLanguageChallenge",
] as const;
function normalizeDashboardUsers(raw: unknown, root?: Record<string, unknown>): DashboardUsers {
const u = isRecord(raw) ? raw : {};
const scope = root ?? u;
return {
total_users: Number(pickField(u, "total_users", "totalUsers", "TotalUsers") ?? 0),
new_today: Number(pickField(u, "new_today", "newToday", "NewToday") ?? 0),
new_week: Number(pickField(u, "new_week", "newWeek", "NewWeek") ?? 0),
new_month: Number(pickField(u, "new_month", "newMonth", "NewMonth") ?? 0),
by_role: asLabelCounts(pickField(u, "by_role", "byRole", "ByRole")),
by_status: asLabelCounts(pickField(u, "by_status", "byStatus", "ByStatus")),
by_age_group: asLabelCounts(pickField(u, "by_age_group", "byAgeGroup", "ByAgeGroup")),
by_education_level: asLabelCounts(
pickField(u, ...EDUCATION_LEVEL_KEYS) ?? pickField(scope, ...EDUCATION_LEVEL_KEYS),
),
by_occupation: asLabelCounts(
pickField(u, ...OCCUPATION_KEYS) ?? pickField(scope, ...OCCUPATION_KEYS),
),
by_learning_goal: asLabelCounts(
pickField(u, ...LEARNING_GOAL_KEYS) ?? pickField(scope, ...LEARNING_GOAL_KEYS),
),
by_language_challange: asLabelCounts(
pickField(u, ...LANGUAGE_CHALLENGE_KEYS) ?? pickField(scope, ...LANGUAGE_CHALLENGE_KEYS),
),
by_knowledge_level: asLabelCounts(
pickField(u, "by_knowledge_level", "byKnowledgeLevel", "ByKnowledgeLevel"),
),
by_country: asLabelCounts(pickField(u, "by_country", "byCountry", "ByCountry")),
by_region: asLabelCounts(pickField(u, "by_region", "byRegion", "ByRegion")),
registrations_last_30_days: asDateCounts(
pickField(
u,
"registrations_last_30_days",
"registrationsLast30Days",
"RegistrationsLast30Days",
),
),
};
}
function normalizeDashboardResponse(body: unknown): DashboardData {
const root = unwrapDashboardPayload(body);
const usersRaw = pickField(root, "users", "Users");
return {
...(root as DashboardData),
users: normalizeDashboardUsers(usersRaw, root),
};
}
export const getDashboard = (filters?: DashboardFilters) =>
http
.get<unknown>("/analytics/dashboard", {
params: buildDashboardQueryParams(filters),
})
.then((res) => ({
...res,
data: normalizeDashboardResponse(res.data),
}));
export const getDashboard = () =>
http.get<DashboardResponse>("/analytics/dashboard");

View File

@ -1,118 +0,0 @@
import http from "./http"
import { DEFAULT_TABLE_PAGE_SIZE } from "../lib/tablePagination"
import type {
AppVersion,
AppVersionMutationResponse,
AppVersionsListData,
AppVersionsListResponse,
CreateAppVersionPayload,
UpdateAppVersionPayload,
} from "../types/app-version.types"
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value)
}
function normalizeAppVersion(raw: unknown): AppVersion | null {
if (!isRecord(raw)) return null
const id = Number(raw.id)
if (!Number.isFinite(id)) return null
return {
id,
platform: String(raw.platform ?? ""),
version_name: String(raw.version_name ?? ""),
version_code: Number(raw.version_code ?? 0),
update_type: String(raw.update_type ?? ""),
release_notes: String(raw.release_notes ?? ""),
store_url: String(raw.store_url ?? ""),
min_supported_version_code: Number(raw.min_supported_version_code ?? 0),
status: String(raw.status ?? ""),
created_at: String(raw.created_at ?? ""),
}
}
export function parseAppVersionsList(body: unknown): AppVersionsListData {
const empty: AppVersionsListData = { versions: [], total_count: 0 }
if (isRecord(body)) {
const data = body.data
if (isRecord(data) && Array.isArray(data.versions)) {
const versions = data.versions
.map(normalizeAppVersion)
.filter((v): v is AppVersion => v !== null)
const total_count = Number(data.total_count ?? versions.length)
return { versions, total_count: Number.isFinite(total_count) ? total_count : versions.length }
}
if (Array.isArray(data)) {
const versions = data.map(normalizeAppVersion).filter((v): v is AppVersion => v !== null)
return { versions, total_count: versions.length }
}
if (Array.isArray(body.versions)) {
const versions = body.versions
.map(normalizeAppVersion)
.filter((v): v is AppVersion => v !== null)
const total_count = Number(body.total_count ?? versions.length)
return { versions, total_count: Number.isFinite(total_count) ? total_count : versions.length }
}
}
if (Array.isArray(body)) {
const versions = body.map(normalizeAppVersion).filter((v): v is AppVersion => v !== null)
return { versions, total_count: versions.length }
}
return empty
}
export function parseAppVersionMutation(body: unknown): AppVersion | null {
if (isRecord(body) && body.data != null) {
return normalizeAppVersion(body.data)
}
return normalizeAppVersion(body)
}
export type GetAppVersionsParams = {
limit?: number
offset?: number
}
export const getAppVersions = (params: GetAppVersionsParams = {}) => {
const limit = params.limit ?? DEFAULT_TABLE_PAGE_SIZE
const offset = params.offset ?? 0
return http
.get<AppVersionsListResponse>("/admin/app-versions", { params: { limit, offset } })
.then((res) => {
const parsed = parseAppVersionsList(res.data)
return {
...res,
data: parsed,
message: isRecord(res.data) ? String(res.data.message ?? "") : undefined,
}
})
}
function mutationResult(res: { data: unknown }) {
const version = parseAppVersionMutation(res.data)
return {
...res,
data: version,
message: isRecord(res.data) ? String(res.data.message ?? "") : undefined,
}
}
export const createAppVersion = (payload: CreateAppVersionPayload) =>
http
.post<AppVersionMutationResponse>("/admin/app-versions", payload)
.then(mutationResult)
export const updateAppVersion = (id: number, payload: UpdateAppVersionPayload) =>
http
.put<AppVersionMutationResponse>(`/admin/app-versions/${id}`, payload)
.then(mutationResult)
export const deleteAppVersion = (id: number) =>
http.delete<{ message?: string }>(`/admin/app-versions/${id}`).then((res) => ({
...res,
message: isRecord(res.data) ? String(res.data.message ?? "") : undefined,
}))

View File

@ -97,9 +97,6 @@ import type {
CreateExamPrepModuleLessonResponse,
UpdateExamPrepModuleLessonRequest,
UpdateExamPrepModuleLessonResponse,
PublishExamPrepModuleLessonRequest,
CreateExamPrepLessonPracticeRequest,
CreateExamPrepLessonPracticeResponse,
GetExamPrepModuleLessonsResponse,
GetTopLevelModuleLessonsResponse,
GetPracticesByParentContextResponse,
@ -107,9 +104,7 @@ import type {
CreateParentLinkedPracticeResponse,
UpdateParentLinkedPracticeRequest,
UpdateParentLinkedPracticeResponse,
PublishParentLinkedPracticeRequest,
UpdateTopLevelModuleLessonRequest,
PublishTopLevelModuleLessonRequest,
CreateTopLevelModuleLessonRequest,
CreateTopLevelModuleLessonResponse,
} from "../types/course.types"
@ -590,26 +585,10 @@ export const updateExamPrepModuleLesson = (
data,
)
/** PUT /exam-prep/lessons/:lessonId — set publish_status only (draft or published). */
export const publishExamPrepModuleLesson = (
lessonId: number,
data: PublishExamPrepModuleLessonRequest,
) => http.put(`/exam-prep/lessons/${lessonId}`, data)
/** English proficiency lesson — DELETE /exam-prep/lessons/:lessonId */
export const deleteExamPrepModuleLesson = (lessonId: number) =>
http.delete(`/exam-prep/lessons/${lessonId}`)
/** POST /exam-prep/lessons/:lessonId/practices */
export const createExamPrepLessonPractice = (
lessonId: number,
data: CreateExamPrepLessonPracticeRequest,
) =>
http.post<CreateExamPrepLessonPracticeResponse>(
`/exam-prep/lessons/${lessonId}/practices`,
data,
)
/** Top-level course resource (Learn English track) — PUT /courses/:id */
export const updateTopLevelCourse = (courseId: number, data: UpdateTopLevelCourseRequest) =>
http.put(`/courses/${courseId}`, data)
@ -667,12 +646,6 @@ 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}`)
@ -708,12 +681,6 @@ 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 }>(

View File

@ -1,69 +0,0 @@
import http from "./http"
import type {
CreateEmailTemplateRequest,
CreateEmailTemplateResponse,
DeleteEmailTemplateResponse,
EmailTemplate,
GetEmailTemplateBySlugResponse,
GetEmailTemplatesResponse,
UpdateEmailTemplateRequest,
UpdateEmailTemplateResponse,
} from "../types/emailTemplate.types"
/** GET /admin/email-templates — list all email templates. */
export const getEmailTemplates = () =>
http.get<GetEmailTemplatesResponse>("/admin/email-templates")
/** GET /admin/email-templates/slug/:slug — single template by slug. */
export const getEmailTemplateBySlug = (slug: string) =>
http.get<GetEmailTemplateBySlugResponse>(
`/admin/email-templates/slug/${encodeURIComponent(slug)}`,
)
function normalizeEmailTemplate(row: unknown): EmailTemplate | null {
if (!row || typeof row !== "object" || !("slug" in row)) return null
const t = row as EmailTemplate
return {
...t,
variables: Array.isArray(t.variables) ? t.variables : [],
status: t.status ?? "ACTIVE",
updated_at: t.updated_at ?? t.created_at ?? "",
}
}
export function parseEmailTemplatesResponse(
response: Awaited<ReturnType<typeof getEmailTemplates>>,
): EmailTemplate[] {
const data = response.data?.data
const rows = Array.isArray(data)
? data
: Array.isArray(data?.templates)
? data.templates
: []
return rows
.map(normalizeEmailTemplate)
.filter((row): row is EmailTemplate => row != null)
}
/** PUT /admin/email-templates/:id — update subject and bodies. */
export const updateEmailTemplate = (
id: number,
data: UpdateEmailTemplateRequest,
) => http.put<UpdateEmailTemplateResponse>(`/admin/email-templates/${id}`, data)
/** POST /admin/email-templates — create a custom template. */
export const createEmailTemplate = (data: CreateEmailTemplateRequest) =>
http.post<CreateEmailTemplateResponse>("/admin/email-templates", data)
/** DELETE /admin/email-templates/:id — delete a custom template. */
export const deleteEmailTemplate = (id: number) =>
http.delete<DeleteEmailTemplateResponse>(`/admin/email-templates/${id}`)
export function parseEmailTemplateResponse(
response:
| Awaited<ReturnType<typeof getEmailTemplateBySlug>>
| Awaited<ReturnType<typeof updateEmailTemplate>>
| Awaited<ReturnType<typeof createEmailTemplate>>,
): EmailTemplate | null {
return normalizeEmailTemplate(response.data?.data)
}

View File

@ -1,6 +1,6 @@
import http from "./http"
export type UploadMediaType = "image" | "audio" | "video" | "pdf"
export type UploadMediaType = "image" | "audio" | "video"
export type UploadProvider = "MINIO" | "VIMEO"
export interface UploadMediaResponse {
@ -121,8 +121,6 @@ export const uploadVideoFile = (fileOrUrl: File | string, options?: UploadMediaO
})
: uploadMediaFile("video", fileOrUrl, options)
export const uploadPdfFile = (file: File) => uploadMediaFile("pdf", file)
export const resolveFileUrl = (key: string) =>
http.get<ResolveFileUrlResponse>("/files/url", {
params: { key },

View File

@ -59,9 +59,7 @@ const isAuthEndpointRequest = (url?: string) => {
return (
url.includes("/team/login") ||
url.includes("/team/google-login") ||
url.includes("/team/refresh") ||
url.includes("/team/invitations/verify") ||
url.includes("/team/invitations/accept")
url.includes("/team/refresh")
);
};

View File

@ -1,125 +1,35 @@
import http from "./http"
import type {
GetNotificationsResponse,
Notification,
UnreadCountResponse,
} from "../types/notification.types"
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value)
}
function unwrapEnvelopeData(body: unknown): unknown {
if (!isRecord(body)) return body
if ("data" in body || "Data" in body) {
return body.data ?? body.Data
}
return body
}
function normalizePayload(raw: unknown): Notification["payload"] {
if (!isRecord(raw)) {
return { tags: null }
}
const tags = Array.isArray(raw.tags)
? raw.tags.filter((tag): tag is string => typeof tag === "string" && tag.length > 0)
: null
return {
headline: raw.headline != null ? String(raw.headline) : undefined,
title: raw.title != null ? String(raw.title) : undefined,
message: raw.message != null ? String(raw.message) : undefined,
body: raw.body != null ? String(raw.body) : undefined,
tags,
}
}
export function normalizeNotification(raw: unknown): Notification | null {
if (!isRecord(raw)) return null
const id = String(raw.id ?? "")
if (!id) return null
return {
id,
recipient_id: Number(raw.recipient_id ?? 0),
receiver_type: raw.receiver_type != null ? String(raw.receiver_type) : undefined,
type: String(raw.type ?? ""),
level: String(raw.level ?? ""),
error_severity: String(raw.error_severity ?? ""),
reciever: String(raw.reciever ?? ""),
is_read: Boolean(raw.is_read),
delivery_status: String(raw.delivery_status ?? ""),
delivery_channel: String(raw.delivery_channel ?? ""),
payload: normalizePayload(raw.payload),
timestamp: String(raw.timestamp ?? ""),
expires: String(raw.expires ?? ""),
image: String(raw.image ?? ""),
}
}
function parseNotificationsListData(body: unknown, limit: number, offset: number): GetNotificationsResponse {
const inner = unwrapEnvelopeData(body)
if (!isRecord(inner)) {
return { notifications: [], total_count: 0, limit, offset }
}
const rows = Array.isArray(inner.notifications) ? inner.notifications : []
const notifications = rows
.map(normalizeNotification)
.filter((n): n is Notification => n !== null)
return {
notifications,
total_count: Number(inner.total_count ?? notifications.length),
limit: Number(inner.limit ?? limit),
offset: Number(inner.offset ?? offset),
}
}
function parseUnreadCount(body: unknown): UnreadCountResponse {
const inner = unwrapEnvelopeData(body)
if (!isRecord(inner)) return { unread: 0 }
return { unread: Number(inner.unread ?? 0) }
}
import http from "./http";
import type { GetNotificationsResponse, UnreadCountResponse } from "../types/notification.types";
export const getNotifications = (limit = 10, offset = 0) =>
http.get<unknown>("/notifications", { params: { limit, offset } }).then((res) => ({
...res,
data: parseNotificationsListData(res.data, limit, offset),
}))
export const getNotificationById = (id: string) =>
http.get<unknown>(`/notifications/${id}`).then((res) => ({
...res,
data: normalizeNotification(unwrapEnvelopeData(res.data)),
}))
http.get<GetNotificationsResponse>("/notifications", {
params: { limit, offset },
});
export const getUnreadCount = () =>
http.get<unknown>("/notifications/unread").then((res) => ({
...res,
data: parseUnreadCount(res.data),
}))
http.get<UnreadCountResponse>("/notifications/unread");
export const markAsRead = (id: string) =>
http.patch(`/notifications/${id}/read`)
http.patch(`/notifications/${id}/read`);
export const markAsUnread = (id: string) =>
http.patch(`/notifications/${id}/unread`)
http.patch(`/notifications/${id}/unread`);
export const markAllRead = () =>
http.post("/notifications/mark-all-read")
http.post("/notifications/mark-all-read");
export const markAllUnread = () =>
http.post("/notifications/mark-all-unread")
http.post("/notifications/mark-all-unread");
export const sendBulkSms = (data: { message: string; user_ids: number[]; scheduled_at?: string }) =>
http.post("/notifications/bulk-sms", data)
http.post("/notifications/bulk-sms", data);
export const sendBulkEmail = (formData: FormData) =>
http.post("/notifications/bulk-email", formData, {
headers: { "Content-Type": "multipart/form-data" },
})
});
export const sendBulkPush = (formData: FormData) =>
http.post("/notifications/bulk-push", formData, {
headers: { "Content-Type": "multipart/form-data" },
})
});

View File

@ -1,98 +0,0 @@
import http from "./http"
import type { GetPaymentsParams, Payment, PaymentsListData, PaymentsListResponse } from "../types/payment.types"
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value)
}
function normalizePayment(raw: unknown): Payment | null {
if (!isRecord(raw)) return null
const id = Number(raw.id)
if (!Number.isFinite(id)) return null
const paid_at = raw.paid_at
const expires_at = raw.expires_at
return {
id,
user_id: Number(raw.user_id ?? 0),
plan_id: Number(raw.plan_id ?? 0),
subscription_id: Number(raw.subscription_id ?? 0),
session_id: String(raw.session_id ?? ""),
transaction_id: String(raw.transaction_id ?? ""),
nonce: String(raw.nonce ?? ""),
amount: Number(raw.amount ?? 0),
currency: String(raw.currency ?? "ETB"),
payment_method: String(raw.payment_method ?? ""),
status: String(raw.status ?? ""),
payment_url: String(raw.payment_url ?? ""),
plan_name: String(raw.plan_name ?? ""),
plan_category: String(raw.plan_category ?? ""),
user_email: String(raw.user_email ?? ""),
user_first_name: String(raw.user_first_name ?? ""),
user_last_name: String(raw.user_last_name ?? ""),
paid_at: paid_at == null || paid_at === "" ? null : String(paid_at),
expires_at: expires_at == null || expires_at === "" ? null : String(expires_at),
created_at: String(raw.created_at ?? ""),
updated_at: String(raw.updated_at ?? ""),
}
}
export function parsePaymentsList(body: unknown): PaymentsListData {
const empty: PaymentsListData = {
payments: [],
total_count: 0,
limit: 0,
offset: 0,
}
if (isRecord(body)) {
const data = body.data
if (isRecord(data) && Array.isArray(data.payments)) {
const payments = data.payments
.map(normalizePayment)
.filter((p): p is Payment => p !== null)
const total_count = Number(data.total_count ?? payments.length)
const limit = Number(data.limit ?? payments.length)
const offset = Number(data.offset ?? 0)
return {
payments,
total_count: Number.isFinite(total_count) ? total_count : payments.length,
limit: Number.isFinite(limit) ? limit : payments.length,
offset: Number.isFinite(offset) ? offset : 0,
}
}
if (Array.isArray(data)) {
const payments = data.map(normalizePayment).filter((p): p is Payment => p !== null)
return { payments, total_count: payments.length, limit: payments.length, offset: 0 }
}
}
if (Array.isArray(body)) {
const payments = body.map(normalizePayment).filter((p): p is Payment => p !== null)
return { payments, total_count: payments.length, limit: payments.length, offset: 0 }
}
return empty
}
function buildQueryParams(params: GetPaymentsParams): Record<string, string | number> {
const query: Record<string, string | number> = {
limit: Math.min(100, Math.max(1, params.limit ?? 20)),
offset: Math.max(0, params.offset ?? 0),
}
if (params.status?.trim()) query.status = params.status.trim()
if (params.provider?.trim()) query.provider = params.provider.trim()
if (params.plan_category?.trim()) query.plan_category = params.plan_category.trim()
return query
}
export const getPayments = (params: GetPaymentsParams = {}) =>
http.get<PaymentsListResponse>("/admin/payments", { params: buildQueryParams(params) }).then((res) => {
const parsed = parsePaymentsList(res.data)
return {
...res,
data: parsed,
message: isRecord(res.data) ? String(res.data.message ?? "") : undefined,
}
})

View File

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

View File

@ -1,360 +0,0 @@
import http from "./http"
import type {
DynamicElementDefinition,
QuestionComponentCatalog,
QuestionTypeDefinition,
QuestionTypeDefinitionCreatePayload,
QuestionTypeDefinitionUpdatePayload,
QuestionTypeDefinitionValidatePayload,
ValidateQuestionTypeDefinitionResult,
} from "../types/questionTypeDefinition.types"
interface ApiEnvelope<T> {
message?: string
data?: T
/** Some routes use PascalCase in JSON */
Data?: T
success?: boolean
status_code?: number
error?: string
}
/**
* Reads the inner payload from a typical API envelope, or the raw body.
* Supports `data` / `Data` and list bodies where `res.data` is already an array
* (e.g. GET /questions/type-definitions `data: [ { ID, Key, … }, … ]`).
*/
export function unwrapApiPayload(res: { data?: unknown }): unknown {
const body = res.data
if (body === null || body === undefined) return undefined
if (Array.isArray(body)) return body
if (typeof body !== "object") return body
const o = body as Record<string, unknown>
if ("data" in o || "Data" in o) {
const inner = o.data ?? o.Data
return inner
}
return body
}
function fromStringArray(arr: unknown): string[] {
return Array.isArray(arr)
? arr.filter((k): k is string => typeof k === "string" && k.length > 0)
: []
}
function sortUniqueStrings(values: string[]): string[] {
return [...new Set(values)].sort((a, b) => a.localeCompare(b))
}
const emptyCatalog = (): QuestionComponentCatalog => ({
stimulus_component_kinds: [],
response_component_kinds: [],
})
/**
* Parse GET /questions/component-catalog body.
* Canonical shape: `data.stimulus_component_kinds` + `data.response_component_kinds`.
*/
export function parseComponentCatalog(payload: unknown): QuestionComponentCatalog {
if (!payload || typeof payload !== "object") return emptyCatalog()
const d = payload as Record<string, unknown>
if (
Array.isArray(d.stimulus_component_kinds) ||
Array.isArray(d.response_component_kinds)
) {
return {
stimulus_component_kinds: sortUniqueStrings(fromStringArray(d.stimulus_component_kinds)),
response_component_kinds: sortUniqueStrings(fromStringArray(d.response_component_kinds)),
}
}
if (d.data !== undefined && d.data !== null && typeof d.data === "object") {
const inner = parseComponentCatalog(d.data)
if (
inner.stimulus_component_kinds.length > 0 ||
inner.response_component_kinds.length > 0
) {
return inner
}
}
const sk = fromStringArray(d.stimulus_kinds)
const rk = fromStringArray(d.response_kinds)
if (sk.length || rk.length) {
return {
stimulus_component_kinds: sortUniqueStrings(sk),
response_component_kinds: sortUniqueStrings(rk),
}
}
const mergedFlat = sortUniqueStrings([
...fromStringArray(d.kinds),
...fromStringArray(d.codes),
...fromStringArray(d.component_kinds),
])
if (mergedFlat.length) {
return {
stimulus_component_kinds: mergedFlat,
response_component_kinds: [...mergedFlat],
}
}
if (Array.isArray(payload)) {
const merged = sortUniqueStrings(fromStringArray(payload))
return {
stimulus_component_kinds: merged,
response_component_kinds: [...merged],
}
}
return emptyCatalog()
}
export async function getQuestionComponentCatalog(): Promise<QuestionComponentCatalog> {
const res = await http.get<ApiEnvelope<unknown>>("/questions/component-catalog")
const raw = unwrapApiPayload(res) ?? res.data
return parseComponentCatalog(raw)
}
function parseInnerValidFlag(inner: unknown): boolean | undefined {
if (inner == null || typeof inner !== "object") return undefined
const o = inner as Record<string, unknown>
const v = o.valid ?? o.Valid
if (typeof v === "boolean") return v
if (v === "true" || v === 1) return true
if (v === "false" || v === 0) return false
return undefined
}
/**
* POST /questions/validate-question-type-definition
* Success: 200 with `data.valid` (envelope `success` may still be false).
* Invalid: 400 with `message` / `error` on the JSON body (axios throws).
*/
export async function validateQuestionTypeDefinition(
body: QuestionTypeDefinitionValidatePayload,
): Promise<ValidateQuestionTypeDefinitionResult> {
try {
const res = await http.post<ApiEnvelope<unknown>>("/questions/validate-question-type-definition", body)
const envelope = res.data as ApiEnvelope<unknown>
const inner = unwrapApiPayload(res)
const validFlag = parseInnerValidFlag(inner)
if (validFlag === true) {
return { valid: true, message: envelope?.message }
}
const envErr =
typeof envelope?.error === "string"
? envelope.error
: envelope && typeof envelope === "object" && "Error" in envelope
? String((envelope as { Error?: unknown }).Error)
: undefined
return {
valid: false,
message: envelope?.message,
error:
envErr ||
(validFlag === false ? "Definition is not valid." : "Validation response did not include a valid flag."),
}
} catch (e: unknown) {
const err = e as { response?: { data?: { message?: string; error?: string } } }
const d = err.response?.data
return {
valid: false,
message: d?.message,
error: d?.error || d?.message || "Validation request failed",
}
}
}
export async function createQuestionTypeDefinition(body: QuestionTypeDefinitionCreatePayload) {
return http.post<ApiEnvelope<unknown>>("/questions/type-definitions", body)
}
function asStr(v: unknown): string {
if (v == null || v === undefined) return ""
return String(v)
}
function asStringArray(v: unknown): string[] {
if (!Array.isArray(v)) return []
return v.filter((x): x is string => typeof x === "string" && x.length > 0)
}
function normalizeSchemaRows(v: unknown): DynamicElementDefinition[] {
if (!Array.isArray(v)) return []
return v.map((row) => {
if (!row || typeof row !== "object") {
return { id: "", kind: "", required: false, label: undefined, config: undefined }
}
const r = row as Record<string, unknown>
const id = asStr(r.id ?? r.Id)
const kind = asStr(r.kind ?? r.Kind)
const labelRaw = r.label ?? r.Label
const config = (r.config ?? r.Config) as Record<string, unknown> | undefined
return {
id,
kind,
label: labelRaw != null && labelRaw !== "" ? asStr(labelRaw) : undefined,
required: Boolean(r.required ?? r.Required),
config: config && typeof config === "object" && !Array.isArray(config) ? config : undefined,
}
})
}
/**
* Maps GET/POST definition objects from PascalCase (ID, Key, StimulusSchema, )
* or snake_case into {@link QuestionTypeDefinition}.
*/
export function normalizeTypeDefinitionFromApi(raw: unknown): QuestionTypeDefinition | null {
if (!raw || typeof raw !== "object") return null
const o = raw as Record<string, unknown>
const id = Number(o.ID ?? o.id)
if (!Number.isFinite(id)) return null
const statusRaw = asStr(o.Status ?? o.status).toUpperCase()
const status = statusRaw === "INACTIVE" ? "INACTIVE" : "ACTIVE"
return {
id,
key: asStr(o.Key ?? o.key),
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
const s = asStr(d)
return s === "" ? null : s
})(),
stimulus_component_kinds: asStringArray(o.StimulusComponentKinds ?? o.stimulus_component_kinds),
response_component_kinds: asStringArray(o.ResponseComponentKinds ?? o.response_component_kinds),
stimulus_schema: normalizeSchemaRows(o.StimulusSchema ?? o.stimulus_schema),
response_schema: normalizeSchemaRows(o.ResponseSchema ?? o.response_schema),
status,
is_system: Boolean(o.IsSystem ?? o.is_system),
created_at: o.CreatedAt != null ? asStr(o.CreatedAt) : o.created_at != null ? asStr(o.created_at) : undefined,
updated_at: o.UpdatedAt != null ? asStr(o.UpdatedAt) : o.updated_at != null ? asStr(o.updated_at) : undefined,
}
}
/** 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 } }`.
*/
export function extractDefinitionMutationId(res: { data?: unknown }): number | undefined {
const data = unwrapApiPayload(res)
if (!data || typeof data !== "object" || Array.isArray(data)) return undefined
const o = data as Record<string, unknown>
const id = Number(o.ID ?? o.id ?? o.Id)
return Number.isFinite(id) && id > 0 ? id : undefined
}
/** @deprecated use extractDefinitionMutationId */
export const extractCreatedDefinitionId = extractDefinitionMutationId
export interface QuestionTypeDefinitionsListParams {
include_system?: boolean
status?: string
limit?: number
offset?: number
}
export interface QuestionTypeDefinitionsListResult {
definitions: QuestionTypeDefinition[]
total_count?: number
}
function parseListTotalCount(body: unknown): number | undefined {
if (!body || typeof body !== "object" || Array.isArray(body)) return undefined
const o = body as Record<string, unknown>
const direct = Number(o.total_count ?? o.TotalCount ?? o.totalCount)
if (Number.isFinite(direct) && direct >= 0) return direct
const meta = o.metadata ?? o.Metadata
if (meta && typeof meta === "object" && !Array.isArray(meta)) {
const m = meta as Record<string, unknown>
const fromMeta = Number(m.total_count ?? m.TotalCount ?? m.totalCount)
if (Number.isFinite(fromMeta) && fromMeta >= 0) return fromMeta
}
const data = o.data ?? o.Data
if (data && typeof data === "object" && !Array.isArray(data)) {
return parseListTotalCount(data)
}
return undefined
}
export function parseDefinitionsList(payload: unknown): QuestionTypeDefinition[] {
if (!payload) return []
if (Array.isArray(payload)) {
return payload
.map((item) => normalizeTypeDefinitionFromApi(item))
.filter((x): x is QuestionTypeDefinition => x != null)
}
if (typeof payload === "object" && payload !== null) {
const o = payload as Record<string, unknown>
const inner =
o.question_type_definitions ??
o.QuestionTypeDefinitions ??
o.definitions ??
o.items ??
o.rows ??
o.Definitions
if (Array.isArray(inner)) return parseDefinitionsList(inner)
if (inner && typeof inner === "object" && !Array.isArray(inner)) return parseDefinitionsList(inner)
const data = o.data ?? o.Data
if (Array.isArray(data)) return parseDefinitionsList(data)
if (data && typeof data === "object") return parseDefinitionsList(data)
const single = normalizeTypeDefinitionFromApi(payload)
return single ? [single] : []
}
return []
}
export async function getQuestionTypeDefinitions(
params?: QuestionTypeDefinitionsListParams,
): Promise<QuestionTypeDefinitionsListResult> {
const res = await http.get<ApiEnvelope<unknown>>("/questions/type-definitions", { params })
const raw = unwrapApiPayload(res) ?? res.data
const definitions = parseDefinitionsList(raw)
const total_count = parseListTotalCount(raw) ?? parseListTotalCount(res.data)
return total_count != null ? { definitions, total_count } : { definitions }
}
/**
* GET /questions/type-definitions/:id
*
* Typical success body (axios `res.data`): envelope with nested `data` or `Data` holding the definition.
* Definition fields are often PascalCase (`ID`, `Key`, `DisplayName`, `StimulusComponentKinds`, `StimulusSchema`,
* `ResponseSchema`, `IsSystem`, `Status`, `CreatedAt`, `UpdatedAt`). Envelope `success` may be false; parsing
* does not rely on it.
*/
export async function getQuestionTypeDefinitionById(id: number): Promise<QuestionTypeDefinition | undefined> {
const res = await http.get<ApiEnvelope<unknown>>(`/questions/type-definitions/${id}`)
const fromEnvelope = unwrapApiPayload(res)
return (
normalizeTypeDefinitionFromApi(fromEnvelope) ?? normalizeTypeDefinitionFromApi(res.data) ?? undefined
)
}
export async function updateQuestionTypeDefinition(
id: number,
body: QuestionTypeDefinitionUpdatePayload,
) {
return http.put<ApiEnvelope<unknown>>(`/questions/type-definitions/${id}`, body)
}
export async function deleteQuestionTypeDefinition(id: number) {
return http.delete<ApiEnvelope<unknown>>(`/questions/type-definitions/${id}`)
}

View File

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

View File

@ -1,84 +0,0 @@
import http from "./http"
import type {
CreateSubscriptionPlanPayload,
SubscriptionPlan,
SubscriptionPlanMutationResponse,
SubscriptionPlansListResponse,
UpdateSubscriptionPlanPayload,
} from "../types/subscription.types"
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value)
}
function normalizeSubscriptionPlan(raw: unknown): SubscriptionPlan | null {
if (!isRecord(raw)) return null
const id = Number(raw.id)
if (!Number.isFinite(id)) return null
return {
id,
name: String(raw.name ?? ""),
description: String(raw.description ?? ""),
category: String(raw.category ?? ""),
duration_value: Number(raw.duration_value ?? 0),
duration_unit: String(raw.duration_unit ?? "MONTH"),
price: Number(raw.price ?? 0),
currency: String(raw.currency ?? "ETB"),
is_active: Boolean(raw.is_active ?? true),
created_at: String(raw.created_at ?? ""),
}
}
export function parseSubscriptionPlansList(body: unknown): SubscriptionPlan[] {
if (Array.isArray(body)) {
return body.map(normalizeSubscriptionPlan).filter((p): p is SubscriptionPlan => p !== null)
}
if (isRecord(body) && Array.isArray(body.data)) {
return body.data
.map(normalizeSubscriptionPlan)
.filter((p): p is SubscriptionPlan => p !== null)
}
return []
}
export function parseSubscriptionPlanMutation(body: unknown): SubscriptionPlan | null {
if (isRecord(body) && body.data != null) {
return normalizeSubscriptionPlan(body.data)
}
return normalizeSubscriptionPlan(body)
}
export const getSubscriptionPlans = () =>
http.get<SubscriptionPlansListResponse | SubscriptionPlan[]>("/subscription-plans").then((res) => {
const plans = parseSubscriptionPlansList(res.data)
return {
...res,
data: plans,
}
})
function mutationResult(res: { data: unknown }) {
const plan = parseSubscriptionPlanMutation(res.data)
return {
...res,
data: plan,
message: isRecord(res.data) ? String(res.data.message ?? "") : undefined,
}
}
export const createSubscriptionPlan = (payload: CreateSubscriptionPlanPayload) =>
http
.post<SubscriptionPlanMutationResponse | SubscriptionPlan>("/subscription-plans", payload)
.then(mutationResult)
export const updateSubscriptionPlan = (id: number, payload: UpdateSubscriptionPlanPayload) =>
http
.put<SubscriptionPlanMutationResponse | SubscriptionPlan>(`/subscription-plans/${id}`, payload)
.then(mutationResult)
export const deleteSubscriptionPlan = (id: number) =>
http.delete<{ message?: string }>(`/subscription-plans/${id}`).then((res) => ({
...res,
message: isRecord(res.data) ? String(res.data.message ?? "") : undefined,
}))

View File

@ -1,14 +1,5 @@
import http from "./http"
import type {
AcceptInvitationRequest,
AcceptInvitationResponse,
InviteTeamMemberRequest,
InviteTeamMemberResponse,
VerifyInvitationResponse,
} from "../types/teamInvitation.types"
import type {
ChangeTeamMemberPasswordRequest,
ChangeTeamMemberPasswordResponse,
GetTeamMembersResponse,
GetTeamMemberResponse,
CreateTeamMemberRequest,
@ -34,31 +25,3 @@ export const updateTeamMemberStatus = (id: number, status: string) =>
export const updateTeamMember = (id: number, data: UpdateTeamMemberRequest) =>
http.put(`/team/members/${id}`, data)
/** POST /team/members/:id/change-password — change the signed-in member's password. */
export const changeTeamMemberPassword = (id: number, data: ChangeTeamMemberPasswordRequest) =>
http.post<ChangeTeamMemberPasswordResponse>(`/team/members/${id}/change-password`, data)
/** POST /team/members/invite — send invitation email (permission: team.members.invite). */
export const inviteTeamMember = (data: InviteTeamMemberRequest) =>
http.post<InviteTeamMemberResponse>("/team/members/invite", data)
/** GET /team/invitations/verify?token= — public (accept-invite page). */
export const verifyTeamInvitation = (token: string) =>
http.get<VerifyInvitationResponse>("/team/invitations/verify", {
params: { token },
})
/** POST /team/invitations/accept — public (set password after invite). */
export const acceptTeamInvitation = (data: AcceptInvitationRequest) =>
http.post<AcceptInvitationResponse>("/team/invitations/accept", data)
export function parseVerifyInvitation(
response: Awaited<ReturnType<typeof verifyTeamInvitation>>,
): VerifyInvitationResponse["data"] | null {
const body = response.data
if (body?.data && typeof body.data === "object" && "valid" in body.data) {
return body.data
}
return null
}

View File

@ -6,46 +6,23 @@ import {
type UserSummaryResponse,
type GetDeletionRequestsParams,
type GetDeletionRequestsResponse,
type UserRecentActivityResponse,
} from "../types/user.types";
/** 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 = {}) =>
export const getUsers = (
page?: number,
pageSize?: number,
role?: string,
status?: string,
query?: string,
) =>
http.get<GetUsersResponse>("/users", {
params: buildGetUsersQuery(params),
params: {
role,
status,
query,
page,
page_size: pageSize,
},
});
export type UserStatus = "ACTIVE" | "DEACTIVATED" | "SUSPENDED" | "PENDING";
@ -61,9 +38,6 @@ export const updateUserStatus = (payload: UpdateUserStatusRequest) =>
export const getUserById = (id: number) =>
http.get<UserProfileResponse>(`/user/single/${id}`);
export const getUserRecentActivity = (userId: number) =>
http.get<UserRecentActivityResponse>(`/admin/users/${userId}/recent-activity`);
export const getMyProfile = () =>
http.get<UserProfileResponse>("/team/me");

View File

@ -15,15 +15,15 @@ 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";
import { CourseModuleDetailPage } from "../pages/content-management/CourseModuleDetailPage";
import { AttachPracticeFlow } from "../pages/content-management/AttachPracticeFlow";
import { AttachProgramPracticeFlow } from "../pages/content-management/AttachProgramPracticeFlow";
import { ProgramTypeSelectionPage } from "../pages/content-management/ProgramTypeSelectionPage";
import { ProgramDetailPage } from "../pages/content-management/ProgramDetailPage";
import { CourseManagementPage } from "../pages/content-management/CourseManagementPage";
@ -33,12 +33,10 @@ import { CreateQuestionTypeFlow } from "../pages/content-management/CreateQuesti
import { NotFoundPage } from "../pages/NotFoundPage";
import { NotificationsPage } from "../pages/notifications/NotificationsPage";
import { CreateNotificationPage } from "../pages/notifications/CreateNotificationPage";
import { EmailTemplatesPage } from "../pages/notifications/EmailTemplatesPage";
import { EmailTemplateDetailPage } from "../pages/notifications/EmailTemplateDetailPage";
import { CreateEmailTemplatePage } from "../pages/notifications/CreateEmailTemplatePage";
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";
@ -52,7 +50,6 @@ import { HumanLanguageHierarchyPage } from "../pages/content-management/HumanLan
import { HumanLanguageSubModulePage } from "../pages/content-management/HumanLanguageSubModulePage";
import { UserLogPage } from "../pages/user-log/UserLogPage";
import { IssuesPage } from "../pages/issues/IssuesPage";
import { PaymentsPage } from "../pages/payments/PaymentsPage";
import { ProfilePage } from "../pages/ProfilePage";
import { SettingsPage } from "../pages/SettingsPage";
import { TeamManagementPage } from "../pages/team/TeamManagementPage";
@ -61,7 +58,6 @@ import { TeamMemberDetailPage } from "../pages/team/TeamMemberDetailPage";
import { LoginPage } from "../pages/auth/LoginPage";
import { ForgotPasswordPage } from "../pages/auth/ForgotPasswordPage";
import { VerificationPage } from "../pages/auth/VerificationPage";
import { AcceptInvitePage } from "../pages/auth/AcceptInvitePage";
import { AboutPage } from "../pages/AboutPage";
import { TermsPage } from "../pages/TermsPage";
import { PrivacyPage } from "../pages/PrivacyPage";
@ -73,7 +69,6 @@ export function AppRoutes() {
<Route path="/login" element={<LoginPage />} />
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
<Route path="/verification" element={<VerificationPage />} />
<Route path="/accept-invite" element={<AcceptInvitePage />} />
<Route path="/about" element={<AboutPage />} />
<Route path="/terms" element={<TermsPage />} />
<Route path="/privacy" element={<PrivacyPage />} />
@ -82,7 +77,7 @@ export function AppRoutes() {
<Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route path="/dashboard" element={<DashboardPage />} />
<Route path="/users" element={<UserManagementLayout />}>
<Route index element={<Navigate to="list" replace />} />
<Route index element={<UserManagementDashboard />} />
<Route path="list" element={<UsersListPage />} />
<Route path="deletion-requests" element={<DeletionRequestsPage />} />
<Route path="groups" element={<UserGroupsPage />} />
@ -167,7 +162,6 @@ export function AppRoutes() {
</Route>
<Route path="/new-content" element={<NewContentPage />} />
<Route path="/new-content/reorder" element={<ReorderContentPage />} />
<Route
path="/new-content/courses"
element={<ProgramTypeSelectionPage />}
@ -176,10 +170,6 @@ export function AppRoutes() {
path="/new-content/question-types"
element={<QuestionTypeLibraryPage />}
/>
<Route
path="/new-content/question-types/:definitionId/edit"
element={<CreateQuestionTypeFlow />}
/>
<Route
path="/new-content/question-types/create"
element={<CreateQuestionTypeFlow />}
@ -189,16 +179,12 @@ export function AppRoutes() {
element={<ProgramDetailPage />}
/>
<Route
path="/new-content/courses/:programType/add-practice"
element={<AddPracticeFlow />}
path="/new-content/courses/:programType/attach-practice"
element={<AttachProgramPracticeFlow />}
/>
<Route
path="/new-content/courses/:programType/:courseId/add-practice"
element={<AddPracticeFlow />}
/>
<Route
path="/new-content/courses/:programType/:courseId/:unitId/:moduleId/add-practice"
element={<AddPracticeFlow />}
path="/new-content/courses/:programType/:courseId/unit/:unitId/module/:moduleId/attach-practice"
element={<AttachPracticeFlow />}
/>
<Route
path="/new-content/courses/:programType/:courseId"
@ -212,10 +198,6 @@ export function AppRoutes() {
path="/new-content/courses/:programType/:courseId/:unitId/:moduleId"
element={<CourseModuleDetailPage />}
/>
<Route
path="/new-content/courses/:programType/:courseId/:unitId/:moduleId/lessons/:lessonId/practices"
element={<LessonPracticesPage />}
/>
<Route
path="/new-content/learn-english"
element={<LearnEnglishPage />}
@ -236,33 +218,16 @@ 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 />}
/>
<Route path="/notifications" element={<NotificationsPage />} />
<Route
path="/notifications/email-templates"
element={<EmailTemplatesPage />}
/>
<Route
path="/notifications/email-templates/new"
element={<CreateEmailTemplatePage />}
/>
<Route
path="/notifications/email-templates/:slug"
element={<EmailTemplateDetailPage />}
/>
<Route
path="/notifications/create"
element={<CreateNotificationPage />}
/>
<Route path="/payments" element={<PaymentsPage />} />
<Route path="/user-log" element={<UserLogPage />} />
<Route path="/issues" element={<IssuesPage />} />
<Route path="/analytics" element={<AnalyticsPage />} />

View File

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

View File

@ -1,241 +0,0 @@
import { Plus, Trash2 } from "lucide-react"
import { Button } from "../ui/button"
import { Input } from "../ui/input"
import {
addMatchingInputRow,
addMatchingPair,
defaultMatchingAnswerFromInputs,
MATCHING_MIN_ITEMS,
parseMatchingAnswerSlotValue,
parseMatchingInputsSlotValue,
removeMatchingInputRow,
removeMatchingPair,
serializeMatchingAnswerSlotValue,
serializeMatchingInputsSlotValue,
type MatchingAnswerSlotValue,
type MatchingInputsSlotValue,
} from "../../lib/matchingSlotValue"
export function DynamicMatchingInputsSlot({
value,
onChange,
disabled,
slotLabel,
}: {
value: string
onChange: (next: string) => void
disabled: boolean
slotLabel: string
}) {
const parsed = parseMatchingInputsSlotValue(value)
const updateValue = (next: MatchingInputsSlotValue) => {
onChange(serializeMatchingInputsSlotValue(next))
}
const updateSide = (
side: "left" | "right",
index: number,
text: string,
) => {
const next = {
left: [...parsed.left],
right: [...parsed.right],
}
next[side][index] = { ...next[side][index], text }
updateValue(next)
}
const rowCount = Math.max(parsed.left.length, parsed.right.length)
const canRemove = rowCount > MATCHING_MIN_ITEMS
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<label className="text-sm font-medium text-grayScale-700">{slotLabel}</label>
<Button
type="button"
variant="outline"
size="sm"
disabled={disabled}
className="h-8 gap-1.5 rounded-lg border-brand-200 text-brand-600 hover:bg-brand-50"
onClick={() => updateValue(addMatchingInputRow(parsed))}
>
<Plus className="h-3.5 w-3.5" />
Add row
</Button>
</div>
<div className="space-y-3">
{Array.from({ length: rowCount }).map((_, index) => (
<div
key={`matching-row-${index}`}
className="grid gap-2 rounded-lg border border-grayScale-200 bg-grayScale-50/50 p-3 md:grid-cols-[1fr_1fr_auto]"
>
<div className="space-y-1">
<p className="text-[10px] font-bold uppercase tracking-wide text-grayScale-400">
Left {parsed.left[index]?.id ?? `l${index + 1}`}
</p>
<Input
value={parsed.left[index]?.text ?? ""}
onChange={(e) => updateSide("left", index, e.target.value)}
placeholder={`Left item ${index + 1}`}
className="rounded-lg border-grayScale-200 bg-white"
disabled={disabled}
/>
</div>
<div className="space-y-1">
<p className="text-[10px] font-bold uppercase tracking-wide text-grayScale-400">
Right {parsed.right[index]?.id ?? `r${index + 1}`}
</p>
<Input
value={parsed.right[index]?.text ?? ""}
onChange={(e) => updateSide("right", index, e.target.value)}
placeholder={`Right item ${index + 1}`}
className="rounded-lg border-grayScale-200 bg-white"
disabled={disabled}
/>
</div>
<div className="flex items-end justify-end">
<Button
type="button"
variant="ghost"
size="icon"
disabled={disabled || !canRemove}
className="h-9 w-9 text-grayScale-400 hover:text-red-600 disabled:opacity-40"
aria-label={`Remove row ${index + 1}`}
onClick={() => updateValue(removeMatchingInputRow(parsed, index))}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
<p className="text-[11px] text-grayScale-500">
Minimum {MATCHING_MIN_ITEMS} rows on each side. Whitespace in text is preserved.
</p>
</div>
)
}
export function DynamicMatchingAnswerSlot({
value,
onChange,
disabled,
slotLabel,
matchingInputs,
}: {
value: string
onChange: (next: string) => void
disabled: boolean
slotLabel: string
matchingInputs: MatchingInputsSlotValue | null
}) {
const parsed = parseMatchingAnswerSlotValue(value, matchingInputs)
const updateValue = (next: MatchingAnswerSlotValue) => {
onChange(serializeMatchingAnswerSlotValue(next))
}
const leftOptions = matchingInputs?.left ?? []
const rightOptions = matchingInputs?.right ?? []
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<label className="text-sm font-medium text-grayScale-700">{slotLabel}</label>
<div className="flex flex-wrap gap-2">
{matchingInputs ? (
<Button
type="button"
variant="outline"
size="sm"
disabled={disabled}
className="h-8 rounded-lg"
onClick={() =>
updateValue(defaultMatchingAnswerFromInputs(matchingInputs))
}
>
Reset from inputs
</Button>
) : null}
<Button
type="button"
variant="outline"
size="sm"
disabled={disabled}
className="h-8 gap-1.5 rounded-lg border-brand-200 text-brand-600 hover:bg-brand-50"
onClick={() => updateValue(addMatchingPair(parsed, matchingInputs))}
>
<Plus className="h-3.5 w-3.5" />
Add pair
</Button>
</div>
</div>
{!matchingInputs ? (
<p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-900">
Fill in matching inputs first so answer pairs can reference left and right ids.
</p>
) : null}
<div className="space-y-2">
{parsed.pairs.map((pair, index) => (
<div
key={`pair-${index}-${pair.left_id}-${pair.right_id}`}
className="flex flex-wrap items-center gap-2"
>
<select
value={pair.left_id}
disabled={disabled || leftOptions.length === 0}
onChange={(e) => {
const pairs = [...parsed.pairs]
pairs[index] = { ...pairs[index], left_id: e.target.value }
updateValue({ pairs })
}}
className="h-10 min-w-[120px] rounded-lg border border-grayScale-200 bg-white px-3 text-sm"
>
{leftOptions.map((item) => (
<option key={item.id} value={item.id}>
{item.id}
{item.text ? `: ${item.text.slice(0, 40)}` : ""}
</option>
))}
</select>
<span className="text-sm text-grayScale-400"></span>
<select
value={pair.right_id}
disabled={disabled || rightOptions.length === 0}
onChange={(e) => {
const pairs = [...parsed.pairs]
pairs[index] = { ...pairs[index], right_id: e.target.value }
updateValue({ pairs })
}}
className="h-10 min-w-[120px] rounded-lg border border-grayScale-200 bg-white px-3 text-sm"
>
{rightOptions.map((item) => (
<option key={item.id} value={item.id}>
{item.id}
{item.text ? `: ${item.text.slice(0, 40)}` : ""}
</option>
))}
</select>
<Button
type="button"
variant="ghost"
size="icon"
disabled={disabled || parsed.pairs.length <= 1}
className="h-8 w-8 shrink-0 text-grayScale-400 hover:text-red-600 disabled:opacity-40"
aria-label={`Remove pair ${index + 1}`}
onClick={() => updateValue(removeMatchingPair(parsed, index))}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@ -1,275 +0,0 @@
import { Plus, Trash2 } from "lucide-react"
import { Button } from "../ui/button"
import { Input } from "../ui/input"
import { Textarea } from "../ui/textarea"
import {
addBlankSegment,
addTextSegment,
addWordBankItem,
defaultSelectMissingWordsResponseFromStimulus,
parseSelectMissingWordsResponseSlotValue,
parseSelectMissingWordsStimulusSlotValue,
removeSegment,
removeWordBankItem,
SELECT_MISSING_WORDS_MIN_BANK,
serializeSelectMissingWordsResponseSlotValue,
serializeSelectMissingWordsStimulusSlotValue,
setAllowReuse,
syncResponseBlanksWithStimulus,
updateTextSegment,
updateWordBankText,
type SelectMissingWordsStimulusValue,
} from "../../lib/selectMissingWordsSlotValue"
export function DynamicSelectMissingWordsStimulusSlot({
value,
onChange,
disabled,
slotLabel,
}: {
value: string
onChange: (next: string) => void
disabled: boolean
slotLabel: string
}) {
const parsed = parseSelectMissingWordsStimulusSlotValue(value)
const updateValue = (next: SelectMissingWordsStimulusValue) => {
onChange(serializeSelectMissingWordsStimulusSlotValue(next))
}
const canRemoveWord = parsed.word_bank.length > SELECT_MISSING_WORDS_MIN_BANK
const canRemoveSegment = parsed.segments.length > 1
return (
<div className="space-y-5">
<div className="flex flex-wrap items-center justify-between gap-2">
<label className="text-sm font-medium text-grayScale-700">{slotLabel}</label>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={disabled}
className="h-8 gap-1.5 rounded-lg border-brand-200 text-brand-600 hover:bg-brand-50"
onClick={() => updateValue(addTextSegment(parsed))}
>
<Plus className="h-3.5 w-3.5" />
Add text
</Button>
<Button
type="button"
variant="outline"
size="sm"
disabled={disabled}
className="h-8 gap-1.5 rounded-lg border-brand-200 text-brand-600 hover:bg-brand-50"
onClick={() => updateValue(addBlankSegment(parsed))}
>
<Plus className="h-3.5 w-3.5" />
Add blank
</Button>
</div>
</div>
<div className="space-y-2">
<p className="text-[10px] font-bold uppercase tracking-wide text-grayScale-400">
Passage segments
</p>
{parsed.segments.map((segment, index) => (
<div
key={`segment-${index}-${segment.type === "blank" ? segment.id : "text"}`}
className="flex flex-wrap items-start gap-2 rounded-lg border border-grayScale-200 bg-grayScale-50/50 p-3"
>
{segment.type === "text" ? (
<div className="min-w-0 flex-1 space-y-1">
<p className="text-[10px] font-bold uppercase tracking-wide text-grayScale-400">
Text
</p>
<Textarea
rows={2}
value={segment.value}
onChange={(e) =>
updateValue(updateTextSegment(parsed, index, e.target.value))
}
placeholder="Text before or after a blank"
className="min-h-[56px] resize-y rounded-lg border-grayScale-200 bg-white font-mono text-sm"
disabled={disabled}
/>
</div>
) : (
<div className="flex min-h-[56px] flex-1 items-center rounded-lg border border-dashed border-brand-200 bg-brand-50/40 px-3">
<span className="text-sm font-medium text-brand-700">
Blank {segment.id}
</span>
</div>
)}
<Button
type="button"
variant="ghost"
size="icon"
disabled={disabled || !canRemoveSegment}
className="h-9 w-9 shrink-0 text-grayScale-400 hover:text-red-600 disabled:opacity-40"
aria-label={`Remove segment ${index + 1}`}
onClick={() => updateValue(removeSegment(parsed, index))}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
<div className="space-y-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-[10px] font-bold uppercase tracking-wide text-grayScale-400">
Word bank
</p>
<Button
type="button"
variant="outline"
size="sm"
disabled={disabled}
className="h-8 gap-1.5 rounded-lg border-brand-200 text-brand-600 hover:bg-brand-50"
onClick={() => updateValue(addWordBankItem(parsed))}
>
<Plus className="h-3.5 w-3.5" />
Add word
</Button>
</div>
{parsed.word_bank.map((item, index) => (
<div
key={`word-${item.id}`}
className="flex flex-wrap items-center gap-2"
>
<span className="w-10 text-xs font-medium text-grayScale-500">{item.id}</span>
<Input
value={item.text}
onChange={(e) =>
updateValue(updateWordBankText(parsed, index, e.target.value))
}
placeholder={`Word ${index + 1}`}
className="h-10 flex-1 rounded-lg border-grayScale-200 bg-white"
disabled={disabled}
/>
<Button
type="button"
variant="ghost"
size="icon"
disabled={disabled || !canRemoveWord}
className="h-9 w-9 shrink-0 text-grayScale-400 hover:text-red-600 disabled:opacity-40"
aria-label={`Remove word ${item.id}`}
onClick={() => updateValue(removeWordBankItem(parsed, index))}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
<label className="flex items-center gap-2 text-sm text-grayScale-700">
<input
type="checkbox"
checked={parsed.allow_reuse}
disabled={disabled}
onChange={(e) => updateValue(setAllowReuse(parsed, e.target.checked))}
className="h-4 w-4 rounded border-grayScale-300"
/>
Allow word reuse across blanks
</label>
<p className="text-[11px] text-grayScale-500">
Minimum {SELECT_MISSING_WORDS_MIN_BANK} words in the bank and at least one blank.
Whitespace in text is preserved.
</p>
</div>
)
}
export function DynamicSelectMissingWordsAnswerSlot({
value,
onChange,
disabled,
slotLabel,
stimulus,
}: {
value: string
onChange: (next: string) => void
disabled: boolean
slotLabel: string
stimulus: SelectMissingWordsStimulusValue | null
}) {
const parsed = parseSelectMissingWordsResponseSlotValue(value, stimulus)
const synced = stimulus
? syncResponseBlanksWithStimulus(parsed, stimulus)
: parsed
const updateValue = (next: ReturnType<typeof parseSelectMissingWordsResponseSlotValue>) => {
onChange(serializeSelectMissingWordsResponseSlotValue(next))
}
const wordOptions =
stimulus?.word_bank.filter((item) => item.text.length > 0) ?? []
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<label className="text-sm font-medium text-grayScale-700">{slotLabel}</label>
{stimulus ? (
<Button
type="button"
variant="outline"
size="sm"
disabled={disabled}
className="h-8 rounded-lg"
onClick={() =>
updateValue(defaultSelectMissingWordsResponseFromStimulus(stimulus))
}
>
Reset from blanks
</Button>
) : null}
</div>
{!stimulus ? (
<p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-900">
Fill in the cloze passage and word bank first so answers can reference blank and word ids.
</p>
) : null}
<div className="space-y-2">
{synced.blanks.map((blank, index) => (
<div
key={`blank-answer-${blank.blank_id}`}
className="flex flex-wrap items-center gap-2 rounded-lg border border-grayScale-200 bg-grayScale-50/50 p-3"
>
<span className="w-10 text-xs font-medium text-grayScale-500">
{blank.blank_id}
</span>
<select
value={blank.word_id}
disabled={disabled || wordOptions.length === 0}
onChange={(e) => {
const selected = wordOptions.find((item) => item.id === e.target.value)
const blanks = [...synced.blanks]
blanks[index] = {
blank_id: blank.blank_id,
word_id: e.target.value,
text: selected?.text ?? "",
}
updateValue({ blanks })
}}
className="h-10 min-w-[160px] flex-1 rounded-lg border border-grayScale-200 bg-white px-3 text-sm"
>
<option value="">Select word</option>
{wordOptions.map((item) => (
<option key={item.id} value={item.id}>
{item.id}
{item.text ? `: ${item.text}` : ""}
</option>
))}
</select>
</div>
))}
</div>
</div>
)
}

View File

@ -1,250 +0,0 @@
import { useMemo } from "react"
import { Plus, Trash2 } from "lucide-react"
import { Button } from "../ui/button"
import { Input } from "../ui/input"
import { cn } from "../../lib/utils"
import {
createEmptyTable,
parseTableSlotValue,
serializeTableSlotValue,
type DynamicTableValue,
} from "../../lib/dynamicTableValue"
export type DynamicTableBuilderProps = {
value: string
onChange: (next: string) => void
disabled?: boolean
slotLabel: string
slotMeta: string
}
function normalizeTable(table: DynamicTableValue): DynamicTableValue {
const columns =
table.columns.length > 0
? table.columns.map((c, i) => c.trim() || `Column ${i + 1}`)
: ["Column 1"]
const colCount = columns.length
const rows =
table.rows.length > 0
? table.rows.map((row) => {
const cells = [...row]
while (cells.length < colCount) cells.push("")
return cells.slice(0, colCount)
})
: [Array(colCount).fill("")]
return { columns, rows }
}
export function DynamicTableBuilder({
value,
onChange,
disabled = false,
slotLabel,
slotMeta,
}: DynamicTableBuilderProps) {
const table = useMemo(() => normalizeTable(parseTableSlotValue(value)), [value])
const commit = (next: DynamicTableValue) => {
onChange(serializeTableSlotValue(normalizeTable(next)))
}
const updateColumn = (colIndex: number, text: string) => {
const columns = [...table.columns]
columns[colIndex] = text
commit({ columns, rows: table.rows })
}
const updateCell = (rowIndex: number, colIndex: number, text: string) => {
const rows = table.rows.map((r) => [...r])
rows[rowIndex][colIndex] = text
commit({ columns: table.columns, rows })
}
const addColumn = () => {
const columns = [...table.columns, `Column ${table.columns.length + 1}`]
const rows = table.rows.map((row) => [...row, ""])
commit({ columns, rows })
}
const removeColumn = (colIndex: number) => {
if (table.columns.length <= 1) return
const columns = table.columns.filter((_, i) => i !== colIndex)
const rows = table.rows.map((row) => row.filter((_, i) => i !== colIndex))
commit({ columns, rows })
}
const addRow = () => {
const rows = [...table.rows, Array(table.columns.length).fill("")]
commit({ columns: table.columns, rows })
}
const removeRow = (rowIndex: number) => {
if (table.rows.length <= 1) return
const rows = table.rows.filter((_, i) => i !== rowIndex)
commit({ columns: table.columns, rows })
}
const resetTable = () => {
commit(createEmptyTable(2, 1))
}
const previewColumns = table.columns.map((c, i) => c.trim() || `Column ${i + 1}`)
const previewRows = table.rows.map((row) =>
row.map((cell, ci) => cell.trim() || ""),
)
return (
<div className="space-y-3">
<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>
<p className="text-xs text-grayScale-500">
Build the reference table learners will see with the question.
</p>
<div className="overflow-x-auto rounded-xl border border-grayScale-200 bg-white">
<table className="w-full min-w-[320px] border-collapse text-sm">
<thead>
<tr className="bg-grayScale-50/90">
{table.columns.map((col, colIndex) => (
<th
key={`col-${colIndex}`}
className="border-b border-r border-grayScale-200 p-1.5 align-top last:border-r-0"
>
<div className="flex min-w-[100px] items-start gap-1">
<Input
value={col}
disabled={disabled}
onChange={(e) => updateColumn(colIndex, e.target.value)}
placeholder={`Column ${colIndex + 1}`}
className="h-9 border-grayScale-200 bg-white text-xs font-semibold"
/>
{table.columns.length > 1 ? (
<Button
type="button"
variant="ghost"
size="sm"
disabled={disabled}
className="h-9 w-9 shrink-0 p-0 text-grayScale-400 hover:text-red-600"
aria-label={`Remove column ${colIndex + 1}`}
onClick={() => removeColumn(colIndex)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
) : null}
</div>
</th>
))}
<th className="w-10 border-b border-grayScale-200 bg-grayScale-50/90 p-1" />
</tr>
</thead>
<tbody>
{table.rows.map((row, rowIndex) => (
<tr key={`row-${rowIndex}`} className="group">
{row.map((cell, colIndex) => (
<td
key={`cell-${rowIndex}-${colIndex}`}
className="border-b border-r border-grayScale-100 p-1.5 last:border-r-0"
>
<Input
value={cell}
disabled={disabled}
onChange={(e) => updateCell(rowIndex, colIndex, e.target.value)}
placeholder="Cell value"
className="h-9 border-grayScale-200 bg-[#F8FAFC] text-sm"
/>
</td>
))}
<td className="border-b border-grayScale-100 p-1 align-middle">
<Button
type="button"
variant="ghost"
size="sm"
disabled={disabled || table.rows.length <= 1}
className="h-9 w-9 p-0 text-grayScale-400 hover:text-red-600 disabled:opacity-30"
aria-label={`Remove row ${rowIndex + 1}`}
onClick={() => removeRow(rowIndex)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={disabled}
className="gap-1.5"
onClick={addColumn}
>
<Plus className="h-3.5 w-3.5" />
Add column
</Button>
<Button
type="button"
variant="outline"
size="sm"
disabled={disabled}
className="gap-1.5"
onClick={addRow}
>
<Plus className="h-3.5 w-3.5" />
Add row
</Button>
<Button
type="button"
variant="ghost"
size="sm"
disabled={disabled}
onClick={resetTable}
>
Reset table
</Button>
</div>
<div className="rounded-xl border border-dashed border-grayScale-200 bg-grayScale-50/60 p-4">
<p className="mb-2 text-[10px] font-bold uppercase tracking-wide text-grayScale-400">
Learner preview
</p>
<div className="overflow-x-auto rounded-lg border border-grayScale-200 bg-white">
<table className={cn("w-full min-w-[240px] border-collapse text-sm text-grayScale-800")}>
<thead>
<tr className="bg-brand-50/80">
{previewColumns.map((col, i) => (
<th
key={`preview-h-${i}`}
className="border border-grayScale-200 px-3 py-2 text-left text-xs font-bold uppercase tracking-wide text-grayScale-700"
>
{col}
</th>
))}
</tr>
</thead>
<tbody>
{previewRows.map((row, ri) => (
<tr key={`preview-r-${ri}`} className={ri % 2 === 0 ? "bg-white" : "bg-grayScale-50/50"}>
{row.map((cell, ci) => (
<td
key={`preview-c-${ri}-${ci}`}
className="border border-grayScale-100 px-3 py-2 text-sm"
>
{cell || <span className="text-grayScale-300"></span>}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
)
}

View File

@ -9,23 +9,15 @@ import { Check, Image as ImageIcon, Mic, Plus, Upload, X } from "lucide-react"
import { toast } from "sonner"
import { resolveMediaPreviewUrl } from "../../lib/practiceMedia"
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"
import { Textarea } from "../ui/textarea"
import { Select } from "../ui/select"
import { Button } from "../ui/button"
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 PracticeQuestionEditorType = "MCQ" | "TRUE_FALSE" | "SHORT" | "AUDIO"
export type PracticeQuestionEditorDifficulty = "EASY" | "MEDIUM" | "HARD"
export interface PracticeQuestionOptionDraft {
@ -40,13 +32,6 @@ const ALLOWED_AUDIO_EXTENSIONS = new Set(["mp3", "wav", "ogg", "m4a", "aac", "we
const MAX_IMAGE_SIZE_BYTES = 10 * 1024 * 1024
const ALLOWED_IMAGE_EXTENSIONS = new Set(["jpg", "jpeg", "png", "webp", "gif"])
export interface PracticeQuestionDynamicRow {
id: string
kind: string
label?: string
required?: boolean
}
export interface PracticeQuestionEditorValue {
questionText: string
questionType: PracticeQuestionEditorType
@ -61,12 +46,6 @@ export interface PracticeQuestionEditorValue {
shortAnswer: string
/** Stored URL or object key; same semantics as Speaking practice editor */
imageUrl: string
/** When `questionType` is DYNAMIC — definition used to shape `dynamic_payload` */
questionTypeDefinitionId: number | null
dynamicStimulusRows: PracticeQuestionDynamicRow[]
dynamicResponseRows: PracticeQuestionDynamicRow[]
/** Keys `stimulus:${elementId}` and `response:${elementId}` (ids from the type definition schema) */
dynamicFieldValues: Record<string, string>
}
export function createEmptyPracticeQuestionDraft(): PracticeQuestionEditorValue {
@ -88,10 +67,6 @@ export function createEmptyPracticeQuestionDraft(): PracticeQuestionEditorValue
audioCorrectAnswerText: "",
shortAnswer: "",
imageUrl: "",
questionTypeDefinitionId: null,
dynamicStimulusRows: [],
dynamicResponseRows: [],
dynamicFieldValues: {},
}
}
@ -109,9 +84,6 @@ function defaultOptionsForType(
previousType: PracticeQuestionEditorType,
current: PracticeQuestionOptionDraft[],
): PracticeQuestionOptionDraft[] {
if (type === "DYNAMIC") {
return current
}
if (type === "TRUE_FALSE") {
if (previousType === "TRUE_FALSE" && current.length >= 2) {
return current.map((o, i) => ({
@ -174,18 +146,6 @@ export function PracticeQuestionEditorFields({
const setType = (questionType: PracticeQuestionEditorType) => {
const options = defaultOptionsForType(questionType, value.questionType, value.options)
if (questionType === "DYNAMIC" || value.questionType === "DYNAMIC") {
onChange({
...value,
questionType,
options,
questionTypeDefinitionId: null,
dynamicStimulusRows: [],
dynamicResponseRows: [],
dynamicFieldValues: {},
})
return
}
onChange({ ...value, questionType, options })
}
@ -626,97 +586,11 @@ export function PracticeQuestionEditorFields({
const controlsDisabled = mediaBusy
const [typeDefinitions, setTypeDefinitions] = useState<QuestionTypeDefinition[]>([])
const [definitionsLoading, setDefinitionsLoading] = useState(false)
const [definitionDetailLoading, setDefinitionDetailLoading] = useState(false)
useEffect(() => {
if (value.questionType !== "DYNAMIC") return
let cancelled = false
setDefinitionsLoading(true)
;(async () => {
try {
const { definitions: rows } = await getQuestionTypeDefinitions({ include_system: true })
if (!cancelled) setTypeDefinitions(rows)
} catch {
if (!cancelled) setTypeDefinitions([])
} finally {
if (!cancelled) setDefinitionsLoading(false)
}
})()
return () => {
cancelled = true
}
}, [value.questionType])
const handleDynamicDefinitionChange = async (rawId: string) => {
if (!rawId) {
onChange({
...value,
questionTypeDefinitionId: null,
dynamicStimulusRows: [],
dynamicResponseRows: [],
dynamicFieldValues: {},
})
return
}
const id = Number(rawId)
if (!Number.isFinite(id) || id <= 0) return
setDefinitionDetailLoading(true)
try {
const def = await getQuestionTypeDefinitionById(id)
if (!def) {
toast.error("Definition not found")
return
}
const fieldValues: Record<string, string> = { ...value.dynamicFieldValues }
const dynamicStimulusRows: PracticeQuestionDynamicRow[] = def.stimulus_schema.map((r) => ({
id: r.id,
kind: r.kind,
label: r.label,
required: r.required,
}))
const dynamicResponseRows: PracticeQuestionDynamicRow[] = def.response_schema.map((r) => ({
id: r.id,
kind: r.kind,
label: r.label,
required: r.required,
}))
for (const r of dynamicStimulusRows) {
const k = `stimulus:${r.id}`
if (fieldValues[k] === undefined) fieldValues[k] = ""
}
for (const r of dynamicResponseRows) {
const k = `response:${r.id}`
if (fieldValues[k] === undefined) fieldValues[k] = ""
}
onChange({
...value,
questionTypeDefinitionId: id,
dynamicStimulusRows,
dynamicResponseRows,
dynamicFieldValues: fieldValues,
})
} catch (e) {
console.error(e)
toast.error("Failed to load definition details")
} finally {
setDefinitionDetailLoading(false)
}
}
const setDynamicField = (key: string, next: string) => {
onChange({
...value,
dynamicFieldValues: { ...value.dynamicFieldValues, [key]: next },
})
}
return (
<>
<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>
<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>
<textarea
value={value.questionText}
onChange={(e) => patch({ questionText: e.target.value })}
@ -733,40 +607,35 @@ export function PracticeQuestionEditorFields({
) : null}
</div>
<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">
<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)}>
<option value="MCQ">Multiple Choice</option>
<option value="TRUE_FALSE">True/False</option>
<option value="SHORT">Short Answer</option>
<option value="AUDIO">Audio</option>
<option value="DYNAMIC">Dynamic (schema-driven)</option>
</Select>
</div>
<div className="space-y-1.5">
<label className="text-[11px] font-medium uppercase tracking-wide text-grayScale-500">Difficulty</label>
<div className="space-y-2">
<label className="text-xs font-medium uppercase tracking-wider 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-1.5">
<label className="text-[11px] font-medium uppercase tracking-wide text-grayScale-500">Points</label>
<div className="space-y-2">
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">Points</label>
<Input
type="number"
value={value.points}
onChange={(e) => patch({ points: Number(e.target.value) || 1 })}
min={1}
className={cn(
"h-9 text-sm",
showFieldErrors && fieldErrors.points ? "border-red-300 ring-1 ring-red-200" : undefined,
)}
className={cn(showFieldErrors && fieldErrors.points ? "border-red-300 ring-1 ring-red-200" : undefined)}
aria-invalid={Boolean(showFieldErrors && fieldErrors.points)}
/>
{showFieldErrors && fieldErrors.points ? (
@ -775,82 +644,8 @@ export function PracticeQuestionEditorFields({
</div>
</div>
{value.questionType === "DYNAMIC" && (
<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">
Image, audio, and PDF slots support upload or a URL. Table slots use the visual builder. Other
fields accept text or structured values where noted.
</p>
<div className="space-y-2">
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">
Question type definition <span className="text-red-500">*</span>
</label>
<Select
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)}>
{questionTypeDefinitionListLabel(d)}
</option>
))}
</Select>
</div>
{definitionDetailLoading ? (
<p className="text-sm font-medium text-grayScale-500">Loading schema</p>
) : null}
{value.dynamicStimulusRows.length > 0 ? (
<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="rounded-lg border border-grayScale-200 bg-white p-2.5 shadow-sm sm:p-3"
>
<DynamicSchemaSlotField
row={row}
side="stimulus"
value={value.dynamicFieldValues[`stimulus:${row.id}`] ?? ""}
onChange={(next) => setDynamicField(`stimulus:${row.id}`, next)}
disabled={controlsDisabled}
allFieldValues={value.dynamicFieldValues}
stimulusSchema={value.dynamicStimulusRows}
responseSchema={value.dynamicResponseRows}
/>
</div>
))}
</div>
) : null}
{value.dynamicResponseRows.length > 0 ? (
<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="rounded-lg border border-grayScale-200 bg-white p-2.5 shadow-sm sm:p-3"
>
<DynamicSchemaSlotField
row={row}
side="response"
value={value.dynamicFieldValues[`response:${row.id}`] ?? ""}
onChange={(next) => setDynamicField(`response:${row.id}`, next)}
disabled={controlsDisabled}
allFieldValues={value.dynamicFieldValues}
stimulusSchema={value.dynamicStimulusRows}
responseSchema={value.dynamicResponseRows}
/>
</div>
))}
</div>
) : null}
</div>
)}
{value.questionType === "MCQ" && (
<div className="space-y-2 rounded-lg bg-grayScale-50/50 p-3">
<div className="space-y-3 rounded-lg bg-grayScale-50/50 p-4">
<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) => (
@ -963,7 +758,7 @@ export function PracticeQuestionEditorFields({
</div>
)}
<div className="grid grid-cols-1 gap-2 lg:grid-cols-2 lg:gap-4">
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2 lg:gap-6">
<div className="space-y-2">
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">Tips (Optional)</label>
<Input
@ -982,8 +777,6 @@ export function PracticeQuestionEditorFields({
</div>
</div>
{value.questionType !== "DYNAMIC" ? (
<>
<div className="space-y-2">
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">Voice Prompt (Optional)</label>
<div className="flex flex-col gap-2">
@ -1115,8 +908,6 @@ export function PracticeQuestionEditorFields({
) : null}
</div>
</div>
</>
) : null}
</div>
{recordingModal ? (

View File

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

View File

@ -1,169 +0,0 @@
import { Badge } from "../ui/badge"
import { Button } from "../ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "../ui/dialog"
import { SpinnerIcon } from "../ui/spinner-icon"
import {
DEFAULT_NOTIFICATION_TYPE_CONFIG,
formatNotificationDateTime,
formatNotificationTimestamp,
formatNotificationTypeLabel,
getNotificationLevelBadge,
isMeaningfulExpiry,
NOTIFICATION_TYPE_CONFIG,
} from "../../lib/notificationDisplay"
import { getNotificationMessage, getNotificationTitle, type Notification } from "../../types/notification.types"
type NotificationDetailDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
notification: Notification | null
loading?: boolean
error?: boolean
onRetry?: () => void
}
export function NotificationDetailDialog({
open,
onOpenChange,
notification,
loading = false,
error = false,
onRetry,
}: NotificationDetailDialogProps) {
const config = notification
? NOTIFICATION_TYPE_CONFIG[notification.type] ?? DEFAULT_NOTIFICATION_TYPE_CONFIG
: DEFAULT_NOTIFICATION_TYPE_CONFIG
const Icon = config.icon
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg">
{loading ? (
<div className="flex flex-col items-center justify-center gap-3 py-12">
<SpinnerIcon className="h-8 w-8 text-brand-500" />
<p className="text-sm text-grayScale-500">Loading notification</p>
</div>
) : error ? (
<div className="flex flex-col items-center justify-center gap-3 py-12 text-center">
<p className="text-sm font-medium text-grayScale-700">Could not load notification</p>
{onRetry ? (
<Button variant="outline" size="sm" onClick={onRetry}>
Try again
</Button>
) : null}
</div>
) : notification ? (
<>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<span
className={`inline-flex h-8 w-8 items-center justify-center rounded-lg ${config.bg} ${config.color}`}
>
<Icon className="h-4 w-4" />
</span>
<span className="truncate text-base">
{getNotificationTitle(notification) || "Notification"}
</span>
</DialogTitle>
<DialogDescription>
Sent via {notification.delivery_channel || "in-app"} ·{" "}
{formatNotificationTimestamp(notification.timestamp)}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{notification.image ? (
<div className="overflow-hidden rounded-lg border border-grayScale-200 bg-grayScale-50">
<img
src={notification.image}
alt=""
className="max-h-48 w-full object-cover"
/>
</div>
) : null}
<div className="rounded-lg bg-grayScale-50 p-3">
<p className="text-sm leading-relaxed text-grayScale-600">
{getNotificationMessage(notification) || "No message content."}
</p>
</div>
<div className="grid gap-3 text-xs text-grayScale-500 sm:grid-cols-2">
<div>
<p className="text-grayScale-400">Type</p>
<p className="mt-0.5 font-medium text-grayScale-700">
{formatNotificationTypeLabel(notification.type)}
</p>
</div>
<div>
<p className="text-grayScale-400">Level</p>
<div className="mt-0.5">
<Badge variant={getNotificationLevelBadge(notification.level)} className="text-[10px]">
{notification.level || "—"}
</Badge>
</div>
</div>
<div>
<p className="text-grayScale-400">Channel</p>
<p className="mt-0.5 font-medium capitalize text-grayScale-700">
{notification.delivery_channel || "—"}
</p>
</div>
<div>
<p className="text-grayScale-400">Delivery status</p>
<p className="mt-0.5 font-medium text-grayScale-700">
{notification.delivery_status || "—"}
</p>
</div>
<div>
<p className="text-grayScale-400">Read status</p>
<p className="mt-0.5 font-medium text-grayScale-700">
{notification.is_read ? "Read" : "Unread"}
</p>
</div>
{notification.receiver_type ? (
<div>
<p className="text-grayScale-400">Receiver</p>
<p className="mt-0.5 font-medium capitalize text-grayScale-700">
{notification.receiver_type}
</p>
</div>
) : null}
<div className="sm:col-span-2">
<p className="text-grayScale-400">Sent at</p>
<p className="mt-0.5 font-medium text-grayScale-700">
{formatNotificationDateTime(notification.timestamp)}
</p>
</div>
{isMeaningfulExpiry(notification.expires) ? (
<div className="sm:col-span-2">
<p className="text-grayScale-400">Expires</p>
<p className="mt-0.5 font-medium text-grayScale-700">
{formatNotificationDateTime(notification.expires)}
</p>
</div>
) : null}
</div>
{notification.payload.tags && notification.payload.tags.length > 0 ? (
<div className="flex flex-wrap gap-2">
{notification.payload.tags.map((tag) => (
<Badge key={tag} variant="secondary" className="text-[10px]">
{tag}
</Badge>
))}
</div>
) : null}
</div>
</>
) : null}
</DialogContent>
</Dialog>
)
}

View File

@ -6,11 +6,12 @@ import {
ChevronRight,
CircleAlert,
ClipboardList,
CreditCard,
LayoutDashboard,
LogOut,
Shield,
UserCircle2,
Users,
Users2,
Settings,
X,
} from "lucide-react";
@ -19,86 +20,27 @@ import { NavLink } from "react-router-dom";
import { cn } from "../../lib/utils";
import { BrandLogo } from "../brand/BrandLogo";
import { getUnreadCount } from "../../api/notifications.api";
import { SidebarNavGroup } from "./SidebarNavGroup";
type NavLinkItem = {
kind: "link";
type NavItem = {
label: string;
to: string;
icon: ComponentType<{ className?: string }>;
};
type NavGroupItem = {
kind: "group";
label: string;
basePath: string;
activePaths?: string[];
icon: ComponentType<{ className?: string }>;
children: { label: string; to: string; end?: boolean }[];
};
const navItems: NavItem[] = [
{ label: "Dashboard", to: "/dashboard", icon: LayoutDashboard },
{ label: "User Management", to: "/users", icon: Users },
{ label: "Role Management", to: "/roles", icon: Shield },
{ label: "Content Management", to: "/content", icon: BookOpen },
{ label: "New Content", to: "/new-content", icon: BookOpen },
type NavSectionItem = {
kind: "section";
label: string;
};
type NavEntry = NavLinkItem | NavGroupItem | NavSectionItem;
const navEntries: NavEntry[] = [
{ kind: "section", label: "Overview" },
{ kind: "link", label: "Dashboard", to: "/dashboard", icon: LayoutDashboard },
{ kind: "link", label: "Analytics", to: "/analytics", icon: BarChart3 },
{ kind: "section", label: "People" },
{
kind: "group",
label: "Users & access",
basePath: "/users",
activePaths: ["/users", "/roles", "/team"],
icon: Users,
children: [
{ label: "All users", to: "/users/list" },
{ label: "Roles", to: "/roles" },
{ label: "Team members", to: "/team" },
],
},
{ kind: "section", label: "Learning content" },
{
kind: "group",
label: "Content",
basePath: "/content",
activePaths: ["/content", "/new-content"],
icon: BookOpen,
children: [
{ label: "Manage practices", to: "/content", end: true },
{ label: "New content", to: "/new-content", end: true },
{ label: "Reorder structure", to: "/new-content/reorder" },
{ label: "Question types", to: "/new-content/question-types" },
],
},
{ kind: "section", label: "Communications" },
{
kind: "group",
label: "Notifications",
basePath: "/notifications",
icon: Bell,
children: [
{ label: "Inbox", to: "/notifications", end: true },
{ label: "Email templates", to: "/notifications/email-templates" },
{ label: "Send notification", to: "/notifications/create" },
],
},
{ kind: "section", label: "Operations" },
{ kind: "link", label: "Payments", to: "/payments", icon: CreditCard },
{ kind: "link", label: "User activity log", to: "/user-log", icon: ClipboardList },
{ kind: "link", label: "Issue reports", to: "/issues", icon: CircleAlert },
{ kind: "section", label: "Account" },
{ kind: "link", label: "Profile", to: "/profile", icon: UserCircle2 },
{ kind: "link", label: "Settings", to: "/settings", icon: Settings },
{ label: "Notifications", to: "/notifications", icon: Bell },
{ label: "User Log", to: "/user-log", icon: ClipboardList },
{ label: "Issue Reports", to: "/issues", icon: CircleAlert },
{ label: "Analytics", to: "/analytics", icon: BarChart3 },
{ label: "Team Management", to: "/team", icon: Users2 },
{ label: "Profile", to: "/profile", icon: UserCircle2 },
{ label: "Settings", to: "/settings", icon: Settings },
];
type SidebarProps = {
@ -133,18 +75,9 @@ export function Sidebar({
window.removeEventListener("notifications-updated", fetchUnread);
}, []);
const unreadBadge = unreadCount > 0 && (
<span className="ml-auto flex h-5 min-w-[20px] items-center justify-center rounded-full bg-destructive px-1.5 text-[10px] font-bold text-white">
{unreadCount > 99 ? "99+" : unreadCount}
</span>
);
const collapsedUnreadDot = unreadCount > 0 && (
<span className="absolute -right-1 -top-1 h-2.5 w-2.5 rounded-full bg-destructive" />
);
return (
<>
{/* Mobile overlay */}
<div
className={cn(
"fixed inset-0 z-40 bg-black/50 transition-opacity lg:hidden",
@ -154,6 +87,7 @@ export function Sidebar({
aria-hidden="true"
/>
{/* Sidebar panel */}
<aside
className={cn(
"group fixed left-0 top-0 z-50 flex h-screen flex-col border-r bg-grayScale-50 py-5 transition-all duration-300",
@ -200,59 +134,13 @@ export function Sidebar({
</button>
</div>
<nav className="mt-6 flex-1 space-y-0.5 overflow-y-auto">
{navEntries.map((entry, index) => {
if (entry.kind === "section") {
if (isCollapsed) {
return index > 0 ? (
<div
key={`section-gap-${entry.label}`}
className="mx-auto my-2 h-px w-6 bg-grayScale-200"
aria-hidden
/>
) : null;
}
return (
<p
key={`section-${entry.label}`}
className={cn(
"mb-1 px-3 pt-3 text-[10px] font-bold uppercase tracking-wider text-grayScale-400",
index === 0 && "pt-0",
)}
>
{entry.label}
</p>
);
}
if (entry.kind === "group") {
const isNotifications = entry.basePath === "/notifications";
return (
<SidebarNavGroup
key={entry.basePath}
label={entry.label}
icon={entry.icon}
basePath={entry.basePath}
activePaths={entry.activePaths}
children={entry.children}
isCollapsed={isCollapsed}
onNavigate={onClose}
trailing={
isNotifications
? !isCollapsed
? unreadBadge
: collapsedUnreadDot
: undefined
}
/>
);
}
const Icon = entry.icon;
<nav className="mt-6 flex-1 space-y-1 overflow-y-auto">
{navItems.map((item) => {
const Icon = item.icon;
return (
<NavLink
key={entry.to}
to={entry.to}
key={item.to}
to={item.to}
onClick={onClose}
className={({ isActive }) =>
cn(
@ -263,22 +151,41 @@ export function Sidebar({
"bg-brand-100/40 text-brand-600 shadow-[0_1px_0_rgba(0,0,0,0.02)] ring-1 ring-brand-100",
)
}
title={isCollapsed ? entry.label : undefined}
title={isCollapsed ? item.label : undefined}
>
{({ isActive }) => (
<>
<span
className={cn(
"grid h-8 w-8 place-items-center rounded-lg bg-grayScale-100 text-grayScale-500 transition group-hover:bg-brand-100 group-hover:text-brand-600",
"relative grid h-8 w-8 place-items-center rounded-lg bg-grayScale-100 text-grayScale-500 transition group-hover:bg-brand-100 group-hover:text-brand-600",
isActive && "bg-brand-500/90 text-white",
)}
>
<Icon className="h-4 w-4" />
{isCollapsed &&
item.to === "/notifications" &&
unreadCount > 0 && (
<span className="absolute -right-1 -top-1 h-2.5 w-2.5 rounded-full bg-destructive" />
)}
</span>
{!isCollapsed && (
<span className="truncate">{entry.label}</span>
<span className="truncate">{item.label}</span>
)}
{!isCollapsed && isActive ? (
{!isCollapsed &&
item.to === "/notifications" &&
unreadCount > 0 && (
<span className="ml-auto flex h-5 min-w-[20px] items-center justify-center rounded-full bg-destructive px-1.5 text-[10px] font-bold text-white">
{unreadCount > 99 ? "99+" : unreadCount}
</span>
)}
{!isCollapsed &&
item.to !== "/notifications" &&
isActive ? (
<span className="ml-auto h-6 w-1 rounded-full bg-brand-500/80" />
) : !isCollapsed &&
item.to === "/notifications" &&
unreadCount === 0 &&
isActive ? (
<span className="ml-auto h-6 w-1 rounded-full bg-brand-500/80" />
) : null}
</>

View File

@ -1,139 +0,0 @@
import { ChevronDown } from "lucide-react";
import { type ComponentType, type ReactNode, useEffect, useId, useState } from "react";
import { NavLink, useLocation } from "react-router-dom";
import { cn } from "../../lib/utils";
export type SidebarNavChild = {
label: string;
to: string;
end?: boolean;
};
type SidebarNavGroupProps = {
label: string;
icon: ComponentType<{ className?: string }>;
basePath: string;
/** When set, any matching prefix marks the group active (e.g. `/content` and `/new-content`). */
activePaths?: string[];
children: SidebarNavChild[];
isCollapsed: boolean;
onNavigate?: () => void;
trailing?: ReactNode;
};
export function SidebarNavGroup({
label,
icon: Icon,
basePath,
activePaths,
children,
isCollapsed,
onNavigate,
trailing,
}: SidebarNavGroupProps) {
const location = useLocation();
const panelId = useId();
const paths = activePaths?.length ? activePaths : [basePath];
const isSectionActive = paths.some((path) => location.pathname.startsWith(path));
const [expanded, setExpanded] = useState(isSectionActive);
useEffect(() => {
if (isSectionActive) {
setExpanded(true);
}
}, [isSectionActive]);
if (isCollapsed) {
return (
<NavLink
to={children[0]?.to ?? basePath}
onClick={onNavigate}
className={({ isActive }) =>
cn(
"group flex items-center justify-center rounded-lg px-2 py-2.5 text-sm font-medium text-grayScale-600 transition",
"hover:bg-grayScale-100 hover:text-brand-600",
isActive &&
"bg-brand-100/40 text-brand-600 shadow-[0_1px_0_rgba(0,0,0,0.02)] ring-1 ring-brand-100",
)
}
title={label}
>
{({ isActive }) => (
<span
className={cn(
"relative grid h-8 w-8 place-items-center rounded-lg bg-grayScale-100 text-grayScale-500 transition group-hover:bg-brand-100 group-hover:text-brand-600",
isActive && "bg-brand-500/90 text-white",
)}
>
<Icon className="h-4 w-4" />
{trailing}
</span>
)}
</NavLink>
);
}
return (
<div className="space-y-0.5">
<button
type="button"
aria-expanded={expanded}
aria-controls={panelId}
onClick={() => setExpanded((open) => !open)}
className={cn(
"group flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left text-sm font-medium text-grayScale-600 transition",
"hover:bg-grayScale-100 hover:text-brand-600",
isSectionActive && "text-brand-600",
)}
>
<span
className={cn(
"grid h-8 w-8 shrink-0 place-items-center rounded-lg bg-grayScale-100 text-grayScale-500 transition group-hover:bg-brand-100 group-hover:text-brand-600",
isSectionActive && "bg-brand-500/90 text-white",
)}
>
<Icon className="h-4 w-4" />
</span>
<span className="min-w-0 flex-1 truncate">{label}</span>
{trailing}
<ChevronDown
className={cn(
"h-4 w-4 shrink-0 text-grayScale-400 transition-transform duration-300 ease-in-out",
expanded && "rotate-180",
)}
/>
</button>
<div
id={panelId}
className={cn(
"grid transition-[grid-template-rows] duration-300 ease-in-out",
expanded ? "grid-rows-[1fr]" : "grid-rows-[0fr]",
)}
>
<div className="overflow-hidden">
<div className="ml-4 space-y-0.5 border-l border-grayScale-200 pl-2 pt-0.5 pb-0.5">
{children.map((child) => (
<NavLink
key={child.to}
to={child.to}
end={child.end ?? false}
onClick={onNavigate}
className={({ isActive }) =>
cn(
"block rounded-lg px-3 py-2 text-sm font-medium transition",
isActive
? "bg-brand-100/40 text-brand-600"
: "text-grayScale-500 hover:bg-grayScale-100 hover:text-brand-600",
)
}
>
{child.label}
</NavLink>
))}
</div>
</div>
</div>
</div>
);
}

View File

@ -1,32 +1,74 @@
import { useCallback, useEffect, useRef, useState } from "react"
import { useEffect, useRef, useState } from "react"
import { useNavigate } from "react-router-dom"
import { Bell, BellOff, CheckCheck, Mail, MailOpen } from "lucide-react"
import { toast } from "sonner"
import {
Bell,
BellOff,
Info,
AlertCircle,
CheckCircle2,
Megaphone,
UserPlus,
CreditCard,
BookOpen,
Video,
ShieldAlert,
MailOpen,
Mail,
CheckCheck,
} from "lucide-react"
import { Badge } from "../ui/badge"
import { cn } from "../../lib/utils"
import { SpinnerIcon } from "../ui/spinner-icon"
import { getNotificationById } from "../../api/notifications.api"
import { useNotifications } from "../../hooks/useNotifications"
import { NotificationDetailDialog } from "../notifications/NotificationDetailDialog"
import {
DEFAULT_NOTIFICATION_TYPE_CONFIG,
formatNotificationTimestamp,
NOTIFICATION_TYPE_CONFIG,
} from "../../lib/notificationDisplay"
import { getNotificationMessage, getNotificationTitle, type Notification } from "../../types/notification.types"
const TYPE_CONFIG: Record<string, { icon: React.ElementType; color: string; bg: string }> = {
announcement: { icon: Megaphone, color: "text-brand-600", bg: "bg-brand-100" },
system_alert: { icon: ShieldAlert, color: "text-amber-600", bg: "bg-amber-50" },
issue_created: { icon: AlertCircle, color: "text-red-500", bg: "bg-red-50" },
issue_status_updated: { icon: CheckCircle2, color: "text-sky-600", bg: "bg-sky-50" },
course_created: { icon: BookOpen, color: "text-indigo-600", bg: "bg-indigo-50" },
course_enrolled: { icon: BookOpen, color: "text-teal-600", bg: "bg-teal-50" },
sub_course_created: { icon: BookOpen, color: "text-violet-600", bg: "bg-violet-50" },
video_added: { icon: Video, color: "text-pink-600", bg: "bg-pink-50" },
user_deleted: { icon: UserPlus, color: "text-red-600", bg: "bg-red-50" },
admin_created: { icon: UserPlus, color: "text-brand-600", bg: "bg-brand-100" },
team_member_created: { icon: UserPlus, color: "text-emerald-600", bg: "bg-emerald-50" },
subscription_activated: { icon: CreditCard, color: "text-green-600", bg: "bg-green-50" },
payment_verified: { icon: CreditCard, color: "text-green-600", bg: "bg-green-50" },
knowledge_level_update: { icon: Info, color: "text-sky-600", bg: "bg-sky-50" },
assessment_assigned: { icon: BookOpen, color: "text-orange-600", bg: "bg-orange-50" },
}
const DEFAULT_TYPE_CONFIG = { icon: Bell, color: "text-grayScale-500", bg: "bg-grayScale-100" }
function formatTimestamp(ts: string) {
const date = new Date(ts)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMin = Math.floor(diffMs / 60_000)
const diffHr = Math.floor(diffMs / 3_600_000)
const diffDay = Math.floor(diffMs / 86_400_000)
if (diffMin < 1) return "Just now"
if (diffMin < 60) return `${diffMin}m ago`
if (diffHr < 24) return `${diffHr}h ago`
if (diffDay < 7) return `${diffDay}d ago`
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: date.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
})
}
function NotificationItem({
notification,
onOpen,
onMarkRead,
onMarkUnread,
}: {
notification: Notification
onOpen: (notification: Notification) => void
onMarkRead: (id: string) => void
onMarkUnread: (id: string) => void
}) {
const cfg = NOTIFICATION_TYPE_CONFIG[notification.type] ?? DEFAULT_NOTIFICATION_TYPE_CONFIG
const cfg = TYPE_CONFIG[notification.type] ?? DEFAULT_TYPE_CONFIG
const Icon = cfg.icon
return (
@ -35,26 +77,31 @@ function NotificationItem({
className={cn(
"group relative flex w-full gap-3 rounded-lg px-3 py-3 text-left transition-colors hover:bg-grayScale-100",
)}
onClick={() => onOpen(notification)}
onClick={() => {
if (!notification.is_read) onMarkRead(notification.id)
}}
>
{/* Unread dot */}
{!notification.is_read && (
<span className="absolute left-0.5 top-1/2 h-2 w-2 -translate-y-1/2 rounded-full bg-brand-500" />
)}
{/* Type icon */}
<span
className={cn(
"ml-3 grid h-9 w-9 shrink-0 place-items-center rounded-lg",
cfg.bg,
cfg.bg
)}
>
<Icon className={cn("h-4 w-4", cfg.color)} />
</span>
{/* Content */}
<div className="min-w-0 flex-1">
<p
className={cn(
"text-sm leading-snug text-grayScale-900",
!notification.is_read && "font-semibold",
!notification.is_read && "font-semibold"
)}
>
{getNotificationTitle(notification) || "Notification"}
@ -63,10 +110,11 @@ function NotificationItem({
{getNotificationMessage(notification) || "No preview text available."}
</p>
<p className="mt-1 text-[11px] text-grayScale-600">
{formatNotificationTimestamp(notification.timestamp)}
{formatTimestamp(notification.timestamp)}
</p>
</div>
{/* Read / Unread toggle */}
<button
type="button"
className="hidden shrink-0 self-center rounded-md p-1.5 text-grayScale-400 hover:bg-grayScale-200 hover:text-grayScale-600 group-hover:block"
@ -92,11 +140,6 @@ function NotificationItem({
export function NotificationDropdown() {
const [open, setOpen] = useState(false)
const [detailOpen, setDetailOpen] = useState(false)
const [detailLoading, setDetailLoading] = useState(false)
const [detailError, setDetailError] = useState(false)
const [selectedNotification, setSelectedNotification] = useState<Notification | null>(null)
const [selectedNotificationId, setSelectedNotificationId] = useState<string | null>(null)
const containerRef = useRef<HTMLDivElement>(null)
const navigate = useNavigate()
const {
@ -108,40 +151,7 @@ export function NotificationDropdown() {
markAllAsRead,
} = useNotifications()
const loadNotificationDetail = useCallback(async (id: string, markReadIfNeeded: boolean) => {
setDetailLoading(true)
setDetailError(false)
setSelectedNotification(null)
setSelectedNotificationId(id)
setDetailOpen(true)
try {
const res = await getNotificationById(id)
if (!res.data) {
setDetailError(true)
toast.error("Notification not found")
return
}
setSelectedNotification(res.data)
if (markReadIfNeeded && !res.data.is_read) {
void markOneRead(id)
}
} catch {
setDetailError(true)
toast.error("Failed to load notification details")
} finally {
setDetailLoading(false)
}
}, [markOneRead])
const handleOpenNotification = useCallback(
(notification: Notification) => {
setOpen(false)
void loadNotificationDetail(notification.id, !notification.is_read)
},
[loadNotificationDetail],
)
// Click-outside handler
useEffect(() => {
function handleMouseDown(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
@ -155,98 +165,89 @@ export function NotificationDropdown() {
}, [open])
return (
<>
<div ref={containerRef} className="relative">
<button
type="button"
className="relative grid h-10 w-10 place-items-center rounded-full border bg-white text-grayScale-500 transition-colors hover:text-brand-600"
aria-label="Notifications"
onClick={() => setOpen((prev) => !prev)}
>
<Bell className="h-5 w-5" />
{unreadCount > 0 && (
<span className="absolute -right-0.5 -top-0.5 flex h-[18px] min-w-[18px] items-center justify-center rounded-full bg-destructive px-1 text-[10px] font-bold text-white">
{unreadCount > 99 ? "99+" : unreadCount}
</span>
)}
</button>
<div ref={containerRef} className="relative">
{/* Bell button */}
<button
type="button"
className="relative grid h-10 w-10 place-items-center rounded-full border bg-white text-grayScale-500 transition-colors hover:text-brand-600"
aria-label="Notifications"
onClick={() => setOpen((prev) => !prev)}
>
<Bell className="h-5 w-5" />
{unreadCount > 0 && (
<span className="absolute -right-0.5 -top-0.5 flex h-[18px] min-w-[18px] items-center justify-center rounded-full bg-destructive px-1 text-[10px] font-bold text-white">
{unreadCount > 99 ? "99+" : unreadCount}
</span>
)}
</button>
{open && (
<div className="animate-in fade-in-0 zoom-in-95 absolute right-0 top-12 z-50 w-[380px] rounded-xl bg-white shadow-lg ring-1 ring-black/5">
<div className="flex items-center justify-between border-b px-4 py-3">
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold text-grayScale-800">Notifications</h3>
{unreadCount > 0 && (
<Badge variant="default" className="px-1.5 py-0 text-[10px]">
{unreadCount}
</Badge>
)}
</div>
{/* Dropdown panel */}
{open && (
<div className="animate-in fade-in-0 zoom-in-95 absolute right-0 top-12 z-50 w-[380px] rounded-xl bg-white shadow-lg ring-1 ring-black/5">
{/* Header */}
<div className="flex items-center justify-between border-b px-4 py-3">
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold text-grayScale-800">
Notifications
</h3>
{unreadCount > 0 && (
<button
type="button"
className="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs font-medium text-grayScale-500 transition-colors hover:bg-grayScale-100 hover:text-grayScale-700"
onClick={markAllAsRead}
>
<CheckCheck className="h-3.5 w-3.5" />
Mark all read
</button>
<Badge variant="default" className="px-1.5 py-0 text-[10px]">
{unreadCount}
</Badge>
)}
</div>
<div className="max-h-[480px] overflow-y-auto">
{loading ? (
<div className="flex items-center justify-center py-12">
<SpinnerIcon className="h-6 w-6" />
</div>
) : notifications.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-2 py-12 text-grayScale-400">
<BellOff className="h-8 w-8" />
<p className="text-sm">No notifications</p>
</div>
) : (
<div className="p-1">
{notifications.map((n) => (
<NotificationItem
key={n.id}
notification={n}
onOpen={handleOpenNotification}
onMarkRead={markOneRead}
onMarkUnread={markOneUnread}
/>
))}
</div>
)}
</div>
<div className="border-t px-4 py-2.5">
{unreadCount > 0 && (
<button
type="button"
className="w-full rounded-lg py-1.5 text-center text-sm font-medium text-brand-600 transition-colors hover:bg-grayScale-100"
onClick={() => {
setOpen(false)
navigate("/notifications")
}}
className="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs font-medium text-grayScale-500 transition-colors hover:bg-grayScale-100 hover:text-grayScale-700"
onClick={markAllAsRead}
>
View all notifications
<CheckCheck className="h-3.5 w-3.5" />
Mark all read
</button>
</div>
)}
</div>
)}
</div>
<NotificationDetailDialog
open={detailOpen}
onOpenChange={setDetailOpen}
notification={selectedNotification}
loading={detailLoading}
error={detailError}
onRetry={
selectedNotificationId
? () => void loadNotificationDetail(selectedNotificationId, false)
: undefined
}
/>
</>
{/* Body */}
<div className="max-h-[480px] overflow-y-auto">
{loading ? (
<div className="flex items-center justify-center py-12">
<SpinnerIcon className="h-6 w-6" />
</div>
) : notifications.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-2 py-12 text-grayScale-400">
<BellOff className="h-8 w-8" />
<p className="text-sm">No notifications</p>
</div>
) : (
<div className="p-1">
{notifications.map((n) => (
<NotificationItem
key={n.id}
notification={n}
onMarkRead={markOneRead}
onMarkUnread={markOneUnread}
/>
))}
</div>
)}
</div>
{/* Footer */}
<div className="border-t px-4 py-2.5">
<button
type="button"
className="w-full rounded-lg py-1.5 text-center text-sm font-medium text-brand-600 transition-colors hover:bg-grayScale-100"
onClick={() => {
setOpen(false)
navigate("/notifications")
}}
>
View all notifications
</button>
</div>
</div>
)}
</div>
)
}

View File

@ -9,8 +9,7 @@ const buttonVariants = cva(
{
variants: {
variant: {
default: "bg-primary text-white hover:bg-brand-600 hover:text-white",
brand: "bg-brand-500 text-white hover:bg-brand-600 hover:text-white",
default: "bg-primary text-primary-foreground hover:bg-brand-600",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
outline: "border bg-background hover:bg-grayScale-100",
ghost: "hover:bg-grayScale-100",

View File

@ -32,7 +32,7 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-border bg-card p-6 text-card-foreground shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] rounded-lg",
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-white p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] rounded-lg",
className,
)}
{...props}

View File

@ -9,7 +9,7 @@ export const Input = React.forwardRef<HTMLInputElement, InputProps>(
<input
type={type}
className={cn(
"flex h-10 w-full rounded-[6px] border border-input bg-grayScale-50 px-3 py-2 text-sm text-foreground ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-grayScale-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
"flex h-10 w-full rounded-[6px] border bg-white px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-grayScale-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
ref={ref}

View File

@ -10,7 +10,7 @@ export const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
<div className="relative">
<select
className={cn(
"flex h-11 w-full appearance-none rounded-xl border border-input bg-grayScale-50 px-3 py-2 pr-8 text-sm text-foreground shadow-sm ring-offset-background transition hover:bg-grayScale-100 focus-visible:border-brand-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-200 disabled:cursor-not-allowed disabled:opacity-50",
"flex h-11 w-full appearance-none rounded-xl border border-grayScale-200 bg-white px-3 py-2 pr-8 text-sm text-grayScale-600 shadow-sm ring-offset-background transition hover:bg-grayScale-50 focus-visible:border-brand-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-200 disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
ref={ref}

View File

@ -8,7 +8,7 @@ export const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
return (
<textarea
className={cn(
"flex min-h-[80px] w-full rounded-lg border border-input bg-grayScale-50 px-3 py-2 text-sm text-foreground ring-offset-background placeholder:text-grayScale-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
"flex min-h-[80px] w-full rounded-lg border bg-white px-3 py-2 text-sm ring-offset-background placeholder:text-grayScale-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
ref={ref}

View File

@ -1,76 +0,0 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react"
import {
applyTheme,
getStoredTheme,
getSystemTheme,
resolveTheme,
THEME_STORAGE_KEY,
watchSystemTheme,
type ResolvedTheme,
type ThemeMode,
} from "../lib/theme"
type ThemeContextValue = {
theme: ThemeMode
resolvedTheme: ResolvedTheme
systemTheme: ResolvedTheme
setTheme: (mode: ThemeMode) => void
}
const ThemeContext = createContext<ThemeContextValue | null>(null)
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setThemeState] = useState<ThemeMode>(() => getStoredTheme())
const [resolvedTheme, setResolvedTheme] = useState<ResolvedTheme>(() =>
resolveTheme(getStoredTheme()),
)
const [systemTheme, setSystemTheme] = useState<ResolvedTheme>(() => getSystemTheme())
const setTheme = useCallback((mode: ThemeMode) => {
localStorage.setItem(THEME_STORAGE_KEY, mode)
setThemeState(mode)
setResolvedTheme(applyTheme(mode))
}, [])
useEffect(() => {
setResolvedTheme(applyTheme(theme))
}, [theme])
useEffect(() => {
return watchSystemTheme((next) => {
setSystemTheme(next)
if (theme === "system") {
setResolvedTheme(applyTheme("system"))
}
})
}, [theme])
const value = useMemo(
() => ({ theme, resolvedTheme, systemTheme, setTheme }),
[theme, resolvedTheme, systemTheme, setTheme],
)
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
}
export function useTheme(): ThemeContextValue {
const ctx = useContext(ThemeContext)
if (!ctx) {
throw new Error("useTheme must be used within ThemeProvider")
}
return ctx
}
export function useThemeOptional(): ThemeContextValue | null {
return useContext(ThemeContext)
}
export { getSystemTheme }

View File

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

View File

@ -1,43 +0,0 @@
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 }
}

View File

@ -3,13 +3,12 @@ import { getNotifications, getUnreadCount, markAsRead, markAsUnread, markAllRead
import type { Notification } from "../types/notification.types"
const MAX_DROPDOWN = 5
const RECONNECT_MS = 5000
function getWsUrl() {
const base = import.meta.env.VITE_API_BASE_URL as string
const wsBase = base.replace(/^https/, "wss").replace(/^http/, "ws")
const token = localStorage.getItem("access_token") ?? ""
return `${wsBase}/ws/connect?token=${encodeURIComponent(token)}`
return `${wsBase}/ws/connect?token=${token}`
}
export function useNotifications() {
@ -19,8 +18,6 @@ export function useNotifications() {
const wsRef = useRef<WebSocket | null>(null)
const reconnectTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const mountedRef = useRef(true)
const intentionalCloseRef = useRef(false)
const connectAttemptRef = useRef(0)
const dispatchUpdate = () => {
window.dispatchEvent(new Event("notifications-updated"))
@ -43,37 +40,11 @@ export function useNotifications() {
}
}, [])
const clearReconnectTimer = useCallback(() => {
if (reconnectTimer.current) {
clearTimeout(reconnectTimer.current)
reconnectTimer.current = null
}
}, [])
const disconnectWs = useCallback(
(intentional: boolean) => {
intentionalCloseRef.current = intentional
clearReconnectTimer()
const ws = wsRef.current
wsRef.current = null
if (!ws) return
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
ws.close()
}
},
[clearReconnectTimer],
)
const connectWs = useCallback(() => {
if (!mountedRef.current) return
if (wsRef.current) {
wsRef.current.close()
}
const token = localStorage.getItem("access_token")?.trim()
if (!token) return
disconnectWs(true)
intentionalCloseRef.current = false
const attempt = ++connectAttemptRef.current
const ws = new WebSocket(getWsUrl())
wsRef.current = ws
@ -107,45 +78,47 @@ export function useNotifications() {
}
}
ws.onerror = () => {
ws.close()
}
ws.onclose = () => {
if (connectAttemptRef.current !== attempt) return
if (wsRef.current === ws) wsRef.current = null
if (!mountedRef.current || intentionalCloseRef.current) return
clearReconnectTimer()
if (!mountedRef.current) return
reconnectTimer.current = setTimeout(() => {
if (mountedRef.current) connectWs()
}, RECONNECT_MS)
}, 5000)
}
}, [clearReconnectTimer, disconnectWs])
}, [])
useEffect(() => {
mountedRef.current = true
intentionalCloseRef.current = false
fetchData()
connectWs()
return () => {
mountedRef.current = false
disconnectWs(true)
wsRef.current?.close()
if (reconnectTimer.current) clearTimeout(reconnectTimer.current)
}
}, [fetchData, connectWs, disconnectWs])
}, [fetchData, connectWs])
const markOneRead = useCallback(async (id: string) => {
setNotifications((prev) =>
prev.map((n) => (n.id === id ? { ...n, is_read: true } : n)),
prev.map((n) => (n.id === id ? { ...n, is_read: true } : n))
)
setUnreadCount((prev) => Math.max(0, prev - 1))
dispatchUpdate()
try {
await markAsRead(id)
} catch {
// revert on failure
await fetchData()
}
}, [fetchData])
const markOneUnread = useCallback(async (id: string) => {
setNotifications((prev) =>
prev.map((n) => (n.id === id ? { ...n, is_read: false } : n)),
prev.map((n) => (n.id === id ? { ...n, is_read: false } : n))
)
setUnreadCount((prev) => prev + 1)
dispatchUpdate()

View File

@ -5,17 +5,7 @@
@tailwind utilities;
@layer base {
html {
color-scheme: light;
--gs-50: #ffffff;
--gs-100: #f5f5f5;
--gs-200: #e0e0e0;
--gs-300: #bdbdbd;
--gs-400: #9e9e9e;
--gs-500: #757575;
--gs-600: #616161;
--shadow-soft: 0 8px 24px rgba(0, 0, 0, 0.06);
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
@ -48,46 +38,6 @@
--radius: 14px;
}
html.dark {
color-scheme: dark;
--gs-50: #1c1c24;
--gs-100: #12121a;
--gs-200: #2e2e3a;
--gs-300: #454552;
--gs-400: #9a9aaa;
--gs-500: #b8b8c6;
--gs-600: #ececf2;
--shadow-soft: 0 8px 24px rgba(0, 0, 0, 0.45);
--background: 240 10% 7%;
--foreground: 210 20% 96%;
--card: 240 8% 12%;
--card-foreground: 210 20% 96%;
--popover: 240 8% 12%;
--popover-foreground: 210 20% 96%;
--primary: 312 59% 45%;
--primary-foreground: 0 0% 100%;
--secondary: 240 6% 18%;
--secondary-foreground: 210 20% 96%;
--muted: 240 6% 18%;
--muted-foreground: 240 5% 65%;
--accent: 240 6% 18%;
--accent-foreground: 210 20% 96%;
--destructive: 0 62% 50%;
--destructive-foreground: 0 0% 100%;
--border: 240 6% 22%;
--input: 240 6% 22%;
--ring: 312 59% 50%;
}
* {
@apply border-border;
}
@ -99,61 +49,6 @@
}
body {
@apply bg-grayScale-100 text-foreground font-sans antialiased transition-colors duration-200;
}
}
@layer components {
/*
* Brand scale uses heavy purple for 50/500/600 enforce high-contrast white
* foreground on solid brand fills (including opacity modifiers).
*/
:is(
.bg-brand-50,
.bg-brand-500,
.bg-brand-600,
[class*="bg-brand-500/"],
[class*="bg-brand-600/"]
) {
@apply text-white;
}
:is(
.bg-brand-50,
.bg-brand-500,
.bg-brand-600,
[class*="bg-brand-500/"],
[class*="bg-brand-600/"]
)
:is(.text-brand-500, .text-brand-600, .text-brand-700, .text-brand-800) {
@apply text-white;
}
:is(
.bg-brand-50,
.bg-brand-500,
.bg-brand-600,
[class*="bg-brand-500/"],
[class*="bg-brand-600/"]
)
svg:not([class*="text-"]) {
@apply text-white;
}
.hover\:bg-brand-50:hover,
.hover\:bg-brand-500:hover,
.hover\:bg-brand-600:hover {
@apply text-white;
}
.hover\:bg-brand-50:hover svg,
.hover\:bg-brand-500:hover svg,
.hover\:bg-brand-600:hover svg {
@apply text-white;
}
/* Map legacy light-only surfaces to theme tokens in dark mode */
html.dark .bg-white {
background-color: var(--gs-50);
@apply bg-grayScale-100 text-foreground font-sans antialiased;
}
}

View File

@ -1,143 +0,0 @@
import { getTeamMemberById } from "../api/team.api"
import { getUserById } from "../api/users.api"
import { TEAM_ROLE_OPTIONS, formatTeamRoleLabel } from "./teamRoles"
import type { TeamMember } from "../types/team.types"
import type { UserProfileData } from "../types/user.types"
const TEAM_ROLE_VALUES = new Set(
TEAM_ROLE_OPTIONS.map((o) => o.value.toUpperCase()),
)
const APP_USER_ROLES = new Set(["STUDENT", "LEARNER", "USER", "SUBSCRIBER"])
export type ActorProfileKind = "team" | "user"
export type ActorProfile =
| {
kind: "team"
id: number
name: string
email: string
roleLabel: string
status: string
emailVerified: boolean
createdAt: string
}
| {
kind: "user"
id: number
name: string
email: string
roleLabel: string
status: string
emailVerified: boolean
country: string
region: string
lastLogin: string | null
subscriptionStatus: string
createdAt: string
}
function normalizeRole(role: string): string {
return role.trim().toUpperCase().replace(/[\s-]+/g, "_")
}
/** Choose API from activity log `actor_role` (team_role vs learner role). */
export function resolveActorKind(actorRole: string | null | undefined): ActorProfileKind | null {
if (!actorRole?.trim()) return null
const upper = normalizeRole(actorRole)
if (TEAM_ROLE_VALUES.has(upper)) return "team"
if (APP_USER_ROLES.has(upper)) return "user"
return null
}
function teamMemberToProfile(member: TeamMember): ActorProfile {
return {
kind: "team",
id: member.id,
name: [member.first_name, member.last_name].filter(Boolean).join(" ") || "—",
email: member.email || "—",
roleLabel: formatTeamRoleLabel(member.team_role),
status: member.status || "—",
emailVerified: Boolean(member.email_verified),
createdAt: member.created_at,
}
}
function userToProfile(user: UserProfileData): ActorProfile {
return {
kind: "user",
id: user.id,
name: [user.first_name, user.last_name].filter(Boolean).join(" ") || "—",
email: user.email || "—",
roleLabel: user.role
? user.role.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())
: "—",
status: user.status || "—",
emailVerified: Boolean(user.email_verified),
country: user.country || "—",
region: user.region || "—",
lastLogin: user.last_login,
subscriptionStatus: user.subscription_status?.trim() || "—",
createdAt: user.created_at,
}
}
const profileCache = new Map<string, ActorProfile | "error">()
function cacheKey(actorId: number, kind: ActorProfileKind): string {
return `${kind}:${actorId}`
}
async function fetchTeamProfile(actorId: number): Promise<ActorProfile> {
const res = await getTeamMemberById(actorId)
return teamMemberToProfile(res.data.data)
}
async function fetchUserProfile(actorId: number): Promise<ActorProfile> {
const res = await getUserById(actorId)
return userToProfile(res.data.data)
}
export async function fetchActorProfile(
actorId: number,
actorRole: string | null | undefined,
): Promise<ActorProfile> {
const kind = resolveActorKind(actorRole)
const load = async (target: ActorProfileKind): Promise<ActorProfile> => {
const key = cacheKey(actorId, target)
const cached = profileCache.get(key)
if (cached && cached !== "error") return cached
if (cached === "error") throw new Error("Actor not found")
try {
const profile =
target === "team" ? await fetchTeamProfile(actorId) : await fetchUserProfile(actorId)
profileCache.set(key, profile)
return profile
} catch (e) {
profileCache.set(key, "error")
throw e
}
}
if (kind === "team") return load("team")
if (kind === "user") return load("user")
try {
return await load("team")
} catch {
return load("user")
}
}
export function formatActorDate(iso: string): string {
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return iso
return d.toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
})
}

View File

@ -1,140 +0,0 @@
import type {
DashboardDateFilter,
DashboardSubscriptions,
DateRevenue,
LabelCount,
} from "../types/analytics.types"
const INACTIVE_SUBSCRIPTION_STATUSES = new Set([
"INACTIVE",
"CANCELLED",
"CANCELED",
"EXPIRED",
"PAUSED",
"SUSPENDED",
])
export interface SubscriptionMetrics {
total: number
active: number
inactive: number
}
/** Derives inactive count from by_status when present, else total active. */
export function getSubscriptionMetrics(
subscriptions: DashboardSubscriptions,
): SubscriptionMetrics {
const total = subscriptions.total_subscriptions ?? 0
const active = subscriptions.active_subscriptions ?? 0
let inactiveFromStatus = 0
if (subscriptions.by_status?.length) {
inactiveFromStatus = subscriptions.by_status
.filter((s) => INACTIVE_SUBSCRIPTION_STATUSES.has(s.label.toUpperCase()))
.reduce((sum, s) => sum + s.count, 0)
if (inactiveFromStatus === 0) {
inactiveFromStatus = subscriptions.by_status
.filter((s) => s.label.toUpperCase() !== "ACTIVE")
.reduce((sum, s) => sum + s.count, 0)
}
}
const inactive =
inactiveFromStatus > 0 ? inactiveFromStatus : Math.max(0, total - active)
return { total, active, inactive }
}
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"
}
}
/** Display label for dashboard breakdown rows (regions, enums, free text). */
export function formatAnalyticsLabel(label: string): string {
const text = label?.trim() ?? ""
if (!text || text.toLowerCase() === "unknown") return "Unknown"
if (text.includes("_")) return text.replace(/_/g, " ")
return text
}

View File

@ -1,79 +0,0 @@
import type {
AppPlatform,
AppUpdateType,
AppVersion,
AppVersionStatus,
} from "../types/app-version.types"
export const APP_PLATFORMS: { value: AppPlatform; label: string }[] = [
{ value: "ANDROID", label: "Android" },
{ value: "IOS", label: "iOS" },
]
export const APP_UPDATE_TYPES: { value: AppUpdateType; label: string; description: string }[] = [
{
value: "FORCE",
label: "Force update",
description: "Users must update before continuing",
},
{
value: "SOFT",
label: "Soft update",
description: "Recommended update; users can dismiss",
},
{
value: "OPTIONAL",
label: "Optional",
description: "Informational prompt only",
},
]
export const APP_VERSION_STATUSES: { value: AppVersionStatus; label: string }[] = [
{ value: "ACTIVE", label: "Active" },
{ value: "INACTIVE", label: "Inactive" },
{ value: "DRAFT", label: "Draft" },
]
export const DEFAULT_STORE_URLS: Record<string, string> = {
ANDROID: "https://play.google.com/store/apps/details?id=com.yimaru.app",
IOS: "https://apps.apple.com/app/id000000000",
}
export function formatAppPlatform(platform: string): string {
const match = APP_PLATFORMS.find((p) => p.value === platform)
if (match) return match.label
return platform.replace(/_/g, " ").toLowerCase().replace(/\b\w/g, (c) => c.toUpperCase())
}
export function formatUpdateType(updateType: string): string {
const match = APP_UPDATE_TYPES.find((t) => t.value === updateType)
if (match) return match.label
return updateType.replace(/_/g, " ").toLowerCase().replace(/\b\w/g, (c) => c.toUpperCase())
}
export function formatVersionStatus(status: string): string {
const match = APP_VERSION_STATUSES.find((s) => s.value === status)
if (match) return match.label
return status.replace(/_/g, " ").toLowerCase().replace(/\b\w/g, (c) => c.toUpperCase())
}
export function formatAppVersionCreatedAt(raw: string): string {
if (!raw) return "—"
const normalized = raw.replace(" +0000 UTC", "Z").replace(/^(\d{4}-\d{2}-\d{2}) /, "$1T")
const d = new Date(normalized)
if (Number.isNaN(d.getTime())) {
const datePart = raw.split(" ")[0]
return datePart || raw
}
return d.toLocaleString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})
}
export function versionLabel(version: Pick<AppVersion, "version_name" | "version_code">): string {
return `v${version.version_name} (${version.version_code})`
}

View File

@ -1,9 +0,0 @@
/** Clear session tokens and redirect to login (full page navigation). */
export function logoutToLogin(options?: { passwordChanged?: boolean }) {
localStorage.removeItem("access_token")
localStorage.removeItem("refresh_token")
localStorage.removeItem("member_id")
localStorage.removeItem("role")
const search = options?.passwordChanged ? "?password_changed=1" : ""
window.location.href = `/login${search}`
}

View File

@ -1,57 +0,0 @@
export type DynamicTableValue = {
columns: string[]
rows: string[][]
}
const DEFAULT_TABLE: DynamicTableValue = {
columns: ["Column 1", "Column 2"],
rows: [["", ""]],
}
function isRecord(v: unknown): v is Record<string, unknown> {
return v !== null && typeof v === "object" && !Array.isArray(v)
}
function normalizeRows(columns: string[], rows: unknown): string[][] {
const colCount = Math.max(1, columns.length)
if (!Array.isArray(rows)) return [Array(colCount).fill("")]
return rows.map((row) => {
if (!Array.isArray(row)) return Array(colCount).fill("")
const cells = row.map((c) => String(c ?? ""))
while (cells.length < colCount) cells.push("")
return cells.slice(0, colCount)
})
}
export function parseTableSlotValue(raw: string | undefined): DynamicTableValue {
const t = (raw ?? "").trim()
if (!t) return { ...DEFAULT_TABLE, columns: [...DEFAULT_TABLE.columns], rows: DEFAULT_TABLE.rows.map((r) => [...r]) }
try {
const parsed = JSON.parse(t) as unknown
if (isRecord(parsed) && Array.isArray(parsed.columns)) {
const columns = parsed.columns.map((c) => String(c ?? "").trim() || "Column")
if (columns.length === 0) columns.push("Column 1")
const rows = normalizeRows(columns, parsed.rows)
return { columns, rows: rows.length > 0 ? rows : [Array(columns.length).fill("")] }
}
} catch {
/* fall through */
}
return { ...DEFAULT_TABLE, columns: [...DEFAULT_TABLE.columns], rows: DEFAULT_TABLE.rows.map((r) => [...r]) }
}
export function serializeTableSlotValue(table: DynamicTableValue): string {
const columns = table.columns.map((c) => c.trim() || "Column")
const rows = normalizeRows(columns, table.rows).map((row) =>
row.map((cell) => cell.trim()),
)
return JSON.stringify({ columns, rows })
}
export function createEmptyTable(columnCount = 2, rowCount = 1): DynamicTableValue {
const columns = Array.from({ length: Math.max(1, columnCount) }, (_, i) => `Column ${i + 1}`)
const rows = Array.from({ length: Math.max(1, rowCount) }, () => Array(columns.length).fill(""))
return { columns, rows }
}

View File

@ -1,56 +0,0 @@
const SAMPLE_VALUES: Record<string, string> = {
OTP: "123456",
FirstName: "Alex",
ExpiresMinutes: "10",
ResetLink: "https://app.yimaruacademy.com/reset?token=sample",
InviteLink: "https://app.yimaruacademy.com/invite?token=sample",
InviterName: "Jordan Admin",
LoginURL: "https://app.yimaruacademy.com/login",
Subject: "Sample announcement subject",
Message:
"This is sample body text shown in the admin preview. Replace variables when sending real emails.",
}
function sampleForVariable(name: string) {
return SAMPLE_VALUES[name] ?? `[${name}]`
}
/** Best-effort preview: substitutes `{{.Var}}` and unwraps simple `{{if .Var}}...{{end}}` blocks. */
export function renderEmailTemplatePreview(
source: string,
variables: string[],
): string {
let result = source
for (const variable of variables) {
const sample = sampleForVariable(variable)
result = result.split(`{{.${variable}}}`).join(sample)
const ifBlock = new RegExp(
`\\{\\{if \\.${variable}\\}\\}([\\s\\S]*?)\\{\\{end\\}\\}`,
"g",
)
result = result.replace(ifBlock, "$1")
}
return result
}
export function formatEmailTemplateDate(raw: string | null | undefined) {
if (raw == null || String(raw).trim() === "") {
return "—"
}
const text = String(raw)
const parsed = new Date(text)
if (Number.isNaN(parsed.getTime())) {
return text.split(" +")[0]?.trim() || text
}
return parsed.toLocaleString(undefined, {
dateStyle: "medium",
timeStyle: "short",
})
}
export function emailTemplateStatusBadgeVariant(status: string) {
const normalized = status.toUpperCase()
if (normalized === "ACTIVE") return "success" as const
if (normalized === "INACTIVE") return "secondary" as const
return "info" as const
}

View File

@ -1,548 +0,0 @@
import type { CreateQuestionRequest, QuestionOption } from "../types/course.types"
import type { QuestionTypeDefinition } from "../types/questionTypeDefinition.types"
import { createEmptyTable, serializeTableSlotValue } from "./dynamicTableValue"
import { buildDynamicQuestionPayload } from "./practiceDynamicQuestionPayload"
import {
parseMultipleChoiceSlotValue,
serializeMultipleChoiceSlotValue,
defaultMultipleChoiceSlotValue,
validateMultipleChoiceSlotValue,
multipleChoiceSlotHasContent,
} from "./multipleChoiceSlotValue"
import {
defaultMatchingInputsSlotValue,
findMatchingInputsInFieldValues,
matchingAnswerSlotHasContent,
matchingInputsSlotHasContent,
parseMatchingAnswerSlotValue,
parseMatchingInputsSlotValue,
serializeMatchingAnswerSlotValue,
serializeMatchingInputsSlotValue,
validateMatchingAnswerSlotValue,
validateMatchingInputsSlotValue,
} from "./matchingSlotValue"
import {
defaultSelectMissingWordsStimulusSlotValue,
findSelectMissingWordsStimulusInFieldValues,
parseSelectMissingWordsResponseSlotValue,
parseSelectMissingWordsStimulusSlotValue,
selectMissingWordsResponseHasContent,
selectMissingWordsStimulusHasContent,
serializeSelectMissingWordsResponseSlotValue,
serializeSelectMissingWordsStimulusSlotValue,
validateSelectMissingWordsResponseSlotValue,
validateSelectMissingWordsStimulusSlotValue,
} from "./selectMissingWordsSlotValue"
function isMultipleChoiceKind(kind: string): boolean {
const u = kind.trim().toUpperCase()
return u === "MULTIPLE_CHOICE" || u === "OPTION"
}
function isMatchingInputsKind(kind: string): boolean {
return kind.trim().toUpperCase() === "MATCHING_INPUTS"
}
function isMatchingAnswerKind(kind: string): boolean {
return kind.trim().toUpperCase() === "MATCHING_ANSWER"
}
function isSelectMissingWordsKind(kind: string): boolean {
return kind.trim().toUpperCase() === "SELECT_MISSING_WORDS"
}
function isStructuredDynamicSlotKind(kind: string): boolean {
return (
isMultipleChoiceKind(kind) ||
isMatchingInputsKind(kind) ||
isMatchingAnswerKind(kind) ||
isSelectMissingWordsKind(kind)
)
}
function defaultValueForSchemaSlot(
kind: string,
side: "stimulus" | "response",
): string {
const u = kind.trim().toUpperCase()
if (u === "TABLE") {
return serializeTableSlotValue(createEmptyTable(2, 1))
}
if (isMultipleChoiceKind(kind)) {
return serializeMultipleChoiceSlotValue(defaultMultipleChoiceSlotValue())
}
if (isMatchingInputsKind(kind)) {
return serializeMatchingInputsSlotValue(defaultMatchingInputsSlotValue())
}
if (isMatchingAnswerKind(kind)) {
return serializeMatchingAnswerSlotValue({ pairs: [] })
}
if (isSelectMissingWordsKind(kind)) {
if (side === "stimulus") {
return serializeSelectMissingWordsStimulusSlotValue(
defaultSelectMissingWordsStimulusSlotValue(),
)
}
return serializeSelectMissingWordsResponseSlotValue({ blanks: [] })
}
return ""
}
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}`] = defaultValueForSchemaSlot(r.kind, "stimulus")
}
for (const r of def.response_schema) {
o[`response:${r.id}`] = defaultValueForSchemaSlot(r.kind, "response")
}
return o
}
const PROMPT_STIMULUS_KINDS = new Set(["QUESTION_TEXT", "INSTRUCTION", "TEXT_PASSAGE"])
/** First stimulus slot used for a plain-text prompt shortcut in the practice UI. */
export function primaryPromptStimulusRow(
def: QuestionTypeDefinition,
): { id: string; kind: string } | null {
for (const kind of ["QUESTION_TEXT", "INSTRUCTION", "TEXT_PASSAGE"] as const) {
const row = def.stimulus_schema.find((r) => r.kind === kind)
if (row) return { id: row.id, kind: row.kind }
}
return def.stimulus_schema[0] ? { id: def.stimulus_schema[0].id, kind: def.stimulus_schema[0].kind } : null
}
export function mergePromptIntoDynamicFieldValues(
def: QuestionTypeDefinition,
questionText: string,
fieldValues: Record<string, string>,
): Record<string, string> {
const merged = { ...fieldValues }
const prompt = questionText.trim()
if (!prompt) return merged
const slot = primaryPromptStimulusRow(def)
if (!slot) return merged
const key = `stimulus:${slot.id}`
if (!merged[key]?.trim()) merged[key] = prompt
return merged
}
export function dynamicPromptFromFieldValues(
def: QuestionTypeDefinition,
fieldValues: Record<string, string>,
): string {
for (const row of def.stimulus_schema) {
if (!PROMPT_STIMULUS_KINDS.has(row.kind)) continue
const v = fieldValues[`stimulus:${row.id}`]?.trim()
if (v) return v
}
return ""
}
/**
* 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 questionRowHasContent(
q: LearnEnglishDefinitionQuestionInput,
def: QuestionTypeDefinition,
): boolean {
if (!definitionUsesDynamicPayload(def)) {
return Boolean(q.questionText.trim())
}
if (q.questionText.trim()) return true
const fv = q.dynamicFieldValues ?? {}
for (const row of def.stimulus_schema) {
if (isMultipleChoiceKind(row.kind)) {
if (
multipleChoiceSlotHasContent(
parseMultipleChoiceSlotValue(fv[`stimulus:${row.id}`]),
)
) {
return true
}
continue
}
if (isMatchingInputsKind(row.kind)) {
if (
matchingInputsSlotHasContent(
parseMatchingInputsSlotValue(fv[`stimulus:${row.id}`]),
)
) {
return true
}
continue
}
if (isMatchingAnswerKind(row.kind)) {
if (
matchingAnswerSlotHasContent(
parseMatchingAnswerSlotValue(fv[`stimulus:${row.id}`]),
)
) {
return true
}
continue
}
if (isSelectMissingWordsKind(row.kind)) {
if (
selectMissingWordsStimulusHasContent(
parseSelectMissingWordsStimulusSlotValue(fv[`stimulus:${row.id}`]),
)
) {
return true
}
continue
}
if (fv[`stimulus:${row.id}`]?.trim()) return true
}
for (const row of def.response_schema) {
if (isMultipleChoiceKind(row.kind)) {
if (
multipleChoiceSlotHasContent(
parseMultipleChoiceSlotValue(fv[`response:${row.id}`]),
)
) {
return true
}
continue
}
if (isMatchingInputsKind(row.kind)) {
if (
matchingInputsSlotHasContent(
parseMatchingInputsSlotValue(fv[`response:${row.id}`]),
)
) {
return true
}
continue
}
if (isMatchingAnswerKind(row.kind)) {
if (
matchingAnswerSlotHasContent(
parseMatchingAnswerSlotValue(fv[`response:${row.id}`]),
)
) {
return true
}
continue
}
if (isSelectMissingWordsKind(row.kind)) {
if (
selectMissingWordsResponseHasContent(
parseSelectMissingWordsResponseSlotValue(fv[`response:${row.id}`]),
)
) {
return true
}
continue
}
if (fv[`response:${row.id}`]?.trim()) return true
}
return false
}
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 fieldValues = mergePromptIntoDynamicFieldValues(
def,
q.questionText,
q.dynamicFieldValues ?? {},
)
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,
mcqOptions: q.mcqOptions,
})
return {
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,
}
}
return {
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 (definitionUsesDynamicPayload(def)) {
const fieldValues = mergePromptIntoDynamicFieldValues(
def,
q.questionText,
q.dynamicFieldValues ?? {},
)
const hasPrompt =
Boolean(q.questionText.trim()) || Boolean(dynamicPromptFromFieldValues(def, fieldValues))
const promptRow = def.stimulus_schema.find((r) => PROMPT_STIMULUS_KINDS.has(r.kind) && r.required)
if (promptRow && !hasPrompt) {
return `Question ${n}: enter prompt text (${promptRow.label || promptRow.id}).`
}
for (const row of def.stimulus_schema) {
if (isStructuredDynamicSlotKind(row.kind)) continue
if (!row.required) continue
const v = fieldValues[`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 (isStructuredDynamicSlotKind(row.kind)) continue
if (!row.required) continue
const v = fieldValues[`response:${row.id}`]?.trim()
if (!v)
return `Question ${n}: fill required response "${row.label || row.id}" (${row.kind}).`
}
const matchingInputs = findMatchingInputsInFieldValues(
fieldValues,
def.stimulus_schema,
def.response_schema,
)
const clozeStimulus = findSelectMissingWordsStimulusInFieldValues(
fieldValues,
def.stimulus_schema,
)
for (const row of def.stimulus_schema) {
if (isMultipleChoiceKind(row.kind)) {
const val = parseMultipleChoiceSlotValue(fieldValues[`stimulus:${row.id}`])
if (!multipleChoiceSlotHasContent(val)) {
if (row.required) {
return `Question ${n}: add choices for stimulus "${row.label || row.id}".`
}
continue
}
const mcqErr = validateMultipleChoiceSlotValue(val)
if (mcqErr) {
return `Question ${n} (stimulus "${row.label || row.id}"): ${mcqErr}`
}
}
if (isMatchingInputsKind(row.kind)) {
const val = parseMatchingInputsSlotValue(fieldValues[`stimulus:${row.id}`])
if (!matchingInputsSlotHasContent(val)) {
if (row.required) {
return `Question ${n}: add matching inputs for stimulus "${row.label || row.id}".`
}
continue
}
const err = validateMatchingInputsSlotValue(val)
if (err) {
return `Question ${n} (stimulus "${row.label || row.id}"): ${err}`
}
}
if (isMatchingAnswerKind(row.kind)) {
const val = parseMatchingAnswerSlotValue(
fieldValues[`stimulus:${row.id}`],
matchingInputs,
)
if (!matchingAnswerSlotHasContent(val)) {
if (row.required) {
return `Question ${n}: add matching pairs for stimulus "${row.label || row.id}".`
}
continue
}
const err = validateMatchingAnswerSlotValue(val, matchingInputs)
if (err) {
return `Question ${n} (stimulus "${row.label || row.id}"): ${err}`
}
}
if (isSelectMissingWordsKind(row.kind)) {
const val = parseSelectMissingWordsStimulusSlotValue(
fieldValues[`stimulus:${row.id}`],
)
if (!selectMissingWordsStimulusHasContent(val)) {
if (row.required) {
return `Question ${n}: add cloze passage for stimulus "${row.label || row.id}".`
}
continue
}
const err = validateSelectMissingWordsStimulusSlotValue(val)
if (err) {
return `Question ${n} (stimulus "${row.label || row.id}"): ${err}`
}
}
}
for (const row of def.response_schema) {
if (isMultipleChoiceKind(row.kind)) {
const val = parseMultipleChoiceSlotValue(fieldValues[`response:${row.id}`])
if (!multipleChoiceSlotHasContent(val)) {
if (row.required) {
return `Question ${n}: add choices for response "${row.label || row.id}".`
}
continue
}
const mcqErr = validateMultipleChoiceSlotValue(val)
if (mcqErr) {
return `Question ${n} (response "${row.label || row.id}"): ${mcqErr}`
}
}
if (isMatchingInputsKind(row.kind)) {
const val = parseMatchingInputsSlotValue(fieldValues[`response:${row.id}`])
if (!matchingInputsSlotHasContent(val)) {
if (row.required) {
return `Question ${n}: add matching inputs for response "${row.label || row.id}".`
}
continue
}
const err = validateMatchingInputsSlotValue(val)
if (err) {
return `Question ${n} (response "${row.label || row.id}"): ${err}`
}
}
if (isMatchingAnswerKind(row.kind)) {
const val = parseMatchingAnswerSlotValue(
fieldValues[`response:${row.id}`],
matchingInputs,
)
if (!matchingAnswerSlotHasContent(val)) {
if (row.required) {
return `Question ${n}: add matching pairs for response "${row.label || row.id}".`
}
continue
}
const err = validateMatchingAnswerSlotValue(val, matchingInputs)
if (err) {
return `Question ${n} (response "${row.label || row.id}"): ${err}`
}
}
if (isSelectMissingWordsKind(row.kind)) {
const val = parseSelectMissingWordsResponseSlotValue(
fieldValues[`response:${row.id}`],
clozeStimulus,
)
if (!selectMissingWordsResponseHasContent(val)) {
if (row.required) {
return `Question ${n}: select words for each blank in response "${row.label || row.id}".`
}
continue
}
const err = validateSelectMissingWordsResponseSlotValue(val, clozeStimulus)
if (err) {
return `Question ${n} (response "${row.label || row.id}"): ${err}`
}
}
}
return null
}
if (!q.questionText.trim()) return `Question ${n}: enter question text.`
const legacy = legacyQuestionTypeFromDefinition(def)
if (legacy === "MCQ") {
const opts = (q.mcqOptions ?? []).filter((o) => o.option_text.trim())
if (opts.length < 2)
return `Question ${n} (${def.display_name}): add at least two choices with text.`
if (!opts.some((o) => o.is_correct))
return `Question ${n} (${def.display_name}): mark one correct choice.`
return null
}
if (legacy === "TRUE_FALSE") return null
if (legacy === "SHORT_ANSWER") {
const answers = (q.shortAnswers ?? []).map((s) => s.trim()).filter(Boolean)
if (answers.length < 1)
return `Question ${n} (${def.display_name}): add at least one acceptable answer.`
return null
}
return null
}

View File

@ -1,164 +0,0 @@
import type { AxiosError } from "axios"
import {
addQuestionToSet,
createExamPrepLessonPractice,
createParentLinkedPractice,
createQuestion,
createQuestionSet,
} from "../api/courses.api"
import type { PracticeParentKind } from "../types/course.types"
import type { QuestionTypeDefinition } from "../types/questionTypeDefinition.types"
import {
buildCreateQuestionFromDefinition,
questionRowHasContent,
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 byId = new Map(definitions.map((d) => [d.id, d]))
const filled = questions.filter((q) => {
const def = byId.get(q.questionTypeDefinitionId)
return def ? questionRowHasContent(q, def) : false
})
if (filled.length === 0) return "Add at least one question with content."
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[]
/** When set, links practice via POST /exam-prep/lessons/:id/practices instead of POST /practices. */
examPrepLessonId?: number
}): 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) => {
const def = byId.get(q.questionTypeDefinitionId)
return def ? questionRowHasContent(q, def) : false
})
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 = opts.examPrepLessonId
? await createExamPrepLessonPractice(opts.examPrepLessonId, {
title: opts.practiceTitle.trim(),
story_description: opts.storyDescription.trim(),
story_image: opts.storyImage.trim(),
persona_id: opts.personaId,
question_set_id: setId,
quick_tips: opts.quickTips.trim(),
})
: await createParentLinkedPractice({
parent_kind: opts.parentKind,
parent_id: opts.parentId,
title: opts.practiceTitle.trim(),
story_description: opts.storyDescription.trim(),
story_image: opts.storyImage.trim(),
question_set_id: setId,
quick_tips: opts.quickTips.trim(),
publish_status: opts.status,
persona_id: opts.personaId,
})
const practiceId = practiceRes.data?.data?.id
if (!practiceId) {
throw new Error(
(practiceRes.data as { message?: string } | undefined)?.message ??
"Could not create practice",
)
}
return { questionSetId: setId, practiceId }
}

View File

@ -1,354 +0,0 @@
export interface MatchingSideItem {
id: string
text: string
}
export interface MatchingInputsSlotValue {
left: MatchingSideItem[]
right: MatchingSideItem[]
}
export interface MatchingPair {
left_id: string
right_id: string
}
export interface MatchingAnswerSlotValue {
pairs: MatchingPair[]
}
export const MATCHING_MIN_ITEMS = 2
const DEFAULT_INPUT_COUNT = 4
function reindexSide(
items: MatchingSideItem[],
prefix: "l" | "r",
): MatchingSideItem[] {
return items.map((item, index) => ({
id: `${prefix}${index + 1}`,
text: item.text,
}))
}
export function defaultMatchingInputsSlotValue(
count = DEFAULT_INPUT_COUNT,
): MatchingInputsSlotValue {
return {
left: Array.from({ length: count }, (_, index) => ({
id: `l${index + 1}`,
text: "",
})),
right: Array.from({ length: count }, (_, index) => ({
id: `r${index + 1}`,
text: "",
})),
}
}
export function defaultMatchingAnswerFromInputs(
inputs: MatchingInputsSlotValue,
): MatchingAnswerSlotValue {
const count = Math.min(inputs.left.length, inputs.right.length)
return {
pairs: Array.from({ length: count }, (_, index) => ({
left_id: inputs.left[index]?.id ?? `l${index + 1}`,
right_id: inputs.right[index]?.id ?? `r${index + 1}`,
})),
}
}
export function serializeMatchingInputsSlotValue(
value: MatchingInputsSlotValue,
): string {
return JSON.stringify(value)
}
export function serializeMatchingAnswerSlotValue(
value: MatchingAnswerSlotValue,
): string {
return JSON.stringify(value)
}
export function matchingItemHasValue(text: string): boolean {
return text.length > 0
}
function normalizeSideItem(raw: unknown, index: number, prefix: "l" | "r"): MatchingSideItem {
if (raw && typeof raw === "object") {
const record = raw as Record<string, unknown>
return {
id: String(record.id ?? `${prefix}${index + 1}`),
text: String(record.text ?? ""),
}
}
return {
id: `${prefix}${index + 1}`,
text: String(raw ?? ""),
}
}
export function normalizeMatchingInputsValue(raw: unknown): MatchingInputsSlotValue {
if (raw && typeof raw === "object") {
const record = raw as Record<string, unknown>
const left = Array.isArray(record.left)
? record.left.map((item, index) => normalizeSideItem(item, index, "l"))
: []
const right = Array.isArray(record.right)
? record.right.map((item, index) => normalizeSideItem(item, index, "r"))
: []
if (left.length > 0 || right.length > 0) {
return ensureMinMatchingInputs({ left, right })
}
}
if (typeof raw === "string" && raw.trim()) {
try {
return normalizeMatchingInputsValue(JSON.parse(raw) as unknown)
} catch {
return defaultMatchingInputsSlotValue()
}
}
return defaultMatchingInputsSlotValue()
}
function normalizePair(raw: unknown, index: number): MatchingPair {
if (raw && typeof raw === "object") {
const record = raw as Record<string, unknown>
return {
left_id: String(record.left_id ?? record.leftId ?? `l${index + 1}`),
right_id: String(record.right_id ?? record.rightId ?? `r${index + 1}`),
}
}
return {
left_id: `l${index + 1}`,
right_id: `r${index + 1}`,
}
}
export function normalizeMatchingAnswerValue(raw: unknown): MatchingAnswerSlotValue {
if (raw && typeof raw === "object") {
const record = raw as Record<string, unknown>
if (Array.isArray(record.pairs)) {
return {
pairs: record.pairs.map((pair, index) => normalizePair(pair, index)),
}
}
}
if (typeof raw === "string" && raw.trim()) {
try {
return normalizeMatchingAnswerValue(JSON.parse(raw) as unknown)
} catch {
return { pairs: [] }
}
}
return { pairs: [] }
}
export function ensureMinMatchingInputs(
value: MatchingInputsSlotValue,
): MatchingInputsSlotValue {
const left = [...value.left]
const right = [...value.right]
while (left.length < MATCHING_MIN_ITEMS) {
left.push({ id: `l${left.length + 1}`, text: "" })
}
while (right.length < MATCHING_MIN_ITEMS) {
right.push({ id: `r${right.length + 1}`, text: "" })
}
return {
left: reindexSide(left, "l"),
right: reindexSide(right, "r"),
}
}
export function parseMatchingInputsSlotValue(
raw: string | undefined,
): MatchingInputsSlotValue {
const trimmed = (raw ?? "").trim()
if (!trimmed) return defaultMatchingInputsSlotValue()
try {
return ensureMinMatchingInputs(
normalizeMatchingInputsValue(JSON.parse(trimmed) as unknown),
)
} catch {
return defaultMatchingInputsSlotValue()
}
}
export function parseMatchingAnswerSlotValue(
raw: string | undefined,
inputs?: MatchingInputsSlotValue | null,
): MatchingAnswerSlotValue {
const trimmed = (raw ?? "").trim()
if (!trimmed) {
return inputs ? defaultMatchingAnswerFromInputs(inputs) : { pairs: [] }
}
try {
const parsed = normalizeMatchingAnswerValue(JSON.parse(trimmed) as unknown)
if (parsed.pairs.length > 0) return parsed
return inputs ? defaultMatchingAnswerFromInputs(inputs) : parsed
} catch {
return inputs ? defaultMatchingAnswerFromInputs(inputs) : { pairs: [] }
}
}
export function matchingInputsSlotHasContent(
value: MatchingInputsSlotValue,
): boolean {
return (
value.left.some((item) => matchingItemHasValue(item.text)) ||
value.right.some((item) => matchingItemHasValue(item.text))
)
}
export function matchingAnswerSlotHasContent(
value: MatchingAnswerSlotValue,
): boolean {
return value.pairs.some(
(pair) => pair.left_id.trim().length > 0 && pair.right_id.trim().length > 0,
)
}
export function finalizeMatchingInputsPayload(
value: MatchingInputsSlotValue,
): MatchingInputsSlotValue {
return {
left: value.left
.filter((item) => matchingItemHasValue(item.text))
.map((item) => ({ id: item.id, text: item.text })),
right: value.right
.filter((item) => matchingItemHasValue(item.text))
.map((item) => ({ id: item.id, text: item.text })),
}
}
export function finalizeMatchingAnswerPayload(
value: MatchingAnswerSlotValue,
): MatchingAnswerSlotValue {
return {
pairs: value.pairs
.filter(
(pair) => pair.left_id.trim().length > 0 && pair.right_id.trim().length > 0,
)
.map((pair) => ({
left_id: pair.left_id,
right_id: pair.right_id,
})),
}
}
export function addMatchingInputRow(
value: MatchingInputsSlotValue,
): MatchingInputsSlotValue {
return ensureMinMatchingInputs({
left: [...value.left, { id: `l${value.left.length + 1}`, text: "" }],
right: [...value.right, { id: `r${value.right.length + 1}`, text: "" }],
})
}
export function removeMatchingInputRow(
value: MatchingInputsSlotValue,
index: number,
): MatchingInputsSlotValue {
if (value.left.length <= MATCHING_MIN_ITEMS) return value
return ensureMinMatchingInputs({
left: value.left.filter((_, i) => i !== index),
right: value.right.filter((_, i) => i !== index),
})
}
export function addMatchingPair(
value: MatchingAnswerSlotValue,
inputs?: MatchingInputsSlotValue | null,
): MatchingAnswerSlotValue {
const left = inputs?.left ?? []
const right = inputs?.right ?? []
const nextIndex = value.pairs.length
return {
pairs: [
...value.pairs,
{
left_id: left[nextIndex]?.id ?? left[0]?.id ?? "l1",
right_id: right[nextIndex]?.id ?? right[0]?.id ?? "r1",
},
],
}
}
export function removeMatchingPair(
value: MatchingAnswerSlotValue,
index: number,
): MatchingAnswerSlotValue {
if (value.pairs.length <= 1) return value
return {
pairs: value.pairs.filter((_, i) => i !== index),
}
}
export function validateMatchingInputsSlotValue(
value: MatchingInputsSlotValue,
): string | null {
if (
value.left.length < MATCHING_MIN_ITEMS ||
value.right.length < MATCHING_MIN_ITEMS
) {
return `Add at least ${MATCHING_MIN_ITEMS} items on each side.`
}
const filledLeft = value.left.filter((item) => matchingItemHasValue(item.text))
const filledRight = value.right.filter((item) => matchingItemHasValue(item.text))
if (filledLeft.length < MATCHING_MIN_ITEMS) {
return `Add at least ${MATCHING_MIN_ITEMS} left-side items with text.`
}
if (filledRight.length < MATCHING_MIN_ITEMS) {
return `Add at least ${MATCHING_MIN_ITEMS} right-side items with text.`
}
return null
}
export function validateMatchingAnswerSlotValue(
value: MatchingAnswerSlotValue,
inputs?: MatchingInputsSlotValue | null,
): string | null {
const pairs = value.pairs.filter(
(pair) => pair.left_id.trim() && pair.right_id.trim(),
)
if (pairs.length < 1) return "Add at least one matching pair."
const leftIds = new Set(inputs?.left.map((item) => item.id) ?? [])
const rightIds = new Set(inputs?.right.map((item) => item.id) ?? [])
const usedLeft = new Set<string>()
for (const pair of pairs) {
if (inputs && leftIds.size > 0 && !leftIds.has(pair.left_id)) {
return `Unknown left id "${pair.left_id}" in matching answer.`
}
if (inputs && rightIds.size > 0 && !rightIds.has(pair.right_id)) {
return `Unknown right id "${pair.right_id}" in matching answer.`
}
if (usedLeft.has(pair.left_id)) {
return `Duplicate left id "${pair.left_id}" in matching answer.`
}
usedLeft.add(pair.left_id)
}
return null
}
export function findMatchingInputsInFieldValues(
fieldValues: Record<string, string>,
stimulusSchema: { id: string; kind: string }[],
responseSchema: { id: string; kind: string }[],
): MatchingInputsSlotValue | null {
for (const row of stimulusSchema) {
if (row.kind.trim().toUpperCase() !== "MATCHING_INPUTS") continue
const parsed = parseMatchingInputsSlotValue(fieldValues[`stimulus:${row.id}`])
if (matchingInputsSlotHasContent(parsed)) return parsed
}
for (const row of responseSchema) {
if (row.kind.trim().toUpperCase() !== "MATCHING_INPUTS") continue
const parsed = parseMatchingInputsSlotValue(fieldValues[`response:${row.id}`])
if (matchingInputsSlotHasContent(parsed)) return parsed
}
return null
}

View File

@ -1,197 +0,0 @@
export interface MultipleChoiceOptionValue {
id: string
text: string
is_correct: boolean
}
export interface MultipleChoiceSlotValue {
options: MultipleChoiceOptionValue[]
}
const DEFAULT_OPTION_IDS = ["a", "b", "c", "d", "e", "f", "g", "h"] as const
export const MULTIPLE_CHOICE_MIN_OPTIONS = 2
export function defaultMultipleChoiceSlotValue(
count = MULTIPLE_CHOICE_MIN_OPTIONS,
): MultipleChoiceSlotValue {
return {
options: Array.from({ length: count }, (_, index) => ({
id: DEFAULT_OPTION_IDS[index] ?? String(index + 1),
text: "",
is_correct: index === 0,
})),
}
}
export function serializeMultipleChoiceSlotValue(
value: MultipleChoiceSlotValue,
): string {
return JSON.stringify(value)
}
export function nextMultipleChoiceOptionId(
existing: MultipleChoiceOptionValue[],
): string {
const used = new Set(existing.map((option) => option.id))
for (const id of DEFAULT_OPTION_IDS) {
if (!used.has(id)) return id
}
return String(existing.length + 1)
}
export function addMultipleChoiceOption(
value: MultipleChoiceSlotValue,
): MultipleChoiceSlotValue {
return {
options: [
...value.options,
{
id: nextMultipleChoiceOptionId(value.options),
text: "",
is_correct: false,
},
],
}
}
export function removeMultipleChoiceOption(
value: MultipleChoiceSlotValue,
index: number,
): MultipleChoiceSlotValue {
if (value.options.length <= MULTIPLE_CHOICE_MIN_OPTIONS) return value
const removed = value.options[index]
let options = value.options.filter((_, i) => i !== index)
if (
removed?.is_correct &&
options.length > 0 &&
!options.some((option) => option.is_correct)
) {
options = options.map((option, i) => ({
...option,
is_correct: i === 0,
}))
}
return { options }
}
export function ensureMinMultipleChoiceOptions(
value: MultipleChoiceSlotValue,
): MultipleChoiceSlotValue {
if (value.options.length >= MULTIPLE_CHOICE_MIN_OPTIONS) return value
const options = [...value.options]
while (options.length < MULTIPLE_CHOICE_MIN_OPTIONS) {
options.push({
id: nextMultipleChoiceOptionId(options),
text: "",
is_correct: options.length === 0,
})
}
return { options }
}
export function multipleChoiceOptionHasValue(text: string): boolean {
return text.length > 0
}
export function parseMultipleChoiceSlotValue(
raw: string | undefined,
): MultipleChoiceSlotValue {
const trimmed = (raw ?? "").trim()
if (!trimmed) return defaultMultipleChoiceSlotValue()
try {
const parsed = JSON.parse(trimmed) as unknown
return ensureMinMultipleChoiceOptions(normalizeMultipleChoiceValue(parsed))
} catch {
return defaultMultipleChoiceSlotValue()
}
}
export function normalizeMultipleChoiceValue(
raw: unknown,
mcqOptions?: { option_text?: string; text?: string; is_correct?: boolean; isCorrect?: boolean }[],
): MultipleChoiceSlotValue {
if (mcqOptions?.some((o) => multipleChoiceOptionHasValue(o.option_text ?? o.text ?? ""))) {
return {
options: mcqOptions
.map((option, index) => ({
id: DEFAULT_OPTION_IDS[index] ?? String(index + 1),
text: option.option_text ?? option.text ?? "",
is_correct: Boolean(option.is_correct ?? option.isCorrect),
}))
.filter((option) => multipleChoiceOptionHasValue(option.text)),
}
}
if (raw && typeof raw === "object") {
const record = raw as Record<string, unknown>
if (Array.isArray(record.options)) {
return {
options: record.options.map((option, index) =>
normalizeMultipleChoiceOption(option, index),
),
}
}
}
if (Array.isArray(raw)) {
return {
options: raw.map((option, index) =>
normalizeMultipleChoiceOption(option, index),
),
}
}
if (typeof raw === "string" && raw.trim()) {
try {
return normalizeMultipleChoiceValue(JSON.parse(raw) as unknown)
} catch {
return defaultMultipleChoiceSlotValue()
}
}
return defaultMultipleChoiceSlotValue()
}
function normalizeMultipleChoiceOption(
raw: unknown,
index: number,
): MultipleChoiceOptionValue {
if (raw && typeof raw === "object") {
const record = raw as Record<string, unknown>
return {
id: String(record.id ?? DEFAULT_OPTION_IDS[index] ?? index + 1),
text: String(record.text ?? record.option_text ?? ""),
is_correct: Boolean(record.is_correct ?? record.isCorrect),
}
}
return {
id: DEFAULT_OPTION_IDS[index] ?? String(index + 1),
text: String(raw ?? ""),
is_correct: false,
}
}
export function multipleChoiceSlotHasContent(
value: MultipleChoiceSlotValue,
): boolean {
return value.options.some((option) => multipleChoiceOptionHasValue(option.text))
}
export function validateMultipleChoiceSlotValue(
value: MultipleChoiceSlotValue,
): string | null {
if (value.options.length < MULTIPLE_CHOICE_MIN_OPTIONS) {
return `Add at least ${MULTIPLE_CHOICE_MIN_OPTIONS} choices.`
}
const filled = value.options.filter((option) =>
multipleChoiceOptionHasValue(option.text),
)
if (filled.length < MULTIPLE_CHOICE_MIN_OPTIONS) {
return `Add at least ${MULTIPLE_CHOICE_MIN_OPTIONS} choices with text.`
}
if (!filled.some((option) => option.is_correct)) {
return "Mark one choice as correct."
}
return null
}

View File

@ -1,101 +0,0 @@
import {
Bell,
Info,
AlertCircle,
CheckCircle2,
Megaphone,
UserPlus,
CreditCard,
BookOpen,
Video,
ShieldAlert,
} from "lucide-react"
export const NOTIFICATION_TYPE_CONFIG: Record<
string,
{ icon: React.ElementType; color: string; bg: string }
> = {
announcement: { icon: Megaphone, color: "text-brand-600", bg: "bg-brand-100" },
system_alert: { icon: ShieldAlert, color: "text-amber-600", bg: "bg-amber-50" },
issue_created: { icon: AlertCircle, color: "text-red-500", bg: "bg-red-50" },
issue_status_updated: { icon: CheckCircle2, color: "text-sky-600", bg: "bg-sky-50" },
course_created: { icon: BookOpen, color: "text-indigo-600", bg: "bg-indigo-50" },
course_enrolled: { icon: BookOpen, color: "text-teal-600", bg: "bg-teal-50" },
sub_course_created: { icon: BookOpen, color: "text-violet-600", bg: "bg-violet-50" },
video_added: { icon: Video, color: "text-pink-600", bg: "bg-pink-50" },
user_deleted: { icon: UserPlus, color: "text-red-600", bg: "bg-red-50" },
admin_created: { icon: UserPlus, color: "text-brand-600", bg: "bg-brand-100" },
team_member_created: { icon: UserPlus, color: "text-emerald-600", bg: "bg-emerald-50" },
subscription_activated: { icon: CreditCard, color: "text-green-600", bg: "bg-green-50" },
payment_verified: { icon: CreditCard, color: "text-green-600", bg: "bg-green-50" },
knowledge_level_update: { icon: Info, color: "text-sky-600", bg: "bg-sky-50" },
assessment_assigned: { icon: BookOpen, color: "text-orange-600", bg: "bg-orange-50" },
}
export const DEFAULT_NOTIFICATION_TYPE_CONFIG = {
icon: Bell,
color: "text-grayScale-500",
bg: "bg-grayScale-100",
}
export function getNotificationLevelBadge(level: string) {
switch (level) {
case "error":
case "critical":
return "destructive" as const
case "warning":
return "warning" as const
case "success":
return "success" as const
case "info":
default:
return "info" as const
}
}
export function formatNotificationTimestamp(ts: string) {
const date = new Date(ts)
if (Number.isNaN(date.getTime())) return "—"
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMin = Math.floor(diffMs / 60_000)
const diffHr = Math.floor(diffMs / 3_600_000)
const diffDay = Math.floor(diffMs / 86_400_000)
if (diffMin < 1) return "Just now"
if (diffMin < 60) return `${diffMin}m ago`
if (diffHr < 24) return `${diffHr}h ago`
if (diffDay < 7) return `${diffDay}d ago`
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: date.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
})
}
export function formatNotificationTypeLabel(type: string) {
return type
.split("_")
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(" ")
}
export function formatNotificationDateTime(ts: string) {
const date = new Date(ts)
if (Number.isNaN(date.getTime())) return "—"
return date.toLocaleString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})
}
export function isMeaningfulExpiry(expires: string) {
if (!expires) return false
const date = new Date(expires)
if (Number.isNaN(date.getTime())) return false
return date.getFullYear() > 1
}

View File

@ -1,48 +0,0 @@
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)
}

View File

@ -1,27 +0,0 @@
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
/** Parse one or more emails from newline-, comma-, or semicolon-separated input. */
export function parseInviteEmails(text: string): string[] {
const seen = new Set<string>()
const result: string[] = []
for (const part of text.split(/[\n,;]+/)) {
const email = part.trim().toLowerCase()
if (!email || seen.has(email)) continue
seen.add(email)
result.push(email)
}
return result
}
export function isValidInviteEmail(email: string): boolean {
return EMAIL_PATTERN.test(email)
}
export type InviteEmailSendResult = {
email: string
success: boolean
message: string
invitationId?: number
}

View File

@ -1,51 +0,0 @@
import { formatPlanCategory } from "./subscriptionPlans"
import type { Payment } from "../types/payment.types"
export function formatPaymentAmount(payment: Pick<Payment, "amount" | "currency">): string {
const amount = Number(payment.amount)
const formatted = Number.isFinite(amount)
? amount.toLocaleString(undefined, { minimumFractionDigits: 0, maximumFractionDigits: 2 })
: String(payment.amount)
return `${formatted} ${payment.currency || "ETB"}`
}
export function formatPaymentDate(iso: string | null | undefined): string {
if (!iso) return "—"
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return iso
return d.toLocaleString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})
}
export function formatPaymentStatus(status: string): string {
return status.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())
}
export function formatPaymentMethod(method: string): string {
if (!method) return "—"
return method.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())
}
export function paymentCustomerName(payment: Payment): string {
const name = [payment.user_first_name, payment.user_last_name].filter(Boolean).join(" ")
return name || payment.user_email || `User #${payment.user_id}`
}
export function formatPaymentPlanCategory(category: string): string {
return formatPlanCategory(category)
}
export function paymentStatusBadgeVariant(
status: string,
): "success" | "warning" | "destructive" | "secondary" | "info" {
const s = status.toUpperCase()
if (s === "SUCCESS" || s === "COMPLETED" || s === "PAID") return "success"
if (s === "PENDING" || s === "PROCESSING") return "warning"
if (s === "FAILED" || s === "CANCELLED" || s === "EXPIRED") return "destructive"
return "secondary"
}

View File

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

View File

@ -1,184 +0,0 @@
import type { DynamicQuestionPayload } from "../types/questionTypeDefinition.types"
import {
finalizeMatchingAnswerPayload,
finalizeMatchingInputsPayload,
findMatchingInputsInFieldValues,
matchingAnswerSlotHasContent,
matchingInputsSlotHasContent,
parseMatchingAnswerSlotValue,
parseMatchingInputsSlotValue,
} from "./matchingSlotValue"
import {
finalizeSelectMissingWordsResponsePayload,
finalizeSelectMissingWordsStimulusPayload,
findSelectMissingWordsStimulusInFieldValues,
parseSelectMissingWordsResponseSlotValue,
parseSelectMissingWordsStimulusSlotValue,
selectMissingWordsResponseHasContent,
selectMissingWordsStimulusHasContent,
} from "./selectMissingWordsSlotValue"
import {
multipleChoiceOptionHasValue,
multipleChoiceSlotHasContent,
normalizeMultipleChoiceValue,
parseMultipleChoiceSlotValue,
} from "./multipleChoiceSlotValue"
/** Parse a single slot value: plain string/URL, or JSON object/array when input looks like JSON. */
export function parseDynamicSlotValue(raw: string | undefined): unknown {
const t = (raw ?? "").trim()
if (!t) return ""
if ((t.startsWith("{") && t.endsWith("}")) || (t.startsWith("[") && t.endsWith("]"))) {
try {
return JSON.parse(t) as unknown
} catch {
return t
}
}
return t
}
const PLAIN_TEXT_STIMULUS_KINDS = new Set([
"INSTRUCTION",
"QUESTION_TEXT",
"TEXT_PASSAGE",
"TEXT",
"TEXT_INPUT",
])
function isMultipleChoiceKind(kind: string): boolean {
const upper = kind.trim().toUpperCase()
return upper === "MULTIPLE_CHOICE" || upper === "OPTION"
}
function isMatchingInputsKind(kind: string): boolean {
return kind.trim().toUpperCase() === "MATCHING_INPUTS"
}
function isMatchingAnswerKind(kind: string): boolean {
return kind.trim().toUpperCase() === "MATCHING_ANSWER"
}
function isSelectMissingWordsKind(kind: string): boolean {
return kind.trim().toUpperCase() === "SELECT_MISSING_WORDS"
}
function slotValueForRow(
row: { id: string; kind: string },
side: "stimulus" | "response",
fieldValues: Record<string, string>,
mcqOptions: { option_text: string; is_correct: boolean }[] | undefined,
mcqOptionsConsumed: { current: boolean },
stimulusRows: { id: string; kind: string }[],
responseRows: { id: string; kind: string }[],
): unknown {
const fieldKey = `${side}:${row.id}`
const rawField = fieldValues[fieldKey]
if (isMultipleChoiceKind(row.kind)) {
const fromField = parseMultipleChoiceSlotValue(rawField)
if (multipleChoiceSlotHasContent(fromField)) {
return {
options: fromField.options
.filter((option) => multipleChoiceOptionHasValue(option.text))
.map((option) => ({
id: option.id,
text: option.text,
is_correct: option.is_correct,
})),
}
}
if (mcqOptions && !mcqOptionsConsumed.current) {
mcqOptionsConsumed.current = true
return normalizeMultipleChoiceValue(undefined, mcqOptions)
}
return { options: [] }
}
if (isMatchingInputsKind(row.kind)) {
const fromField = parseMatchingInputsSlotValue(rawField)
if (matchingInputsSlotHasContent(fromField)) {
return finalizeMatchingInputsPayload(fromField)
}
return { left: [], right: [] }
}
if (isMatchingAnswerKind(row.kind)) {
const matchingInputs = findMatchingInputsInFieldValues(
fieldValues,
stimulusRows,
responseRows,
)
const fromField = parseMatchingAnswerSlotValue(rawField, matchingInputs)
if (matchingAnswerSlotHasContent(fromField)) {
return finalizeMatchingAnswerPayload(fromField)
}
return { pairs: [] }
}
if (isSelectMissingWordsKind(row.kind)) {
if (side === "stimulus") {
const fromField = parseSelectMissingWordsStimulusSlotValue(rawField)
if (selectMissingWordsStimulusHasContent(fromField)) {
return finalizeSelectMissingWordsStimulusPayload(fromField)
}
return { segments: [], word_bank: [], allow_reuse: false }
}
const clozeStimulus = findSelectMissingWordsStimulusInFieldValues(
fieldValues,
stimulusRows,
)
const fromField = parseSelectMissingWordsResponseSlotValue(
rawField,
clozeStimulus,
)
if (selectMissingWordsResponseHasContent(fromField)) {
return finalizeSelectMissingWordsResponsePayload(fromField)
}
return { blanks: [] }
}
if (side === "stimulus" && PLAIN_TEXT_STIMULUS_KINDS.has(row.kind.trim().toUpperCase())) {
return (rawField ?? "").trim()
}
return parseDynamicSlotValue(rawField)
}
export function buildDynamicQuestionPayload(input: {
stimulusRows: { id: string; kind: string }[]
responseRows: { id: string; kind: string }[]
fieldValues: Record<string, string>
mcqOptions?: { option_text: string; is_correct: boolean }[]
}): DynamicQuestionPayload {
const mcqOptionsConsumed = { current: false }
return {
stimulus: input.stimulusRows.map((row) => ({
id: row.id,
kind: row.kind,
value: slotValueForRow(
row,
"stimulus",
input.fieldValues,
input.mcqOptions,
mcqOptionsConsumed,
input.stimulusRows,
input.responseRows,
),
})),
response: input.responseRows.map((row) => ({
id: row.id,
kind: row.kind,
value: slotValueForRow(
row,
"response",
input.fieldValues,
input.mcqOptions,
mcqOptionsConsumed,
input.stimulusRows,
input.responseRows,
),
})),
}
}

View File

@ -1,41 +0,0 @@
/** Author-facing default labels for dynamic schema slots. */
const KIND_DEFAULT_LABELS: Record<string, string> = {
QUESTION_TEXT: "Question prompt",
PREP_TIME: "Preparation time (seconds)",
INSTRUCTION: "Instructions",
AUDIO_PROMPT: "Audio",
TEXT_PASSAGE: "Reading passage",
IMAGE: "Image",
MATCHING_INPUTS: "Matching inputs",
SELECT_MISSING_WORDS: "Select missing words",
TABLE: "Reference table",
PDF_ATTACHMENT: "PDF document",
AUDIO_RESPONSE: "Audio response",
TEXT_INPUT: "Text input",
SHORT_ANSWER: "Short answer",
MULTIPLE_CHOICE: "Multiple choice",
OPTION: "Answer choices",
ANSWER_TIMER: "Time limit (seconds)",
PDF_UPLOAD: "PDF upload",
MATCHING_ANSWER: "Matching answer",
LABEL_SELECTION: "Label selection",
SEQUENCE_ORDER: "Sequence order",
}
export function humanizeKind(kind: string): string {
return kind
.replace(/_/g, " ")
.toLowerCase()
.replace(/\b\w/g, (c) => c.toUpperCase())
}
export function defaultLabelForKind(kind: string): string {
const k = kind.trim()
return KIND_DEFAULT_LABELS[k] ?? humanizeKind(k)
}
export function slotLabel(schema: { label?: string | null; kind: string }): string {
const trimmed = schema.label?.trim()
if (trimmed) return trimmed
return defaultLabelForKind(schema.kind)
}

View File

@ -1,524 +0,0 @@
export interface ClozeTextSegment {
type: "text"
value: string
}
export interface ClozeBlankSegment {
type: "blank"
id: string
}
export type ClozeSegment = ClozeTextSegment | ClozeBlankSegment
export interface WordBankItem {
id: string
text: string
}
export interface SelectMissingWordsStimulusValue {
segments: ClozeSegment[]
word_bank: WordBankItem[]
allow_reuse: boolean
}
export interface ClozeBlankAnswer {
blank_id: string
text: string
word_id: string
}
export interface SelectMissingWordsResponseValue {
blanks: ClozeBlankAnswer[]
}
export const SELECT_MISSING_WORDS_MIN_BANK = 2
export const SELECT_MISSING_WORDS_MIN_BLANKS = 1
const DEFAULT_BLANK_COUNT = 2
const DEFAULT_WORD_BANK_COUNT = 4
function reindexBlankSegments(segments: ClozeSegment[]): ClozeSegment[] {
let blankIndex = 0
return segments.map((segment) => {
if (segment.type !== "blank") return segment
blankIndex += 1
return { type: "blank", id: `b${blankIndex}` }
})
}
function reindexWordBank(items: WordBankItem[]): WordBankItem[] {
return items.map((item, index) => ({
id: `w${index + 1}`,
text: item.text,
}))
}
function normalizeTextSegment(raw: unknown, index: number): ClozeTextSegment {
if (raw && typeof raw === "object") {
const record = raw as Record<string, unknown>
if (record.type === "text") {
return {
type: "text",
value: String(record.value ?? ""),
}
}
}
return { type: "text", value: index === 0 ? "" : "" }
}
function normalizeBlankSegment(raw: unknown, index: number): ClozeBlankSegment {
if (raw && typeof raw === "object") {
const record = raw as Record<string, unknown>
if (record.type === "blank") {
return {
type: "blank",
id: String(record.id ?? `b${index + 1}`),
}
}
}
return { type: "blank", id: `b${index + 1}` }
}
function normalizeSegment(raw: unknown, index: number): ClozeSegment {
if (raw && typeof raw === "object") {
const record = raw as Record<string, unknown>
if (record.type === "blank") return normalizeBlankSegment(raw, index)
if (record.type === "text") return normalizeTextSegment(raw, index)
}
return { type: "text", value: "" }
}
function normalizeWordBankItem(raw: unknown, index: number): WordBankItem {
if (raw && typeof raw === "object") {
const record = raw as Record<string, unknown>
return {
id: String(record.id ?? `w${index + 1}`),
text: String(record.text ?? ""),
}
}
return { id: `w${index + 1}`, text: "" }
}
export function defaultSelectMissingWordsStimulusSlotValue(
blankCount = DEFAULT_BLANK_COUNT,
wordBankCount = DEFAULT_WORD_BANK_COUNT,
): SelectMissingWordsStimulusValue {
const segments: ClozeSegment[] = [{ type: "text", value: "" }]
for (let index = 0; index < blankCount; index += 1) {
segments.push({ type: "blank", id: `b${index + 1}` })
segments.push({ type: "text", value: "" })
}
return {
segments,
word_bank: Array.from({ length: wordBankCount }, (_, index) => ({
id: `w${index + 1}`,
text: "",
})),
allow_reuse: false,
}
}
export function blankIdsFromStimulus(
stimulus: SelectMissingWordsStimulusValue,
): string[] {
return stimulus.segments
.filter((segment): segment is ClozeBlankSegment => segment.type === "blank")
.map((segment) => segment.id)
}
export function defaultSelectMissingWordsResponseFromStimulus(
stimulus: SelectMissingWordsStimulusValue,
): SelectMissingWordsResponseValue {
return {
blanks: blankIdsFromStimulus(stimulus).map((blankId) => ({
blank_id: blankId,
text: "",
word_id: "",
})),
}
}
export function serializeSelectMissingWordsStimulusSlotValue(
value: SelectMissingWordsStimulusValue,
): string {
return JSON.stringify(value)
}
export function serializeSelectMissingWordsResponseSlotValue(
value: SelectMissingWordsResponseValue,
): string {
return JSON.stringify(value)
}
export function wordBankItemHasValue(text: string): boolean {
return text.length > 0
}
function ensureMinWordBank(
value: SelectMissingWordsStimulusValue,
): SelectMissingWordsStimulusValue {
const wordBank = [...value.word_bank]
while (wordBank.length < SELECT_MISSING_WORDS_MIN_BANK) {
wordBank.push({ id: `w${wordBank.length + 1}`, text: "" })
}
return {
...value,
segments:
value.segments.length > 0
? reindexBlankSegments(value.segments)
: defaultSelectMissingWordsStimulusSlotValue().segments,
word_bank: reindexWordBank(wordBank),
}
}
export function normalizeSelectMissingWordsStimulusValue(
raw: unknown,
): SelectMissingWordsStimulusValue {
if (raw && typeof raw === "object") {
const record = raw as Record<string, unknown>
const segments = Array.isArray(record.segments)
? record.segments.map((segment, index) => normalizeSegment(segment, index))
: []
const wordBank = Array.isArray(record.word_bank)
? record.word_bank.map((item, index) => normalizeWordBankItem(item, index))
: []
if (segments.length > 0 || wordBank.length > 0) {
return ensureMinWordBank({
segments,
word_bank: wordBank,
allow_reuse: Boolean(record.allow_reuse),
})
}
}
if (typeof raw === "string" && raw.trim()) {
try {
return normalizeSelectMissingWordsStimulusValue(JSON.parse(raw) as unknown)
} catch {
return defaultSelectMissingWordsStimulusSlotValue()
}
}
return defaultSelectMissingWordsStimulusSlotValue()
}
function normalizeBlankAnswer(raw: unknown, index: number): ClozeBlankAnswer {
if (raw && typeof raw === "object") {
const record = raw as Record<string, unknown>
return {
blank_id: String(record.blank_id ?? record.blankId ?? `b${index + 1}`),
text: String(record.text ?? ""),
word_id: String(record.word_id ?? record.wordId ?? ""),
}
}
return { blank_id: `b${index + 1}`, text: "", word_id: "" }
}
export function normalizeSelectMissingWordsResponseValue(
raw: unknown,
): SelectMissingWordsResponseValue {
if (raw && typeof raw === "object") {
const record = raw as Record<string, unknown>
if (Array.isArray(record.blanks)) {
return {
blanks: record.blanks.map((blank, index) =>
normalizeBlankAnswer(blank, index),
),
}
}
}
if (typeof raw === "string" && raw.trim()) {
try {
return normalizeSelectMissingWordsResponseValue(JSON.parse(raw) as unknown)
} catch {
return { blanks: [] }
}
}
return { blanks: [] }
}
export function parseSelectMissingWordsStimulusSlotValue(
raw: string | undefined,
): SelectMissingWordsStimulusValue {
const trimmed = (raw ?? "").trim()
if (!trimmed) return defaultSelectMissingWordsStimulusSlotValue()
try {
return ensureMinWordBank(
normalizeSelectMissingWordsStimulusValue(JSON.parse(trimmed) as unknown),
)
} catch {
return defaultSelectMissingWordsStimulusSlotValue()
}
}
export function parseSelectMissingWordsResponseSlotValue(
raw: string | undefined,
stimulus?: SelectMissingWordsStimulusValue | null,
): SelectMissingWordsResponseValue {
const trimmed = (raw ?? "").trim()
if (!trimmed) {
return stimulus
? defaultSelectMissingWordsResponseFromStimulus(stimulus)
: { blanks: [] }
}
try {
const parsed = normalizeSelectMissingWordsResponseValue(
JSON.parse(trimmed) as unknown,
)
if (parsed.blanks.length > 0) return parsed
return stimulus
? defaultSelectMissingWordsResponseFromStimulus(stimulus)
: parsed
} catch {
return stimulus
? defaultSelectMissingWordsResponseFromStimulus(stimulus)
: { blanks: [] }
}
}
export function selectMissingWordsStimulusHasContent(
value: SelectMissingWordsStimulusValue,
): boolean {
return (
value.segments.some(
(segment) =>
segment.type === "blank" ||
(segment.type === "text" && segment.value.length > 0),
) || value.word_bank.some((item) => wordBankItemHasValue(item.text))
)
}
export function selectMissingWordsResponseHasContent(
value: SelectMissingWordsResponseValue,
): boolean {
return value.blanks.some(
(blank) =>
blank.blank_id.trim().length > 0 &&
blank.word_id.trim().length > 0 &&
blank.text.length > 0,
)
}
export function finalizeSelectMissingWordsStimulusPayload(
value: SelectMissingWordsStimulusValue,
): SelectMissingWordsStimulusValue {
return {
segments: value.segments.map((segment) =>
segment.type === "text"
? { type: "text", value: segment.value }
: { type: "blank", id: segment.id },
),
word_bank: value.word_bank
.filter((item) => wordBankItemHasValue(item.text))
.map((item) => ({ id: item.id, text: item.text })),
allow_reuse: value.allow_reuse,
}
}
export function finalizeSelectMissingWordsResponsePayload(
value: SelectMissingWordsResponseValue,
): SelectMissingWordsResponseValue {
return {
blanks: value.blanks
.filter(
(blank) =>
blank.blank_id.trim().length > 0 &&
blank.word_id.trim().length > 0 &&
blank.text.length > 0,
)
.map((blank) => ({
blank_id: blank.blank_id,
text: blank.text,
word_id: blank.word_id,
})),
}
}
export function addWordBankItem(
value: SelectMissingWordsStimulusValue,
): SelectMissingWordsStimulusValue {
const next = ensureMinWordBank(value)
return {
...next,
word_bank: reindexWordBank([
...next.word_bank,
{ id: `w${next.word_bank.length + 1}`, text: "" },
]),
}
}
export function removeWordBankItem(
value: SelectMissingWordsStimulusValue,
index: number,
): SelectMissingWordsStimulusValue {
if (value.word_bank.length <= SELECT_MISSING_WORDS_MIN_BANK) return value
return ensureMinWordBank({
...value,
word_bank: value.word_bank.filter((_, itemIndex) => itemIndex !== index),
})
}
export function addTextSegment(
value: SelectMissingWordsStimulusValue,
): SelectMissingWordsStimulusValue {
return {
...value,
segments: [...value.segments, { type: "text", value: "" }],
}
}
export function addBlankSegment(
value: SelectMissingWordsStimulusValue,
): SelectMissingWordsStimulusValue {
const blankCount = blankIdsFromStimulus(value).length
return ensureMinWordBank({
...value,
segments: [
...value.segments,
{ type: "blank", id: `b${blankCount + 1}` },
],
})
}
export function removeSegment(
value: SelectMissingWordsStimulusValue,
index: number,
): SelectMissingWordsStimulusValue {
if (value.segments.length <= 1) return value
return ensureMinWordBank({
...value,
segments: value.segments.filter((_, segmentIndex) => segmentIndex !== index),
})
}
export function updateTextSegment(
value: SelectMissingWordsStimulusValue,
index: number,
text: string,
): SelectMissingWordsStimulusValue {
const segments = [...value.segments]
const segment = segments[index]
if (!segment || segment.type !== "text") return value
segments[index] = { type: "text", value: text }
return { ...value, segments }
}
export function updateWordBankText(
value: SelectMissingWordsStimulusValue,
index: number,
text: string,
): SelectMissingWordsStimulusValue {
const wordBank = [...value.word_bank]
if (!wordBank[index]) return value
wordBank[index] = { ...wordBank[index], text }
return { ...value, word_bank: wordBank }
}
export function setAllowReuse(
value: SelectMissingWordsStimulusValue,
allowReuse: boolean,
): SelectMissingWordsStimulusValue {
return { ...value, allow_reuse: allowReuse }
}
export function syncResponseBlanksWithStimulus(
response: SelectMissingWordsResponseValue,
stimulus: SelectMissingWordsStimulusValue,
): SelectMissingWordsResponseValue {
const blankIds = blankIdsFromStimulus(stimulus)
const existing = new Map(
response.blanks.map((blank) => [blank.blank_id, blank]),
)
return {
blanks: blankIds.map((blankId) => {
const current = existing.get(blankId)
return (
current ?? {
blank_id: blankId,
text: "",
word_id: "",
}
)
}),
}
}
export function validateSelectMissingWordsStimulusSlotValue(
value: SelectMissingWordsStimulusValue,
): string | null {
const blankCount = blankIdsFromStimulus(value).length
if (blankCount < SELECT_MISSING_WORDS_MIN_BLANKS) {
return `Add at least ${SELECT_MISSING_WORDS_MIN_BLANKS} blank in the passage.`
}
const filledWords = value.word_bank.filter((item) =>
wordBankItemHasValue(item.text),
)
if (filledWords.length < SELECT_MISSING_WORDS_MIN_BANK) {
return `Add at least ${SELECT_MISSING_WORDS_MIN_BANK} words in the word bank.`
}
return null
}
export function validateSelectMissingWordsResponseSlotValue(
value: SelectMissingWordsResponseValue,
stimulus?: SelectMissingWordsStimulusValue | null,
): string | null {
const filled = value.blanks.filter(
(blank) =>
blank.blank_id.trim() &&
blank.word_id.trim() &&
blank.text.length > 0,
)
if (filled.length < SELECT_MISSING_WORDS_MIN_BLANKS) {
return "Select a word for each blank."
}
const blankIds = new Set(stimulus ? blankIdsFromStimulus(stimulus) : [])
const wordIds = new Set(
stimulus?.word_bank
.filter((item) => wordBankItemHasValue(item.text))
.map((item) => item.id) ?? [],
)
const wordTextById = new Map(
stimulus?.word_bank.map((item) => [item.id, item.text]) ?? [],
)
for (const blank of filled) {
if (blankIds.size > 0 && !blankIds.has(blank.blank_id)) {
return `Unknown blank id "${blank.blank_id}".`
}
if (wordIds.size > 0 && !wordIds.has(blank.word_id)) {
return `Unknown word id "${blank.word_id}" for blank "${blank.blank_id}".`
}
const expectedText = wordTextById.get(blank.word_id)
if (expectedText !== undefined && blank.text !== expectedText) {
return `Answer text for blank "${blank.blank_id}" must match the selected word.`
}
}
if (stimulus && !stimulus.allow_reuse) {
const usedWordIds = filled.map((blank) => blank.word_id)
const unique = new Set(usedWordIds)
if (unique.size !== usedWordIds.length) {
return "Each word can only be used once when reuse is disabled."
}
}
return null
}
export function findSelectMissingWordsStimulusInFieldValues(
fieldValues: Record<string, string>,
stimulusSchema: { id: string; kind: string }[],
): SelectMissingWordsStimulusValue | null {
for (const row of stimulusSchema) {
if (row.kind.trim().toUpperCase() !== "SELECT_MISSING_WORDS") continue
const parsed = parseSelectMissingWordsStimulusSlotValue(
fieldValues[`stimulus:${row.id}`],
)
if (selectMissingWordsStimulusHasContent(parsed)) return parsed
}
return null
}

View File

@ -1,61 +0,0 @@
import type {
SubscriptionPlan,
SubscriptionPlanCategory,
SubscriptionPlanDurationUnit,
} from "../types/subscription.types"
export const SUBSCRIPTION_PLAN_CATEGORIES: {
value: SubscriptionPlanCategory
label: string
}[] = [
{ value: "LEARN_ENGLISH", label: "Learn English" },
{ value: "EXAM_PREP", label: "Exam prep" },
{ value: "SKILLS", label: "Skills" },
]
export const SUBSCRIPTION_DURATION_UNITS: {
value: SubscriptionPlanDurationUnit
label: string
}[] = [
{ value: "DAY", label: "Day(s)" },
{ value: "WEEK", label: "Week(s)" },
{ value: "MONTH", label: "Month(s)" },
{ value: "YEAR", label: "Year(s)" },
]
export const SUBSCRIPTION_CURRENCIES = ["ETB", "USD"] as const
export function formatPlanDuration(plan: Pick<SubscriptionPlan, "duration_value" | "duration_unit">): string {
const v = plan.duration_value
const u = String(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 formatPlanPrice(plan: Pick<SubscriptionPlan, "price" | "currency">): string {
const amount = Number(plan.price)
const formatted = Number.isFinite(amount)
? amount.toLocaleString(undefined, { minimumFractionDigits: 0, maximumFractionDigits: 2 })
: String(plan.price)
return `${formatted} ${plan.currency}`
}
export function formatPlanCategory(category: string): string {
const match = SUBSCRIPTION_PLAN_CATEGORIES.find((c) => c.value === category)
if (match) return match.label
return category.replace(/_/g, " ").toLowerCase().replace(/\b\w/g, (c) => c.toUpperCase())
}
export function formatPlanCreatedAt(iso: string): string {
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return iso
return d.toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
})
}

View File

@ -1,6 +0,0 @@
/** Standard page-size choices for admin data tables. */
export const TABLE_PAGE_SIZE_OPTIONS = [5, 10, 30, 50, 100] as const
export type TablePageSize = (typeof TABLE_PAGE_SIZE_OPTIONS)[number]
export const DEFAULT_TABLE_PAGE_SIZE: TablePageSize = 10

View File

@ -1,44 +0,0 @@
import type { VerifyInvitationData } from "../types/teamInvitation.types"
export function formatTeamRoleLabel(role: string | undefined): string {
if (!role) return "—"
return role.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())
}
export function formatInvitationExpiry(raw: string | undefined): string | null {
if (!raw) return null
const d = new Date(raw)
if (Number.isNaN(d.getTime())) return raw
return d.toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" })
}
/** User-facing title when verify returns valid: false. */
export function getInvalidInvitationTitle(data: VerifyInvitationData | null): string {
const status = data?.status?.toLowerCase() ?? ""
const message = (data?.message ?? "").toLowerCase()
if (status === "expired" || message.includes("expir")) {
return "This invitation has expired"
}
if (
status === "accepted" ||
message.includes("already") ||
message.includes("used") ||
message.includes("accepted")
) {
return "This invitation was already used"
}
if (status === "revoked" || message.includes("revok")) {
return "This invitation was revoked"
}
return "This invitation link is invalid"
}
export function getInvalidInvitationDescription(
data: VerifyInvitationData | null,
apiMessage?: string,
): string {
const specific = data?.message?.trim() || apiMessage?.trim()
if (specific) return specific
return "The link may be expired, invalid, or already used. Ask your administrator to send a new invitation."
}

View File

@ -1,43 +0,0 @@
import type { Role } from "../types/rbac.types"
export const TEAM_ROLE_OPTIONS = [
{ value: "SUPER_ADMIN", label: "Super Admin" },
{ value: "ADMIN", label: "Admin" },
{ value: "CONTENT_MANAGER", label: "Content Manager" },
{ value: "SUPPORT_AGENT", label: "Support Agent" },
{ value: "INSTRUCTOR", label: "Instructor" },
{ value: "FINANCE", label: "Finance" },
{ value: "HR", label: "HR" },
{ value: "ANALYST", label: "Analyst" },
] as const
export const EMPLOYMENT_TYPE_OPTIONS = [
{ value: "full_time", label: "Full-time" },
{ value: "part_time", label: "Part-time" },
{ value: "contractor", label: "Contractor" },
{ value: "intern", label: "Intern" },
] as const
/** Map RBAC role display name to API team_role (e.g. CONTENT_MANAGER). */
export function rbacRoleNameToTeamRole(roleName: string): string {
const normalized = roleName.trim().toUpperCase().replace(/[\s-]+/g, "_")
const byValue = TEAM_ROLE_OPTIONS.find((o) => o.value === normalized)
if (byValue) return byValue.value
const byLabel = TEAM_ROLE_OPTIONS.find(
(o) => o.label.toUpperCase().replace(/[\s-]+/g, "_") === normalized,
)
if (byLabel) return byLabel.value
return normalized
}
export function teamRoleFromRbacRole(role: Role): string {
return rbacRoleNameToTeamRole(role.name)
}
export function formatTeamRoleLabel(teamRole: string): string {
const found = TEAM_ROLE_OPTIONS.find(
(o) => o.value === teamRole || o.value === teamRole.toUpperCase(),
)
if (found) return found.label
return teamRole.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())
}

View File

@ -1,64 +0,0 @@
export type ThemeMode = "light" | "dark" | "system"
export type ResolvedTheme = "light" | "dark"
export const THEME_STORAGE_KEY = "yimaru-admin-theme"
const MEDIA_QUERY = "(prefers-color-scheme: dark)"
export function getSystemTheme(): ResolvedTheme {
if (typeof window === "undefined") return "light"
return window.matchMedia(MEDIA_QUERY).matches ? "dark" : "light"
}
/** Resolved appearance: light mode always forces light; dark forces dark; system follows OS. */
export function resolveTheme(mode: ThemeMode): ResolvedTheme {
if (mode === "light") return "light"
if (mode === "dark") return "dark"
return getSystemTheme()
}
export function getStoredTheme(): ThemeMode {
if (typeof window === "undefined") return "light"
const value = localStorage.getItem(THEME_STORAGE_KEY)
if (value === "light" || value === "dark" || value === "system") return value
return "light"
}
function syncMetaThemeColor(resolved: ResolvedTheme) {
if (typeof document === "undefined") return
let meta = document.querySelector('meta[name="theme-color"]') as HTMLMetaElement | null
if (!meta) {
meta = document.createElement("meta")
meta.name = "theme-color"
document.head.appendChild(meta)
}
meta.content = resolved === "dark" ? "#12121a" : "#f5f5f5"
}
export function applyTheme(mode: ThemeMode): ResolvedTheme {
const resolved = resolveTheme(mode)
const root = document.documentElement
root.classList.remove("dark")
if (resolved === "dark") {
root.classList.add("dark")
}
root.dataset.theme = resolved
root.dataset.themePreference = mode
root.style.colorScheme = resolved
syncMetaThemeColor(resolved)
return resolved
}
export function watchSystemTheme(onChange: (resolved: ResolvedTheme) => void): () => void {
if (typeof window === "undefined") return () => undefined
const media = window.matchMedia(MEDIA_QUERY)
const handler = () => onChange(getSystemTheme())
media.addEventListener("change", handler)
return () => media.removeEventListener("change", handler)
}

View File

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

View File

@ -3,14 +3,11 @@ import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import './index.css'
import App from './App.tsx'
import { ThemeProvider } from './contexts/ThemeContext.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<ThemeProvider>
<BrowserRouter>
<App />
</BrowserRouter>
</ThemeProvider>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>,
)

View File

@ -1,7 +1,7 @@
import {
// Activity,
BadgeCheck,
Video,
BookOpen,
// Coins,
DollarSign,
HelpCircle,
@ -10,15 +10,15 @@ import {
TicketCheck,
// TrendingUp,
Users,
UserX,
Bell,
CreditCard,
UsersRound,
} from "lucide-react"
import spinnerSrc from "../assets/Circular-indeterminate progress indicator.svg"
import {
Area,
AreaChart,
Bar,
BarChart,
CartesianGrid,
Cell,
Pie,
@ -28,27 +28,15 @@ 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 { AnalyticsTimeRangeFilter } from "../components/analytics/AnalyticsTimeRangeFilter"
import {
getPrimaryQuestionTypeSummary,
getSeriesPeriodLabel,
getSubscriptionMetrics,
getVideoLessonsSummary,
} from "../lib/analytics"
import type { DashboardData, DashboardFilters } from "../types/analytics.types"
import { formatPlanDuration } from "../lib/subscriptionPlans"
import type { SubscriptionPlan } from "../types/subscription.types"
import type { DashboardData } from "../types/analytics.types"
import type { Rating } from "../types/course.types"
const PIE_COLORS = ["#9E2891", "#FFD23F", "#1DE9B6", "#C26FC0", "#6366F1", "#F97316", "#14B8A6", "#EF4444"]
@ -58,8 +46,6 @@ function formatDate(dateStr: string) {
return d.toLocaleDateString("en-US", { month: "short", day: "numeric" })
}
const DEFAULT_FILTERS: DashboardFilters = { mode: "all_time" }
export function DashboardPage() {
const [userFirstName, setUserFirstName] = useState<string>("")
const [dashboard, setDashboard] = useState<DashboardData | null>(null)
@ -67,9 +53,6 @@ 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 () => {
@ -87,10 +70,17 @@ export function DashboardPage() {
}
}
fetchUser()
}, [])
const fetchDashboard = async () => {
try {
const res = await getDashboard()
setDashboard(res.data as unknown as DashboardData)
} catch (err) {
console.error(err)
} finally {
setLoading(false)
}
}
useEffect(() => {
const fetchAppRatings = async () => {
try {
const res = await getRatings({ target_type: "app", target_id: 1, limit: 5 })
@ -102,49 +92,23 @@ export function DashboardPage() {
}
}
fetchUser()
fetchDashboard()
fetchAppRatings()
}, [])
useEffect(() => {
const fetchPlans = async () => {
setSubscriptionPlansLoading(true)
try {
const res = await getSubscriptionPlans()
setSubscriptionPlans(res.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,
@ -159,17 +123,9 @@ export function DashboardPage() {
color: PIE_COLORS[i % PIE_COLORS.length],
})) ?? []
const seriesPeriodLabel = dashboard ? getSeriesPeriodLabel(dashboard.date_filter) : "Last 30 Days"
const subscriptionMetrics = dashboard
? getSubscriptionMetrics(dashboard.subscriptions)
: null
return (
<div className="mx-auto w-full max-w-6xl">
<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-2 text-sm font-semibold text-grayScale-500">Dashboard</div>
<div className="mb-5 text-2xl font-semibold tracking-tight">
Welcome, {userFirstName || localStorage.getItem("user_first_name")}
</div>
@ -233,11 +189,11 @@ export function DashboardPage() {
deltaPositive={dashboard.users.new_month > 0}
/>
<StatCard
icon={CreditCard}
label="Payments"
value={dashboard.payments.total_payments.toLocaleString()}
deltaLabel={`${dashboard.payments.successful_payments} successful`}
deltaPositive={dashboard.payments.successful_payments > 0}
icon={BadgeCheck}
label="Active Subscribers"
value={dashboard.subscriptions.active_subscriptions.toLocaleString()}
deltaLabel={`+${dashboard.subscriptions.new_month} this month`}
deltaPositive={dashboard.subscriptions.new_month > 0}
/>
<StatCard
icon={DollarSign}
@ -257,49 +213,21 @@ export function DashboardPage() {
)}
{/* Secondary Stats */}
{activeStatTab === "secondary" && subscriptionMetrics && (
{activeStatTab === "secondary" && (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<StatCard
icon={CreditCard}
label="Total Subscriptions"
value={subscriptionMetrics.total.toLocaleString()}
deltaLabel={`+${dashboard.subscriptions.new_month} this month`}
deltaPositive={dashboard.subscriptions.new_month > 0}
/>
<StatCard
icon={BadgeCheck}
label="Active Subscriptions"
value={subscriptionMetrics.active.toLocaleString()}
deltaLabel={`+${dashboard.subscriptions.new_today} today · +${dashboard.subscriptions.new_week} this week`}
deltaPositive={subscriptionMetrics.active > 0}
/>
<StatCard
icon={UserX}
label="Inactive Subscriptions"
value={subscriptionMetrics.inactive.toLocaleString()}
deltaLabel={
dashboard.subscriptions.by_status.length > 0
? "From subscription status breakdown"
: "Total minus active"
}
deltaPositive={subscriptionMetrics.inactive === 0}
/>
<StatCard
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}
icon={BookOpen}
label="Courses"
value={dashboard.courses.total_courses.toLocaleString()}
deltaLabel={`${dashboard.courses.total_sub_courses} sub-modules, ${dashboard.courses.total_videos} videos`}
deltaPositive
/>
<StatCard
icon={HelpCircle}
label="Questions"
value={dashboard.content.total_questions.toLocaleString()}
deltaLabel={getPrimaryQuestionTypeSummary(dashboard.content.questions_by_type)}
deltaPositive={dashboard.content.total_questions > 0}
deltaLabel={`${dashboard.content.total_question_sets} question sets`}
deltaPositive
/>
<StatCard
icon={Bell}
@ -333,7 +261,7 @@ export function DashboardPage() {
</div>
</div>
<div className="rounded-full bg-grayScale-100 px-3 py-1 text-xs font-semibold text-grayScale-500">
{seriesPeriodLabel}
Last 30 Days
</div>
</div>
</CardHeader>
@ -429,69 +357,76 @@ export function DashboardPage() {
</CardContent>
</Card>
<RevenueTrendCard />
{/* 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>
</div>
{/* 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,
})}
{/* 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>
</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>
)}
</CardContent>
</Card>
) : (
<div className="flex items-center justify-center py-6 text-sm text-grayScale-400">
No data available
</div>
)}
</CardContent>
</Card>
))}
</div>
{/* App Ratings */}
<Card className="shadow-none">

View File

@ -1,8 +1,11 @@
import React, { useEffect, useState } from "react";
import {
Bell,
Eye,
EyeOff,
Globe,
KeyRound,
Languages,
Lock,
Moon,
Palette,
@ -11,7 +14,8 @@ import {
Sun,
User,
CreditCard,
Smartphone,
AlertTriangle,
X,
} from "lucide-react";
import {
Card,
@ -22,33 +26,228 @@ import {
import { Input } from "../components/ui/input";
import { Button } from "../components/ui/button";
import { Select } from "../components/ui/select";
import { Separator } from "../components/ui/separator";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "../components/ui/dialog";
import { cn } from "../lib/utils";
import { SpinnerIcon } from "../components/ui/spinner-icon";
import { changeTeamMemberPassword } from "../api/team.api";
import { logoutToLogin } from "../lib/auth";
import { getMyProfile, updateProfile } from "../api/users.api";
import type { UserProfileData } from "../types/user.types";
import { toast } from "sonner";
import { AppVersionsTab } from "./settings/AppVersionsTab";
import { SubscriptionPlansTab } from "./settings/SubscriptionPlansTab";
import { ThemeModePreview } from "./settings/components/ThemeModePreview";
import { useTheme } from "../contexts/ThemeContext";
type SettingsTab =
| "subscription"
| "app-versions"
| "profile"
| "security"
| "notifications"
| "appearance";
const tabs: { id: SettingsTab; label: string; icon: typeof User }[] = [
{ id: "subscription", label: "Subscription packages", icon: CreditCard },
{ id: "app-versions", label: "App versions", icon: Smartphone },
{ id: "subscription", label: "Subscription", icon: CreditCard },
{ id: "profile", label: "Profile", icon: User },
{ id: "security", label: "Security", icon: Shield },
{ id: "notifications", label: "Notifications", icon: Bell },
{ id: "appearance", label: "Appearance", icon: Palette },
];
function Toggle({
enabled,
onToggle,
}: {
enabled: boolean;
onToggle: () => void;
}) {
return (
<button
type="button"
role="switch"
aria-checked={enabled}
onClick={onToggle}
className={cn(
"relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full transition-colors focus-visible:outline-none",
enabled ? "bg-brand-500" : "bg-grayScale-200",
)}
>
<span
className={cn(
"pointer-events-none inline-block h-3.5 w-3.5 rounded-full bg-white shadow-sm transition-transform",
enabled ? "translate-x-5" : "translate-x-0.5",
)}
/>
</button>
);
}
function SettingRow({
icon: Icon,
title,
description,
children,
}: {
icon: any;
title: string;
description: string;
children: React.ReactNode;
}) {
return (
<div className="flex items-center justify-between gap-4 rounded-[6px] px-3 py-4 transition-colors hover:bg-grayScale-100/50">
<div className="flex items-start gap-3">
<div className="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-[6px] bg-grayScale-100 text-grayScale-400">
<Icon className="h-4 w-4" />
</div>
<div>
<p className="text-sm font-medium text-grayScale-800">{title}</p>
<p className="mt-0.5 text-xs text-grayScale-500">{description}</p>
</div>
</div>
<div className="shrink-0">{children}</div>
</div>
);
}
// --- Subscription Tab ---
function SubscriptionTab() {
const [subs, setSubs] = useState([
{
id: "auto_renew",
name: "Auto-renewal",
desc: "Automatically renew your subscription when it expires",
enabled: true,
},
{
id: "marketing_emails",
name: "Marketing Emails",
desc: "Receive updates about new features and promotions",
enabled: true,
},
{
id: "priority_support",
name: "Priority Support",
desc: "Access 24/7 priority customer support",
enabled: true,
},
]);
const [pendingToggle, setPendingToggle] = useState<string | null>(null);
const [showWarning, setShowWarning] = useState(false);
const handleToggle = (id: string) => {
const item = subs.find((s) => s.id === id);
if (item?.enabled) {
setPendingToggle(id);
setShowWarning(true);
} else {
setSubs((prev) =>
prev.map((s) => (s.id === id ? { ...s, enabled: true } : s)),
);
}
};
const confirmToggleOff = () => {
if (pendingToggle) {
setSubs((prev) =>
prev.map((s) =>
s.id === pendingToggle ? { ...s, enabled: false } : s,
),
);
setShowWarning(false);
setPendingToggle(null);
}
};
return (
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-2 duration-300">
<Card className="border border-grayScale-100 rounded-[6px] overflow-hidden">
<CardHeader className="pb-3 border-b border-grayScale-50">
<CardTitle className="text-sm font-bold text-grayScale-900">
Subscription Features
</CardTitle>
<p className="text-[11px] text-grayScale-500">
Customize your subscription experience and management preferences
</p>
</CardHeader>
<CardContent className="space-y-0 p-0">
{subs.map((sub, idx) => (
<React.Fragment key={sub.id}>
<div
className={cn(
"px-2",
idx < subs.length - 1 && "border-b border-grayScale-50",
)}
>
<SettingRow
icon={CreditCard}
title={sub.name}
description={sub.desc}
>
<Toggle
enabled={sub.enabled}
onToggle={() => handleToggle(sub.id)}
/>
</SettingRow>
</div>
</React.Fragment>
))}
</CardContent>
</Card>
<Dialog open={showWarning} onOpenChange={setShowWarning}>
<DialogContent className="max-w-md p-0 overflow-hidden border border-grayScale-100 rounded-[12px] shadow-2xl">
<div className="relative p-8">
<div className="flex items-start gap-5 mb-6">
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-full bg-red-50 text-red-500 border border-red-100">
<AlertTriangle className="h-7 w-7" />
</div>
<div className="pt-1">
<h3 className="text-xl font-bold text-grayScale-900 tracking-tight">
Are you absolutely sure?
</h3>
<p className="text-sm text-grayScale-500 mt-1">
Disabling this feature might limit your experience.
</p>
</div>
</div>
<div className="bg-grayScale-50/80 border border-grayScale-100 p-5 rounded-[8px] mb-8">
<p className="text-sm text-grayScale-600 leading-relaxed font-medium">
By turning this off, you will no longer receive the benefits
associated with this feature. Some changes might take up to 24
hours to reflect.
</p>
</div>
<div className="flex flex-col gap-3">
<Button
variant="destructive"
onClick={confirmToggleOff}
className="w-full rounded-[8px] py-6 text-sm font-bold bg-red-500 hover:bg-red-600 text-white border-none shadow-sm transition-all active:scale-[0.98]"
>
Yes, Disable Feature
</Button>
<Button
variant="outline"
onClick={() => setShowWarning(false)}
className="w-full rounded-[8px] py-6 text-sm font-bold border-grayScale-200 text-grayScale-600 hover:bg-grayScale-50 transition-all active:scale-[0.98]"
>
Cancel
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
);
}
// --- Other Tabs (Existing, but with sidebar layout updates) ---
function ProfileTab({ profile }: { profile: UserProfileData }) {
const [firstName, setFirstName] = useState(profile.first_name);
const [lastName, setLastName] = useState(profile.last_name);
@ -164,46 +363,17 @@ function ProfileTab({ profile }: { profile: UserProfileData }) {
);
}
function SecurityTab({ memberId }: { memberId: number }) {
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
function SecurityTab() {
const [showCurrent, setShowCurrent] = useState(false);
const [showNew, setShowNew] = useState(false);
const [showConfirm, setShowConfirm] = useState(false);
const [saving, setSaving] = useState(false);
const handleChangePassword = async () => {
if (!currentPassword.trim()) {
toast.error("Enter your current password.");
return;
}
if (newPassword.length < 8) {
toast.error("New password must be at least 8 characters.");
return;
}
if (newPassword !== confirmPassword) {
toast.error("New password and confirmation do not match.");
return;
}
if (currentPassword === newPassword) {
toast.error("New password must be different from your current password.");
return;
}
setSaving(true);
try {
await changeTeamMemberPassword(memberId, {
current_password: currentPassword,
new_password: newPassword,
});
logoutToLogin({ passwordChanged: true });
return;
} catch (e: unknown) {
const msg =
(e as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to update password.";
toast.error(msg);
await new Promise((r) => setTimeout(r, 600));
toast.success("Password updated successfully");
} finally {
setSaving(false);
}
@ -227,11 +397,7 @@ function SecurityTab({ memberId }: { memberId: number }) {
<Input
type={showCurrent ? "text" : "password"}
placeholder="Enter current password"
className="rounded-[6px] pr-10"
autoComplete="current-password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
disabled={saving}
className="rounded-[6px]"
/>
<button
type="button"
@ -255,11 +421,7 @@ function SecurityTab({ memberId }: { memberId: number }) {
<Input
type={showNew ? "text" : "password"}
placeholder="Enter new password"
className="rounded-[6px] pr-10"
autoComplete="new-password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
disabled={saving}
className="rounded-[6px]"
/>
<button
type="button"
@ -282,11 +444,7 @@ function SecurityTab({ memberId }: { memberId: number }) {
<Input
type={showConfirm ? "text" : "password"}
placeholder="Confirm new password"
className="rounded-[6px] pr-10"
autoComplete="new-password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
disabled={saving}
className="rounded-[6px]"
/>
<button
type="button"
@ -374,101 +532,52 @@ function NotificationsTab() {
}
function AppearanceTab() {
const { theme, setTheme, resolvedTheme, systemTheme } = useTheme();
const options = [
{
id: "light" as const,
label: "Light",
description: "Always bright UI",
icon: Sun,
preview: "light" as const,
},
{
id: "dark" as const,
label: "Dark",
description: "Always dark UI",
icon: Moon,
preview: "dark" as const,
},
{
id: "system" as const,
label: "System",
description: `Follows device (${systemTheme})`,
icon: Globe,
preview: "system" as const,
},
];
const [theme, setTheme] = useState<"light" | "dark" | "system">("light");
return (
<div className="space-y-4 animate-in fade-in slide-in-from-bottom-2 duration-300">
<Card className="overflow-hidden rounded-[6px] border border-grayScale-200">
<div className="h-1 w-full bg-brand-400" />
<CardHeader className="border-b border-grayScale-200 pb-3">
<CardTitle className="text-sm font-bold text-grayScale-600">Theme</CardTitle>
<p className="text-xs text-grayScale-400">
Active appearance:{" "}
<span className="font-semibold capitalize text-grayScale-600">{resolvedTheme}</span>
{theme === "system" ? " (from your device setting)" : null}
{theme === "light" ? " (fixed — not tied to device)" : null}
</p>
</CardHeader>
<CardContent className="pb-6 pt-4">
<div className="grid gap-3 sm:grid-cols-3">
{options.map(({ id, label, description, icon: Icon, preview }) => {
const selected = theme === id;
return (
<button
key={id}
type="button"
onClick={() => setTheme(id)}
className={cn(
"flex flex-col items-stretch gap-3 rounded-[8px] border-2 p-3 text-left transition-all",
selected
? "border-brand-500 bg-brand-500/10 shadow-sm ring-1 ring-brand-500/30"
: "border-grayScale-200 bg-grayScale-50 hover:border-grayScale-300 hover:bg-grayScale-100",
)}
>
<ThemeModePreview
variant={preview}
systemResolved={systemTheme}
/>
<div className="flex items-center gap-2">
<div
className={cn(
"flex h-9 w-9 shrink-0 items-center justify-center rounded-[6px]",
selected
? "bg-brand-500 text-white"
: "bg-grayScale-100 text-grayScale-500",
)}
>
<Icon className="h-4 w-4" />
</div>
<div className="min-w-0">
<p
className={cn(
"text-sm font-semibold",
selected ? "text-grayScale-600" : "text-grayScale-500",
)}
>
{label}
</p>
<p className="text-[11px] text-grayScale-400">{description}</p>
</div>
</div>
</button>
);
})}
</div>
<p className="mt-4 rounded-[6px] border border-dashed border-grayScale-200 bg-grayScale-100 px-3 py-2 text-[11px] leading-relaxed text-grayScale-500">
<strong className="font-semibold text-grayScale-600">Light vs System:</strong> Light
always stays bright. System copies your Windows/macOS theme if your device is in
light mode, System will match Light; switch your device to dark to see System use the
dark admin theme.
</p>
</CardContent>
</Card>
</div>
<Card className="border border-grayScale-100 rounded-[6px] overflow-hidden animate-in fade-in slide-in-from-bottom-2 duration-300">
<div className="h-1 w-full bg-brand-400" />
<CardHeader className="pb-3 border-b border-grayScale-50">
<CardTitle className="text-sm font-bold text-grayScale-900">
Theme
</CardTitle>
</CardHeader>
<CardContent className="pb-6">
<div className="grid gap-3 sm:grid-cols-3">
{(
[
{ id: "light", label: "Light", icon: Sun },
{ id: "dark", label: "Dark", icon: Moon },
{ id: "system", label: "System", icon: Globe },
] as const
).map(({ id, label, icon: Icon }) => (
<button
key={id}
type="button"
onClick={() => setTheme(id)}
className={cn(
"flex flex-col items-center gap-2.5 rounded-[6px] border-2 px-4 py-5 transition-all",
theme === id
? "border-brand-500 bg-brand-50 text-brand-600 shadow-sm"
: "border-grayScale-100 bg-white text-grayScale-400 hover:border-grayScale-200 hover:bg-grayScale-50",
)}
>
<div
className={cn(
"flex h-10 w-10 items-center justify-center rounded-[6px]",
theme === id
? "bg-brand-500 text-white"
: "bg-grayScale-100 text-grayScale-400",
)}
>
<Icon className="h-5 w-5" />
</div>
<span className="text-sm font-medium">{label}</span>
</button>
))}
</div>
</CardContent>
</Card>
);
}
@ -535,36 +644,10 @@ export function SettingsPage() {
</p>
</div>
<div className="flex min-w-0 flex-col gap-8 lg:flex-row lg:items-start">
<nav className="flex shrink-0 flex-row gap-1 overflow-x-auto rounded-[8px] border border-grayScale-100 bg-white p-1 lg:w-56 lg:flex-col">
{tabs.map((tab) => {
const Icon = tab.icon;
const active = activeTab === tab.id;
return (
<button
key={tab.id}
type="button"
onClick={() => setActiveTab(tab.id)}
className={cn(
"flex items-center gap-2.5 whitespace-nowrap rounded-[6px] px-3 py-2.5 text-left text-sm font-medium transition-colors",
active
? "bg-brand-50 text-brand-600"
: "text-grayScale-600 hover:bg-grayScale-50",
)}
>
<Icon className="h-4 w-4 shrink-0" />
{tab.label}
</button>
);
})}
</nav>
<main className="min-h-[400px] min-w-0 w-full flex-1">
{activeTab === "subscription" && <SubscriptionPlansTab />}
{activeTab === "app-versions" && <AppVersionsTab />}
{activeTab === "profile" && <ProfileTab profile={profile} />}
{activeTab === "security" && <SecurityTab memberId={profile.id} />}
{activeTab === "appearance" && <AppearanceTab />}
<div className="flex flex-col gap-8">
{/* Content Area */}
<main className="min-h-[400px]">
{activeTab === "subscription" && <SubscriptionTab />}
</main>
</div>
</div>

View File

@ -39,15 +39,7 @@ import { Badge } from "../../components/ui/badge"
import { Button } from "../../components/ui/button"
import { cn } from "../../lib/utils"
import { getDashboard } from "../../api/analytics.api"
import { AnalyticsTimeRangeFilter, getDashboardFilterLabel } from "../../components/analytics/AnalyticsTimeRangeFilter"
import {
getPrimaryQuestionTypeSummary,
getSeriesPeriodLabel,
formatAnalyticsLabel,
getSubscriptionMetrics,
getVideoLessonsSummary,
} from "../../lib/analytics"
import type { DashboardData, DashboardFilters, LabelCount } from "../../types/analytics.types"
import type { DashboardData, LabelCount } from "../../types/analytics.types"
const PIE_COLORS = ["#9E2891", "#FFD23F", "#1DE9B6", "#C26FC0", "#6366F1", "#F97316", "#14B8A6", "#EF4444", "#8B5CF6", "#EC4899", "#06B6D4", "#84CC16"]
@ -117,43 +109,31 @@ function BreakdownList({
title,
data,
total,
scrollable,
}: {
title: string
data: LabelCount[]
total?: number
/** Enable vertical scroll for long breakdowns (e.g. occupation). */
scrollable?: boolean
}) {
const computedTotal = total ?? data.reduce((s, d) => s + d.count, 0)
const sorted = [...data].sort((a, b) => b.count - a.count)
return (
<Card className="shadow-none">
<CardHeader className="pb-2">
<CardTitle className="text-sm">{title}</CardTitle>
</CardHeader>
<CardContent className="p-4 pt-0">
{sorted.length > 0 ? (
<div
className={cn(
"space-y-2.5",
scrollable && "max-h-64 overflow-y-auto overscroll-contain pr-1",
)}
>
{sorted.map((item, i) => {
{data.length > 0 ? (
<div className="space-y-2.5">
{data.map((item, i) => {
const pct = computedTotal > 0 ? (item.count / computedTotal) * 100 : 0
const displayLabel = formatAnalyticsLabel(item.label)
return (
<div key={`${item.label}-${i}`}>
<div className="mb-1 flex items-center justify-between gap-2 text-xs">
<div className="flex min-w-0 items-center gap-2">
<div key={item.label}>
<div className="mb-1 flex items-center justify-between text-xs">
<div className="flex items-center gap-2">
<span
className="h-2 w-2 shrink-0 rounded-full"
className="h-2 w-2 rounded-full"
style={{ backgroundColor: PIE_COLORS[i % PIE_COLORS.length] }}
/>
<span className="truncate text-grayScale-600" title={displayLabel}>
{displayLabel}
</span>
<span className="text-grayScale-600">{item.label}</span>
</div>
<span className="font-semibold text-grayScale-700">
{item.count.toLocaleString()}
@ -305,21 +285,18 @@ 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 (nextFilters: DashboardFilters = filters) => {
const fetchData = async () => {
setLoading(true)
setError(false)
try {
const res = await getDashboard(nextFilters)
setDashboard(res.data)
const res = await getDashboard()
setDashboard(res.data as unknown as DashboardData)
} catch {
setError(true)
} finally {
@ -328,11 +305,10 @@ export function AnalyticsPage() {
}
useEffect(() => {
fetchData(filters)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filters])
fetchData()
}, [])
if (!dashboard && loading) {
if (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>
@ -347,14 +323,11 @@ 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 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="mb-6 text-xs font-semibold uppercase tracking-wide text-grayScale-400">Analytics</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(filters)}>
<Button variant="outline" size="sm" onClick={fetchData}>
<RefreshCw className="mr-2 h-4 w-4" />
Retry
</Button>
@ -364,10 +337,6 @@ export function AnalyticsPage() {
}
const { users, subscriptions, payments, courses, content, notifications, issues, team } = dashboard
const subscriptionMetrics = getSubscriptionMetrics(subscriptions)
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),
@ -418,25 +387,15 @@ 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 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")} />
<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" />
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">
@ -493,10 +452,10 @@ export function AnalyticsPage() {
trend={users.new_month > 0 ? "up" : "neutral"}
/>
<KpiCard
icon={CreditCard}
label="Total Subscriptions"
value={formatNumber(subscriptionMetrics.total)}
sub={`${subscriptionMetrics.active} active · ${subscriptionMetrics.inactive} inactive`}
icon={BadgeCheck}
label="Active Subscriptions"
value={formatNumber(subscriptions.active_subscriptions)}
sub={`${subscriptions.total_subscriptions} total · +${subscriptions.new_month} this month`}
trend={subscriptions.new_month > 0 ? "up" : "neutral"}
/>
<KpiCard
@ -524,7 +483,7 @@ export function AnalyticsPage() {
<Section
title="Content & Platform"
icon={BookOpen}
count={courses.total_videos + content.total_questions}
count={courses.total_courses + content.total_questions}
defaultOpen
>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
@ -532,29 +491,28 @@ export function AnalyticsPage() {
icon={FolderOpen}
label="Categories"
value={courses.total_categories.toLocaleString()}
sub={`${courses.total_courses} courses · ${courses.total_sub_courses} modules`}
sub={`${courses.total_courses} courses`}
trend="neutral"
/>
<KpiCard
icon={BookOpen}
label="LMS Programs"
value={(lms?.programs ?? 0).toLocaleString()}
sub={`${lms?.courses ?? 0} courses · ${lms?.practices ?? 0} practices`}
label="Sub-Courses"
value={courses.total_sub_courses.toLocaleString()}
sub={`across ${courses.total_courses} courses`}
trend="neutral"
/>
<KpiCard
icon={Video}
label="Videos"
value={courses.total_videos.toLocaleString()}
sub={getVideoLessonsSummary(lms?.lessons_with_video, examPrep?.lessons_with_video)}
trend={courses.total_videos > 0 ? "up" : "neutral"}
trend="neutral"
/>
<KpiCard
icon={HelpCircle}
label="Questions"
value={content.total_questions.toLocaleString()}
sub={getPrimaryQuestionTypeSummary(content.questions_by_type)}
trend={content.total_questions > 0 ? "up" : "neutral"}
sub={`${content.total_question_sets} question sets`}
trend="neutral"
/>
</div>
</Section>
@ -615,7 +573,7 @@ export function AnalyticsPage() {
</Badge>
</div>
</div>
<Badge variant="secondary">{seriesPeriodLabel}</Badge>
<Badge variant="secondary">Last 30 Days</Badge>
</div>
</CardHeader>
<CardContent className="h-[280px] p-6 pt-2">
@ -643,77 +601,12 @@ export function AnalyticsPage() {
</ResponsiveContainer>
</CardContent>
</Card>
<div className="mt-4 space-y-6">
<div>
<p className="mb-3 text-xs font-semibold uppercase tracking-wide text-grayScale-400">
Profile & demographics
</p>
<div className="grid items-start gap-4 sm:grid-cols-2 lg:grid-cols-3">
<BreakdownList
title="Education level"
data={users.by_education_level ?? []}
total={users.total_users}
/>
<BreakdownList
title="Occupation"
data={users.by_occupation ?? []}
total={users.total_users}
scrollable
/>
<BreakdownList
title="Age group"
data={users.by_age_group ?? []}
total={users.total_users}
/>
<BreakdownList
title="Country"
data={users.by_country ?? []}
total={users.total_users}
/>
<BreakdownList
title="Region"
data={users.by_region ?? []}
total={users.total_users}
scrollable
/>
<BreakdownList
title="Account role"
data={users.by_role ?? []}
total={users.total_users}
/>
<BreakdownList
title="Account status"
data={users.by_status ?? []}
total={users.total_users}
/>
{(users.by_knowledge_level?.length ?? 0) > 0 ? (
<BreakdownList
title="Knowledge level"
data={users.by_knowledge_level ?? []}
total={users.total_users}
/>
) : null}
</div>
</div>
<div>
<p className="mb-3 text-xs font-semibold uppercase tracking-wide text-grayScale-400">
Learning goals & challenges
</p>
<div className="grid items-start gap-4 sm:grid-cols-2 lg:grid-cols-3">
<BreakdownList
title="Learning goal"
data={users.by_learning_goal ?? []}
total={users.total_users}
scrollable
/>
<BreakdownList
title="Language challenge"
data={users.by_language_challange ?? []}
total={users.total_users}
scrollable
/>
</div>
</div>
<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 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>
@ -732,7 +625,7 @@ export function AnalyticsPage() {
+{subscriptions.new_today} today · +{subscriptions.new_week} this week
</div>
</div>
<Badge variant="secondary">{seriesPeriodLabel}</Badge>
<Badge variant="secondary">Last 30 Days</Badge>
</div>
</CardHeader>
<CardContent className="h-[240px] p-6 pt-2">
@ -771,7 +664,7 @@ export function AnalyticsPage() {
</div>
<div className="text-xs text-grayScale-400">Daily revenue over last 30 days</div>
</div>
<Badge variant="secondary">{seriesPeriodLabel}</Badge>
<Badge variant="secondary">Last 30 Days</Badge>
</div>
</CardHeader>
<CardContent className="h-[240px] p-6 pt-2">
@ -835,43 +728,6 @@ export function AnalyticsPage() {
</div>
</Section>
{/* ─── Course Management ─── */}
{(lms || examPrep) && (
<Section title="Course Management" icon={BookOpen} count={courses.total_videos} defaultOpen={false}>
<div className="grid items-start gap-4 lg:grid-cols-2">
{lms && (
<BreakdownList
title="LMS"
data={[
{ label: "Programs", count: lms.programs },
{ label: "Courses", count: lms.courses },
{ label: "Modules", count: lms.modules },
{ label: "Lessons", count: lms.lessons },
{ label: "Lessons with video", count: lms.lessons_with_video },
{ label: "Practices", count: lms.practices },
{ label: "Practices at course", count: lms.practices_at_course },
{ label: "Practices at module", count: lms.practices_at_module },
{ label: "Practices at lesson", count: lms.practices_at_lesson },
]}
/>
)}
{examPrep && (
<BreakdownList
title="Exam prep"
data={[
{ label: "Catalog courses", count: examPrep.catalog_courses },
{ label: "Units", count: examPrep.units },
{ label: "Unit modules", count: examPrep.unit_modules },
{ label: "Lessons", count: examPrep.lessons },
{ label: "Lessons with video", count: examPrep.lessons_with_video },
{ label: "Lesson practices", count: examPrep.lesson_practices },
]}
/>
)}
</div>
</Section>
)}
{/* ─── Content Breakdown ─── */}
<Section title="Content Breakdown" icon={HelpCircle} count={content.total_questions} defaultOpen={false}>
<div className="grid items-start gap-4 sm:grid-cols-2">

View File

@ -1,467 +0,0 @@
import { useCallback, useEffect, useState } from "react"
import { Link, Navigate, useNavigate, useSearchParams } from "react-router-dom"
import {
AlertCircle,
Briefcase,
Building2,
Eye,
EyeOff,
Mail,
Phone,
Shield,
User,
} from "lucide-react"
import { toast } from "sonner"
import {
acceptTeamInvitation,
parseVerifyInvitation,
verifyTeamInvitation,
} from "../../api/team.api"
import { BrandLogo } from "../../components/brand/BrandLogo"
import { Button } from "../../components/ui/button"
import { Input } from "../../components/ui/input"
import { SpinnerIcon } from "../../components/ui/spinner-icon"
import { cn } from "../../lib/utils"
import {
formatInvitationExpiry,
formatTeamRoleLabel,
getInvalidInvitationDescription,
getInvalidInvitationTitle,
} from "../../lib/teamInvitation"
import type { VerifyInvitationData } from "../../types/teamInvitation.types"
export function AcceptInvitePage() {
const navigate = useNavigate()
const [searchParams] = useSearchParams()
const token = searchParams.get("token")?.trim() ?? ""
const [verifyState, setVerifyState] = useState<
"loading" | "invalid" | "ready" | "success"
>("loading")
const [inviteInfo, setInviteInfo] = useState<VerifyInvitationData | null>(null)
const [invalidTitle, setInvalidTitle] = useState("")
const [invalidDescription, setInvalidDescription] = useState("")
const [firstName, setFirstName] = useState("")
const [lastName, setLastName] = useState("")
const [phoneNumber, setPhoneNumber] = useState("")
const [department, setDepartment] = useState("")
const [jobTitle, setJobTitle] = useState("")
const [password, setPassword] = useState("")
const [confirmPassword, setConfirmPassword] = useState("")
const [showPassword, setShowPassword] = useState(false)
const [submitting, setSubmitting] = useState(false)
const loadVerification = useCallback(async () => {
if (!token) {
setInviteInfo(null)
setInvalidTitle("This invitation link is invalid")
setInvalidDescription("Invitation link is missing a token.")
setVerifyState("invalid")
return
}
setVerifyState("loading")
setInviteInfo(null)
try {
const res = await verifyTeamInvitation(token)
const data = parseVerifyInvitation(res)
if (!data || data.valid !== true) {
setInviteInfo(data)
setInvalidTitle(getInvalidInvitationTitle(data))
setInvalidDescription(
getInvalidInvitationDescription(data, res.data?.message),
)
setVerifyState("invalid")
return
}
setInviteInfo(data)
setFirstName(data.first_name?.trim() ?? "")
setLastName(data.last_name?.trim() ?? "")
setVerifyState("ready")
} catch (e: unknown) {
setInviteInfo(null)
setInvalidTitle("This invitation link is invalid")
setInvalidDescription(
(e as { response?: { data?: { message?: string } } })?.response?.data
?.message ??
"The link may be expired, invalid, or already used. Ask your administrator to send a new invitation.",
)
setVerifyState("invalid")
}
}, [token])
useEffect(() => {
void loadVerification()
}, [loadVerification])
const handleAccept = async (e: React.FormEvent) => {
e.preventDefault()
if (!token) return
if (!firstName.trim() || !lastName.trim()) {
toast.error("First name and last name are required")
return
}
if (password.length < 8) {
toast.error("Password must be at least 8 characters")
return
}
if (password !== confirmPassword) {
toast.error("Passwords do not match")
return
}
setSubmitting(true)
try {
const res = await acceptTeamInvitation({
token,
password,
first_name: firstName.trim(),
last_name: lastName.trim(),
phone_number: phoneNumber.trim(),
department: department.trim(),
job_title: jobTitle.trim(),
})
setVerifyState("success")
toast.success(res.data?.message ?? "Account setup complete. You can sign in now.")
navigate("/login", { replace: true })
} catch (e: unknown) {
const msg =
(e as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to complete setup"
toast.error(msg)
} finally {
setSubmitting(false)
}
}
const expiryLabel = formatInvitationExpiry(inviteInfo?.expires_at)
const setupTitle = inviteInfo?.needs_profile_setup
? "Complete your account setup"
: "Set your password"
if (localStorage.getItem("access_token")) {
return <Navigate to="/dashboard" replace />
}
return (
<div className="relative flex min-h-screen overflow-hidden">
<div className="relative hidden items-center justify-center bg-gradient-to-br from-brand-600 via-brand-500 to-brand-400 lg:flex lg:w-1/2 xl:w-[55%]">
<div className="absolute inset-0 overflow-hidden">
<div className="absolute -left-20 -top-20 h-96 w-96 rounded-full bg-white/5" />
<div className="absolute -bottom-32 -right-16 h-[500px] w-[500px] rounded-full bg-white/5" />
</div>
<div className="relative z-10 max-w-md px-12 text-center">
<BrandLogo variant="light" className="mx-auto mb-8 h-16" />
<p className="text-base leading-relaxed text-white/70">
You have been invited to join the Yimaru admin panel. Verify your invitation,
then complete setup to activate your account.
</p>
</div>
</div>
<div className="flex h-screen min-h-0 w-full flex-col overflow-hidden bg-white px-6 py-6 lg:w-1/2 lg:py-8 xl:w-[45%]">
<div className="mx-auto flex h-full min-h-0 w-full max-w-[440px] flex-col">
<div className="shrink-0">
<div className="mb-6 flex justify-center lg:hidden">
<BrandLogo />
</div>
<div className="mb-4 lg:mb-6">
<p className="mb-1.5 text-sm font-medium uppercase tracking-widest text-brand-400">
Team invitation
</p>
<h1 className="mb-2 text-2xl font-bold tracking-tight text-grayScale-600 sm:text-3xl">
{verifyState === "success"
? "You're all set"
: verifyState === "invalid"
? invalidTitle
: "Accept invitation"}
</h1>
<p className="text-sm leading-relaxed text-grayScale-400">
{verifyState === "success"
? "Redirecting you to sign in…"
: verifyState === "invalid"
? invalidDescription
: verifyState === "ready"
? setupTitle
: "Verifying your invitation link…"}
</p>
</div>
</div>
<div
className={cn(
"min-h-0 flex-1",
verifyState === "ready" ? "overflow-y-auto overscroll-contain" : "flex flex-col justify-center",
)}
>
{verifyState === "loading" && (
<div className="flex flex-col items-center gap-3 py-16">
<SpinnerIcon className="h-8 w-8" />
<p className="text-sm text-grayScale-400">Verifying invitation</p>
</div>
)}
{verifyState === "invalid" && (
<div className="space-y-4 rounded-xl border border-red-200 bg-red-50 px-4 py-4">
<div className="flex gap-3">
<AlertCircle className="mt-0.5 h-5 w-5 shrink-0 text-red-600" />
<div className="space-y-2 text-sm text-red-800">
<p className="font-semibold">{invalidTitle}</p>
<p>{invalidDescription}</p>
<p className="text-xs text-red-700/90">
Common reasons: expired, invalid, or already used.
</p>
</div>
</div>
{inviteInfo?.email ? (
<p className="border-t border-red-200/80 pt-3 text-xs text-red-700/80">
Invitation email: <span className="font-medium">{inviteInfo.email}</span>
</p>
) : null}
<Button
variant="outline"
size="sm"
className="border-red-200 bg-white"
onClick={() => void loadVerification()}
>
Try again
</Button>
</div>
)}
{verifyState === "ready" && inviteInfo && (
<form
onSubmit={(e) => void handleAccept(e)}
className="space-y-5 pr-1 pb-4"
>
<div>
<label
htmlFor="invite-email"
className="mb-1.5 flex items-center gap-1.5 text-sm font-medium text-grayScale-600"
>
<Mail className="h-3.5 w-3.5" />
Email
</label>
<Input
id="invite-email"
type="email"
readOnly
value={inviteInfo.email ?? ""}
className="cursor-not-allowed bg-grayScale-50 text-grayScale-700"
/>
</div>
<div>
<label
htmlFor="invite-role"
className="mb-1.5 flex items-center gap-1.5 text-sm font-medium text-grayScale-600"
>
<Shield className="h-3.5 w-3.5" />
Role
</label>
<Input
id="invite-role"
readOnly
value={formatTeamRoleLabel(inviteInfo.team_role)}
className="cursor-not-allowed bg-grayScale-50 text-grayScale-700"
/>
</div>
{expiryLabel ? (
<p className="text-xs text-grayScale-400">
Invitation expires {expiryLabel}
{inviteInfo.status ? ` · Status: ${inviteInfo.status}` : null}
</p>
) : null}
<div className="border-t border-grayScale-100 pt-4">
<p className="mb-3 text-xs font-semibold uppercase tracking-wide text-grayScale-400">
Your details
</p>
<div className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label
htmlFor="first-name"
className="mb-1.5 flex items-center gap-1.5 text-sm font-medium text-grayScale-600"
>
<User className="h-3.5 w-3.5" />
First name
</label>
<Input
id="first-name"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
placeholder="John"
autoComplete="given-name"
disabled={submitting}
required
/>
</div>
<div>
<label
htmlFor="last-name"
className="mb-1.5 flex items-center gap-1.5 text-sm font-medium text-grayScale-600"
>
<User className="h-3.5 w-3.5" />
Last name
</label>
<Input
id="last-name"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
placeholder="Doe"
autoComplete="family-name"
disabled={submitting}
required
/>
</div>
</div>
<div>
<label
htmlFor="phone"
className="mb-1.5 flex items-center gap-1.5 text-sm font-medium text-grayScale-600"
>
<Phone className="h-3.5 w-3.5" />
Phone number
<span className="font-normal text-grayScale-400">(optional)</span>
</label>
<Input
id="phone"
type="tel"
value={phoneNumber}
onChange={(e) => setPhoneNumber(e.target.value)}
placeholder="+251..."
autoComplete="tel"
disabled={submitting}
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label
htmlFor="department"
className="mb-1.5 flex items-center gap-1.5 text-sm font-medium text-grayScale-600"
>
<Building2 className="h-3.5 w-3.5" />
Department
<span className="font-normal text-grayScale-400">(optional)</span>
</label>
<Input
id="department"
value={department}
onChange={(e) => setDepartment(e.target.value)}
placeholder="e.g. LMS"
disabled={submitting}
/>
</div>
<div>
<label
htmlFor="job-title"
className="mb-1.5 flex items-center gap-1.5 text-sm font-medium text-grayScale-600"
>
<Briefcase className="h-3.5 w-3.5" />
Job title
<span className="font-normal text-grayScale-400">(optional)</span>
</label>
<Input
id="job-title"
value={jobTitle}
onChange={(e) => setJobTitle(e.target.value)}
placeholder="e.g. Content Lead"
disabled={submitting}
/>
</div>
</div>
</div>
</div>
<div className="border-t border-grayScale-100 pt-4">
<p className="mb-3 text-xs font-semibold uppercase tracking-wide text-grayScale-400">
Account password
</p>
<div className="space-y-4">
<div>
<label
htmlFor="password"
className="mb-1.5 block text-sm font-medium text-grayScale-600"
>
Password
</label>
<div className="relative">
<Input
id="password"
type={showPassword ? "text" : "password"}
autoComplete="new-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="At least 8 characters"
className="pr-10"
disabled={submitting}
/>
<button
type="button"
className="absolute right-3 top-1/2 -translate-y-1/2 text-grayScale-400"
onClick={() => setShowPassword((v) => !v)}
tabIndex={-1}
>
{showPassword ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</button>
</div>
</div>
<div>
<label
htmlFor="confirmPassword"
className="mb-1.5 block text-sm font-medium text-grayScale-600"
>
Confirm password
</label>
<Input
id="confirmPassword"
type={showPassword ? "text" : "password"}
autoComplete="new-password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
disabled={submitting}
/>
</div>
</div>
</div>
<Button
type="submit"
className="h-11 w-full bg-brand-500 text-white hover:bg-brand-600"
disabled={submitting}
>
{submitting ? "Completing setup…" : "Complete account setup"}
</Button>
</form>
)}
{verifyState === "success" && (
<div className="flex flex-col items-center gap-3 py-8 text-center">
<SpinnerIcon className="h-6 w-6" />
<p className="text-sm text-grayScale-500">Taking you to sign in</p>
</div>
)}
</div>
<p className="shrink-0 border-t border-grayScale-100 pt-4 text-center text-sm text-grayScale-400">
Already have an account?{" "}
<Link to="/login" className="font-semibold text-brand-500 hover:text-brand-600">
Sign in
</Link>
</p>
</div>
</div>
</div>
)
}

View File

@ -1,5 +1,5 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { Link, Navigate, useNavigate, useSearchParams } from "react-router-dom";
import { Link, Navigate, useNavigate } from "react-router-dom";
import { Eye, EyeOff } from "lucide-react";
import { BrandLogo } from "../../components/brand/BrandLogo";
@ -65,18 +65,9 @@ function GoogleIcon({ className }: { className?: string }) {
export function LoginPage() {
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const token = localStorage.getItem("access_token");
useEffect(() => {
if (searchParams.get("password_changed") !== "1") return;
toast.success("Password updated", {
description: "Sign in with your new password.",
});
setSearchParams({}, { replace: true });
}, [searchParams, setSearchParams]);
const [showPassword, setShowPassword] = useState(false);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");

View File

@ -5,8 +5,6 @@ import { toast } from "sonner"
import { addQuestionToSet, createLesson, createQuestion } from "../../api/courses.api"
import { uploadVideoFile } from "../../api/files.api"
import { PracticeQuestionEditorFields } from "../../components/content-management/PracticeQuestionEditorFields"
import type { PracticeQuestionDynamicRow } from "../../components/content-management/PracticeQuestionEditorFields"
import { buildDynamicQuestionPayload } from "../../lib/practiceDynamicQuestionPayload"
import { Button } from "../../components/ui/button"
import { Card } from "../../components/ui/card"
import { Input } from "../../components/ui/input"
@ -14,7 +12,7 @@ import { SpinnerIcon } from "../../components/ui/spinner-icon"
import type { QuestionOption } from "../../types/course.types"
type Step = 1 | 2 | 3 | 4
type QuestionType = "MCQ" | "TRUE_FALSE" | "SHORT" | "AUDIO" | "DYNAMIC"
type QuestionType = "MCQ" | "TRUE_FALSE" | "SHORT" | "AUDIO"
type DifficultyLevel = "EASY" | "MEDIUM" | "HARD"
type ResultStatus = "success" | "error"
@ -37,10 +35,6 @@ interface Question {
audioCorrectAnswerText: string
shortAnswers: string[]
imageUrl: string
questionTypeDefinitionId: number | null
dynamicStimulusRows: PracticeQuestionDynamicRow[]
dynamicResponseRows: PracticeQuestionDynamicRow[]
dynamicFieldValues: Record<string, string>
}
const STEPS = [
@ -69,10 +63,6 @@ function createEmptyQuestion(id: string): Question {
audioCorrectAnswerText: "",
shortAnswers: [],
imageUrl: "",
questionTypeDefinitionId: null,
dynamicStimulusRows: [],
dynamicResponseRows: [],
dynamicFieldValues: {},
}
}
@ -114,7 +104,6 @@ function questionTypeLabel(type: QuestionType): string {
if (type === "TRUE_FALSE") return "True/False"
if (type === "SHORT") return "Short Answer"
if (type === "AUDIO") return "Audio"
if (type === "DYNAMIC") return "Dynamic"
return "Multiple Choice"
}
@ -235,39 +224,6 @@ export function AddNewLessonPage() {
for (let i = 0; i < questions.length; i++) {
const q = questions[i]
if (!q.questionText.trim()) continue
if (q.questionType === "DYNAMIC") {
if (q.questionTypeDefinitionId == null || q.questionTypeDefinitionId <= 0) {
toast.error(`Question ${i + 1}: select a question type definition for dynamic questions.`)
setSaving(false)
return
}
const missingStimulus = q.dynamicStimulusRows.find(
(row) =>
row.required &&
!(q.dynamicFieldValues[`stimulus:${row.id}`]?.trim()),
)
if (missingStimulus) {
toast.error(
`Question ${i + 1}: fill required stimulus "${missingStimulus.label || missingStimulus.id}".`,
)
setSaving(false)
return
}
const missingResponse = q.dynamicResponseRows.find(
(row) =>
row.required &&
!(q.dynamicFieldValues[`response:${row.id}`]?.trim()),
)
if (missingResponse) {
toast.error(
`Question ${i + 1}: fill required response "${missingResponse.label || missingResponse.id}".`,
)
setSaving(false)
return
}
}
const options: QuestionOption[] =
q.questionType === "MCQ"
? q.options.map((opt, idx) => ({
@ -277,15 +233,6 @@ export function AddNewLessonPage() {
}))
: []
const dynamicPayload =
q.questionType === "DYNAMIC" && q.questionTypeDefinitionId != null
? buildDynamicQuestionPayload({
stimulusRows: q.dynamicStimulusRows,
responseRows: q.dynamicResponseRows,
fieldValues: q.dynamicFieldValues,
})
: undefined
const qRes = await createQuestion({
question_text: q.questionText,
question_type: q.questionType,
@ -293,22 +240,13 @@ export function AddNewLessonPage() {
points: q.points,
tips: q.tips || undefined,
explanation: q.explanation || undefined,
status,
status: "PUBLISHED",
options: options.length > 0 ? options : undefined,
voice_prompt: q.questionType === "DYNAMIC" ? undefined : q.voicePrompt || undefined,
sample_answer_voice_prompt:
q.questionType === "DYNAMIC" ? undefined : q.sampleAnswerVoicePrompt || undefined,
audio_correct_answer_text:
q.questionType === "DYNAMIC" ? undefined : q.audioCorrectAnswerText || undefined,
image_url: q.questionType === "DYNAMIC" ? undefined : q.imageUrl.trim() || undefined,
short_answers:
q.questionType !== "DYNAMIC" && q.shortAnswers.length > 0 ? q.shortAnswers : undefined,
...(q.questionType === "DYNAMIC" && q.questionTypeDefinitionId != null && dynamicPayload
? {
question_type_definition_id: q.questionTypeDefinitionId,
dynamic_payload: dynamicPayload,
}
: {}),
voice_prompt: q.voicePrompt || undefined,
sample_answer_voice_prompt: q.sampleAnswerVoicePrompt || undefined,
audio_correct_answer_text: q.audioCorrectAnswerText || undefined,
image_url: q.imageUrl.trim() || undefined,
short_answers: q.shortAnswers.length > 0 ? q.shortAnswers : undefined,
})
const questionId = qRes.data?.data?.id
if (questionId) {
@ -519,10 +457,6 @@ export function AddNewLessonPage() {
audioCorrectAnswerText: question.audioCorrectAnswerText,
shortAnswer: question.shortAnswers[0] ?? "",
imageUrl: question.imageUrl,
questionTypeDefinitionId: question.questionTypeDefinitionId,
dynamicStimulusRows: question.dynamicStimulusRows,
dynamicResponseRows: question.dynamicResponseRows,
dynamicFieldValues: question.dynamicFieldValues,
}}
onChange={(next) =>
updateQuestion(question.id, {
@ -538,10 +472,6 @@ export function AddNewLessonPage() {
audioCorrectAnswerText: next.audioCorrectAnswerText,
shortAnswers: next.shortAnswer.trim() ? [next.shortAnswer.trim()] : [],
imageUrl: next.imageUrl,
questionTypeDefinitionId: next.questionTypeDefinitionId,
dynamicStimulusRows: next.dynamicStimulusRows,
dynamicResponseRows: next.dynamicResponseRows,
dynamicFieldValues: next.dynamicFieldValues,
})
}
mediaBusy={saving}

View File

@ -9,6 +9,8 @@ import {
Plus,
Trash2,
GripVertical,
Edit,
Rocket,
Loader2,
Upload,
} from "lucide-react";
@ -17,24 +19,26 @@ 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,
addQuestionToSet,
} from "../../api/courses.api";
import { uploadVideoFile } from "../../api/files.api";
import { Select } from "../../components/ui/select";
import type { QuestionOption } from "../../types/course.types";
import type { PracticeQuestionDynamicRow } from "../../components/content-management/PracticeQuestionEditorFields";
import { buildDynamicQuestionPayload } from "../../lib/practiceDynamicQuestionPayload";
type Step = 1 | 2 | 3 | 4 | 5;
type ResultStatus = "success" | "error";
type QuestionType = "MCQ" | "TRUE_FALSE" | "SHORT" | "AUDIO" | "DYNAMIC";
type QuestionType = "MCQ" | "TRUE_FALSE" | "SHORT" | "AUDIO";
type DifficultyLevel = "EASY" | "MEDIUM" | "HARD";
interface Persona {
id: string;
name: string;
avatar: string;
}
interface MCQOption {
text: string;
isCorrect: boolean;
@ -54,12 +58,51 @@ interface Question {
audioCorrectAnswerText: string;
shortAnswers: string[];
imageUrl: string;
questionTypeDefinitionId: number | null;
dynamicStimulusRows: PracticeQuestionDynamicRow[];
dynamicResponseRows: PracticeQuestionDynamicRow[];
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" },
@ -110,6 +153,64 @@ function isDirectVideoFile(url: string): boolean {
return /\.(mp4|webm|ogg|mov|m4v)$/.test(clean);
}
function escapeHtml(raw: string): string {
return raw
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
function sanitizeAdminRichTextHtml(input: string): string {
if (!input.trim()) return "";
try {
const parser = new DOMParser();
const doc = parser.parseFromString(input, "text/html");
const blockedTags = new Set([
"script",
"style",
"iframe",
"object",
"embed",
"link",
"meta",
]);
doc.body.querySelectorAll("*").forEach((el) => {
const tagName = el.tagName.toLowerCase();
if (blockedTags.has(tagName)) {
el.remove();
return;
}
const attrs = [...el.attributes];
attrs.forEach((attr) => {
const name = attr.name.toLowerCase();
const value = attr.value.trim().toLowerCase();
if (name.startsWith("on")) {
el.removeAttribute(attr.name);
return;
}
if (
(name === "href" || name === "src") &&
value.startsWith("javascript:")
) {
el.removeAttribute(attr.name);
}
});
});
return doc.body.innerHTML;
} catch {
return escapeHtml(input).replace(/\r?\n/g, "<br />");
}
}
function formatDescriptionForPreview(raw: string): string {
if (!raw.trim()) return "";
const hasHtml = /<\/?[a-z][\s\S]*>/i.test(raw);
if (hasHtml) return sanitizeAdminRichTextHtml(raw);
return escapeHtml(raw).replace(/\r?\n/g, "<br />");
}
function createEmptyQuestion(id: string): Question {
return {
id,
@ -130,10 +231,6 @@ function createEmptyQuestion(id: string): Question {
audioCorrectAnswerText: "",
shortAnswers: [],
imageUrl: "",
questionTypeDefinitionId: null,
dynamicStimulusRows: [],
dynamicResponseRows: [],
dynamicFieldValues: {},
};
}
@ -175,12 +272,6 @@ 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[]>([
@ -273,6 +364,11 @@ export function AddNewPracticePage() {
return null;
}, [introVideoUrl]);
const descriptionPreviewHtml = useMemo(
() => formatDescriptionForPreview(practiceDescription),
[practiceDescription],
);
const addQuestion = () => {
setQuestions([...questions, createEmptyQuestion(String(Date.now()))]);
};
@ -293,12 +389,7 @@ export function AddNewPracticePage() {
setSaving(true);
setSaveError(null);
try {
if (!selectedPersona) {
toast.error("Select a persona before saving.");
setSaving(false);
return;
}
const persona = personas.find((p) => p.id === selectedPersona);
const persona = PERSONAS.find((p) => p.id === selectedPersona);
const setRes = await createQuestionSet({
title: practiceTitle || "Untitled Practice",
set_type: "PRACTICE",
@ -323,38 +414,6 @@ export function AddNewPracticePage() {
const q = questions[i];
if (!q.questionText.trim()) continue;
if (q.questionType === "DYNAMIC") {
if (q.questionTypeDefinitionId == null || q.questionTypeDefinitionId <= 0) {
toast.error(`Question ${i + 1}: select a question type definition for dynamic questions.`);
setSaving(false);
return;
}
const missingStimulus = q.dynamicStimulusRows.find(
(row) =>
row.required &&
!(q.dynamicFieldValues[`stimulus:${row.id}`]?.trim()),
);
if (missingStimulus) {
toast.error(
`Question ${i + 1}: fill required stimulus "${missingStimulus.label || missingStimulus.id}".`,
);
setSaving(false);
return;
}
const missingResponse = q.dynamicResponseRows.find(
(row) =>
row.required &&
!(q.dynamicFieldValues[`response:${row.id}`]?.trim()),
);
if (missingResponse) {
toast.error(
`Question ${i + 1}: fill required response "${missingResponse.label || missingResponse.id}".`,
);
setSaving(false);
return;
}
}
const options: QuestionOption[] =
q.questionType === "MCQ"
? q.options.map((opt, idx) => ({
@ -364,43 +423,22 @@ export function AddNewPracticePage() {
}))
: [];
const dynamicPayload =
q.questionType === "DYNAMIC" && q.questionTypeDefinitionId != null
? buildDynamicQuestionPayload({
stimulusRows: q.dynamicStimulusRows,
responseRows: q.dynamicResponseRows,
fieldValues: q.dynamicFieldValues,
})
: undefined;
const qRes = await createQuestion(
q.questionType === "DYNAMIC" && q.questionTypeDefinitionId != null && dynamicPayload
? {
question_type: "DYNAMIC",
question_type_definition_id: q.questionTypeDefinitionId,
dynamic_payload: dynamicPayload,
difficulty_level: q.difficultyLevel,
points: q.points,
tips: q.tips || undefined,
explanation: q.explanation || undefined,
status,
}
: {
question_text: q.questionText,
question_type: q.questionType,
difficulty_level: q.difficultyLevel,
points: q.points,
tips: q.tips || undefined,
explanation: q.explanation || undefined,
status,
options: options.length > 0 ? options : undefined,
voice_prompt: q.voicePrompt || undefined,
sample_answer_voice_prompt: q.sampleAnswerVoicePrompt || undefined,
audio_correct_answer_text: q.audioCorrectAnswerText || undefined,
image_url: q.imageUrl.trim() || undefined,
short_answers: q.shortAnswers.length > 0 ? q.shortAnswers : undefined,
},
);
const qRes = await createQuestion({
question_text: q.questionText,
question_type: q.questionType,
difficulty_level: q.difficultyLevel,
points: q.points,
tips: q.tips || undefined,
explanation: q.explanation || undefined,
status: "PUBLISHED",
options: options.length > 0 ? options : undefined,
voice_prompt: q.voicePrompt || undefined,
sample_answer_voice_prompt: q.sampleAnswerVoicePrompt || undefined,
audio_correct_answer_text: q.audioCorrectAnswerText || undefined,
image_url: q.imageUrl.trim() || undefined,
short_answers:
q.shortAnswers.length > 0 ? q.shortAnswers : undefined,
});
const questionId = qRes.data?.data?.id;
if (questionId) {
@ -803,17 +841,66 @@ export function AddNewPracticePage() {
practice.
</p>
</div>
<div className="p-5 sm:p-8 lg:p-10">
<PersonaStep
personas={personas}
loading={personasLoading}
error={personasError}
onRetry={() => void reloadPersonas()}
selectedPersona={selectedPersona}
setSelectedPersona={setSelectedPersona}
nextStep={handleNext}
prevStep={handleBack}
/>
<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>
</div>
</Card>
)}
@ -825,7 +912,7 @@ export function AddNewPracticePage() {
Step 3: Questions
</h2>
<p className="mt-1.5 max-w-3xl text-sm leading-relaxed text-grayScale-500">
Add MCQ, True/False, Short Answer, Audio, or Dynamic (schema-driven) items. Use the full
Add MCQ, True/False, Short Answer, or Audio items. Use the full
width for stems and options.
</p>
</div>
@ -865,10 +952,6 @@ export function AddNewPracticePage() {
audioCorrectAnswerText: question.audioCorrectAnswerText,
shortAnswer: question.shortAnswers[0] ?? "",
imageUrl: question.imageUrl,
questionTypeDefinitionId: question.questionTypeDefinitionId,
dynamicStimulusRows: question.dynamicStimulusRows,
dynamicResponseRows: question.dynamicResponseRows,
dynamicFieldValues: question.dynamicFieldValues,
}}
onChange={(next) => {
updateQuestion(question.id, {
@ -887,10 +970,6 @@ export function AddNewPracticePage() {
? [next.shortAnswer.trim()]
: [],
imageUrl: next.imageUrl,
questionTypeDefinitionId: next.questionTypeDefinitionId,
dynamicStimulusRows: next.dynamicStimulusRows,
dynamicResponseRows: next.dynamicResponseRows,
dynamicFieldValues: next.dynamicFieldValues,
});
}}
mediaBusy={saving}
@ -930,26 +1009,259 @@ export function AddNewPracticePage() {
)}
{currentStep === 4 && (
<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}
/>
<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"
: "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>
)}
{/* Step 5: Result */}

View File

@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react";
import { useState } from "react";
import {
Link,
useNavigate,
@ -6,370 +6,70 @@ 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();
const {
level,
programType,
courseId: routeCourseId,
unitId: routeUnitId,
moduleId: routeModuleId,
} = useParams<{
level?: string;
programType?: string;
courseId?: string;
unitId?: string;
moduleId?: string;
}>();
const { level } = useParams<{ level: string }>();
const [searchParams] = useSearchParams();
const backToParam = searchParams.get("backTo");
const lessonId = searchParams.get("lessonId");
const lessonTitleRaw = searchParams.get("lessonTitle");
const backTo = searchParams.get("backTo");
const courseId = searchParams.get("courseId");
const moduleId = searchParams.get("moduleId");
const isExamPrep = Boolean(programType?.trim());
const effectiveBackTo = useMemo(() => {
if (backToParam?.trim()) return backToParam.trim();
if (isExamPrep && routeModuleId) return "module";
if (isExamPrep && routeCourseId) return "courses";
return null;
}, [backToParam, isExamPrep, routeModuleId, routeCourseId]);
const courseId = isExamPrep
? routeCourseId ?? searchParams.get("courseId")
: searchParams.get("courseId");
const moduleId = isExamPrep
? routeModuleId ?? searchParams.get("moduleId")
: searchParams.get("moduleId");
const unitId = isExamPrep ? routeUnitId : null;
const lessonTitleDisplay = (() => {
const raw = lessonTitleRaw?.trim();
if (!raw) return null;
try {
return decodeURIComponent(raw);
} catch {
return raw;
}
})();
const isModuleContext = effectiveBackTo === "module";
const isCourseContext =
effectiveBackTo === "modules" || effectiveBackTo === "courses";
const isLessonPractice = useMemo(() => {
const lid = lessonId ? Number(lessonId) : NaN;
return Number.isFinite(lid) && lid > 0;
}, [lessonId]);
/** Learn English lesson practices skip story fields; exam prep lessons use the full form. */
const isLearnEnglishLessonPractice = isLessonPractice && !isExamPrep;
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 programLabel = isExamPrep
? programType === "skill"
? "Skill-Based Courses"
: "English Proficiency Exams"
: level
? `Program ${level}`
: null;
const isModuleContext = backTo === "module";
const isCourseContext = backTo === "modules";
const backLabel =
effectiveBackTo === "module"
backTo === "module"
? "Back to Module"
: effectiveBackTo === "modules"
: backTo === "modules"
? "Back to Modules"
: effectiveBackTo === "courses"
? "Back to Course"
: isExamPrep
? "Back to Program"
: "Back to Courses";
: "Back to Courses";
const backPath =
backTo === "module" && courseId && moduleId
? `/new-content/learn-english/${level}/courses/${courseId}/modules/${moduleId}`
: backTo === "modules" && courseId
? `/new-content/learn-english/${level}/courses/${courseId}`
: `/new-content/learn-english/${level}/courses`;
const backPath = useMemo(() => {
if (isExamPrep) {
if (
effectiveBackTo === "module" &&
programType &&
courseId &&
unitId &&
moduleId
) {
return `/new-content/courses/${programType}/${courseId}/${unitId}/${moduleId}`;
}
if (effectiveBackTo === "courses" && programType && courseId) {
return `/new-content/courses/${programType}/${courseId}`;
}
if (programType) {
return `/new-content/courses/${programType}`;
}
return "/new-content";
}
if (effectiveBackTo === "module" && level && courseId && moduleId) {
return `/new-content/learn-english/${level}/courses/${courseId}/modules/${moduleId}`;
}
if (effectiveBackTo === "modules" && level && courseId) {
return `/new-content/learn-english/${level}/courses/${courseId}`;
}
return `/new-content/learn-english/${level}/courses`;
}, [
isExamPrep,
effectiveBackTo,
programType,
courseId,
unitId,
moduleId,
level,
]);
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: "",
storyImageUrl: "",
shuffleQuestions: false,
tips: "",
selectedVideo: "",
tips: "Focus on using the present perfect continuous tense to describe an action that started in the past and continues now.",
questions: [
{
id: "q1",
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: [""],
text: "How long have you been studying English?",
type: "Speaking",
voicePrompt: "prompt_q1_en.mp3",
sampleAnswer: "prompt_q1_en.mp3",
},
],
});
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 { definitions: 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 (
!isLearnEnglishLessonPractice &&
(!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.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 ?? ""),
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");
const useExamPrepLessonApi =
isExamPrep &&
isLessonPractice &&
parentContext.kind === "LESSON" &&
Number.isFinite(parentContext.id);
setSubmitting(true);
try {
await executeLearnEnglishPracticeCreation({
parentKind: parentContext.kind,
parentId: parentContext.id,
examPrepLessonId: useExamPrepLessonApi ? parentContext.id : undefined,
status,
questionSetTitle: isLearnEnglishLessonPractice
? lessonDefaultTitle
: formData.title.trim() || "Practice set",
questionSetDescription: isLearnEnglishLessonPractice
? null
: formData.description.trim() || null,
shuffleQuestions: formData.shuffleQuestions,
practiceTitle: isLearnEnglishLessonPractice
? lessonDefaultTitle
: formData.title.trim() || "Untitled practice",
storyDescription: isLearnEnglishLessonPractice
? ""
: formData.description.trim(),
storyImage: isLearnEnglishLessonPractice
? ""
: 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, STEP_LABELS.length));
setCurrentStep((prev) => Math.min(prev + 1, flowSteps.length));
const prevStep = () => setCurrentStep((prev) => Math.max(prev - 1, 1));
if (isPublished) {
@ -387,47 +87,23 @@ export function AddPracticeFlow() {
Practice Published Successfully!
</h1>
<p className="text-grayScale-600 text-md mb-14 max-w-lg font-medium leading-relaxed">
{lessonId
? "Your speaking practice is saved and linked to this lessons question set."
: "Your speaking practice is saved for the linked course or module."}
Your speaking practice is now active and available inside the 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 "
>
{backLabel}
Go back to Module
</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"
@ -440,8 +116,9 @@ 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 (
@ -449,19 +126,70 @@ export function AddPracticeFlow() {
formData={formData}
setFormData={setFormData}
nextStep={nextStep}
onCancel={() => navigate(backPath)}
isLessonPractice={isLearnEnglishLessonPractice}
lessonTitle={lessonTitleDisplay}
parentSummary={parentSummary}
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}
/>
);
case 2:
return (
<PersonaStep
personas={personas}
loading={personasLoading}
error={personasError}
onRetry={() => void reloadPersonas()}
selectedPersona={selectedPersona}
setSelectedPersona={setSelectedPersona}
nextStep={nextStep}
@ -475,9 +203,6 @@ export function AddPracticeFlow() {
setFormData={setFormData}
nextStep={nextStep}
prevStep={prevStep}
typeDefinitions={typeDefinitions}
definitionsLoading={definitionsLoading}
definitionsError={definitionsError}
/>
);
case 4:
@ -485,92 +210,20 @@ export function AddPracticeFlow() {
<ReviewStep
formData={formData}
selectedPersona={selectedPersona}
personas={personas}
isLessonPractice={isLearnEnglishLessonPractice}
lessonTitle={lessonTitleDisplay}
programLabel={programLabel}
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")}
setIsPublished={setIsPublished}
isModuleContext={isModuleContext}
/>
);
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={isLearnEnglishLessonPractice}
lessonTitle={lessonTitleDisplay}
programLabel={programLabel}
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
@ -596,34 +249,16 @@ export function AddPracticeFlow() {
</Button>
</div>
<p className="text-grayScale-400 text-base">
Create a practice with story details, a persona, and questions from your question type library.
Create a new immersive practice session for students.
</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">Lesson practice</p>
<p className="mt-1 text-violet-800/90">
Linked to lesson{" "}
<span className="font-mono font-bold text-violet-950">
#{lessonId}
</span>
{lessonTitleDisplay ? (
<>
{" "}
<span className="font-medium">{lessonTitleDisplay}</span>
</>
) : null}
.
</p>
</div>
) : null}
</div>
<div className="mx-auto w-[70%] mb-12">
<Stepper steps={[...STEP_LABELS]} currentStep={currentStep} />
<Stepper steps={flowSteps} currentStep={currentStep} />
</div>
<div
className={`mx-auto ${currentStep === 3 || currentStep === 4 ? "max-w-6xl" : "max-w-4xl"}`}
className={`mx-auto ${(!isModuleContext && currentStep === 3) || (isModuleContext && currentStep === 2) || currentStep === 5 ? "max-w-6xl" : "max-w-4xl"}`}
>
{renderStep()}
</div>

View File

@ -8,21 +8,11 @@ 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,
questionTypeDefinitionListLabel,
} from "../../api/questionTypeDefinitions.api"
import type { QuestionTypeDefinition } from "../../types/questionTypeDefinition.types"
type QuestionType = "MCQ" | "TRUE_FALSE" | "SHORT_ANSWER" | "AUDIO" | "DYNAMIC"
type QuestionType = "MCQ" | "TRUE_FALSE" | "SHORT_ANSWER" | "AUDIO"
type Difficulty = "EASY" | "MEDIUM" | "HARD"
type QuestionStatus = "DRAFT" | "PUBLISHED" | "INACTIVE"
const defaultDynamicPayloadJson = `{
"stimulus": [],
"response": []
}`
interface Question {
id?: number
question: string
@ -37,9 +27,6 @@ interface Question {
voicePrompt: string
sampleAnswerVoicePrompt: string
audioCorrectAnswerText: string
/** Definition id as string for select value */
questionTypeDefinitionId: string
dynamicPayloadJson: string
}
const initialForm: Question = {
@ -55,8 +42,6 @@ const initialForm: Question = {
voicePrompt: "",
sampleAnswerVoicePrompt: "",
audioCorrectAnswerText: "",
questionTypeDefinitionId: "",
dynamicPayloadJson: defaultDynamicPayloadJson,
}
export function AddQuestionPage() {
@ -67,7 +52,6 @@ export function AddQuestionPage() {
const [formData, setFormData] = useState<Question>(initialForm)
const [loading, setLoading] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [typeDefinitions, setTypeDefinitions] = useState<QuestionTypeDefinition[]>([])
useEffect(() => {
const loadQuestion = async () => {
@ -80,8 +64,7 @@ export function AddQuestionPage() {
q.question_type === "MCQ" ||
q.question_type === "TRUE_FALSE" ||
q.question_type === "SHORT_ANSWER" ||
q.question_type === "AUDIO" ||
q.question_type === "DYNAMIC"
q.question_type === "AUDIO"
? q.question_type
: "MCQ"
const shortAnswer = Array.isArray(q.short_answers) && q.short_answers.length > 0
@ -117,14 +100,6 @@ export function AddQuestionPage() {
voicePrompt: q.voice_prompt || "",
sampleAnswerVoicePrompt: q.sample_answer_voice_prompt || "",
audioCorrectAnswerText: q.audio_correct_answer_text || "",
questionTypeDefinitionId:
mappedType === "DYNAMIC" && q.question_type_definition_id != null
? String(q.question_type_definition_id)
: "",
dynamicPayloadJson:
mappedType === "DYNAMIC" && q.dynamic_payload
? JSON.stringify(q.dynamic_payload, null, 2)
: defaultDynamicPayloadJson,
})
} catch (error) {
console.error("Failed to load question:", error)
@ -136,22 +111,6 @@ export function AddQuestionPage() {
loadQuestion()
}, [isEditing, id])
useEffect(() => {
if (formData.type !== "DYNAMIC") return
let cancelled = false
;(async () => {
try {
const { definitions: rows } = await getQuestionTypeDefinitions({ include_system: true })
if (!cancelled) setTypeDefinitions(rows)
} catch {
if (!cancelled) setTypeDefinitions([])
}
})()
return () => {
cancelled = true
}
}, [formData.type])
const handleTypeChange = (type: QuestionType) => {
setFormData((prev) => {
if (type === "TRUE_FALSE") {
@ -161,15 +120,6 @@ export function AddQuestionPage() {
options: ["True", "False"],
correctAnswer: prev.correctAnswer === "True" || prev.correctAnswer === "False" ? prev.correctAnswer : "",
}
} else if (type === "DYNAMIC") {
return {
...prev,
type,
options: [],
correctAnswer: "",
questionTypeDefinitionId: "",
dynamicPayloadJson: defaultDynamicPayloadJson,
}
} else if (type === "SHORT_ANSWER" || type === "AUDIO") {
return {
...prev,
@ -250,27 +200,6 @@ export function AddQuestionPage() {
})
return
}
} else if (formData.type === "DYNAMIC") {
const defId = Number(formData.questionTypeDefinitionId)
if (!Number.isFinite(defId) || defId < 1) {
toast.error("Definition required", { description: "Select a question type definition." })
return
}
try {
const parsed = JSON.parse(formData.dynamicPayloadJson || "{}") as {
stimulus?: unknown
response?: unknown
}
if (!Array.isArray(parsed.stimulus) || !Array.isArray(parsed.response)) {
toast.error("Invalid dynamic payload", {
description: 'JSON must include "stimulus" and "response" arrays.',
})
return
}
} catch {
toast.error("Invalid JSON", { description: "Fix the dynamic content JSON before saving." })
return
}
}
setSubmitting(true)
@ -292,18 +221,6 @@ export function AddQuestionPage() {
{ acceptable_answer: formData.correctAnswer.trim(), match_type: "CASE_INSENSITIVE" as const },
]
: undefined
let dynamicPayload: { stimulus: unknown[]; response: unknown[] } | undefined
if (formData.type === "DYNAMIC") {
try {
dynamicPayload = JSON.parse(formData.dynamicPayloadJson) as {
stimulus: unknown[]
response: unknown[]
}
} catch {
dynamicPayload = { stimulus: [], response: [] }
}
}
const payload = {
question_text: formData.question,
question_type: formData.type,
@ -319,12 +236,6 @@ export function AddQuestionPage() {
formData.type === "AUDIO" ? formData.sampleAnswerVoicePrompt : formData.sampleAnswerVoicePrompt || undefined,
audio_correct_answer_text:
formData.type === "AUDIO" ? formData.audioCorrectAnswerText : undefined,
...(formData.type === "DYNAMIC" && dynamicPayload
? {
question_type_definition_id: Number(formData.questionTypeDefinitionId),
dynamic_payload: dynamicPayload,
}
: {}),
}
if (isEditing && id) {
await updateQuestion(Number(id), payload)
@ -346,105 +257,68 @@ export function AddQuestionPage() {
}
return (
<div className="space-y-4 pb-6">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
<div className="space-y-8">
{/* Page Header */}
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-4">
<Button
variant="ghost"
size="icon"
onClick={() => navigate("/content/questions")}
className="h-9 w-9 shrink-0 rounded-lg bg-grayScale-50 hover:bg-brand-500/10 hover:text-brand-500"
className="rounded-lg bg-grayScale-50 hover:bg-brand-500/10 hover:text-brand-500 transition-colors"
>
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="min-w-0">
<h1 className="text-lg font-bold tracking-tight text-grayScale-800 sm:text-xl">
<div>
<h1 className="text-2xl font-bold tracking-tight text-grayScale-600">
{isEditing ? "Edit Question" : "Add New Question"}
</h1>
<p className="mt-0.5 text-xs text-grayScale-500 sm:text-sm">
{isEditing ? "Update fields below" : "Create a bank question"}
<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>
</div>
</div>
<div className="mx-auto max-w-2xl">
<div className="max-w-3xl mx-auto">
{loading && (
<Card className="mb-2 border border-grayScale-200">
<CardContent className="py-2.5 text-xs text-grayScale-500">Loading</CardContent>
<Card className="mb-4 border border-grayScale-200">
<CardContent className="py-4 text-sm text-grayScale-500">Loading question details...</CardContent>
</Card>
)}
<form onSubmit={handleSubmit}>
<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>
<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>
</CardHeader>
<CardContent className="space-y-3 px-4 pb-4 pt-0 sm:px-5 sm:pb-5">
<CardContent className="space-y-7">
{/* Question Type */}
<div>
<label className="mb-1 block text-xs font-medium text-grayScale-600">
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
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>
<option value="SHORT_ANSWER">Short Answer</option>
<option value="AUDIO">Audio</option>
<option value="DYNAMIC">Dynamic (schema-driven)</option>
</Select>
</div>
{formData.type === "DYNAMIC" && (
<>
<div>
<label className="mb-1 block text-xs font-medium text-grayScale-600">
Question type definition <span className="text-red-500">*</span>
</label>
<Select
value={formData.questionTypeDefinitionId}
onChange={(e) =>
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)}>
{questionTypeDefinitionListLabel(d)}
</option>
))}
</Select>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-grayScale-600">
Dynamic content (JSON) <span className="text-red-500">*</span>
</label>
<Textarea
value={formData.dynamicPayloadJson}
onChange={(e) => setFormData((prev) => ({ ...prev, dynamicPayloadJson: e.target.value }))}
rows={7}
className="min-h-0 font-mono text-[11px] leading-snug"
spellCheck={false}
/>
</div>
</>
)}
<hr className="border-grayScale-100" />
{/* Question Text */}
<div>
<label htmlFor="question" className="mb-1 block text-xs font-medium text-grayScale-600">
{formData.type === "DYNAMIC" ? "Title / stem" : "Question"}
<label htmlFor="question" className="mb-1.5 block text-sm font-medium text-grayScale-500">
Question
</label>
<Textarea
id="question"
placeholder="Enter your question here..."
value={formData.question}
onChange={(e) => setFormData((prev) => ({ ...prev, question: e.target.value }))}
rows={2}
className="min-h-[72px] text-sm"
rows={3}
required
/>
</div>
@ -452,11 +326,13 @@ export function AddQuestionPage() {
{/* Options for Multiple Choice */}
{(formData.type === "MCQ" || formData.type === "TRUE_FALSE") && (
<div>
<label className="mb-1 block text-xs font-medium text-grayScale-600">Options</label>
<div className="space-y-1.5">
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
Options
</label>
<div className="space-y-3">
{formData.options.map((option, index) => (
<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">
<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">
{index + 1}
</span>
<Input
@ -464,7 +340,6 @@ 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 && (
@ -473,22 +348,17 @@ export function AddQuestionPage() {
variant="ghost"
size="icon"
onClick={() => removeOption(index)}
className="h-8 w-8 shrink-0 opacity-0 transition-all group-hover:opacity-100 hover:bg-red-50 hover:text-red-500"
className="opacity-0 group-hover:opacity-100 hover:bg-red-50 hover:text-red-500 transition-all"
>
<X className="h-3.5 w-3.5" />
<X className="h-4 w-4" />
</Button>
)}
</div>
))}
{formData.type === "MCQ" && (
<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 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>
)}
</div>
@ -498,10 +368,9 @@ export function AddQuestionPage() {
<hr className="border-grayScale-100" />
{/* Correct Answer */}
{formData.type !== "DYNAMIC" && (
<div>
<label className="mb-1 block text-xs font-medium text-grayScale-600">
{formData.type === "AUDIO" ? "Audio correct answer" : "Correct answer"}
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
{formData.type === "AUDIO" ? "Audio Correct Answer Text" : "Correct Answer"}
</label>
{formData.type === "MCQ" || formData.type === "TRUE_FALSE" ? (
<Select
@ -509,7 +378,6 @@ export function AddQuestionPage() {
onChange={(e) =>
setFormData((prev) => ({ ...prev, correctAnswer: e.target.value }))
}
className="h-9 text-sm"
required
>
<option value="">Select correct answer</option>
@ -521,7 +389,7 @@ export function AddQuestionPage() {
</Select>
) : (
<Textarea
placeholder={formData.type === "AUDIO" ? "Expected spoken answer…" : "Correct answer…"}
placeholder={formData.type === "AUDIO" ? "Enter audio correct answer text..." : "Enter the correct answer..."}
value={formData.type === "AUDIO" ? formData.audioCorrectAnswerText : formData.correctAnswer}
onChange={(e) =>
setFormData((prev) =>
@ -531,26 +399,24 @@ export function AddQuestionPage() {
)
}
rows={2}
className="min-h-[60px] text-sm"
required
/>
)}
</div>
)}
<hr className="border-grayScale-100" />
{/* Points and Difficulty side by side */}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
{/* Points */}
<div>
<label htmlFor="points" className="mb-1 block text-xs font-medium text-grayScale-600">
<label htmlFor="points" className="mb-1.5 block text-sm font-medium text-grayScale-500">
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 }))
@ -559,12 +425,14 @@ export function AddQuestionPage() {
/>
</div>
{/* Difficulty */}
<div>
<label className="mb-1 block text-xs font-medium text-grayScale-600">Difficulty</label>
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
Difficulty (Optional)
</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>
@ -575,11 +443,12 @@ export function AddQuestionPage() {
{/* Status */}
<div>
<label className="mb-1 block text-xs font-medium text-grayScale-600">Status</label>
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
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,71 +457,58 @@ 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 block text-xs font-medium text-grayScale-600">
Voice prompt{formData.type === "AUDIO" ? "" : " (opt.)"}
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
Voice Prompt{formData.type === "AUDIO" ? "" : " (Optional)"}
</label>
<Textarea
value={formData.voicePrompt}
onChange={(e) => setFormData((prev) => ({ ...prev, voicePrompt: e.target.value }))}
rows={2}
placeholder="URL or key…"
className="min-h-[60px] text-sm"
placeholder="Please say your answer..."
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-grayScale-600">
Sample answer (voice){formData.type === "AUDIO" ? "" : " (opt.)"}
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
Sample Answer Voice Prompt{formData.type === "AUDIO" ? "" : " (Optional)"}
</label>
<Textarea
value={formData.sampleAnswerVoicePrompt}
onChange={(e) => setFormData((prev) => ({ ...prev, sampleAnswerVoicePrompt: e.target.value }))}
rows={2}
placeholder="URL or key…"
className="min-h-[60px] text-sm"
placeholder="Sample spoken answer..."
/>
</div>
</div>
</>
)}
<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>
<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>
<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"
>
<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">
Cancel
</Button>
<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 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>
</div>
</CardContent>

View File

@ -5,7 +5,6 @@ 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";
@ -18,7 +17,7 @@ const STEPS = [
export type AddLessonFormData = {
title: string;
sortOrder: string;
order: string;
description: string;
videoUrl: string;
thumbnailUrl: string;
@ -26,7 +25,7 @@ export type AddLessonFormData = {
const emptyForm = (): AddLessonFormData => ({
title: "",
sortOrder: "0",
order: "1",
description: "",
videoUrl: "",
thumbnailUrl: "",
@ -52,8 +51,6 @@ 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);
@ -63,7 +60,7 @@ export function AddVideoFlow() {
const backPath = `/new-content/learn-english/${level}/courses/${courseId}/modules/${moduleId}`;
const handleCreateLesson = async (publishStatus: PracticePublishStatus) => {
const handlePublish = async () => {
const mid = Number(moduleId);
if (!Number.isFinite(mid) || mid < 1) {
toast.error("Invalid module");
@ -89,16 +86,6 @@ 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, {
@ -106,15 +93,8 @@ export function AddVideoFlow() {
video_url: videoUrl,
thumbnail,
description,
sort_order,
publish_status: publishStatus,
});
setLastCreatedPublishStatus(publishStatus);
toast.success(
publishStatus === "DRAFT"
? "Lesson saved as draft"
: "Lesson published",
);
toast.success("Lesson created");
setIsPublished(true);
} catch (e: unknown) {
console.error(e);
@ -143,14 +123,10 @@ export function AddVideoFlow() {
</div>
<h1 className="text-[26px] font-bold text-grayScale-900 mb-4">
{lastCreatedPublishStatus === "DRAFT"
? "Lesson saved as draft"
: "Lesson published successfully"}
Lesson created successfully
</h1>
<p className="text-grayScale-600 text-base mb-14 max-w-lg font-medium leading-relaxed">
{lastCreatedPublishStatus === "DRAFT"
? "You can finish editing and publish it later from the module."
: "Your lesson is now available in this module."}
Your lesson is now available in this module.
</p>
<div className="flex flex-col gap-4 w-full max-w-[400px]">
@ -164,7 +140,6 @@ export function AddVideoFlow() {
onClick={() => {
setFormData(emptyForm());
setFormResetKey((k) => k + 1);
setLastCreatedPublishStatus("PUBLISHED");
setIsPublished(false);
setCurrentStep(1);
}}
@ -230,7 +205,7 @@ export function AddVideoFlow() {
<ReviewPublishStep
formData={formData}
prevStep={prevStep}
onCreateLesson={(status) => void handleCreateLesson(status)}
onPublish={() => void handlePublish()}
publishing={publishing}
/>
)}

View File

@ -28,7 +28,6 @@ import {
import { Textarea } from "../../components/ui/textarea"
import { toast } from "sonner"
import { cn } from "../../lib/utils"
import { TABLE_PAGE_SIZE_OPTIONS } from "../../lib/tablePagination"
import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg"
type CourseWithCategory = Course & { category_name: string }
@ -423,7 +422,7 @@ export function AllCoursesPage() {
}}
className="h-8 appearance-none rounded-md border bg-white pl-2 pr-7 text-sm font-medium text-grayScale-600 focus:outline-none"
>
{TABLE_PAGE_SIZE_OPTIONS.map((size) => (
{[10, 20, 50].map((size) => (
<option key={size} value={size}>
{size}
</option>

View File

@ -1,10 +1,11 @@
import { Outlet } from "react-router-dom";
import { ContentHierarchyList } from "./components/ContentHierarchyList";
export function ContentManagementLayout() {
return (
<div className="mx-auto w-full max-w-7xl px-4 py-6 sm:px-6 lg:px-8">
<div className="mb-8">
<div className="mb-8 flex items-center gap-3">
<div className="flex items-center gap-3 mb-8">
<div className="h-9 w-1 rounded-full bg-gradient-to-b from-brand-500 to-brand-600" />
<div>
<h1 className="text-2xl font-bold tracking-tight text-grayScale-900 sm:text-[1.65rem]">
@ -15,6 +16,8 @@ export function ContentManagementLayout() {
</p>
</div>
</div>
<ContentHierarchyList />
</div>
<Outlet />

View File

@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import {
ArrowLeft,
Plus,
@ -21,33 +21,23 @@ 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";
import {
deleteTopLevelCourseModule,
getPracticesByParentCourse,
getProgramCourses,
getTopLevelCourseModules,
publishParentLinkedPractice,
updateParentLinkedPractice,
updateTopLevelCourseModule,
} from "../../api/courses.api";
import { refreshFileUrl, resolveFileUrl } from "../../api/files.api";
import type {
ParentContextPractice,
ProgramCourseListItem,
TopLevelCourseModuleItem,
} from "../../types/course.types";
import {
isPracticeDraft,
isPracticePublished,
unwrapPracticesList,
} from "../../lib/parentContextPractice";
import { AddModuleModal } from "./components/AddModuleModal";
import { ModuleIconUploadField } from "./components/ModuleIconUploadField";
import { ModulePracticeCard } from "./components/ModulePracticeCard";
import { PublishPracticeButton } from "./components/PublishPracticeButton";
const MODULE_CARD_GRADIENT = "from-[#8E44AD] to-[#C39BD3]" as const;
@ -155,7 +145,7 @@ export function CourseDetailPage() {
const [editingModule, setEditingModule] =
useState<TopLevelCourseModuleItem | null>(null);
const [editModuleName, setEditModuleName] = useState("");
const [editModuleSortOrder, setEditModuleSortOrder] = useState("");
const [editModuleDescription, setEditModuleDescription] = useState("");
const [editModuleIcon, setEditModuleIcon] = useState("");
const [editModuleIconUploadBusy, setEditModuleIconUploadBusy] =
useState(false);
@ -165,21 +155,10 @@ export function CourseDetailPage() {
useState<TopLevelCourseModuleItem | null>(null);
const [deletingModuleInFlight, setDeletingModuleInFlight] = useState(false);
const [activeTab, setActiveTab] = useState<"modules" | "practice">("modules");
const [practiceFilter, setPracticeFilter] = useState("All");
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 openEditModule = (module: TopLevelCourseModuleItem) => {
setEditingModule(module);
setEditModuleName(module.name ?? "");
setEditModuleSortOrder(String(module.sort_order ?? 0));
setEditModuleDescription(module.description ?? "");
setEditModuleIcon(module.icon?.trim() ?? "");
setEditModuleIconUploadBusy(false);
};
@ -281,91 +260,6 @@ export function CourseDetailPage() {
void loadPage();
}, [loadPage]);
const loadCoursePractices = useCallback(async () => {
if (!Number.isFinite(courseIdNum) || courseIdNum < 1) {
setPractices([]);
setPracticesLoadError(null);
setPracticesLoading(false);
return;
}
setPracticesLoading(true);
setPracticesLoadError(null);
try {
const res = await getPracticesByParentCourse(courseIdNum, {
limit: 100,
offset: 0,
});
setPractices(unwrapPracticesList(res));
} catch {
setPractices([]);
setPracticesLoadError("Failed to load practices. Please try again.");
} finally {
setPracticesLoading(false);
}
}, [courseIdNum]);
useEffect(() => {
if (activeTab !== "practice") return;
void loadCoursePractices();
}, [activeTab, loadCoursePractices]);
const filteredPractices = useMemo(() => {
if (practiceFilter === "Published") {
return practices.filter(isPracticePublished);
}
if (practiceFilter === "Draft") {
return practices.filter(isPracticeDraft);
}
if (practiceFilter === "Archived") {
return [];
}
return practices;
}, [practices, practiceFilter]);
const 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 handleSaveModuleEdit = async () => {
if (!editingModule) return;
const name = editModuleName.trim();
@ -373,23 +267,12 @@ 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: editingModule.description?.trim() ?? "",
description: editModuleDescription.trim(),
icon: editModuleIcon.trim(),
sort_order,
});
toast.success("Module updated");
setEditModuleIconUploadBusy(false);
@ -497,32 +380,20 @@ export function CourseDetailPage() {
</Button>
</div>
</div>
<div className="border-b border-grayScale-200">
<div className="flex gap-10">
<button
type="button"
onClick={() => setActiveTab("modules")}
className={cn(
"pb-4 text-[16px] font-medium transition-all relative",
activeTab === "modules"
? "text-brand-500 after:absolute after:bottom-0 after:left-0 after:right-0 after:h-[3px] after:bg-brand-500 after:rounded-t-full"
: "text-grayScale-400 hover:text-grayScale-600",
)}
>
Modules
</button>
<button
type="button"
onClick={() => setActiveTab("practice")}
className={cn(
"pb-4 text-[16px] font-medium transition-all relative",
activeTab === "practice"
? "text-brand-500 after:absolute after:bottom-0 after:left-0 after:right-0 after:h-[3px] after:bg-brand-500 after:rounded-t-full"
: "text-grayScale-400 hover:text-grayScale-600",
)}
>
Practice
</button>
<div className="relative">
<div
className="absolute inset-0 flex items-center"
aria-hidden="true"
>
<div className="w-full border-t border-grayScale-200" />
</div>
<div className="relative flex justify-center">
<div
className="h-[0.5px] w-full rounded-full opacity-20"
style={{
background: "gray",
}}
/>
</div>
</div>
@ -541,15 +412,18 @@ export function CourseDetailPage() {
if (!open) closeEditModule();
}}
>
<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">
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Edit module</DialogTitle>
<DialogDescription>
Update name, sort order, and icon (upload or URL).
Update name, description, 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="min-h-0 flex-1 overflow-y-auto overscroll-contain px-6 py-4">
<div className="grid gap-4">
<div className="grid gap-4 py-2">
<div className="space-y-2">
<label className="text-sm font-medium text-grayScale-700">
Name
@ -563,27 +437,17 @@ export function CourseDetailPage() {
/>
</div>
<div className="space-y-2">
<label
htmlFor="edit-module-sort-order"
className="text-sm font-medium text-grayScale-700"
>
Sort Order
<label className="text-sm font-medium text-grayScale-700">
Description
</label>
<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}
<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}
/>
<p className="text-xs text-grayScale-500">
Lower numbers appear first when modules are listed.
</p>
</div>
<ModuleIconUploadField
value={editModuleIcon}
@ -592,8 +456,7 @@ export function CourseDetailPage() {
onUploadBusyChange={setEditModuleIconUploadBusy}
/>
</div>
</div>
<DialogFooter className="shrink-0 gap-2 border-t border-grayScale-100 bg-white px-6 py-4 sm:gap-0">
<DialogFooter className="gap-2 sm:gap-0">
<Button
type="button"
variant="outline"
@ -614,184 +477,97 @@ export function CourseDetailPage() {
</DialogContent>
</Dialog>
{activeTab === "modules" ? (
modules.length === 0 ? (
<div className="rounded-xl border border-dashed border-grayScale-200 bg-grayScale-50/50 px-6 py-14 text-center">
<p className="text-sm font-medium text-grayScale-600">
No modules in this course yet
</p>
<p className="mt-1 text-sm text-grayScale-400">
Add a module to organize lessons and practices for this course.
</p>
</div>
) : (
<div
className="grid justify-start gap-10"
style={{
gridTemplateColumns: "repeat(auto-fill, minmax(330px, 330px))",
}}
>
{modules.map((module, index) => {
const iconSrc = module.icon?.trim() ?? "";
return (
<Card
key={module.id}
className="group relative flex h-full min-h-0 w-full flex-col overflow-hidden rounded-[16px] border border-grayScale-50 bg-white shadow-sm transition-all duration-300 hover:shadow-lg"
>
<div className="absolute right-2 top-2 z-10 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">
<Button
type="button"
variant="secondary"
size="icon"
className="h-8 w-8 rounded-md bg-white/95 text-grayScale-600 shadow-sm transition-colors hover:bg-white"
aria-label={`Edit ${module.name}`}
onClick={() => openEditModule(module)}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
type="button"
variant="secondary"
size="icon"
className="h-8 w-8 rounded-md bg-white/95 text-red-600 shadow-sm transition-colors hover:bg-red-50"
aria-label={`Delete ${module.name}`}
onClick={() => setDeletingModule(module)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
<ModuleCardTopMedia iconSrc={iconSrc} />
<div className="flex min-h-0 flex-1 flex-col gap-6 p-2 pb-4 pt-4">
<div className="flex min-h-0 flex-1 gap-4">
<ModuleIconCircle iconSrc={iconSrc} index={index} />
<div className="min-w-0 flex-1 space-y-1">
<h3 className="text-lg font-bold tracking-tight text-[#0F172A]">
{module.name}
</h3>
<p className="text-[12px] font-medium leading-snug text-grayScale-400 line-clamp-3">
{module.description?.trim()
? module.description
: "—"}
</p>
</div>
</div>
<div className="mt-auto flex shrink-0 items-center gap-3">
<Button
variant="outline"
className="h-10 flex-1 rounded-[6px] border-[#9E2891] text-sm text-[#9E2891] transition-all"
onClick={() =>
navigate(
`/new-content/learn-english/${programIdParam}/courses/${courseIdParam}/modules/${module.id}`,
{
state: {
moduleName: module.name,
moduleDescription:
module.description?.trim() ?? "",
},
},
)
}
>
View Detail
</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>
);
})}
</div>
)
{modules.length === 0 ? (
<div className="rounded-xl border border-dashed border-grayScale-200 bg-grayScale-50/50 px-6 py-14 text-center">
<p className="text-sm font-medium text-grayScale-600">
No modules in this course yet
</p>
<p className="mt-1 text-sm text-grayScale-400">
Add modules when your workflow is connected, or create them via
the API.
</p>
</div>
) : (
<div className="space-y-8">
<div className="flex items-center gap-10 overflow-x-auto whitespace-nowrap rounded-2xl border border-grayScale-100 bg-white px-8 py-4 shadow-sm">
<div className="mr-2 flex items-center gap-2 text-[12px] font-bold uppercase tracking-widest text-grayScale-300">
STATUS:
</div>
<div className="flex items-center gap-3">
{["All", "Published", "Draft", "Archived"].map((label) => (
<button
key={label}
type="button"
onClick={() => setPracticeFilter(label)}
className={cn(
"h-9 rounded-full px-5 text-[13px] font-bold transition-all",
practiceFilter === label
? "bg-brand-500 text-white shadow-md shadow-brand-500/20"
: "bg-[#F1F5F9] text-grayScale-500 hover:bg-grayScale-100",
)}
>
{label}
</button>
))}
</div>
</div>
{practicesLoading ? (
<div className="flex flex-col items-center justify-center py-24 text-[15px] font-medium text-grayScale-500">
Loading practices
</div>
) : practicesLoadError ? (
<div className="mx-auto max-w-lg rounded-2xl border border-amber-100 bg-amber-50/80 px-6 py-8 text-center text-sm text-amber-900">
{practicesLoadError}
</div>
) : filteredPractices.length > 0 ? (
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4">
{filteredPractices.map((practice) => (
<ModulePracticeCard
key={practice.id}
practice={practice}
statusUpdating={publishStatusPracticeId === practice.id}
onEdit={() =>
navigate(`/content/practices?type=course&id=${courseIdNum}`)
}
onPublish={() => void handlePublishPractice(practice.id)}
onSaveAsDraft={() =>
void handleSavePracticeAsDraft(practice.id)
}
/>
))}
</div>
) : (
<div className="mx-auto flex max-w-4xl flex-col items-center justify-center rounded-[40px] border-2 border-dashed border-[#F1F5F9] bg-white px-4 py-32 shadow-sm">
<div className="mb-6 flex h-20 w-20 items-center justify-center rounded-full bg-[#FAF5FF]">
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-[#F5EBFF]">
<Calendar className="h-7 w-7 text-brand-500" />
<div
className="grid justify-start gap-10"
style={{
gridTemplateColumns: "repeat(auto-fill, minmax(330px, 330px))",
}}
>
{modules.map((module, index) => {
const iconSrc = module.icon?.trim() ?? "";
return (
<Card
key={module.id}
className="group relative flex h-full min-h-0 w-full flex-col overflow-hidden rounded-[16px] border border-grayScale-50 bg-white shadow-sm transition-all duration-300 hover:shadow-lg"
>
<div className="absolute right-2 top-2 z-10 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">
<Button
type="button"
variant="secondary"
size="icon"
className="h-8 w-8 rounded-md bg-white/95 text-grayScale-600 shadow-sm transition-colors hover:bg-white"
aria-label={`Edit ${module.name}`}
onClick={() => openEditModule(module)}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
type="button"
variant="secondary"
size="icon"
className="h-8 w-8 rounded-md bg-white/95 text-red-600 shadow-sm transition-colors hover:bg-red-50"
aria-label={`Delete ${module.name}`}
onClick={() => setDeletingModule(module)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
<h2 className="mb-3 text-2xl font-extrabold text-grayScale-900">
{practices.length === 0
? "No practices for this course yet"
: "No practices match this filter"}
</h2>
<p className="mb-10 max-w-sm text-center text-[15px] font-medium leading-relaxed text-grayScale-400">
{practices.length === 0
? "Add a course-level practice to give learners exercises attached to this course."
: "Try another status filter or add a new practice."}
</p>
{practices.length === 0 ? (
<Button
variant="outline"
className="flex h-12 items-center gap-2 rounded-xl border-brand-500 px-8 font-bold text-brand-500 transition-all hover:bg-brand-50"
onClick={() =>
navigate(
`/new-content/learn-english/${programIdParam}/courses/add-practice?backTo=modules&courseId=${courseIdParam}`,
)
}
>
<Calendar className="h-5 w-5" />
Add Practice
</Button>
) : null}
</div>
)}
<ModuleCardTopMedia iconSrc={iconSrc} />
<div className="flex min-h-0 flex-1 flex-col gap-6 p-2 pb-4 pt-4">
<div className="flex min-h-0 flex-1 gap-4">
<ModuleIconCircle iconSrc={iconSrc} index={index} />
<div className="min-w-0 flex-1 space-y-1">
<h3 className="text-lg font-bold tracking-tight text-[#0F172A]">
{module.name}
</h3>
<p className="text-[12px] font-medium leading-snug text-grayScale-400 line-clamp-3">
{module.description?.trim()
? module.description
: "—"}
</p>
</div>
</div>
<div className="mt-auto flex shrink-0 items-center gap-3">
<Button
variant="outline"
className="h-10 flex-1 rounded-[6px] border-[#9E2891] text-sm text-[#9E2891] transition-all"
onClick={() =>
navigate(
`/new-content/learn-english/${programIdParam}/courses/${courseIdParam}/modules/${module.id}`,
{
state: {
moduleName: module.name,
moduleDescription:
module.description?.trim() ?? "",
},
},
)
}
>
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>
</div>
</div>
</Card>
);
})}
</div>
)}

View File

@ -3,6 +3,7 @@ import { Link, useParams, useNavigate } from "react-router-dom";
import {
ArrowLeft,
Plus,
FileText,
LayoutGrid,
PlayCircle,
ClipboardCheck,
@ -14,6 +15,7 @@ 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,
@ -43,7 +45,7 @@ export function CourseManagementPage() {
const catalogCourseId = Number(courseId);
const [addUnitOpen, setAddUnitOpen] = useState(false);
const [createName, setCreateName] = useState("");
const [createSortOrder, setCreateSortOrder] = useState("");
const [createDescription, setCreateDescription] = useState("");
const [createThumbnail, setCreateThumbnail] = useState("");
const [creating, setCreating] = useState(false);
const [uploadingThumbnail, setUploadingThumbnail] = useState(false);
@ -64,6 +66,7 @@ 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);
@ -149,7 +152,7 @@ export function CourseManagementPage() {
const clearCreateUnitForm = () => {
setCreateName("");
setCreateSortOrder("");
setCreateDescription("");
setCreateThumbnail("");
if (createThumbnailFileInputRef.current) {
createThumbnailFileInputRef.current.value = "";
@ -199,24 +202,13 @@ 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: null,
description: createDescription.trim() || null,
thumbnail: minioThumbnail || null,
sort_order,
});
void response;
await loadUnits();
@ -279,16 +271,18 @@ 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 ?? 0));
setEditSortOrder(String(unit.sortOrder ?? 1));
};
const closeEditUnit = () => {
if (savingEdit || uploadingEditThumbnail) return;
setEditingUnitId(null);
setEditName("");
setEditDescription("");
setEditThumbnail("");
setEditSortOrder("");
setEditSortOrder("1");
};
const handleEditUnitThumbnailFile = async (
@ -326,30 +320,20 @@ export function CourseManagementPage() {
toast.error("Unit 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");
const sortOrderNum = Number(editSortOrder);
if (!Number.isFinite(sortOrderNum) || sortOrderNum < 0) {
toast.error("Sort order must be a valid number");
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: preservedDescription,
description: editDescription.trim() || null,
thumbnail: minioThumbnail || null,
sort_order,
sort_order: sortOrderNum,
});
await loadUnits();
toast.success("Unit updated");
@ -441,29 +425,18 @@ export function CourseManagementPage() {
disabled={creating || uploadingThumbnail}
/>
</div>
<div className="space-y-3">
<label
htmlFor="create-unit-sort-order"
className="text-[15px] text-grayScale-800"
>
Sort Order
<label className="text-[15px] text-grayScale-800">
Description
</label>
<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"
<Textarea
value={createDescription}
onChange={(e) => setCreateDescription(e.target.value)}
placeholder="Short unit description"
rows={4}
className="min-h-[96px] rounded-[8px] border-grayScale-400"
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">
@ -555,6 +528,17 @@ export function CourseManagementPage() {
</div>
</DialogContent>
</Dialog>
<Button
variant="outline"
className="h-10 px-6 rounded-[6px] border-brand-500 text-brand-500 font-bold hover:bg-brand-50 transition-all flex items-center gap-2"
onClick={() =>
navigate(`/new-content/courses/${programType}/attach-practice`)
}
>
<FileText className="h-5 w-5" />
Attach Practice
</Button>
</div>
</div>
@ -706,27 +690,25 @@ export function CourseManagementPage() {
/>
</div>
<div className="space-y-3">
<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)}
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"
<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
type="number"
min={0}
value={editSortOrder}
onChange={(e) => setEditSortOrder(e.target.value)}
className="h-12 border-grayScale-400 rounded-[8px] px-4"
disabled={savingEdit || uploadingEditThumbnail}
/>
<p className="text-xs text-grayScale-500">
Lower numbers appear first when units are listed.
</p>
</div>
<div className="space-y-3">
<label className="text-[15px] text-grayScale-800">Thumbnail</label>

View File

@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { ArrowLeft, Plus, FileText, Video } from "lucide-react";
import { ArrowLeft, Plus, FileText, Pencil, Trash2 } from "lucide-react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { Button } from "../../components/ui/button";
import { cn } from "../../lib/utils";
@ -23,18 +23,8 @@ import {
updateExamPrepModuleLesson,
deleteExamPrepModuleLesson,
getExamPrepModuleLessons,
publishExamPrepModuleLesson,
} from "../../api/courses.api";
import { uploadImageFile, uploadVideoFile } from "../../api/files.api";
import { resolveThumbnailForPreview } from "../../lib/videoPreview";
import type { PracticePublishStatus } from "../../types/course.types";
const LESSON_THUMB_GRADIENTS = [
"from-[#CBD5E1] to-[#94A3B8]",
"from-[#DBEAFE] to-[#93C5FD]",
"from-[#FEF3C7] to-[#FCD34D]",
"from-[#FCE7F3] to-[#F9A8D4]",
] as const;
const MOCK_PRACTICES = [
{
@ -70,17 +60,12 @@ export function CourseModuleDetailPage() {
id: number;
title: string;
videoUrl: string;
description: string | null;
description: string;
thumbnail: string;
sortOrder: number;
publishStatus: PracticePublishStatus | string | null;
durationSeconds: number | null;
gradient: string;
}>
>([]);
const [lessonsLoadError, setLessonsLoadError] = useState<string | null>(null);
const [publishStatusLessonId, setPublishStatusLessonId] = useState<
number | null
>(null);
const [createLessonOpen, setCreateLessonOpen] = useState(false);
const [createTitle, setCreateTitle] = useState("");
const [createVideoUrl, setCreateVideoUrl] = useState("");
@ -136,7 +121,6 @@ export function CourseModuleDetailPage() {
return;
}
setLessonsLoading(true);
setLessonsLoadError(null);
try {
const response = await getExamPrepModuleLessons(parsedModuleId, {
limit: 20,
@ -145,27 +129,24 @@ export function CourseModuleDetailPage() {
const rows = response.data?.data?.lessons;
const list = Array.isArray(rows) ? rows : [];
setLessons(
list.map((row) => {
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() || null,
thumbnail: row.thumbnail?.trim() || "",
sortOrder: Number(row.sort_order ?? 0),
publishStatus: row.publish_status ?? null,
durationSeconds,
};
}),
list.map((row, index) => ({
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),
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);
setLessonsLoadError("Failed to load lessons. Please try again.");
toast.error("Failed to load lessons");
setLessons([]);
} finally {
setLessonsLoading(false);
@ -271,7 +252,7 @@ export function CourseModuleDetailPage() {
}
};
const handleCreateLesson = async (publishStatus: PracticePublishStatus) => {
const handleCreateLesson = async () => {
if (!Number.isFinite(parsedModuleId) || parsedModuleId < 1) {
toast.error("Invalid module");
return;
@ -295,14 +276,9 @@ export function CourseModuleDetailPage() {
video_url: videoUrl,
thumbnail: minioThumbnail || null,
description: createDescription.trim() || null,
publish_status: publishStatus,
});
await loadLessons();
toast.success(
publishStatus === "DRAFT"
? "Lesson saved as draft"
: "Lesson created",
);
toast.success("Lesson created");
clearCreateLessonForm();
setCreateLessonOpen(false);
} catch (error: unknown) {
@ -472,45 +448,6 @@ export function CourseModuleDetailPage() {
}
};
const handleToggleLessonPublishStatus = async (
lessonId: number,
nextStatus: PracticePublishStatus,
) => {
setPublishStatusLessonId(lessonId);
try {
await publishExamPrepModuleLesson(lessonId, {
publish_status: nextStatus,
});
setLessons((prev) =>
prev.map((l) =>
l.id === lessonId ? { ...l, publishStatus: nextStatus } : l,
),
);
toast.success(
nextStatus === "PUBLISHED"
? "Lesson published"
: "Lesson saved as draft",
);
} catch (error: unknown) {
console.error(error);
const message =
(error as { response?: { data?: { message?: string } } })?.response?.data
?.message ??
(nextStatus === "PUBLISHED"
? "Failed to publish lesson"
: "Failed to save lesson as draft");
toast.error(message);
} finally {
setPublishStatusLessonId(null);
}
};
const lessonAttachPracticePath = (lesson: (typeof lessons)[number]) =>
`/new-content/courses/${programType}/${courseId}/${unitId}/${moduleId}/add-practice?lessonId=${lesson.id}&lessonTitle=${encodeURIComponent(lesson.title)}`;
const lessonPracticesPath = (lesson: (typeof lessons)[number]) =>
`/new-content/courses/${programType}/${courseId}/${unitId}/${moduleId}/lessons/${lesson.id}/practices?lessonTitle=${encodeURIComponent(lesson.title)}`;
return (
<div className="space-y-8 animate-in fade-in duration-500 pb-10">
{/* Navigation */}
@ -539,12 +476,12 @@ export function CourseModuleDetailPage() {
className="h-10 px-6 rounded-[6px] border-brand-500 text-brand-500 font-bold hover:bg-brand-50 transition-all flex items-center gap-2 shadow-sm"
onClick={() =>
navigate(
`/new-content/courses/${programType}/${courseId}/${unitId}/${moduleId}/add-practice`,
`/new-content/courses/${programType}/${courseId}/unit/${unitId}/module/${moduleId}/attach-practice`,
)
}
>
<FileText className="h-5 w-5" />
Add Practice
Attach Practice
</Button>
<Dialog
open={createLessonOpen}
@ -704,7 +641,7 @@ export function CourseModuleDetailPage() {
/>
</div>
</div>
<div className="shrink-0 px-8 py-6 bg-grayScale-50/30 border-t border-grayScale-50 flex flex-wrap justify-end gap-3">
<div className="shrink-0 px-8 py-6 bg-grayScale-50/30 border-t border-grayScale-50 flex justify-end gap-3">
<DialogClose asChild>
<Button
type="button"
@ -716,22 +653,13 @@ 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("PUBLISHED")}
onClick={() => void handleCreateLesson()}
>
{creatingLesson ? "Creating..." : "Publish lesson"}
{creatingLesson ? "Creating..." : "Create Lesson"}
</Button>
</div>
</div>
@ -741,101 +669,61 @@ export function CourseModuleDetailPage() {
</div>
{/* Tabs */}
<div className="border-b border-grayScale-200">
<div className="flex gap-10">
<button
onClick={() => setActiveTab("video")}
className={cn(
"pb-4 text-[16px] font-medium transition-all relative",
activeTab === "video"
? "text-brand-500 after:absolute after:bottom-0 after:left-0 after:right-0 after:h-[3px] after:bg-brand-500 after:rounded-t-full"
: "text-grayScale-400 hover:text-grayScale-600",
)}
>
Lesson
</button>
<button
onClick={() => setActiveTab("practice")}
className={cn(
"pb-4 text-[16px] font-medium transition-all relative",
activeTab === "practice"
? "text-brand-500 after:absolute after:bottom-0 after:left-0 after:right-0 after:h-[3px] after:bg-brand-500 after:rounded-t-full"
: "text-grayScale-400 hover:text-grayScale-600",
)}
>
Practice
</button>
</div>
<div className="flex gap-10 border-b border-grayScale-100">
<button
onClick={() => setActiveTab("video")}
className={cn(
"pb-4 text-[16px] font-bold transition-all relative px-2",
activeTab === "video"
? "text-brand-500 after:absolute after:bottom-0 after:left-0 after:right-0 after:h-[2px] after:bg-brand-500"
: "text-grayScale-400 hover:text-grayScale-600",
)}
>
Lesson
</button>
<button
onClick={() => setActiveTab("practice")}
className={cn(
"pb-4 text-[16px] font-bold transition-all relative px-2",
activeTab === "practice"
? "text-brand-500 after:absolute after:bottom-0 after:left-0 after:right-0 after:h-[2px] after:bg-brand-500"
: "text-grayScale-400 hover:text-grayScale-600",
)}
>
Practice
</button>
</div>
{/* Content */}
<div className="mt-8">
{/* Grid of Content */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 pt-4">
{activeTab === "video" ? (
lessonsLoading ? (
<div className="flex flex-col items-center justify-center py-24 text-grayScale-500 text-[15px] font-medium">
Loading lessons
</div>
) : lessonsLoadError ? (
<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">
{lessonsLoadError}
</div>
) : lessons.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{lessons.map((lesson, i) => (
<VideoCard
key={lesson.id}
id={lesson.id}
title={lesson.title}
videoUrl={lesson.videoUrl}
publishStatus={lesson.publishStatus}
hoverModuleActions
thumbnailUrl={resolveThumbnailForPreview(lesson.thumbnail)}
thumbnailGradient={
LESSON_THUMB_GRADIENTS[i % LESSON_THUMB_GRADIENTS.length]
}
durationSeconds={lesson.durationSeconds}
onEdit={() => openEditLesson(lesson)}
onDelete={() => setDeletingLessonId(lesson.id)}
description={lesson.description}
onAddPractice={() => navigate(lessonAttachPracticePath(lesson))}
onViewPractices={() => navigate(lessonPracticesPath(lesson))}
onTogglePublishStatus={(nextStatus) =>
void handleToggleLessonPublishStatus(lesson.id, nextStatus)
}
publishStatusUpdating={publishStatusLessonId === lesson.id}
/>
))}
<p className="text-sm text-grayScale-500">Loading lessons...</p>
) : lessons.length === 0 ? (
<div className="col-span-full rounded-xl border border-dashed border-grayScale-200 bg-grayScale-50/50 px-6 py-14 text-center">
<p className="text-sm font-medium text-grayScale-600">
No lessons for this module yet
</p>
<p className="mt-1 text-sm text-grayScale-400">
Create your first lesson to start building this module.
</p>
</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">
<Video className="h-7 w-7 text-brand-500 fill-brand-500/10" />
</div>
</div>
<h2 className="text-2xl font-extrabold text-grayScale-900 mb-3">
No lessons in this module yet
</h2>
<p className="text-grayScale-400 font-medium text-[15px] text-center max-w-sm mb-10 leading-relaxed">
Lessons are a great way to engage students. Add your first
lesson to get started.
</p>
<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={() => setCreateLessonOpen(true)}
>
<Video className="h-5 w-5" />
Add Lesson
</Button>
</div>
lessons.map((lesson) => (
<VideoCard
key={lesson.id}
title={lesson.title}
thumbnailUrl={lesson.thumbnail}
videoUrl={lesson.videoUrl}
thumbnailGradient={lesson.gradient}
hoverModuleActions
onEdit={() => openEditLesson(lesson)}
onDelete={() => setDeletingLessonId(lesson.id)}
/>
))
)
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{MOCK_PRACTICES.map((item) => (
<PracticeCard key={item.id} {...item} />
))}
</div>
MOCK_PRACTICES.map((item) => <PracticeCard key={item.id} {...item} />)
)}
</div>

View File

@ -39,7 +39,6 @@ import {
} from "../../api/courses.api"
import type { CategorySubCategoryListItem, CourseCategory } from "../../types/course.types"
import { cn } from "../../lib/utils"
import { TABLE_PAGE_SIZE_OPTIONS } from "../../lib/tablePagination"
export function CoursesPage() {
const { categoryId } = useParams<{ categoryId: string }>()
@ -514,7 +513,7 @@ export function CoursesPage() {
}}
className="h-8 appearance-none rounded-md border bg-white pl-2 pr-7 text-sm font-medium text-grayScale-600 focus:outline-none"
>
{TABLE_PAGE_SIZE_OPTIONS.map((size) => (
{[10, 20, 50].map((size) => (
<option key={size} value={size}>
{size}
</option>

View File

@ -1,281 +1,31 @@
import { useEffect, useMemo, useState } from "react"
import { Link, useNavigate, useParams } from "react-router-dom"
import { ArrowLeft } from "lucide-react"
import { toast } from "sonner"
import { Button } from "../../components/ui/button"
import { Card } from "../../components/ui/card"
import { Stepper } from "../../components/ui/stepper"
import {
createQuestionTypeDefinition,
validateQuestionTypeDefinition,
extractDefinitionMutationId,
getQuestionComponentCatalog,
getQuestionTypeDefinitionById,
updateQuestionTypeDefinition,
} from "../../api/questionTypeDefinitions.api"
import type {
QuestionComponentCatalog,
QuestionTypeDefinition,
QuestionTypeDefinitionCreatePayload,
} from "../../types/questionTypeDefinition.types"
import {
buildCreatePayload,
buildValidateKindsPayload,
validateDefinitionBasic,
validateDefinitionKinds,
validateDefinitionSchemas,
type FieldErrorMap,
} from "./lib/questionTypeDefinitionValidation"
import { QuestionTypeBasicInfoStep } from "./components/question-type-steps/QuestionTypeBasicInfoStep"
import { QuestionTypeConfigStep } from "./components/question-type-steps/QuestionTypeConfigStep"
import { QuestionTypeValidatePreviewStep } from "./components/question-type-steps/QuestionTypeValidatePreviewStep"
import { QuestionTypeReviewPublishStep } from "./components/question-type-steps/QuestionTypeReviewPublishStep"
import { defaultLabelForKind } from "../../lib/schemaSlotLabel"
const initialDraft = (): QuestionTypeDefinitionCreatePayload => ({
key: "",
display_name: "",
description: "",
status: "ACTIVE",
stimulus_component_kinds: [],
response_component_kinds: [],
stimulus_schema: [],
response_schema: [],
})
function seedSchemaFromKinds(kinds: string[]) {
return kinds.map((k, i) => ({
id: `${(k || "field").toLowerCase().replace(/[^a-z0-9]+/g, "_") || "field"}_${i + 1}`,
kind: k,
label: defaultLabelForKind(k),
required: true as boolean,
}))
}
function definitionToDraft(def: QuestionTypeDefinition): QuestionTypeDefinitionCreatePayload {
return {
key: def.key,
display_name: def.display_name,
description: def.description ?? "",
status: def.status === "INACTIVE" ? "INACTIVE" : "ACTIVE",
stimulus_component_kinds: [...(def.stimulus_component_kinds ?? [])],
response_component_kinds: [...(def.response_component_kinds ?? [])],
stimulus_schema: (def.stimulus_schema ?? []).map((r) => ({
...r,
label: r.label?.trim() || defaultLabelForKind(r.kind),
})),
response_schema: (def.response_schema ?? []).map((r) => ({
...r,
label: r.label?.trim() || defaultLabelForKind(r.kind),
})),
}
}
import { useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { ArrowLeft } from "lucide-react";
import { Button } from "../../components/ui/button";
import { Stepper } from "../../components/ui/stepper";
import { QuestionTypeBasicInfoStep } from "./components/question-type-steps/QuestionTypeBasicInfoStep";
import { QuestionTypeConfigStep } from "./components/question-type-steps/QuestionTypeConfigStep";
export function CreateQuestionTypeFlow() {
const navigate = useNavigate()
const { definitionId: definitionIdParam } = useParams<{ definitionId?: string }>()
const editDefinitionId = useMemo(() => {
if (!definitionIdParam || !/^\d+$/.test(definitionIdParam)) return null
const n = Number(definitionIdParam)
return Number.isFinite(n) && n > 0 ? n : null
}, [definitionIdParam])
const isEdit = editDefinitionId != null
const navigate = useNavigate();
const [currentStep, setCurrentStep] = useState(1);
const [currentStep, setCurrentStep] = useState(1)
const [draft, setDraft] = useState<QuestionTypeDefinitionCreatePayload>(initialDraft)
const [stepErrors, setStepErrors] = useState<FieldErrorMap>({})
const [definitionReady, setDefinitionReady] = useState(!isEdit)
const [isSystemDefinition, setIsSystemDefinition] = useState(false)
const steps = [
"Basic Info",
"Input & Answer Configuration",
"Versions",
"Review & Publish",
];
const [componentCatalog, setComponentCatalog] = useState<QuestionComponentCatalog>({
stimulus_component_kinds: [],
response_component_kinds: [],
})
const [catalogLoading, setCatalogLoading] = useState(true)
const [catalogError, setCatalogError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
;(async () => {
setCatalogLoading(true)
setCatalogError(null)
try {
const cat = await getQuestionComponentCatalog()
if (!cancelled) {
setComponentCatalog(cat)
if (
!cat.stimulus_component_kinds.length &&
!cat.response_component_kinds.length
) {
setCatalogError("Catalog returned no kinds — check API response shape.")
}
}
} catch (e) {
if (!cancelled) {
console.error(e)
setCatalogError("Failed to load component catalog.")
toast.error("Failed to load component catalog")
}
} finally {
if (!cancelled) setCatalogLoading(false)
}
})()
return () => {
cancelled = true
}
}, [])
useEffect(() => {
if (!isEdit || editDefinitionId == null) {
setDefinitionReady(true)
return
}
let cancelled = false
setDefinitionReady(false)
;(async () => {
try {
const def = await getQuestionTypeDefinitionById(editDefinitionId)
if (cancelled) return
if (!def) {
toast.error("Definition not found")
navigate("/new-content/question-types")
return
}
setDraft(definitionToDraft(def))
setIsSystemDefinition(Boolean(def.is_system))
setCurrentStep(1)
setStepErrors({})
} catch (e) {
if (!cancelled) {
console.error(e)
toast.error("Failed to load definition")
navigate("/new-content/question-types")
}
} finally {
if (!cancelled) setDefinitionReady(true)
}
})()
return () => {
cancelled = true
}
}, [isEdit, editDefinitionId, navigate])
useEffect(() => {
if (!isEdit) {
setDraft(initialDraft())
setCurrentStep(1)
setStepErrors({})
setDefinitionReady(true)
}
}, [isEdit])
const catalogForSchemaValidation = {
stimulus: new Set(componentCatalog.stimulus_component_kinds),
response: new Set(componentCatalog.response_component_kinds),
}
const handleNextFromStep1 = () => {
const e1 = validateDefinitionBasic(draft)
setStepErrors(e1)
if (Object.keys(e1).length) {
toast.error("Fix the highlighted fields before continuing.")
return
}
setStepErrors({})
setCurrentStep(2)
}
const handleNextFromStep2 = () => {
const eKinds = validateDefinitionKinds(draft, componentCatalog)
setStepErrors(eKinds)
if (Object.keys(eKinds).length) {
toast.error("Select valid stimulus and response component kinds.")
return
}
const nextDraft: QuestionTypeDefinitionCreatePayload = { ...draft }
if (!nextDraft.stimulus_schema.length && nextDraft.stimulus_component_kinds.length) {
nextDraft.stimulus_schema = seedSchemaFromKinds(nextDraft.stimulus_component_kinds)
}
if (!nextDraft.response_schema.length && nextDraft.response_component_kinds.length) {
nextDraft.response_schema = seedSchemaFromKinds(nextDraft.response_component_kinds)
}
setDraft(nextDraft)
const mergedSchema = validateDefinitionSchemas(nextDraft, catalogForSchemaValidation)
setStepErrors(mergedSchema)
if (Object.keys(mergedSchema).length) {
toast.error("Fix schema issues (expand Advanced) or adjust selected kinds.", {
description: "Open “Advanced: edit schema rows” to fix row ids and kinds.",
})
return
}
setStepErrors({})
setCurrentStep(3)
}
const handleBack = () => setCurrentStep((prev) => Math.max(prev - 1, 1))
const handleHeaderSaveDraft = async () => {
setDraft((d) => ({ ...d, status: "INACTIVE" }))
if (currentStep < 4) {
toast.message("Status set to Inactive", {
description: "Complete the wizard; on the last step you can save the definition to the server.",
})
return
}
const body = { ...buildCreatePayload({ ...draft, status: "INACTIVE" }), status: "INACTIVE" as const }
try {
if (isEdit && editDefinitionId != null) {
const res = await updateQuestionTypeDefinition(editDefinitionId, body)
const id = extractDefinitionMutationId(res) ?? editDefinitionId
toast.success(res.data?.message || "Definition saved as draft", {
description: `Definition id: ${id}`,
})
navigate(`/new-content/question-types?updated=${id}`)
return
}
const validation = await validateQuestionTypeDefinition(buildValidateKindsPayload(body))
if (!validation.valid) {
toast.error(validation.message || "Invalid question type definition", {
description: validation.error ? String(validation.error) : undefined,
})
return
}
const res = await createQuestionTypeDefinition(body)
const id = extractDefinitionMutationId(res)
if (id == null) {
toast.error(res.data?.message ?? "Save failed: missing definition id in response.")
return
}
toast.success(res.data?.message || "Definition saved as draft", {
description: `Definition id: ${id}`,
})
navigate(`/new-content/question-types?created=${id}`)
} catch (e: unknown) {
const err = e as { response?: { data?: { message?: string; error?: string } } }
toast.error(String(err.response?.data?.message || "Save failed"), {
description: err.response?.data?.error ? String(err.response.data.error) : undefined,
})
}
}
const steps = ["Basic Info", "Input & answer types", "Validate", "Review & publish"]
if (isEdit && !definitionReady) {
return (
<div className="min-h-screen pb-20 flex items-center justify-center px-6">
<Card className="p-10 max-w-md w-full text-center border-grayScale-200">
<p className="text-grayScale-600 font-medium">Loading definition</p>
</Card>
</div>
)
}
const handleNext = () =>
setCurrentStep((prev) => Math.min(prev + 1, steps.length));
const handleBack = () => setCurrentStep((prev) => Math.max(prev - 1, 1));
return (
<div className="min-h-screen pb-20 overflow-x-hidden">
<div className=" border-b border-grayScale-100 sticky top-0 z-50 bg-white/95 backdrop-blur">
<div className="max-w-[1440px] mx-auto py-6 px-4 sm:px-6">
{/* Header */}
<div className=" border-b border-grayScale-100 sticky top-0 z-50">
<div className="max-w-[1440px] mx-auto py-6">
<div className="flex items-center justify-between mb-8">
<Link
to="/new-content/question-types"
@ -286,18 +36,16 @@ export function CreateQuestionTypeFlow() {
</Link>
</div>
<div className="flex flex-col lg:flex-row lg:items-start lg:justify-between gap-6">
<div className="flex items-start justify-between">
<div className="space-y-1">
<h1 className="text-[28px] font-bold text-grayScale-900 tracking-tight">
{isEdit ? "Edit question type definition" : "Create question type definition"}
Create Question Type
</h1>
<p className="text-grayScale-500 text-[14px] font-medium max-w-2xl">
{isEdit
? `Update reusable question type definition #${editDefinitionId}.`
: "Build a reusable question type template for dynamic practice and assessment questions."}
<p className="text-grayScale-500 text-[14px] font-medium">
Create a new immersive practice session for students.
</p>
</div>
<div className="flex items-center gap-4 shrink-0">
<div className="flex items-center gap-4">
<Button
variant="outline"
className="h-10 px-8 rounded-[6px] border-grayScale-200 text-grayScale-900 font-medium hover:bg-grayScale-50"
@ -305,10 +53,7 @@ export function CreateQuestionTypeFlow() {
>
Cancel
</Button>
<Button
className="h-10 px-8 rounded-[6px] bg-[#9E2891] font-medium text-white shadow-lg shadow-brand-500/10 hover:bg-[#8A237E] transition-all"
onClick={handleHeaderSaveDraft}
>
<Button className="h-10 px-8 rounded-[6px] bg-[#9E2891] font-medium text-white shadow-lg shadow-brand-500/10 hover:bg-[#8A237E] transition-all">
Save as Draft
</Button>
</div>
@ -320,41 +65,23 @@ export function CreateQuestionTypeFlow() {
</div>
</div>
<div className="max-w-[1440px] mx-auto px-4 sm:px-10 mt-12 animate-in fade-in slide-in-from-bottom-4 duration-500">
{currentStep === 1 && (
<QuestionTypeBasicInfoStep
draft={draft}
setDraft={setDraft}
errors={stepErrors}
keyReadOnly={isEdit}
onNext={handleNextFromStep1}
/>
)}
{/* Main Content */}
<div className="max-w-[1440px] mx-auto px-10 mt-12 animate-in fade-in slide-in-from-bottom-4 duration-500">
{currentStep === 1 && <QuestionTypeBasicInfoStep onNext={handleNext} />}
{currentStep === 2 && (
<QuestionTypeConfigStep
draft={draft}
setDraft={setDraft}
stimulusCatalogKinds={componentCatalog.stimulus_component_kinds}
responseCatalogKinds={componentCatalog.response_component_kinds}
catalogLoading={catalogLoading}
catalogError={catalogError}
errors={stepErrors}
onNext={handleNextFromStep2}
onBack={handleBack}
/>
<QuestionTypeConfigStep onNext={handleNext} onBack={handleBack} />
)}
{currentStep === 3 && (
<QuestionTypeValidatePreviewStep draft={draft} onNext={() => setCurrentStep(4)} onBack={handleBack} />
)}
{currentStep === 4 && (
<QuestionTypeReviewPublishStep
draft={draft}
onBack={handleBack}
editDefinitionId={editDefinitionId}
isSystem={isSystemDefinition}
/>
{currentStep > 2 && (
<div className="bg-white rounded-2xl p-12 text-center border border-grayScale-100 shadow-sm">
<p className="text-grayScale-400 font-medium">
Step {currentStep} implementation in progress...
</p>
<Button onClick={handleBack} variant="outline" className="mt-4">
Go Back
</Button>
</div>
)}
</div>
</div>
)
);
}

View File

@ -9,6 +9,7 @@ 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,
@ -240,6 +241,7 @@ 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)
@ -251,6 +253,7 @@ 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("")
@ -464,6 +467,7 @@ export function HumanLanguageHierarchyPage() {
setCreateModuleUseDefaultNaming(false)
setCreateModuleDefaultTitle(getNextDefaultModuleName(level))
setCreateModuleTitle("")
setCreateModuleDescription("")
setCreateModuleIconSource("url")
setCreateModuleIconUrl("")
setCreateModuleIconFile(null)
@ -499,6 +503,7 @@ export function HumanLanguageHierarchyPage() {
await createModule({
level_id: createModuleLevelId,
title,
description: createModuleDescription.trim() || undefined,
icon_url: uploadedIconUrl,
display_order: createModuleDisplayOrder,
is_active: true,
@ -548,6 +553,7 @@ export function HumanLanguageHierarchyPage() {
levelKey,
})
setEditModuleTitle(module.title)
setEditModuleDescription("")
setEditModuleDisplayOrder(moduleDisplayOrder)
setEditModuleIconSource("url")
setEditModuleIconUrl(existingIconUrl)
@ -588,6 +594,7 @@ export function HumanLanguageHierarchyPage() {
await updateModule(editModuleTarget.moduleId, {
title,
description: editModuleDescription.trim() || undefined,
icon_url: uploadedIconUrl,
display_order: editModuleDisplayOrder,
is_active: true,
@ -787,7 +794,7 @@ export function HumanLanguageHierarchyPage() {
<div>
<p className="text-sm font-medium text-grayScale-800">Select a sub-category to start managing hierarchy</p>
<p className="mt-1 text-sm text-grayScale-500">
Choose a sub-category from the list to view and manage its course structure.
Powered by `GET /course-management/human-language/hierarchy` and `GET /course-management/courses/:courseId/hierarchy`.
</p>
</div>
</CardContent>
@ -1019,7 +1026,7 @@ export function HumanLanguageHierarchyPage() {
<DialogHeader>
<DialogTitle>Create module</DialogTitle>
<DialogDescription>
Add a module to this level.
Add a module to this level. This will call `POST /course-management/modules`.
</DialogDescription>
</DialogHeader>
@ -1061,6 +1068,17 @@ 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">
@ -1140,7 +1158,7 @@ export function HumanLanguageHierarchyPage() {
<DialogHeader>
<DialogTitle>Update module</DialogTitle>
<DialogDescription>
Update this module&apos;s name, order, and settings.
Update this module using `PUT /course-management/modules/:moduleId`.
</DialogDescription>
</DialogHeader>
@ -1155,6 +1173,17 @@ export function HumanLanguageHierarchyPage() {
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-grayScale-600">Description</label>
<Textarea
rows={3}
value={editModuleDescription}
onChange={(event) => setEditModuleDescription(event.target.value)}
placeholder="New description"
disabled={editModuleSaving}
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-grayScale-600">Display order</label>
<Input

View File

@ -1029,7 +1029,7 @@ export function HumanLanguageSubModulePage() {
<DialogHeader>
<DialogTitle>Lesson detail</DialogTitle>
<DialogDescription>
View and edit lesson details.
Loaded from `GET /course-management/sub-module-lessons/:lessonId`.
</DialogDescription>
</DialogHeader>

View File

@ -24,26 +24,9 @@ import {
updateLearningProgram,
deleteLearningProgram,
} from "../../api/courses.api";
import { refreshFileUrl, uploadImageFile } from "../../api/files.api";
import { 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);
@ -53,7 +36,6 @@ 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);
@ -62,7 +44,6 @@ 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);
@ -76,7 +57,6 @@ export function LearnEnglishPage() {
setEditingProgram(program);
setEditName(program.name ?? "");
setEditDescription(program.description?.trim() ?? "");
setEditSortOrder(String(program.sort_order ?? 0));
setEditThumbnail(program.thumbnail?.trim() ?? "");
};
@ -84,7 +64,6 @@ export function LearnEnglishPage() {
setEditingProgram(null);
setEditName("");
setEditDescription("");
setEditSortOrder("");
setEditThumbnail("");
setUploadingEditThumbnail(false);
if (editThumbnailFileInputRef.current) editThumbnailFileInputRef.current.value = "";
@ -128,7 +107,6 @@ export function LearnEnglishPage() {
const clearCreateFormFields = () => {
setCreateName("");
setCreateDescription("");
setCreateSortOrder("");
setCreateThumbnail("");
if (createThumbnailFileInputRef.current) {
createThumbnailFileInputRef.current.value = "";
@ -182,23 +160,12 @@ 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();
@ -222,23 +189,12 @@ 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();
@ -284,35 +240,6 @@ 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");
@ -356,8 +283,15 @@ export function LearnEnglishPage() {
Add New Program
</DialogTitle>
<DialogDescription className="text-sm text-grayScale-400">
Create a new learning program. Add a thumbnail as an image URL or by uploading a
file.
Create a learning program via{" "}
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px] text-grayScale-600">
POST /programs
</code>
. Thumbnail can be a URL or a file uploaded through{" "}
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px] text-grayScale-600">
POST /files/upload
</code>
.
</DialogDescription>
</DialogHeader>
{/* Gradient Divider */}
@ -414,27 +348,6 @@ 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
@ -636,17 +549,16 @@ export function LearnEnglishPage() {
if (!open) closeEdit();
}}
>
<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">
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Edit program</DialogTitle>
<DialogDescription>
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.
Update name, description, 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="min-h-0 flex-1 overflow-y-auto overscroll-contain px-6 py-4">
<div className="grid gap-4">
<div className="grid gap-4 py-2">
<div className="space-y-2">
<label className="text-sm font-medium text-grayScale-700">
Name
@ -672,26 +584,6 @@ 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
@ -732,12 +624,15 @@ export function LearnEnglishPage() {
disabled={savingEdit || uploadingEditThumbnail}
/>
<p className="text-xs text-grayScale-500">
Uploaded images are stored and used as the program thumbnail.
Local images are sent to{" "}
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px]">
POST /files/upload
</code>
; the returned URL is stored as the program thumbnail.
</p>
</div>
</div>
</div>
<DialogFooter className="shrink-0 gap-2 border-t border-grayScale-100 bg-white px-6 py-4 sm:gap-0">
<DialogFooter className="gap-2 sm:gap-0">
<Button
type="button"
variant="outline"

View File

@ -1,380 +0,0 @@
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, programType, courseId, unitId, moduleId, lessonId } = useParams<{
level?: string;
programType?: string;
courseId?: string;
unitId?: string;
moduleId?: string;
lessonId?: string;
}>();
const [searchParams] = useSearchParams();
const lessonTitle = searchParams.get("lessonTitle")?.trim() || "";
const isExamPrep = Boolean(programType?.trim());
const backHref = isExamPrep
? `/new-content/courses/${programType}/${courseId}/${unitId}/${moduleId}`
: `/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 = isExamPrep
? `/new-content/courses/${programType}/${courseId}/${unitId}/${moduleId}/add-practice?lessonId=${lid}&lessonTitle=${encodeURIComponent(lessonTitle || displayTitle)}`
: `/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}
/>
))}
</div>
)}
</div>
</div>
</div>
);
}

View File

@ -1,27 +1,23 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { ArrowLeft, Video, Calendar, Trash2, X } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import {
ArrowLeft,
Video,
Calendar,
Mic,
Layers,
Edit2,
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 {
ParentContextPractice,
PracticePublishStatus,
TopLevelModuleLessonItem,
} from "../../types/course.types";
import {
isPracticeDraft,
isPracticePublished,
unwrapPracticesList,
} from "../../lib/parentContextPractice";
import type { TopLevelModuleLessonItem } from "../../types/course.types";
import { Button } from "../../components/ui/button";
import {
Dialog,
@ -36,7 +32,6 @@ 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 = [
@ -46,6 +41,37 @@ 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;
@ -61,14 +87,13 @@ export function ModuleDetailPage() {
moduleId: string;
}>();
const [activeTab, setActiveTab] = useState<"video" | "practice">("video");
const [activeFilter, setActiveFilter] = useState("All");
const [activeFilter, setActiveFilter] = useState("Draft");
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("");
@ -79,17 +104,7 @@ export function ModuleDetailPage() {
const [deletingLesson, setDeletingLesson] =
useState<TopLevelModuleLessonItem | null>(null);
const [deletingLessonInFlight, setDeletingLessonInFlight] = useState(false);
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 [practices] = useState(MOCK_PRACTICES);
const [loadedModuleName, setLoadedModuleName] = useState<string | null>(null);
const [loadedModuleDescription, setLoadedModuleDescription] = useState<
string | null
@ -218,96 +233,9 @@ 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 ?? "");
@ -325,16 +253,6 @@ 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, {
@ -342,7 +260,6 @@ export function ModuleDetailPage() {
video_url: editLessonVideoUrl.trim(),
thumbnail: editLessonThumbnail.trim(),
description: editLessonDescription.trim(),
sort_order,
});
toast.success("Lesson updated");
setEditingLesson(null);
@ -358,39 +275,6 @@ 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);
@ -509,34 +393,11 @@ 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}
onAddPractice={() =>
navigate(
`/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>
@ -593,66 +454,12 @@ export function ModuleDetailPage() {
</div>
</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>
)}
{/* 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>
</div>
)}
</div>
@ -668,7 +475,15 @@ export function ModuleDetailPage() {
<DialogHeader>
<DialogTitle>Edit lesson</DialogTitle>
<DialogDescription>
Update lesson details. Uploaded video and thumbnail files are stored automatically.
Update details. Video and thumbnail files use{" "}
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px]">
POST /files/upload
</code>
; the form is saved with{" "}
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px]">
PUT /lessons/:id
</code>
.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-2">
@ -686,28 +501,6 @@ 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}
@ -816,3 +609,68 @@ export function ModuleDetailPage() {
</div>
);
}
function PracticeCard({
title,
level,
variations,
status,
}: {
title: string;
level: string;
variations: number;
status: string;
}) {
return (
<div className="bg-white rounded-[24px] border border-grayScale-50 shadow-sm overflow-hidden hover:shadow-xl hover:shadow-grayScale-400/5 transition-all group p-6 flex flex-col h-full min-h-[340px]">
<div className="flex-1 space-y-6">
<div className="flex items-center justify-between">
<h3 className="text-[18px] font-bold text-grayScale-900 line-clamp-1">
{title}
</h3>
</div>
<div className="flex items-center justify-between gap-3">
<span className="bg-[#22C55E] text-white text-[11px] font-bold px-2 py-1 rounded-[4px]">
{level}
</span>
<div className="flex items-center gap-1.5 text-grayScale-500">
<Mic className="h-4 w-4" />
<span className="text-[13px] font-bold">Speaking</span>
</div>
</div>
<div className="flex items-center gap-2.5 text-brand-400 w-fit py-2 rounded-xl">
<Layers className="h-4 w-4" />
<span className="text-[14px] font-bold">{variations} Variations</span>
</div>
<div className="flex border-t border-grayScale-200 items-center justify-between pt-2">
<div className="bg-grayScale-100 text-grayScale-400 text-[11px] font-bold px-3 py-1.5 rounded-[6px] tracking-wide uppercase">
{status}
</div>
<div className="flex items-center gap-3">
<button className="h-8 w-8 rounded-lg flex items-center justify-center text-grayScale-400 hover:text-brand-500 hover:border-brand-100 transition-all">
<Edit2 className="h-5 w-5" />
</button>
<button className="h-8 w-8 rounded-lg flex items-center justify-center text-grayScale-400 hover:text-red-500 hover:border-red-100 transition-all">
<Trash2 className="h-5 w-5" />
</button>
</div>
</div>
</div>
<div className="mt-8 grid grid-cols-2 gap-3">
<Button className="bg-brand-500 text-white rounded-xl h-11 text-[13px] font-bold shadow-md shadow-brand-500/10 hover:bg-brand-600 transition-all px-0">
Publish Practice
</Button>
<Button
variant="outline"
className="border-brand-500 text-brand-500 rounded-xl h-11 text-[13px] font-bold bg-white hover:bg-brand-50 transition-all px-0"
>
Publish Video
</Button>
</div>
</div>
);
}

View File

@ -7,31 +7,14 @@ export function NewContentPage() {
return (
<div className="space-y-8">
{/* Header section */}
<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>
<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>
{/* Gradient Divider */}

View File

@ -1093,6 +1093,9 @@ export function PracticeDetailsPage() {
placeholder="Optional"
/>
</div>
<p className="text-xs text-grayScale-500">
Uses <span className="font-mono">PUT /practices/&#123;id&#125;</span> with the fields above.
</p>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button type="button" variant="outline" onClick={() => setEditOpen(false)} disabled={savePracticeLoading}>
@ -1112,8 +1115,9 @@ export function PracticeDetailsPage() {
<DialogTitle>Delete this practice?</DialogTitle>
</DialogHeader>
<p className="text-sm text-grayScale-600">
This permanently removes the practice for this {parentTabCopy[parentTab].label.toLowerCase()}. The linked
question set may remain unless you remove it separately.
This will call <span className="font-mono">DELETE /practices/&#123;id&#125;</span> and remove the practice
for this {parentTabCopy[parentTab].label.toLowerCase()}. The question set is not deleted unless your API
cascades.
</p>
<DialogFooter className="gap-2 sm:gap-0">
<Button

View File

@ -20,7 +20,6 @@ import { Input } from "../../components/ui/input"
import { Select } from "../../components/ui/select"
import { Textarea } from "../../components/ui/textarea"
import type { PracticeQuestion, QuestionSetQuestion, QuestionDetail } from "../../types/course.types"
import { DEFAULT_TABLE_PAGE_SIZE, TABLE_PAGE_SIZE_OPTIONS } from "../../lib/tablePagination"
type QuestionType = "MCQ" | "TRUE_FALSE" | "SHORT" | "AUDIO"
type DifficultyLevel = "EASY" | "MEDIUM" | "HARD"
@ -85,7 +84,7 @@ export function PracticeQuestionsPage() {
const [loadingDetailIds, setLoadingDetailIds] = useState<Record<number, boolean>>({})
const [groupBy, setGroupBy] = useState<GroupByOption>("none")
const [pointsSort, setPointsSort] = useState<PointsSortOption>("desc")
const [pageSize, setPageSize] = useState(DEFAULT_TABLE_PAGE_SIZE)
const [pageSize] = useState(10)
const [currentPage, setCurrentPage] = useState(1)
const [totalQuestions, setTotalQuestions] = useState(0)
@ -737,56 +736,29 @@ export function PracticeQuestionsPage() {
))}
</div>
))}
{totalQuestions > 0 && (
<div className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-grayScale-200 bg-white px-4 py-3 text-sm text-grayScale-500">
<div className="flex flex-wrap items-center gap-2">
<span>
Page {currentPage} of {Math.max(1, Math.ceil(totalQuestions / pageSize))} ({totalQuestions}{" "}
total)
</span>
<span className="hidden h-4 w-px bg-grayScale-200 sm:inline" />
<span className="flex items-center gap-2">
Rows per page
<div className="relative">
<select
value={pageSize}
onChange={(e) => {
setPageSize(Number(e.target.value))
setCurrentPage(1)
void fetchQuestions(1)
}}
className="h-8 appearance-none rounded-md border bg-white pl-2 pr-7 text-sm font-medium text-grayScale-600 focus:outline-none"
>
{TABLE_PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>
{size}
</option>
))}
</select>
<ChevronDown className="pointer-events-none absolute right-2 top-1/2 h-3 w-3 -translate-y-1/2 text-grayScale-400" />
</div>
</span>
{totalQuestions > pageSize && (
<div className="flex items-center justify-between rounded-xl border border-grayScale-200 bg-white px-4 py-3">
<p className="text-sm text-grayScale-500">
Page {currentPage} of {Math.max(1, Math.ceil(totalQuestions / pageSize))}
</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
disabled={currentPage <= 1}
onClick={() => void fetchQuestions(currentPage - 1)}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
disabled={currentPage >= Math.ceil(totalQuestions / pageSize)}
onClick={() => void fetchQuestions(currentPage + 1)}
>
Next
</Button>
</div>
{totalQuestions > pageSize ? (
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
disabled={currentPage <= 1}
onClick={() => void fetchQuestions(currentPage - 1)}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
disabled={currentPage >= Math.ceil(totalQuestions / pageSize)}
onClick={() => void fetchQuestions(currentPage + 1)}
>
Next
</Button>
</div>
) : null}
</div>
)}
</div>

View File

@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { ArrowLeft, Plus, Pencil, Trash2, X } from "lucide-react";
import { ArrowLeft, Plus, FileText, 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,6 +14,7 @@ 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";
@ -29,7 +30,6 @@ 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 [editSortOrder, setEditSortOrder] = useState("");
const [editDescription, setEditDescription] = 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 [createSortOrder, setCreateSortOrder] = useState("");
const [createDescription, setCreateDescription] = 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("");
setEditSortOrder("");
setEditDescription("");
setEditThumbnail("");
setUploadingEditThumbnail(false);
if (editThumbnailFileInputRef.current) {
@ -192,23 +192,12 @@ 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: editingCourse.description?.trim() ?? "",
description: editDescription.trim(),
thumbnail: editThumbnail.trim(),
sort_order,
});
toast.success("Course updated");
closeEditCourse();
@ -226,7 +215,7 @@ export function ProgramCoursesPage() {
const clearCreateCourseForm = () => {
setCreateName("");
setCreateSortOrder("");
setCreateDescription("");
setCreateThumbnail("");
setCreateUploadingThumbnail(false);
if (createThumbnailFileInputRef.current) {
@ -282,23 +271,12 @@ 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: "",
description: createDescription.trim(),
thumbnail: createThumbnail.trim(),
sort_order,
});
toast.success("Course created");
clearCreateCourseForm();
@ -359,6 +337,18 @@ 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}
@ -379,8 +369,15 @@ export function ProgramCoursesPage() {
Add New Course
</DialogTitle>
<DialogDescription className="text-sm text-grayScale-400">
Add a new course to this program. Use an image URL or upload a file for the
thumbnail.
Create a course via{" "}
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px] text-grayScale-600">
POST /programs/:program_id/courses
</code>
. Thumbnail can be a URL or a file from{" "}
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px] text-grayScale-600">
POST /files/upload
</code>
.
</DialogDescription>
</DialogHeader>
@ -425,27 +422,17 @@ export function ProgramCoursesPage() {
</div>
<div className="space-y-2">
<label
htmlFor="create-course-sort-order"
className="text-[15px] font-medium text-grayScale-700"
>
Sort Order
<label className="text-[15px] font-medium text-grayScale-700">
Description
</label>
<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"
<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"
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">
@ -677,11 +664,9 @@ export function ProgramCoursesPage() {
>
View Detail
</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"
/>
<Button className="h-10 flex-1 rounded-[6px] bg-brand-500 text-[13px] font-semibold ">
Publish Practice
</Button>
</div>
</CardContent>
</Card>
@ -697,15 +682,18 @@ export function ProgramCoursesPage() {
if (!open) closeEditCourse();
}}
>
<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">
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Edit course</DialogTitle>
<DialogDescription>
Update name, sort order, and thumbnail.
Update name, description, 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="min-h-0 flex-1 overflow-y-auto overscroll-contain px-6 py-4">
<div className="grid gap-4">
<div className="grid gap-4 py-2">
<div className="space-y-2">
<label className="text-sm font-medium text-grayScale-700">
Name
@ -719,27 +707,17 @@ export function ProgramCoursesPage() {
/>
</div>
<div className="space-y-2">
<label
htmlFor="edit-course-sort-order"
className="text-sm font-medium text-grayScale-700"
>
Sort Order
<label className="text-sm font-medium text-grayScale-700">
Description
</label>
<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"
<Textarea
value={editDescription}
onChange={(e) => setEditDescription(e.target.value)}
rows={4}
className="min-h-[100px] resize-y rounded-xl"
placeholder="Short summary"
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">
@ -782,8 +760,7 @@ export function ProgramCoursesPage() {
/>
</div>
</div>
</div>
<DialogFooter className="shrink-0 gap-2 border-t border-grayScale-100 bg-white px-6 py-4 sm:gap-0">
<DialogFooter className="gap-2 sm:gap-0">
<Button
type="button"
variant="outline"

View File

@ -13,6 +13,7 @@ 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,
@ -38,6 +39,7 @@ 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);
@ -58,6 +60,7 @@ 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);
@ -213,7 +216,7 @@ export function ProgramDetailPage() {
const response = await createExamPrepCatalogCourse({
name,
description: null,
description: createDescription.trim() || null,
thumbnail: thumbnailToSend,
});
const row = response.data?.data;
@ -224,7 +227,7 @@ export function ProgramDetailPage() {
{
id: row.id,
name: row.name ?? name,
description: row.description?.trim() || "—",
description: row.description?.trim() || createDescription.trim() || "—",
thumbnail: row.thumbnail?.trim() || null,
sortOrder: Number(row.sort_order ?? 0),
unitsCount: Number(row.units_count ?? 0),
@ -236,6 +239,7 @@ export function ProgramDetailPage() {
await loadCatalogCourses();
toast.success("Course created");
setCreateName("");
setCreateDescription("");
setCreateThumbnail("");
setCreateThumbnailFromUpload(false);
setCreateOpen(false);
@ -255,6 +259,7 @@ 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));
};
@ -263,6 +268,7 @@ export function ProgramDetailPage() {
if (savingEdit || uploadingEditThumbnail) return;
setEditingCourseId(null);
setEditName("");
setEditDescription("");
setEditThumbnail("");
setEditSortOrder("1");
};
@ -311,14 +317,9 @@ 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: preservedDescription,
description: editDescription.trim() || null,
thumbnail: minioThumbnail || null,
sort_order: sortOrderNum,
});
@ -329,7 +330,7 @@ export function ProgramDetailPage() {
? {
...course,
name: row?.name ?? name,
description: row?.description?.trim() || preservedDescription || "—",
description: row?.description?.trim() || editDescription.trim() || "—",
thumbnail: row?.thumbnail?.trim() || null,
sortOrder: Number(row?.sort_order ?? sortOrderNum),
unitsCount: Number(row?.units_count ?? course.unitsCount ?? 0),
@ -466,6 +467,20 @@ 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
@ -557,11 +572,11 @@ export function ProgramDetailPage() {
variant="outline"
className="h-10 px-6 rounded-[6px] border-brand-500 text-brand-500 font-bold flex items-center gap-2"
onClick={() =>
navigate(`/new-content/courses/${programType}/add-practice`)
navigate(`/new-content/courses/${programType}/attach-practice`)
}
>
<FileText className="h-5 w-5" />
Add Practice
Attach Practice
</Button>
</div>
</div>
@ -720,6 +735,17 @@ export function ProgramDetailPage() {
/>
</div>
<div className="space-y-3">
<label className="text-[15px] text-grayScale-800">Description</label>
<Textarea
value={editDescription}
onChange={(e) => setEditDescription(e.target.value)}
rows={4}
className="min-h-[96px] rounded-[8px] border-grayScale-400"
disabled={savingEdit || uploadingEditThumbnail}
/>
</div>
<div className="space-y-3">
<label className="text-[15px] text-grayScale-800">Sort Order</label>
<Input

View File

@ -1,18 +1,26 @@
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="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 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>
{/* Gradient Divider */}

View File

@ -1,519 +1,127 @@
import { useCallback, useEffect, useMemo, useState } from "react"
import { Link, useNavigate, useSearchParams } from "react-router-dom"
import {
ArrowLeft,
ChevronDown,
ChevronLeft,
ChevronRight,
Layers,
Plus,
RefreshCw,
Search,
Trash2,
X,
} from "lucide-react"
import { toast } from "sonner"
import { Button } from "../../components/ui/button"
import { Input } from "../../components/ui/input"
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "../../components/ui/dialog"
import { SpinnerIcon } from "../../components/ui/spinner-icon"
import { cn } from "../../lib/utils"
import { DEFAULT_TABLE_PAGE_SIZE, TABLE_PAGE_SIZE_OPTIONS } from "../../lib/tablePagination"
import { QuestionTypeCard } from "./components/QuestionTypeCard"
import {
deleteQuestionTypeDefinition,
getQuestionTypeDefinitions,
} from "../../api/questionTypeDefinitions.api"
import type { QuestionTypeDefinition } from "../../types/questionTypeDefinition.types"
type StatusFilter = "All" | "ACTIVE" | "INACTIVE"
type ScopeFilter = "all" | "system" | "custom"
const STATUS_OPTIONS: { value: StatusFilter; label: string }[] = [
{ value: "All", label: "All statuses" },
{ value: "ACTIVE", label: "Active" },
{ value: "INACTIVE", label: "Inactive" },
]
const SCOPE_OPTIONS: { value: ScopeFilter; label: string }[] = [
{ value: "all", label: "All types" },
{ value: "system", label: "System only" },
{ value: "custom", label: "Custom only" },
]
const SYSTEM_SCOPE_FETCH_LIMIT = 100
import { useState } from "react";
import { Link } from "react-router-dom";
import { ArrowLeft, Plus, Search } from "lucide-react";
import { Button } from "../../components/ui/button";
import { Input } from "../../components/ui/input";
import { Select } from "../../components/ui/select";
import { Card } from "../../components/ui/card";
import { cn } from "../../lib/utils";
import { QuestionTypeCard } from "./components/QuestionTypeCard";
export function QuestionTypeLibraryPage() {
const navigate = useNavigate()
const [searchParams] = useSearchParams()
const createdId = searchParams.get("created")
const updatedId = searchParams.get("updated")
const [activeTab, setActiveTab] = useState("All");
const [loading, setLoading] = useState(true)
const [definitions, setDefinitions] = useState<QuestionTypeDefinition[]>([])
const [totalCount, setTotalCount] = useState<number | undefined>(undefined)
const [query, setQuery] = useState("")
const [statusFilter, setStatusFilter] = useState<StatusFilter>("All")
const [scopeFilter, setScopeFilter] = useState<ScopeFilter>("all")
const [pageSize, setPageSize] = useState(DEFAULT_TABLE_PAGE_SIZE)
const [offset, setOffset] = useState(0)
const [definitionPendingDelete, setDefinitionPendingDelete] = useState<QuestionTypeDefinition | null>(null)
const [deleteSubmitting, setDeleteSubmitting] = useState(false)
const hasActiveFilters =
query.trim().length > 0 || statusFilter !== "All" || scopeFilter !== "all"
const load = useCallback(async () => {
setLoading(true)
try {
const isSystemScope = scopeFilter === "system"
const { definitions: rows, total_count } = await getQuestionTypeDefinitions({
include_system: scopeFilter !== "custom",
...(statusFilter !== "All" ? { status: statusFilter } : {}),
limit: isSystemScope ? SYSTEM_SCOPE_FETCH_LIMIT : pageSize,
offset: isSystemScope ? 0 : offset,
})
const visibleRows = isSystemScope ? rows.filter((d) => d.is_system) : rows
setDefinitions(visibleRows)
if (isSystemScope) {
setTotalCount(visibleRows.length)
} else if (total_count != null) {
setTotalCount(total_count)
} else if (rows.length < pageSize) {
setTotalCount(offset + rows.length)
} else {
setTotalCount(undefined)
}
} catch (e) {
console.error(e)
toast.error("Failed to load question type definitions")
setDefinitions([])
setTotalCount(0)
} finally {
setLoading(false)
}
}, [offset, pageSize, scopeFilter, statusFilter])
useEffect(() => {
void load()
}, [load])
useEffect(() => {
if (createdId) {
toast.success("Definition created", { description: `Id ${createdId}` })
}
}, [createdId])
useEffect(() => {
if (updatedId) {
toast.success("Definition updated", { description: `Id ${updatedId}` })
}
}, [updatedId])
const filtered = useMemo(() => {
const q = query.trim().toLowerCase()
if (!q) return definitions
return definitions.filter((d) => {
const name = (d.display_name || "").toLowerCase()
const key = (d.key || "").toLowerCase()
return name.includes(q) || key.includes(q) || String(d.id).includes(q)
})
}, [definitions, query])
const isSystemScope = scopeFilter === "system"
const canPrev = !isSystemScope && offset > 0
const canNext =
!isSystemScope &&
(totalCount != null ? offset + pageSize < totalCount : definitions.length === pageSize)
const pageStart = totalCount === 0 ? 0 : isSystemScope ? 1 : offset + 1
const pageEnd =
isSystemScope
? filtered.length
: totalCount != null
? Math.min(offset + definitions.length, totalCount)
: offset + definitions.length
const resetPagination = () => setOffset(0)
const clearFilters = () => {
setQuery("")
setStatusFilter("All")
setScopeFilter("all")
setOffset(0)
}
const openDeleteConfirm = (row: QuestionTypeDefinition) => {
if (row.is_system) {
toast.error("System definitions cannot be deleted.")
return
}
setDefinitionPendingDelete(row)
}
const handleDeleteDialogOpenChange = (open: boolean) => {
if (!open && !deleteSubmitting) setDefinitionPendingDelete(null)
}
const handleConfirmDeleteDefinition = async () => {
const row = definitionPendingDelete
if (!row) return
setDeleteSubmitting(true)
try {
await deleteQuestionTypeDefinition(row.id)
toast.success("Definition deleted")
setDefinitionPendingDelete(null)
void load()
} catch (e: unknown) {
const err = e as { response?: { data?: { message?: string } } }
toast.error(String(err.response?.data?.message || "Delete failed"))
} finally {
setDeleteSubmitting(false)
}
}
const questionTypes = [
{
title: "Describe a Photo",
exam: "DUOLINGO" as const,
skill: "Speaking" as const,
variations: 12,
status: "Published" as const,
},
{
title: "Write About the Topic",
exam: "DUOLINGO" as const,
skill: "Writing" as const,
variations: 12,
status: "Published" as const,
},
{
title: "Fill in the Blanks",
exam: "IELTS" as const,
skill: "Writing" as const,
variations: 12,
status: "Published" as const,
},
{
title: "Describe a Photo",
exam: "DUOLINGO" as const,
skill: "Speaking" as const,
variations: 12,
status: "Published" as const,
},
];
return (
<div className="space-y-8 animate-in fade-in duration-500 pb-20">
{/* Navigation & Header */}
<div className="space-y-6">
<Link
to="/new-content"
to="/new-content/courses"
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
Back to Courses
</Link>
<div className="flex items-start justify-between gap-4 flex-wrap">
<div className="flex items-start justify-between">
<div className="space-y-1">
<h1 className="text-[32px] font-medium text-grayScale-900 tracking-tight">Question type definitions</h1>
<p className="text-grayScale-500 text-[16px] font-medium max-w-2xl">
Reusable templates that define how practice and assessment questions are structured and answered.
<h1 className="text-[32px] font-medium text-grayScale-900 tracking-tight">
Question Type Library
</h1>
<p className="text-grayScale-500 text-[16px] font-medium">
Create and manage reusable question structures for practices and
assessments.
</p>
</div>
<Link to="/new-content/question-types/create">
<Button className="h-12 px-8 rounded-[10px] bg-[#9E2891] font-bold text-white shadow-lg shadow-brand-500/10 hover:bg-[#8A237E] transition-all flex items-center gap-3">
<Plus className="h-5 w-5" />
Create definition
Create Question Type
</Button>
</Link>
</div>
</div>
<Card className="overflow-hidden rounded-2xl border border-grayScale-200 bg-white shadow-none">
<CardHeader className="border-b border-grayScale-100 px-6 py-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-brand-50 text-brand-600">
<Layers className="h-5 w-5" aria-hidden />
</div>
<div>
<CardTitle className="text-base font-bold text-grayScale-900">Definition library</CardTitle>
<p className="text-xs text-grayScale-500 mt-0.5">
Browse and filter templates from the question type catalog
</p>
</div>
</div>
<Button
type="button"
variant="outline"
size="sm"
className="rounded-[8px] border-grayScale-200"
disabled={loading}
onClick={() => void load()}
>
<RefreshCw className={cn("mr-2 h-4 w-4", loading && "animate-spin")} />
Refresh
</Button>
{/* Control Bar */}
<Card className="p-6 border-grayScale-200 rounded-2xl bg-white space-y-6">
<div className="flex items-center gap-4">
<div className="relative flex-1">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 h-5 w-5 text-grayScale-600" />
<Input
className="h-10 pl-12 rounded-[6px] border-grayScale-200 placeholder:text-grayScale-600 bg-[#F8FAFC] transition-all text-sm"
placeholder="Search by practice name, ID, or keywords..."
/>
</div>
</CardHeader>
<Select className="h-10 w-[180px] rounded-[6px] border-grayScale-200 placeholder:text-grayScale-600 text-grayScale-700 bg-[#F8FAFC] transition-all text-sm">
<option>All Exams</option>
<option>IELTS</option>
<option>Duolingo</option>
</Select>
<Select className="h-10 w-[180px] rounded-[6px] border-grayScale-200 placeholder:text-grayScale-600 text-grayScale-700 bg-[#F8FAFC] transition-all text-sm">
<option>All Skills</option>
<option>Speaking</option>
<option>Writing</option>
</Select>
</div>
<CardContent className="p-0">
<div className="border-b border-grayScale-100 bg-grayScale-50/60 px-6 py-5 space-y-4">
<div className="relative">
<Search className="absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-400" />
<Input
className="h-11 pl-11 pr-10 rounded-[10px] border-grayScale-200 bg-white placeholder:text-grayScale-400 text-sm shadow-sm"
placeholder="Search by display name, key, or id on this page…"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
{query ? (
<button
type="button"
onClick={() => setQuery("")}
className="absolute right-3 top-1/2 -translate-y-1/2 rounded-md p-1 text-grayScale-400 hover:bg-grayScale-100 hover:text-grayScale-600"
aria-label="Clear search"
>
<X className="h-4 w-4" />
</button>
) : null}
</div>
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
<div className="flex flex-wrap items-center gap-2">
<span className="mr-1 text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
Status
</span>
{STATUS_OPTIONS.map(({ value, label }) => (
<FilterChip
key={value}
label={label}
active={statusFilter === value}
disabled={loading}
onClick={() => {
setStatusFilter(value)
resetPagination()
}}
/>
))}
</div>
<span className="hidden h-5 w-px bg-grayScale-200 sm:block" />
<div className="flex flex-wrap items-center gap-2">
<span className="mr-1 text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
Scope
</span>
{SCOPE_OPTIONS.map(({ value, label }) => (
<FilterChip
key={value}
label={label}
active={scopeFilter === value}
disabled={loading}
onClick={() => {
setScopeFilter(value)
resetPagination()
}}
/>
))}
</div>
</div>
{hasActiveFilters ? (
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 shrink-0 self-start rounded-[8px] px-3 text-xs font-semibold text-grayScale-500 hover:text-brand-600 lg:self-center"
disabled={loading}
onClick={clearFilters}
>
Clear filters
</Button>
) : null}
</div>
</div>
{loading ? (
<div className="flex flex-col items-center justify-center gap-3 px-6 py-20">
<SpinnerIcon className="h-8 w-8 text-brand-500" />
<p className="text-sm text-grayScale-500">Loading definitions</p>
</div>
) : filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-3 px-6 py-20 text-center">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-grayScale-100 text-grayScale-400">
<Layers className="h-7 w-7" aria-hidden />
</div>
<p className="text-sm font-semibold text-grayScale-700">
{hasActiveFilters ? "No definitions match your filters" : "No definitions yet"}
</p>
<p className="max-w-sm text-xs text-grayScale-500">
{hasActiveFilters
? "Try different filters or clear them to see more results."
: "Create a definition to start building custom question templates."}
</p>
{hasActiveFilters ? (
<Button
type="button"
variant="outline"
size="sm"
className="rounded-[8px]"
onClick={clearFilters}
>
Clear filters
</Button>
) : (
<Link to="/new-content/question-types/create">
<Button size="sm" className="rounded-[8px] bg-brand-600 hover:bg-brand-500">
<Plus className="mr-2 h-4 w-4" />
Create definition
</Button>
</Link>
<div className="flex items-center gap-3">
<span className="text-[12px] font-medium text-grayScale-400 uppercase tracking-widest mr-2">
STATUS:
</span>
{["All", "Published", "Drafts", "Archived"].map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={cn(
"h-10 px-4 rounded-full text-[13px] font-medium transition-all",
activeTab === tab
? "bg-[#9E2891] text-white shadow-md shadow-brand-500/20"
: "bg-grayScale-100 text-grayScale-400 hover:bg-grayScale-100",
)}
</div>
) : (
<div className="grid grid-cols-1 gap-5 px-6 py-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{filtered.map((d) => (
<QuestionTypeCard
key={d.id}
id={d.id}
definitionKey={d.key}
display_name={d.display_name}
status={d.status}
is_system={d.is_system}
stimulusKindsCount={d.stimulus_component_kinds?.length ?? 0}
responseKindsCount={d.response_component_kinds?.length ?? 0}
deleteDisabled={!!d.is_system}
onEdit={() => navigate(`/new-content/question-types/${d.id}/edit`)}
onDelete={() => openDeleteConfirm(d)}
/>
))}
</div>
)}
{!loading && (definitions.length > 0 || offset > 0) ? (
<div className="flex flex-wrap items-center justify-between gap-4 border-t border-grayScale-100 bg-white px-6 py-4">
<div className="flex flex-wrap items-center gap-3 text-xs text-grayScale-500">
<span>
{totalCount != null
? `Showing ${pageStart}${pageEnd} of ${totalCount}`
: `Showing ${pageStart}${pageEnd}`}
</span>
{query.trim() && filtered.length !== definitions.length ? (
<span className="rounded-full bg-brand-50 px-2.5 py-0.5 text-[11px] font-semibold text-brand-600">
{filtered.length} match{filtered.length === 1 ? "" : "es"} on this page
</span>
) : null}
{!isSystemScope ? (
<>
<span className="hidden h-4 w-px bg-grayScale-200 sm:inline" />
<span className="flex items-center gap-2">
Per page
<div className="relative">
<select
value={pageSize}
disabled={loading}
onChange={(e) => {
setPageSize(Number(e.target.value))
setOffset(0)
}}
className="h-8 appearance-none rounded-[8px] border border-grayScale-200 bg-white pl-2.5 pr-8 text-sm font-medium text-grayScale-600 focus:outline-none focus:ring-2 focus:ring-brand-200"
>
{TABLE_PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>
{size}
</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>
</span>
</>
) : null}
</div>
{!isSystemScope ? (
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
className="rounded-[8px] border-grayScale-200"
disabled={!canPrev || loading}
onClick={() => setOffset((o) => Math.max(0, o - pageSize))}
>
<ChevronLeft className="mr-1 h-4 w-4" />
Previous
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="rounded-[8px] border-grayScale-200"
disabled={!canNext || loading}
onClick={() => setOffset((o) => o + pageSize)}
>
Next
<ChevronRight className="ml-1 h-4 w-4" />
</Button>
</div>
) : null}
</div>
) : null}
</CardContent>
>
{tab}
</button>
))}
</div>
</Card>
<Dialog open={definitionPendingDelete !== null} onOpenChange={handleDeleteDialogOpenChange}>
<DialogContent className="max-w-md rounded-2xl border-grayScale-200 sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-lg font-bold text-grayScale-900">
<Trash2 className="h-5 w-5 text-red-600 shrink-0" aria-hidden />
Delete question type definition?
</DialogTitle>
<DialogDescription className="text-left text-grayScale-600">
This removes the template from the library. Existing questions that reference it may be affected. This
action cannot be undone.
</DialogDescription>
</DialogHeader>
{definitionPendingDelete ? (
<div className="rounded-xl border border-grayScale-200 bg-grayScale-50 px-4 py-3 space-y-1">
<p className="text-sm font-semibold text-grayScale-900">{definitionPendingDelete.display_name}</p>
<p className="text-xs font-mono text-grayScale-500 break-all">
#{definitionPendingDelete.id} · {definitionPendingDelete.key}
</p>
</div>
) : null}
<DialogFooter className="gap-2 sm:gap-0">
<Button
type="button"
variant="outline"
className="border-grayScale-200"
disabled={deleteSubmitting}
onClick={() => setDefinitionPendingDelete(null)}
>
Cancel
</Button>
<Button
type="button"
variant="destructive"
disabled={deleteSubmitting}
className="gap-2"
onClick={() => void handleConfirmDeleteDefinition()}
>
{deleteSubmitting ? <SpinnerIcon className="h-4 w-4" /> : <Trash2 className="h-4 w-4" aria-hidden />}
{deleteSubmitting ? "Deleting…" : "Delete definition"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Grid of Cards */}
<div className="grid grid-cols-4 gap-6">
{questionTypes.map((qt, index) => (
<QuestionTypeCard key={index} {...qt} />
))}
</div>
</div>
)
}
function FilterChip({
label,
active,
disabled,
onClick,
}: {
label: string
active: boolean
disabled?: boolean
onClick: () => void
}) {
return (
<button
type="button"
disabled={disabled}
onClick={onClick}
className={cn(
"rounded-full border px-3.5 py-1.5 text-xs font-semibold transition-all",
active
? "border-brand-500 bg-brand-500 text-white shadow-sm shadow-brand-500/20"
: "border-grayScale-200 bg-white text-grayScale-600 hover:border-brand-200 hover:text-brand-600",
disabled && "pointer-events-none opacity-50",
)}
>
{label}
</button>
)
);
}

View File

@ -19,7 +19,6 @@ import { Badge } from "../../components/ui/badge"
import { deleteQuestion, getQuestionById, getQuestions, updateQuestion } from "../../api/courses.api"
import type { QuestionDetail } from "../../types/course.types"
import { cn } from "../../lib/utils"
import { TABLE_PAGE_SIZE_OPTIONS } from "../../lib/tablePagination"
type QuestionTypeFilter = "all" | "MCQ" | "TRUE_FALSE" | "SHORT_ANSWER" | "AUDIO"
type DifficultyFilter = "all" | "EASY" | "MEDIUM" | "HARD"
@ -559,7 +558,7 @@ export function QuestionsPage() {
}}
className="h-8 appearance-none rounded-md border bg-white pl-2 pr-7 text-sm font-medium text-grayScale-600 focus:outline-none"
>
{TABLE_PAGE_SIZE_OPTIONS.map((size) => (
{[10, 20, 50].map((size) => (
<option key={size} value={size}>
{size}
</option>

View File

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

View File

@ -30,7 +30,6 @@ import {
} from "../../components/ui/dropdown-menu"
import type { Course, CourseCategory, QuestionDetail, QuestionSet, SubCourse } from "../../types/course.types"
import { toast } from "sonner"
import { DEFAULT_TABLE_PAGE_SIZE, TABLE_PAGE_SIZE_OPTIONS } from "../../lib/tablePagination"
const MAX_AUDIO_SIZE_BYTES = 50 * 1024 * 1024
const ALLOWED_AUDIO_EXTENSIONS = new Set(["mp3", "wav", "ogg", "m4a", "aac", "webm", "flac"])
@ -150,7 +149,7 @@ export function SpeakingPage() {
const [audioQuestions, setAudioQuestions] = useState<AudioListQuestion[]>([])
const [audioTotalCount, setAudioTotalCount] = useState(0)
const [audioPage, setAudioPage] = useState(1)
const [audioPageSize, setAudioPageSize] = useState(DEFAULT_TABLE_PAGE_SIZE)
const [audioPageSize] = useState(12)
const [practiceOptions, setPracticeOptions] = useState<PracticeFilterOption[]>([])
const [selectedPracticeId, setSelectedPracticeId] = useState<string>("")
const [practiceFilterOpen, setPracticeFilterOpen] = useState(false)
@ -1511,58 +1510,31 @@ export function SpeakingPage() {
))}
</div>
))}
{audioTotalCount > 0 ? (
<div className="mt-4 flex flex-wrap items-center justify-between gap-3 rounded-xl border border-grayScale-200 bg-white px-3 py-2 text-xs text-grayScale-500 sm:text-sm">
<div className="flex flex-wrap items-center gap-2">
<span>
Page {audioPage} of {Math.max(1, Math.ceil(audioTotalCount / audioPageSize))} (
{audioTotalCount} total)
</span>
<span className="hidden h-4 w-px bg-grayScale-200 sm:inline" />
<span className="flex items-center gap-2">
Rows per page
<div className="relative">
<select
value={audioPageSize}
disabled={loading}
onChange={(e) => {
setAudioPageSize(Number(e.target.value))
void fetchAudioQuestions(1)
}}
className="h-8 appearance-none rounded-md border bg-white pl-2 pr-7 text-sm font-medium text-grayScale-600 focus:outline-none"
>
{TABLE_PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>
{size}
</option>
))}
</select>
<ChevronDown className="pointer-events-none absolute right-2 top-1/2 h-3 w-3 -translate-y-1/2 text-grayScale-400" />
</div>
</span>
{audioTotalCount > audioPageSize ? (
<div className="mt-4 flex items-center justify-between rounded-xl border border-grayScale-200 bg-white px-3 py-2">
<p className="text-xs text-grayScale-500 sm:text-sm">
Page {audioPage} of {Math.max(1, Math.ceil(audioTotalCount / audioPageSize))}
</p>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={audioPage <= 1 || loading}
onClick={() => fetchAudioQuestions(audioPage - 1)}
>
Previous
</Button>
<Button
type="button"
variant="outline"
size="sm"
disabled={audioPage >= Math.ceil(audioTotalCount / audioPageSize) || loading}
onClick={() => fetchAudioQuestions(audioPage + 1)}
>
Next
</Button>
</div>
{audioTotalCount > audioPageSize ? (
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={audioPage <= 1 || loading}
onClick={() => fetchAudioQuestions(audioPage - 1)}
>
Previous
</Button>
<Button
type="button"
variant="outline"
size="sm"
disabled={audioPage >= Math.ceil(audioTotalCount / audioPageSize) || loading}
onClick={() => fetchAudioQuestions(audioPage + 1)}
>
Next
</Button>
</div>
) : null}
</div>
) : null}
</div>

View File

@ -13,6 +13,7 @@ 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,
@ -54,6 +55,7 @@ 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);
@ -77,6 +79,7 @@ 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");
@ -156,6 +159,7 @@ export function UnitManagementPage() {
const clearCreateModuleForm = () => {
setCreateName("");
setCreateDescription("");
setCreateThumbnail("");
setCreateIcon("");
if (createThumbnailFileInputRef.current) {
@ -260,7 +264,7 @@ export function UnitManagementPage() {
const minioIcon = await resolveToMinioUrl(createIcon);
await createExamPrepUnitModule(parsedUnitId, {
name,
description: null,
description: createDescription.trim() || null,
thumbnail: minioThumbnail || null,
icon: minioIcon || null,
});
@ -282,6 +286,7 @@ 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));
@ -291,6 +296,7 @@ export function UnitManagementPage() {
if (savingEdit || uploadingEditThumbnail || uploadingEditIcon) return;
setEditingModuleId(null);
setEditName("");
setEditDescription("");
setEditThumbnail("");
setEditIcon("");
setEditSortOrder("1");
@ -385,16 +391,11 @@ 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: preservedDescription,
description: editDescription.trim() || null,
thumbnail: minioThumbnail || null,
icon: minioIcon || null,
sort_order: sortOrderNum,
@ -488,6 +489,20 @@ 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
@ -797,6 +812,16 @@ export function UnitManagementPage() {
disabled={savingEdit || uploadingEditThumbnail || uploadingEditIcon}
/>
</div>
<div className="space-y-3">
<label className="text-[15px] text-grayScale-800">Description</label>
<Textarea
value={editDescription}
onChange={(e) => setEditDescription(e.target.value)}
rows={4}
className="min-h-[96px] rounded-[8px] border-grayScale-400"
disabled={savingEdit || uploadingEditThumbnail || uploadingEditIcon}
/>
</div>
<div className="space-y-3">
<label className="text-[15px] text-grayScale-800">Sort Order</label>
<Input

View File

@ -9,6 +9,7 @@ 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";
@ -27,7 +28,7 @@ export function AddModuleModal({
onCreated,
}: AddModuleModalProps) {
const [name, setName] = useState("");
const [sortOrder, setSortOrder] = useState("");
const [description, setDescription] = useState("");
const [icon, setIcon] = useState("");
const [submitting, setSubmitting] = useState(false);
const [iconUploadBusy, setIconUploadBusy] = useState(false);
@ -35,7 +36,7 @@ export function AddModuleModal({
useEffect(() => {
if (isOpen) {
setName("");
setSortOrder("");
setDescription("");
setIcon("");
setSubmitting(false);
setIconUploadBusy(false);
@ -44,7 +45,7 @@ export function AddModuleModal({
const resetAndClose = () => {
setName("");
setSortOrder("");
setDescription("");
setIcon("");
setIconUploadBusy(false);
onClose();
@ -68,23 +69,12 @@ 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: description.trim(),
icon: icon.trim(),
sort_order,
});
toast.success("Module created");
if (onCreated) {
@ -111,7 +101,11 @@ export function AddModuleModal({
Add New Module
</DialogTitle>
<DialogDescription className="text-sm text-grayScale-400">
Add a new module to this course.
Create a module with{" "}
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px]">
POST /courses/:courseId/modules
</code>
.
</DialogDescription>
</DialogHeader>
@ -150,27 +144,17 @@ export function AddModuleModal({
</div>
<div className="space-y-2">
<label
htmlFor="create-module-sort-order"
className="text-[15px] font-medium text-grayScale-700"
>
Sort Order
<label className="text-[15px] font-medium text-grayScale-700">
Description
</label>
<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}
<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}
/>
<p className="text-xs text-grayScale-500">
Lower numbers appear first when modules are listed.
</p>
</div>
<ModuleIconUploadField

View File

@ -1,104 +0,0 @@
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}
/>
);
}

Some files were not shown because too many files have changed in this diff Show More