Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b21c679e56 | |||
| 095e690a68 | |||
| 1014f4a72f | |||
| 92a2fab833 | |||
| 2c3f0da6f7 | |||
| e75420e756 | |||
| b8a73c73db | |||
| 38550f9519 | |||
| 385f58fd22 | |||
| 2b556d9d09 | |||
| f1b6172f91 | |||
| bb6680acfb | |||
| 77b71abfd8 | |||
| 9b35a8bf30 | |||
| 457d09f02b |
21
index.html
21
index.html
|
|
@ -5,6 +5,27 @@
|
|||
<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
82
package-lock.json
generated
|
|
@ -26,6 +26,7 @@
|
|||
"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"
|
||||
|
|
@ -92,6 +93,7 @@
|
|||
"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",
|
||||
|
|
@ -360,6 +362,7 @@
|
|||
"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",
|
||||
|
|
@ -2670,6 +2673,12 @@
|
|||
"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",
|
||||
|
|
@ -2810,6 +2819,7 @@
|
|||
"integrity": "sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~7.16.0"
|
||||
}
|
||||
|
|
@ -2820,6 +2830,7 @@
|
|||
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
|
|
@ -2830,6 +2841,7 @@
|
|||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
|
|
@ -2885,6 +2897,7 @@
|
|||
"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",
|
||||
|
|
@ -3136,6 +3149,7 @@
|
|||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
|
|
@ -3374,6 +3388,7 @@
|
|||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
|
|
@ -3950,6 +3965,7 @@
|
|||
"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",
|
||||
|
|
@ -4185,6 +4201,12 @@
|
|||
"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",
|
||||
|
|
@ -5064,6 +5086,7 @@
|
|||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
|
|
@ -5091,6 +5114,12 @@
|
|||
"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",
|
||||
|
|
@ -5111,6 +5140,7 @@
|
|||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
|
|
@ -5299,6 +5329,7 @@
|
|||
"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"
|
||||
}
|
||||
|
|
@ -5308,6 +5339,7 @@
|
|||
"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"
|
||||
},
|
||||
|
|
@ -5319,13 +5351,15 @@
|
|||
"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"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"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"
|
||||
|
|
@ -5531,7 +5565,8 @@
|
|||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/redux-thunk": {
|
||||
"version": "3.1.0",
|
||||
|
|
@ -5548,6 +5583,27 @@
|
|||
"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",
|
||||
|
|
@ -5721,6 +5777,16 @@
|
|||
"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",
|
||||
|
|
@ -5783,6 +5849,15 @@
|
|||
"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",
|
||||
|
|
@ -5935,6 +6010,7 @@
|
|||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
|
|
@ -6102,6 +6178,7 @@
|
|||
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.0",
|
||||
"fdir": "^6.5.0",
|
||||
|
|
@ -6239,6 +6316,7 @@
|
|||
"integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@
|
|||
"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"
|
||||
|
|
|
|||
33
src/App.tsx
33
src/App.tsx
|
|
@ -1,9 +1,29 @@
|
|||
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)) {
|
||||
|
|
@ -18,18 +38,7 @@ export default function App() {
|
|||
return (
|
||||
<>
|
||||
<AppRoutes />
|
||||
<Toaster
|
||||
position="top-center"
|
||||
toastOptions={{
|
||||
className: 'font-sans',
|
||||
style: {
|
||||
padding: '14px 20px',
|
||||
borderRadius: '12px',
|
||||
fontSize: '14px',
|
||||
},
|
||||
}}
|
||||
richColors
|
||||
/>
|
||||
<AppToaster />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,177 @@
|
|||
import http from "./http";
|
||||
import type { DashboardResponse } from "../types/analytics.types";
|
||||
import type {
|
||||
DashboardData,
|
||||
DashboardFilters,
|
||||
DashboardUsers,
|
||||
DateCount,
|
||||
LabelCount,
|
||||
} from "../types/analytics.types";
|
||||
|
||||
export const getDashboard = () =>
|
||||
http.get<DashboardResponse>("/analytics/dashboard");
|
||||
function buildDashboardQueryParams(filters?: DashboardFilters): Record<string, string | number> {
|
||||
if (!filters || filters.mode === "all_time") {
|
||||
return {};
|
||||
}
|
||||
|
||||
if (filters.mode === "year" && filters.year != null) {
|
||||
return { year: filters.year };
|
||||
}
|
||||
|
||||
if (filters.mode === "year_month" && filters.year != null && filters.month != null) {
|
||||
return { year: filters.year, month: filters.month };
|
||||
}
|
||||
|
||||
if (filters.mode === "custom" && filters.from && filters.to) {
|
||||
return { from: filters.from, to: filters.to };
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
function 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),
|
||||
}));
|
||||
|
|
|
|||
118
src/api/app-versions.api.ts
Normal file
118
src/api/app-versions.api.ts
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
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,
|
||||
}))
|
||||
|
|
@ -97,6 +97,9 @@ import type {
|
|||
CreateExamPrepModuleLessonResponse,
|
||||
UpdateExamPrepModuleLessonRequest,
|
||||
UpdateExamPrepModuleLessonResponse,
|
||||
PublishExamPrepModuleLessonRequest,
|
||||
CreateExamPrepLessonPracticeRequest,
|
||||
CreateExamPrepLessonPracticeResponse,
|
||||
GetExamPrepModuleLessonsResponse,
|
||||
GetTopLevelModuleLessonsResponse,
|
||||
GetPracticesByParentContextResponse,
|
||||
|
|
@ -104,7 +107,9 @@ import type {
|
|||
CreateParentLinkedPracticeResponse,
|
||||
UpdateParentLinkedPracticeRequest,
|
||||
UpdateParentLinkedPracticeResponse,
|
||||
PublishParentLinkedPracticeRequest,
|
||||
UpdateTopLevelModuleLessonRequest,
|
||||
PublishTopLevelModuleLessonRequest,
|
||||
CreateTopLevelModuleLessonRequest,
|
||||
CreateTopLevelModuleLessonResponse,
|
||||
} from "../types/course.types"
|
||||
|
|
@ -585,10 +590,26 @@ 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)
|
||||
|
|
@ -646,6 +667,12 @@ export const updateTopLevelModuleLesson = (
|
|||
data: UpdateTopLevelModuleLessonRequest,
|
||||
) => http.put(`/lessons/${lessonId}`, data)
|
||||
|
||||
/** PUT /lessons/:id — set publish_status only (draft or published). */
|
||||
export const publishTopLevelModuleLesson = (
|
||||
lessonId: number,
|
||||
data: PublishTopLevelModuleLessonRequest,
|
||||
) => http.put(`/lessons/${lessonId}`, data)
|
||||
|
||||
/** Learn English top-level module lesson — DELETE /lessons/:id */
|
||||
export const deleteTopLevelModuleLesson = (lessonId: number) =>
|
||||
http.delete(`/lessons/${lessonId}`)
|
||||
|
|
@ -681,6 +708,12 @@ export const updateParentLinkedPractice = (
|
|||
data: UpdateParentLinkedPracticeRequest,
|
||||
) => http.put<UpdateParentLinkedPracticeResponse>(`/practices/${practiceId}`, data)
|
||||
|
||||
/** PUT /practices/:id — set publish_status (e.g. publish a draft). */
|
||||
export const publishParentLinkedPractice = (practiceId: number) =>
|
||||
http.put<UpdateParentLinkedPracticeResponse>(`/practices/${practiceId}`, {
|
||||
publish_status: "PUBLISHED",
|
||||
} satisfies PublishParentLinkedPracticeRequest)
|
||||
|
||||
/** DELETE /practices/:id */
|
||||
export const deleteParentLinkedPractice = (practiceId: number) =>
|
||||
http.delete<{ message: string; success: boolean; status_code: number; metadata: unknown }>(
|
||||
|
|
|
|||
69
src/api/emailTemplates.api.ts
Normal file
69
src/api/emailTemplates.api.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
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)
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import http from "./http"
|
||||
|
||||
export type UploadMediaType = "image" | "audio" | "video"
|
||||
export type UploadMediaType = "image" | "audio" | "video" | "pdf"
|
||||
export type UploadProvider = "MINIO" | "VIMEO"
|
||||
|
||||
export interface UploadMediaResponse {
|
||||
|
|
@ -121,6 +121,8 @@ 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 },
|
||||
|
|
|
|||
|
|
@ -59,7 +59,9 @@ const isAuthEndpointRequest = (url?: string) => {
|
|||
return (
|
||||
url.includes("/team/login") ||
|
||||
url.includes("/team/google-login") ||
|
||||
url.includes("/team/refresh")
|
||||
url.includes("/team/refresh") ||
|
||||
url.includes("/team/invitations/verify") ||
|
||||
url.includes("/team/invitations/accept")
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,35 +1,125 @@
|
|||
import http from "./http";
|
||||
import type { GetNotificationsResponse, UnreadCountResponse } from "../types/notification.types";
|
||||
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) }
|
||||
}
|
||||
|
||||
export const getNotifications = (limit = 10, offset = 0) =>
|
||||
http.get<GetNotificationsResponse>("/notifications", {
|
||||
params: { limit, offset },
|
||||
});
|
||||
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)),
|
||||
}))
|
||||
|
||||
export const getUnreadCount = () =>
|
||||
http.get<UnreadCountResponse>("/notifications/unread");
|
||||
http.get<unknown>("/notifications/unread").then((res) => ({
|
||||
...res,
|
||||
data: parseUnreadCount(res.data),
|
||||
}))
|
||||
|
||||
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" },
|
||||
});
|
||||
})
|
||||
|
|
|
|||
98
src/api/payments.api.ts
Normal file
98
src/api/payments.api.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
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,
|
||||
}
|
||||
})
|
||||
6
src/api/personas.api.ts
Normal file
6
src/api/personas.api.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import http from "./http"
|
||||
import type { GetPersonasParams, GetPersonasResponse } from "../types/persona.types"
|
||||
|
||||
/** GET /personas — list personas (filter active client-side when needed). */
|
||||
export const getPersonas = (params?: GetPersonasParams) =>
|
||||
http.get<GetPersonasResponse>("/personas", { params })
|
||||
360
src/api/questionTypeDefinitions.api.ts
Normal file
360
src/api/questionTypeDefinitions.api.ts
Normal file
|
|
@ -0,0 +1,360 @@
|
|||
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}`)
|
||||
}
|
||||
|
|
@ -8,6 +8,8 @@ import type {
|
|||
DeleteRoleResponse,
|
||||
SetRolePermissionsRequest,
|
||||
GetPermissionsResponse,
|
||||
BulkRoleDeactivateResponse,
|
||||
BulkRoleReactivateResponse,
|
||||
} from "../types/rbac.types"
|
||||
|
||||
export const getRoles = (params?: GetRolesParams) =>
|
||||
|
|
@ -30,3 +32,11 @@ export const getAllPermissions = () =>
|
|||
|
||||
export const deleteRole = (roleId: number) =>
|
||||
http.delete<DeleteRoleResponse>(`/rbac/roles/${roleId}`)
|
||||
|
||||
/** Deactivate all users and team members tied to this role (admin). */
|
||||
export const bulkDeactivateRole = (roleId: number) =>
|
||||
http.post<BulkRoleDeactivateResponse>(`/admin/roles/${roleId}/bulk-deactivate`, {})
|
||||
|
||||
/** Reactivate users and team members tied to this role (admin). */
|
||||
export const bulkReactivateRole = (roleId: number) =>
|
||||
http.post<BulkRoleReactivateResponse>(`/admin/roles/${roleId}/bulk-reactivate`, {})
|
||||
|
|
|
|||
84
src/api/subscription-plans.api.ts
Normal file
84
src/api/subscription-plans.api.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
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,
|
||||
}))
|
||||
|
|
@ -1,5 +1,14 @@
|
|||
import http from "./http"
|
||||
import type {
|
||||
AcceptInvitationRequest,
|
||||
AcceptInvitationResponse,
|
||||
InviteTeamMemberRequest,
|
||||
InviteTeamMemberResponse,
|
||||
VerifyInvitationResponse,
|
||||
} from "../types/teamInvitation.types"
|
||||
import type {
|
||||
ChangeTeamMemberPasswordRequest,
|
||||
ChangeTeamMemberPasswordResponse,
|
||||
GetTeamMembersResponse,
|
||||
GetTeamMemberResponse,
|
||||
CreateTeamMemberRequest,
|
||||
|
|
@ -25,3 +34,31 @@ 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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,23 +6,46 @@ import {
|
|||
type UserSummaryResponse,
|
||||
type GetDeletionRequestsParams,
|
||||
type GetDeletionRequestsResponse,
|
||||
type UserRecentActivityResponse,
|
||||
} from "../types/user.types";
|
||||
|
||||
export const getUsers = (
|
||||
page?: number,
|
||||
pageSize?: number,
|
||||
role?: string,
|
||||
status?: string,
|
||||
query?: string,
|
||||
) =>
|
||||
/** Query params for GET /users (RFC3339 for created_*; subscription_status: ACTIVE | PENDING | Unsubscribed). */
|
||||
export interface GetUsersParams {
|
||||
page?: number
|
||||
page_size?: number
|
||||
role?: string
|
||||
status?: string
|
||||
query?: string
|
||||
created_before?: string
|
||||
created_after?: string
|
||||
country?: string
|
||||
region?: string
|
||||
subscription_status?: string
|
||||
}
|
||||
|
||||
function buildGetUsersQuery(params: GetUsersParams): Record<string, string | number> {
|
||||
const q: Record<string, string | number> = {}
|
||||
const addString = (key: string, value: string | undefined) => {
|
||||
const v = value?.trim()
|
||||
if (!v) return
|
||||
q[key] = v
|
||||
}
|
||||
if (params.page !== undefined) q.page = params.page
|
||||
if (params.page_size !== undefined) q.page_size = params.page_size
|
||||
addString("role", params.role)
|
||||
addString("status", params.status)
|
||||
addString("query", params.query)
|
||||
addString("created_before", params.created_before)
|
||||
addString("created_after", params.created_after)
|
||||
addString("country", params.country)
|
||||
addString("region", params.region)
|
||||
addString("subscription_status", params.subscription_status)
|
||||
return q
|
||||
}
|
||||
|
||||
export const getUsers = (params: GetUsersParams = {}) =>
|
||||
http.get<GetUsersResponse>("/users", {
|
||||
params: {
|
||||
role,
|
||||
status,
|
||||
query,
|
||||
page,
|
||||
page_size: pageSize,
|
||||
},
|
||||
params: buildGetUsersQuery(params),
|
||||
});
|
||||
|
||||
export type UserStatus = "ACTIVE" | "DEACTIVATED" | "SUSPENDED" | "PENDING";
|
||||
|
|
@ -38,6 +61,9 @@ export const updateUserStatus = (payload: UpdateUserStatusRequest) =>
|
|||
export const getUserById = (id: number) =>
|
||||
http.get<UserProfileResponse>(`/user/single/${id}`);
|
||||
|
||||
export const getUserRecentActivity = (userId: number) =>
|
||||
http.get<UserRecentActivityResponse>(`/admin/users/${userId}/recent-activity`);
|
||||
|
||||
export const getMyProfile = () =>
|
||||
http.get<UserProfileResponse>("/team/me");
|
||||
|
||||
|
|
|
|||
|
|
@ -15,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,10 +33,12 @@ 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";
|
||||
|
|
@ -50,6 +52,7 @@ 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";
|
||||
|
|
@ -58,6 +61,7 @@ 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";
|
||||
|
|
@ -69,6 +73,7 @@ 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 />} />
|
||||
|
|
@ -77,7 +82,7 @@ export function AppRoutes() {
|
|||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="/dashboard" element={<DashboardPage />} />
|
||||
<Route path="/users" element={<UserManagementLayout />}>
|
||||
<Route index element={<UserManagementDashboard />} />
|
||||
<Route index element={<Navigate to="list" replace />} />
|
||||
<Route path="list" element={<UsersListPage />} />
|
||||
<Route path="deletion-requests" element={<DeletionRequestsPage />} />
|
||||
<Route path="groups" element={<UserGroupsPage />} />
|
||||
|
|
@ -162,6 +167,7 @@ export function AppRoutes() {
|
|||
</Route>
|
||||
|
||||
<Route path="/new-content" element={<NewContentPage />} />
|
||||
<Route path="/new-content/reorder" element={<ReorderContentPage />} />
|
||||
<Route
|
||||
path="/new-content/courses"
|
||||
element={<ProgramTypeSelectionPage />}
|
||||
|
|
@ -170,6 +176,10 @@ 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 />}
|
||||
|
|
@ -179,12 +189,16 @@ export function AppRoutes() {
|
|||
element={<ProgramDetailPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/new-content/courses/:programType/attach-practice"
|
||||
element={<AttachProgramPracticeFlow />}
|
||||
path="/new-content/courses/:programType/add-practice"
|
||||
element={<AddPracticeFlow />}
|
||||
/>
|
||||
<Route
|
||||
path="/new-content/courses/:programType/:courseId/unit/:unitId/module/:moduleId/attach-practice"
|
||||
element={<AttachPracticeFlow />}
|
||||
path="/new-content/courses/:programType/:courseId/add-practice"
|
||||
element={<AddPracticeFlow />}
|
||||
/>
|
||||
<Route
|
||||
path="/new-content/courses/:programType/:courseId/:unitId/:moduleId/add-practice"
|
||||
element={<AddPracticeFlow />}
|
||||
/>
|
||||
<Route
|
||||
path="/new-content/courses/:programType/:courseId"
|
||||
|
|
@ -198,6 +212,10 @@ 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 />}
|
||||
|
|
@ -218,16 +236,33 @@ 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 />} />
|
||||
|
|
|
|||
272
src/components/analytics/AnalyticsTimeRangeFilter.tsx
Normal file
272
src/components/analytics/AnalyticsTimeRangeFilter.tsx
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
import { useEffect, useRef, useState } from "react"
|
||||
import { ChevronDown } from "lucide-react"
|
||||
import { cn } from "../../lib/utils"
|
||||
import { Input } from "../ui/input"
|
||||
import { Button } from "../ui/button"
|
||||
import type { DashboardFilters } from "../../types/analytics.types"
|
||||
|
||||
const MONTH_LABELS = [
|
||||
"January",
|
||||
"February",
|
||||
"March",
|
||||
"April",
|
||||
"May",
|
||||
"June",
|
||||
"July",
|
||||
"August",
|
||||
"September",
|
||||
"October",
|
||||
"November",
|
||||
"December",
|
||||
] as const
|
||||
|
||||
const MIN_SELECTABLE_YEAR = 2000
|
||||
|
||||
export function getYearOptions(): number[] {
|
||||
const currentYear = new Date().getFullYear()
|
||||
const years: number[] = []
|
||||
for (let year = currentYear; year >= MIN_SELECTABLE_YEAR; year--) {
|
||||
years.push(year)
|
||||
}
|
||||
return years
|
||||
}
|
||||
|
||||
export function getDashboardFilterLabel(filters: DashboardFilters): string {
|
||||
if (filters.mode === "year" && filters.year != null) {
|
||||
return String(filters.year)
|
||||
}
|
||||
if (filters.mode === "year_month" && filters.year != null && filters.month != null) {
|
||||
return `${MONTH_LABELS[filters.month - 1]} ${filters.year}`
|
||||
}
|
||||
if (filters.mode === "custom" && filters.from && filters.to) {
|
||||
const from = new Date(`${filters.from}T00:00:00`)
|
||||
const to = new Date(`${filters.to}T00:00:00`)
|
||||
const opts: Intl.DateTimeFormatOptions = { month: "short", day: "numeric", year: "numeric" }
|
||||
return `${from.toLocaleDateString("en-US", opts)} – ${to.toLocaleDateString("en-US", opts)}`
|
||||
}
|
||||
return "All Time"
|
||||
}
|
||||
|
||||
type AnalyticsTimeRangeFilterProps = {
|
||||
value: DashboardFilters
|
||||
onChange: (filters: DashboardFilters) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function AnalyticsTimeRangeFilter({ value, onChange, className }: AnalyticsTimeRangeFilterProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [yearOpen, setYearOpen] = useState(true)
|
||||
const [monthOpen, setMonthOpen] = useState(false)
|
||||
const [customOpen, setCustomOpen] = useState(false)
|
||||
const [contextYear, setContextYear] = useState(() => value.year ?? new Date().getFullYear())
|
||||
const [customFrom, setCustomFrom] = useState(value.from ?? "")
|
||||
const [customTo, setCustomTo] = useState(value.to ?? "")
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const years = getYearOptions()
|
||||
|
||||
useEffect(() => {
|
||||
if (value.year != null) {
|
||||
setContextYear(value.year)
|
||||
}
|
||||
}, [value.year])
|
||||
|
||||
useEffect(() => {
|
||||
if (value.mode === "custom") {
|
||||
setCustomFrom(value.from ?? "")
|
||||
setCustomTo(value.to ?? "")
|
||||
}
|
||||
}, [value.from, value.mode, value.to])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
|
||||
const handlePointerDown = (event: MouseEvent) => {
|
||||
if (!containerRef.current?.contains(event.target as Node)) {
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("mousedown", handlePointerDown)
|
||||
return () => document.removeEventListener("mousedown", handlePointerDown)
|
||||
}, [open])
|
||||
|
||||
const selectAllTime = () => {
|
||||
onChange({ mode: "all_time" })
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const selectYear = (year: number) => {
|
||||
setContextYear(year)
|
||||
onChange({ mode: "year", year })
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const selectMonth = (month: number) => {
|
||||
onChange({ mode: "year_month", year: contextYear, month })
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const applyCustomRange = () => {
|
||||
if (!customFrom || !customTo) return
|
||||
onChange({ mode: "custom", from: customFrom, to: customTo })
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={cn("relative", className)}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((prev) => !prev)}
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-grayScale-200 bg-white px-4 py-2 text-sm font-medium text-grayScale-700 shadow-sm transition-colors hover:bg-grayScale-50"
|
||||
>
|
||||
Time Range
|
||||
<ChevronDown className={cn("h-4 w-4 text-grayScale-400 transition-transform", open && "rotate-180")} />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute right-0 z-50 mt-2 w-[220px] overflow-hidden rounded-xl border border-grayScale-100 bg-white py-2 shadow-lg">
|
||||
<button
|
||||
type="button"
|
||||
onClick={selectAllTime}
|
||||
className={cn(
|
||||
"flex w-full px-4 py-2.5 text-left text-sm transition-colors hover:bg-grayScale-50",
|
||||
value.mode === "all_time" ? "font-semibold text-grayScale-900" : "text-grayScale-700",
|
||||
)}
|
||||
>
|
||||
All Time
|
||||
</button>
|
||||
|
||||
<div className="border-t border-grayScale-100">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setYearOpen((prev) => !prev)}
|
||||
className="flex w-full items-center justify-between px-4 py-2.5 text-left text-sm font-medium text-grayScale-800 hover:bg-grayScale-50"
|
||||
>
|
||||
Year
|
||||
<ChevronDown
|
||||
className={cn("h-4 w-4 text-grayScale-400 transition-transform", yearOpen && "rotate-180")}
|
||||
/>
|
||||
</button>
|
||||
{yearOpen && (
|
||||
<div className="max-h-[220px] overflow-y-auto pb-1">
|
||||
{years.map((year) => (
|
||||
<button
|
||||
key={year}
|
||||
type="button"
|
||||
onClick={() => selectYear(year)}
|
||||
className={cn(
|
||||
"flex w-full px-6 py-2 text-left text-sm transition-colors hover:bg-grayScale-50",
|
||||
value.mode === "year" && value.year === year
|
||||
? "font-semibold text-brand-600"
|
||||
: "text-grayScale-600",
|
||||
)}
|
||||
>
|
||||
{year}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-grayScale-100">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMonthOpen((prev) => !prev)}
|
||||
className="flex w-full items-center justify-between px-4 py-2.5 text-left text-sm font-medium text-grayScale-800 hover:bg-grayScale-50"
|
||||
>
|
||||
Month
|
||||
<ChevronDown
|
||||
className={cn("h-4 w-4 text-grayScale-400 transition-transform", monthOpen && "rotate-180")}
|
||||
/>
|
||||
</button>
|
||||
{monthOpen && (
|
||||
<div className="max-h-[260px] overflow-y-auto pb-1">
|
||||
<div className="flex max-h-[88px] flex-wrap gap-1 overflow-y-auto px-4 pb-2">
|
||||
{years.map((year) => (
|
||||
<button
|
||||
key={year}
|
||||
type="button"
|
||||
onClick={() => setContextYear(year)}
|
||||
className={cn(
|
||||
"rounded-md px-2 py-0.5 text-[11px] font-medium transition-colors",
|
||||
contextYear === year
|
||||
? "bg-brand-100 text-brand-700"
|
||||
: "text-grayScale-500 hover:bg-grayScale-100",
|
||||
)}
|
||||
>
|
||||
{year}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{MONTH_LABELS.map((label, index) => {
|
||||
const month = index + 1
|
||||
const isSelected =
|
||||
value.mode === "year_month" && value.year === contextYear && value.month === month
|
||||
|
||||
return (
|
||||
<button
|
||||
key={label}
|
||||
type="button"
|
||||
onClick={() => selectMonth(month)}
|
||||
className={cn(
|
||||
"flex w-full px-6 py-2 text-left text-sm transition-colors hover:bg-grayScale-50",
|
||||
isSelected ? "font-semibold text-brand-600" : "text-grayScale-600",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-grayScale-100">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCustomOpen((prev) => !prev)}
|
||||
className="flex w-full items-center justify-between px-4 py-2.5 text-left text-sm font-medium text-grayScale-800 hover:bg-grayScale-50"
|
||||
>
|
||||
Date Range
|
||||
<ChevronDown
|
||||
className={cn("h-4 w-4 text-grayScale-400 transition-transform", customOpen && "rotate-180")}
|
||||
/>
|
||||
</button>
|
||||
{customOpen && (
|
||||
<div className="space-y-2 px-4 pb-3">
|
||||
<div className="space-y-1">
|
||||
<label className="text-[11px] font-medium text-grayScale-500">From</label>
|
||||
<Input
|
||||
type="date"
|
||||
value={customFrom}
|
||||
onChange={(e) => setCustomFrom(e.target.value)}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-[11px] font-medium text-grayScale-500">To</label>
|
||||
<Input
|
||||
type="date"
|
||||
value={customTo}
|
||||
onChange={(e) => setCustomTo(e.target.value)}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="h-8 w-full text-xs"
|
||||
disabled={!customFrom || !customTo}
|
||||
onClick={applyCustomRange}
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
241
src/components/content-management/DynamicMatchingSlotField.tsx
Normal file
241
src/components/content-management/DynamicMatchingSlotField.tsx
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
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>
|
||||
)
|
||||
}
|
||||
1009
src/components/content-management/DynamicSchemaSlotField.tsx
Normal file
1009
src/components/content-management/DynamicSchemaSlotField.tsx
Normal file
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,275 @@
|
|||
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>
|
||||
)
|
||||
}
|
||||
250
src/components/content-management/DynamicTableBuilder.tsx
Normal file
250
src/components/content-management/DynamicTableBuilder.tsx
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
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>
|
||||
)
|
||||
}
|
||||
|
|
@ -9,15 +9,23 @@ 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"
|
||||
export type PracticeQuestionEditorType = "MCQ" | "TRUE_FALSE" | "SHORT" | "AUDIO" | "DYNAMIC"
|
||||
export type PracticeQuestionEditorDifficulty = "EASY" | "MEDIUM" | "HARD"
|
||||
|
||||
export interface PracticeQuestionOptionDraft {
|
||||
|
|
@ -32,6 +40,13 @@ 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
|
||||
|
|
@ -46,6 +61,12 @@ 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 {
|
||||
|
|
@ -67,6 +88,10 @@ export function createEmptyPracticeQuestionDraft(): PracticeQuestionEditorValue
|
|||
audioCorrectAnswerText: "",
|
||||
shortAnswer: "",
|
||||
imageUrl: "",
|
||||
questionTypeDefinitionId: null,
|
||||
dynamicStimulusRows: [],
|
||||
dynamicResponseRows: [],
|
||||
dynamicFieldValues: {},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -84,6 +109,9 @@ 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) => ({
|
||||
|
|
@ -146,6 +174,18 @@ 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 })
|
||||
}
|
||||
|
||||
|
|
@ -586,11 +626,97 @@ 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-5 space-y-5">
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">Question Text</label>
|
||||
<div className="mt-3 space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[11px] font-medium uppercase tracking-wide text-grayScale-500">Question Text</label>
|
||||
<textarea
|
||||
value={value.questionText}
|
||||
onChange={(e) => patch({ questionText: e.target.value })}
|
||||
|
|
@ -607,35 +733,40 @@ export function PracticeQuestionEditorFields({
|
|||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 lg:gap-5">
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">Type</label>
|
||||
<Select value={value.questionType} onChange={(e) => setType(e.target.value as PracticeQuestionEditorType)}>
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3 lg:gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[11px] font-medium uppercase tracking-wide text-grayScale-500">Type</label>
|
||||
<Select value={value.questionType} onChange={(e) => setType(e.target.value as PracticeQuestionEditorType)} className="h-9 text-sm">
|
||||
<option value="MCQ">Multiple Choice</option>
|
||||
<option value="TRUE_FALSE">True/False</option>
|
||||
<option value="SHORT">Short Answer</option>
|
||||
<option value="AUDIO">Audio</option>
|
||||
<option value="DYNAMIC">Dynamic (schema-driven)</option>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">Difficulty</label>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[11px] font-medium uppercase tracking-wide text-grayScale-500">Difficulty</label>
|
||||
<Select
|
||||
value={value.difficultyLevel}
|
||||
onChange={(e) => patch({ difficultyLevel: e.target.value as PracticeQuestionEditorDifficulty })}
|
||||
className="h-9 text-sm"
|
||||
>
|
||||
<option value="EASY">Easy</option>
|
||||
<option value="MEDIUM">Medium</option>
|
||||
<option value="HARD">Hard</option>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">Points</label>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[11px] font-medium uppercase tracking-wide text-grayScale-500">Points</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={value.points}
|
||||
onChange={(e) => patch({ points: Number(e.target.value) || 1 })}
|
||||
min={1}
|
||||
className={cn(showFieldErrors && fieldErrors.points ? "border-red-300 ring-1 ring-red-200" : undefined)}
|
||||
className={cn(
|
||||
"h-9 text-sm",
|
||||
showFieldErrors && fieldErrors.points ? "border-red-300 ring-1 ring-red-200" : undefined,
|
||||
)}
|
||||
aria-invalid={Boolean(showFieldErrors && fieldErrors.points)}
|
||||
/>
|
||||
{showFieldErrors && fieldErrors.points ? (
|
||||
|
|
@ -644,8 +775,82 @@ 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-3 rounded-lg bg-grayScale-50/50 p-4">
|
||||
<div className="space-y-2 rounded-lg bg-grayScale-50/50 p-3">
|
||||
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">Options</label>
|
||||
<div className="space-y-2.5">
|
||||
{value.options.map((option, optIdx) => (
|
||||
|
|
@ -758,7 +963,7 @@ export function PracticeQuestionEditorFields({
|
|||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2 lg:gap-6">
|
||||
<div className="grid grid-cols-1 gap-2 lg:grid-cols-2 lg:gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">Tips (Optional)</label>
|
||||
<Input
|
||||
|
|
@ -777,6 +982,8 @@ 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">
|
||||
|
|
@ -908,6 +1115,8 @@ export function PracticeQuestionEditorFields({
|
|||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{recordingModal ? (
|
||||
|
|
|
|||
130
src/components/dashboard/RevenueTrendCard.tsx
Normal file
130
src/components/dashboard/RevenueTrendCard.tsx
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
import { useEffect, useMemo, useState } from "react"
|
||||
import { Bar, CartesianGrid, Cell, ComposedChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"
|
||||
import { getDashboard } from "../../api/analytics.api"
|
||||
import { getYearOptions } from "../analytics/AnalyticsTimeRangeFilter"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "../ui/card"
|
||||
import { Select } from "../ui/select"
|
||||
import { aggregateRevenueByMonth, formatRevenueAxisTick } from "../../lib/analytics"
|
||||
import type { DateRevenue } from "../../types/analytics.types"
|
||||
import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg"
|
||||
|
||||
const TRACK_COLOR = "#E8E8E8"
|
||||
const BAR_COLOR = "#9E2891"
|
||||
|
||||
export function RevenueTrendCard() {
|
||||
const currentYear = new Date().getFullYear()
|
||||
const [year, setYear] = useState(currentYear)
|
||||
const [totalRevenue, setTotalRevenue] = useState(0)
|
||||
const [dailyRevenue, setDailyRevenue] = useState<DateRevenue[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const years = useMemo(() => getYearOptions(), [])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
const fetchRevenueTrend = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await getDashboard({ mode: "year", year })
|
||||
if (cancelled) return
|
||||
setTotalRevenue(res.data.payments.total_revenue)
|
||||
setDailyRevenue(res.data.payments.revenue_last_30_days)
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setTotalRevenue(0)
|
||||
setDailyRevenue([])
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchRevenueTrend()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [year])
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
const monthly = aggregateRevenueByMonth(dailyRevenue, year)
|
||||
const peak = Math.max(...monthly.map((point) => point.revenue), 1)
|
||||
const trackMax = peak * 1.15
|
||||
|
||||
return monthly.map((point) => ({
|
||||
month: point.month,
|
||||
revenue: point.revenue,
|
||||
track: trackMax,
|
||||
}))
|
||||
}, [dailyRevenue, year])
|
||||
|
||||
return (
|
||||
<Card className="shadow-none">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle>Revenue Trend</CardTitle>
|
||||
<div className="mt-2 text-2xl font-semibold tracking-tight">
|
||||
ETB {totalRevenue.toLocaleString()}
|
||||
</div>
|
||||
<div className="text-xs font-medium text-grayScale-500">Monthly · {year} (ETB)</div>
|
||||
</div>
|
||||
<Select
|
||||
value={String(year)}
|
||||
onChange={(e) => setYear(Number(e.target.value))}
|
||||
className="h-9 w-[96px] shrink-0 rounded-lg py-1 text-sm font-medium"
|
||||
aria-label="Revenue trend year"
|
||||
>
|
||||
{years.map((optionYear) => (
|
||||
<option key={optionYear} value={optionYear}>
|
||||
{optionYear}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="h-[240px] p-6 pt-2">
|
||||
{loading ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<img src={spinnerSrc} alt="" className="h-8 w-8 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ComposedChart data={chartData} margin={{ left: 4, right: 8, top: 8, bottom: 0 }} barGap={-28}>
|
||||
<CartesianGrid vertical={false} stroke="#E0E0E0" strokeDasharray="4 4" />
|
||||
<XAxis dataKey="month" tickLine={false} axisLine={false} fontSize={12} />
|
||||
<YAxis
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
fontSize={12}
|
||||
width={44}
|
||||
tickFormatter={formatRevenueAxisTick}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value, name) => {
|
||||
if (name !== "revenue") return null
|
||||
return [`ETB ${Number(value).toLocaleString()}`, "Revenue"]
|
||||
}}
|
||||
contentStyle={{
|
||||
borderRadius: 12,
|
||||
border: "1px solid #E0E0E0",
|
||||
boxShadow: "0 10px 30px rgba(0,0,0,0.08)",
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="track" barSize={28} radius={[8, 8, 0, 0]} isAnimationActive={false}>
|
||||
{chartData.map((entry) => (
|
||||
<Cell key={`track-${entry.month}`} fill={TRACK_COLOR} />
|
||||
))}
|
||||
</Bar>
|
||||
<Bar dataKey="revenue" barSize={28} radius={[8, 8, 0, 0]}>
|
||||
{chartData.map((entry) => (
|
||||
<Cell key={`revenue-${entry.month}`} fill={BAR_COLOR} />
|
||||
))}
|
||||
</Bar>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
169
src/components/notifications/NotificationDetailDialog.tsx
Normal file
169
src/components/notifications/NotificationDetailDialog.tsx
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
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>
|
||||
)
|
||||
}
|
||||
|
|
@ -6,12 +6,11 @@ import {
|
|||
ChevronRight,
|
||||
CircleAlert,
|
||||
ClipboardList,
|
||||
CreditCard,
|
||||
LayoutDashboard,
|
||||
LogOut,
|
||||
Shield,
|
||||
UserCircle2,
|
||||
Users,
|
||||
Users2,
|
||||
Settings,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
|
@ -20,27 +19,86 @@ 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 NavItem = {
|
||||
type NavLinkItem = {
|
||||
kind: "link";
|
||||
label: string;
|
||||
to: string;
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
};
|
||||
|
||||
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 NavGroupItem = {
|
||||
kind: "group";
|
||||
label: string;
|
||||
basePath: string;
|
||||
activePaths?: string[];
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
children: { label: string; to: string; end?: boolean }[];
|
||||
};
|
||||
|
||||
{ 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 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 },
|
||||
];
|
||||
|
||||
type SidebarProps = {
|
||||
|
|
@ -75,9 +133,18 @@ 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",
|
||||
|
|
@ -87,7 +154,6 @@ 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",
|
||||
|
|
@ -134,13 +200,59 @@ export function Sidebar({
|
|||
</button>
|
||||
</div>
|
||||
|
||||
<nav className="mt-6 flex-1 space-y-1 overflow-y-auto">
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
<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;
|
||||
return (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
key={entry.to}
|
||||
to={entry.to}
|
||||
onClick={onClose}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
|
|
@ -151,41 +263,22 @@ 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 ? item.label : undefined}
|
||||
title={isCollapsed ? entry.label : undefined}
|
||||
>
|
||||
{({ 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",
|
||||
"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">{item.label}</span>
|
||||
<span className="truncate">{entry.label}</span>
|
||||
)}
|
||||
{!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 ? (
|
||||
{!isCollapsed && isActive ? (
|
||||
<span className="ml-auto h-6 w-1 rounded-full bg-brand-500/80" />
|
||||
) : null}
|
||||
</>
|
||||
|
|
|
|||
139
src/components/sidebar/SidebarNavGroup.tsx
Normal file
139
src/components/sidebar/SidebarNavGroup.tsx
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,74 +1,32 @@
|
|||
import { useEffect, useRef, useState } from "react"
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import {
|
||||
Bell,
|
||||
BellOff,
|
||||
Info,
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
Megaphone,
|
||||
UserPlus,
|
||||
CreditCard,
|
||||
BookOpen,
|
||||
Video,
|
||||
ShieldAlert,
|
||||
MailOpen,
|
||||
Mail,
|
||||
CheckCheck,
|
||||
} from "lucide-react"
|
||||
import { Bell, BellOff, CheckCheck, Mail, MailOpen } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
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 = TYPE_CONFIG[notification.type] ?? DEFAULT_TYPE_CONFIG
|
||||
const cfg = NOTIFICATION_TYPE_CONFIG[notification.type] ?? DEFAULT_NOTIFICATION_TYPE_CONFIG
|
||||
const Icon = cfg.icon
|
||||
|
||||
return (
|
||||
|
|
@ -77,31 +35,26 @@ 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={() => {
|
||||
if (!notification.is_read) onMarkRead(notification.id)
|
||||
}}
|
||||
onClick={() => onOpen(notification)}
|
||||
>
|
||||
{/* 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"}
|
||||
|
|
@ -110,11 +63,10 @@ function NotificationItem({
|
|||
{getNotificationMessage(notification) || "No preview text available."}
|
||||
</p>
|
||||
<p className="mt-1 text-[11px] text-grayScale-600">
|
||||
{formatTimestamp(notification.timestamp)}
|
||||
{formatNotificationTimestamp(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"
|
||||
|
|
@ -140,6 +92,11 @@ 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 {
|
||||
|
|
@ -151,7 +108,40 @@ export function NotificationDropdown() {
|
|||
markAllAsRead,
|
||||
} = useNotifications()
|
||||
|
||||
// Click-outside handler
|
||||
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],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
function handleMouseDown(e: MouseEvent) {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
|
|
@ -165,89 +155,98 @@ export function NotificationDropdown() {
|
|||
}, [open])
|
||||
|
||||
return (
|
||||
<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>
|
||||
<>
|
||||
<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>
|
||||
|
||||
{/* 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>
|
||||
{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>
|
||||
{unreadCount > 0 && (
|
||||
<Badge variant="default" className="px-1.5 py-0 text-[10px]">
|
||||
{unreadCount}
|
||||
</Badge>
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
{unreadCount > 0 && (
|
||||
|
||||
<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">
|
||||
<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}
|
||||
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")
|
||||
}}
|
||||
>
|
||||
<CheckCheck className="h-3.5 w-3.5" />
|
||||
Mark all read
|
||||
View all notifications
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
<NotificationDetailDialog
|
||||
open={detailOpen}
|
||||
onOpenChange={setDetailOpen}
|
||||
notification={selectedNotification}
|
||||
loading={detailLoading}
|
||||
error={detailError}
|
||||
onRetry={
|
||||
selectedNotificationId
|
||||
? () => void loadNotificationDetail(selectedNotificationId, false)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,8 @@ const buttonVariants = cva(
|
|||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-brand-600",
|
||||
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",
|
||||
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
outline: "border bg-background hover:bg-grayScale-100",
|
||||
ghost: "hover:bg-grayScale-100",
|
||||
|
|
|
|||
|
|
@ -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 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",
|
||||
"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",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ export const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"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",
|
||||
"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",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
|
|
|
|||
|
|
@ -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-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",
|
||||
"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",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ export const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
|||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"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",
|
||||
"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",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
|
|
|
|||
76
src/contexts/ThemeContext.tsx
Normal file
76
src/contexts/ThemeContext.tsx
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
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 }
|
||||
228
src/data/userFilterLocations.ts
Normal file
228
src/data/userFilterLocations.ts
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
/**
|
||||
* Static options for GET /users filters (`country`, `region`).
|
||||
* Country: common English short names (ISO-style), sorted A–Z.
|
||||
* Region: Ethiopia — federal regions & chartered cities (typical `users.region` values).
|
||||
*/
|
||||
|
||||
const COUNTRY_NAMES_RAW = [
|
||||
"Afghanistan",
|
||||
"Albania",
|
||||
"Algeria",
|
||||
"Andorra",
|
||||
"Angola",
|
||||
"Antigua and Barbuda",
|
||||
"Argentina",
|
||||
"Armenia",
|
||||
"Australia",
|
||||
"Austria",
|
||||
"Azerbaijan",
|
||||
"Bahamas",
|
||||
"Bahrain",
|
||||
"Bangladesh",
|
||||
"Barbados",
|
||||
"Belarus",
|
||||
"Belgium",
|
||||
"Belize",
|
||||
"Benin",
|
||||
"Bhutan",
|
||||
"Bolivia",
|
||||
"Bosnia and Herzegovina",
|
||||
"Botswana",
|
||||
"Brazil",
|
||||
"Brunei",
|
||||
"Bulgaria",
|
||||
"Burkina Faso",
|
||||
"Burundi",
|
||||
"Cabo Verde",
|
||||
"Cambodia",
|
||||
"Cameroon",
|
||||
"Canada",
|
||||
"Central African Republic",
|
||||
"Chad",
|
||||
"Chile",
|
||||
"China",
|
||||
"Colombia",
|
||||
"Comoros",
|
||||
"Congo",
|
||||
"Costa Rica",
|
||||
"Croatia",
|
||||
"Cuba",
|
||||
"Cyprus",
|
||||
"Czechia",
|
||||
"Democratic Republic of the Congo",
|
||||
"Denmark",
|
||||
"Djibouti",
|
||||
"Dominica",
|
||||
"Dominican Republic",
|
||||
"Ecuador",
|
||||
"Egypt",
|
||||
"El Salvador",
|
||||
"Equatorial Guinea",
|
||||
"Eritrea",
|
||||
"Estonia",
|
||||
"Eswatini",
|
||||
"Ethiopia",
|
||||
"Fiji",
|
||||
"Finland",
|
||||
"France",
|
||||
"Gabon",
|
||||
"Gambia",
|
||||
"Georgia",
|
||||
"Germany",
|
||||
"Ghana",
|
||||
"Greece",
|
||||
"Grenada",
|
||||
"Guatemala",
|
||||
"Guinea",
|
||||
"Guinea-Bissau",
|
||||
"Guyana",
|
||||
"Haiti",
|
||||
"Honduras",
|
||||
"Hungary",
|
||||
"Iceland",
|
||||
"India",
|
||||
"Indonesia",
|
||||
"Iran",
|
||||
"Iraq",
|
||||
"Ireland",
|
||||
"Israel",
|
||||
"Italy",
|
||||
"Jamaica",
|
||||
"Japan",
|
||||
"Jordan",
|
||||
"Kazakhstan",
|
||||
"Kenya",
|
||||
"Kiribati",
|
||||
"Kuwait",
|
||||
"Kyrgyzstan",
|
||||
"Laos",
|
||||
"Latvia",
|
||||
"Lebanon",
|
||||
"Lesotho",
|
||||
"Liberia",
|
||||
"Libya",
|
||||
"Liechtenstein",
|
||||
"Lithuania",
|
||||
"Luxembourg",
|
||||
"Madagascar",
|
||||
"Malawi",
|
||||
"Malaysia",
|
||||
"Maldives",
|
||||
"Mali",
|
||||
"Malta",
|
||||
"Marshall Islands",
|
||||
"Mauritania",
|
||||
"Mauritius",
|
||||
"Mexico",
|
||||
"Micronesia",
|
||||
"Moldova",
|
||||
"Monaco",
|
||||
"Mongolia",
|
||||
"Montenegro",
|
||||
"Morocco",
|
||||
"Mozambique",
|
||||
"Myanmar",
|
||||
"Namibia",
|
||||
"Nauru",
|
||||
"Nepal",
|
||||
"Netherlands",
|
||||
"New Zealand",
|
||||
"Nicaragua",
|
||||
"Niger",
|
||||
"Nigeria",
|
||||
"North Korea",
|
||||
"North Macedonia",
|
||||
"Norway",
|
||||
"Oman",
|
||||
"Pakistan",
|
||||
"Palau",
|
||||
"Panama",
|
||||
"Papua New Guinea",
|
||||
"Paraguay",
|
||||
"Peru",
|
||||
"Philippines",
|
||||
"Poland",
|
||||
"Portugal",
|
||||
"Qatar",
|
||||
"Romania",
|
||||
"Russia",
|
||||
"Rwanda",
|
||||
"Saint Kitts and Nevis",
|
||||
"Saint Lucia",
|
||||
"Saint Vincent and the Grenadines",
|
||||
"Samoa",
|
||||
"San Marino",
|
||||
"Sao Tome and Principe",
|
||||
"Saudi Arabia",
|
||||
"Senegal",
|
||||
"Serbia",
|
||||
"Seychelles",
|
||||
"Sierra Leone",
|
||||
"Singapore",
|
||||
"Slovakia",
|
||||
"Slovenia",
|
||||
"Solomon Islands",
|
||||
"Somalia",
|
||||
"South Africa",
|
||||
"South Korea",
|
||||
"South Sudan",
|
||||
"Spain",
|
||||
"Sri Lanka",
|
||||
"Sudan",
|
||||
"Suriname",
|
||||
"Sweden",
|
||||
"Switzerland",
|
||||
"Syria",
|
||||
"Tajikistan",
|
||||
"Tanzania",
|
||||
"Thailand",
|
||||
"Timor-Leste",
|
||||
"Togo",
|
||||
"Tonga",
|
||||
"Trinidad and Tobago",
|
||||
"Tunisia",
|
||||
"Turkey",
|
||||
"Turkmenistan",
|
||||
"Tuvalu",
|
||||
"Uganda",
|
||||
"Ukraine",
|
||||
"United Arab Emirates",
|
||||
"United Kingdom",
|
||||
"United States",
|
||||
"Uruguay",
|
||||
"Uzbekistan",
|
||||
"Vanuatu",
|
||||
"Vatican City",
|
||||
"Venezuela",
|
||||
"Vietnam",
|
||||
"Yemen",
|
||||
"Zambia",
|
||||
"Zimbabwe",
|
||||
] as const
|
||||
|
||||
/** English short names, A–Z (for `<select>` options). */
|
||||
export const USER_FILTER_COUNTRIES: readonly string[] = [...COUNTRY_NAMES_RAW].sort((a, b) =>
|
||||
a.localeCompare(b, "en"),
|
||||
)
|
||||
|
||||
/**
|
||||
* Ethiopia — regions & chartered cities (canonical spelling for filters).
|
||||
* Backend matches case-insensitively; use these labels so UI aligns with stored data.
|
||||
*/
|
||||
export const USER_FILTER_ETHIOPIA_REGIONS = [
|
||||
"Addis Ababa",
|
||||
"Afar",
|
||||
"Amhara",
|
||||
"Benishangul-Gumuz",
|
||||
"Dire Dawa",
|
||||
"Gambela Peoples' Region",
|
||||
"Harari",
|
||||
"Oromia",
|
||||
"Sidama",
|
||||
"Somali",
|
||||
"Southern Nations, Nationalities, and Peoples' Region",
|
||||
"South West Ethiopia Peoples' Region",
|
||||
"Tigray",
|
||||
] as const satisfies readonly string[]
|
||||
|
||||
export type UserFilterEthiopiaRegion = (typeof USER_FILTER_ETHIOPIA_REGIONS)[number]
|
||||
43
src/hooks/useActivePersonas.ts
Normal file
43
src/hooks/useActivePersonas.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { useCallback, useEffect, useState } from "react"
|
||||
import { getPersonas } from "../api/personas.api"
|
||||
import {
|
||||
mapPersonaToCard,
|
||||
unwrapPersonasList,
|
||||
type PersonaCardModel,
|
||||
} from "../lib/personaDisplay"
|
||||
|
||||
type UseActivePersonasOptions = {
|
||||
limit?: number
|
||||
offset?: number
|
||||
}
|
||||
|
||||
export function useActivePersonas(options: UseActivePersonasOptions = {}) {
|
||||
const { limit = 50, offset = 0 } = options
|
||||
const [personas, setPersonas] = useState<PersonaCardModel[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await getPersonas({ limit, offset })
|
||||
const list = unwrapPersonasList(res).filter((p) => p.is_active)
|
||||
setPersonas(list.map(mapPersonaToCard))
|
||||
} catch (e: unknown) {
|
||||
const msg =
|
||||
(e as { response?: { data?: { message?: string } } })?.response?.data
|
||||
?.message ?? "Failed to load personas"
|
||||
setError(msg)
|
||||
setPersonas([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [limit, offset])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
return { personas, loading, error, reload: load }
|
||||
}
|
||||
|
|
@ -3,12 +3,13 @@ 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=${token}`
|
||||
return `${wsBase}/ws/connect?token=${encodeURIComponent(token)}`
|
||||
}
|
||||
|
||||
export function useNotifications() {
|
||||
|
|
@ -18,6 +19,8 @@ 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"))
|
||||
|
|
@ -40,11 +43,37 @@ export function useNotifications() {
|
|||
}
|
||||
}, [])
|
||||
|
||||
const connectWs = useCallback(() => {
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close()
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
|
|
@ -78,47 +107,45 @@ export function useNotifications() {
|
|||
}
|
||||
}
|
||||
|
||||
ws.onerror = () => {
|
||||
ws.close()
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
if (!mountedRef.current) return
|
||||
if (connectAttemptRef.current !== attempt) return
|
||||
if (wsRef.current === ws) wsRef.current = null
|
||||
if (!mountedRef.current || intentionalCloseRef.current) return
|
||||
clearReconnectTimer()
|
||||
reconnectTimer.current = setTimeout(() => {
|
||||
if (mountedRef.current) connectWs()
|
||||
}, 5000)
|
||||
}, RECONNECT_MS)
|
||||
}
|
||||
}, [])
|
||||
}, [clearReconnectTimer, disconnectWs])
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true
|
||||
intentionalCloseRef.current = false
|
||||
fetchData()
|
||||
connectWs()
|
||||
|
||||
return () => {
|
||||
mountedRef.current = false
|
||||
wsRef.current?.close()
|
||||
if (reconnectTimer.current) clearTimeout(reconnectTimer.current)
|
||||
disconnectWs(true)
|
||||
}
|
||||
}, [fetchData, connectWs])
|
||||
}, [fetchData, connectWs, disconnectWs])
|
||||
|
||||
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()
|
||||
|
|
|
|||
109
src/index.css
109
src/index.css
|
|
@ -5,7 +5,17 @@
|
|||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
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);
|
||||
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
|
||||
|
|
@ -38,6 +48,46 @@
|
|||
--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;
|
||||
}
|
||||
|
|
@ -49,6 +99,61 @@
|
|||
}
|
||||
|
||||
body {
|
||||
@apply bg-grayScale-100 text-foreground font-sans antialiased;
|
||||
@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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
143
src/lib/activityLogActor.ts
Normal file
143
src/lib/activityLogActor.ts
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
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",
|
||||
})
|
||||
}
|
||||
140
src/lib/analytics.ts
Normal file
140
src/lib/analytics.ts
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
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
|
||||
}
|
||||
79
src/lib/appVersions.ts
Normal file
79
src/lib/appVersions.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
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})`
|
||||
}
|
||||
9
src/lib/auth.ts
Normal file
9
src/lib/auth.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
/** 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}`
|
||||
}
|
||||
57
src/lib/dynamicTableValue.ts
Normal file
57
src/lib/dynamicTableValue.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
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 }
|
||||
}
|
||||
56
src/lib/emailTemplatePreview.ts
Normal file
56
src/lib/emailTemplatePreview.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
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
|
||||
}
|
||||
548
src/lib/learnEnglishDefinitionQuestion.ts
Normal file
548
src/lib/learnEnglishDefinitionQuestion.ts
Normal file
|
|
@ -0,0 +1,548 @@
|
|||
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
|
||||
}
|
||||
164
src/lib/learnEnglishPracticePublish.ts
Normal file
164
src/lib/learnEnglishPracticePublish.ts
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
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 }
|
||||
}
|
||||
354
src/lib/matchingSlotValue.ts
Normal file
354
src/lib/matchingSlotValue.ts
Normal file
|
|
@ -0,0 +1,354 @@
|
|||
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
|
||||
}
|
||||
197
src/lib/multipleChoiceSlotValue.ts
Normal file
197
src/lib/multipleChoiceSlotValue.ts
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
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
|
||||
}
|
||||
101
src/lib/notificationDisplay.ts
Normal file
101
src/lib/notificationDisplay.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
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
|
||||
}
|
||||
48
src/lib/parentContextPractice.ts
Normal file
48
src/lib/parentContextPractice.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import type {
|
||||
GetPracticesByParentContextResponse,
|
||||
ParentContextPractice,
|
||||
PracticePublishStatus,
|
||||
} from "../types/course.types"
|
||||
|
||||
export function unwrapPracticesList(
|
||||
res: {
|
||||
data?: GetPracticesByParentContextResponse & {
|
||||
Data?: GetPracticesByParentContextResponse["data"]
|
||||
}
|
||||
},
|
||||
): ParentContextPractice[] {
|
||||
const body = res.data
|
||||
if (!body) return []
|
||||
const data = body.data ?? body.Data
|
||||
const raw = data?.practices
|
||||
return Array.isArray(raw) ? raw : []
|
||||
}
|
||||
|
||||
export function practicePublishStatus(
|
||||
practice: ParentContextPractice,
|
||||
): PracticePublishStatus | null {
|
||||
const raw = practice.publish_status
|
||||
if (raw === "DRAFT" || raw === "PUBLISHED") return raw
|
||||
if (typeof raw === "string") {
|
||||
const upper = raw.toUpperCase()
|
||||
if (upper === "DRAFT" || upper === "PUBLISHED") {
|
||||
return upper as PracticePublishStatus
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function isPracticePublished(practice: ParentContextPractice): boolean {
|
||||
return practicePublishStatus(practice) === "PUBLISHED"
|
||||
}
|
||||
|
||||
export function isPracticeDraft(practice: ParentContextPractice): boolean {
|
||||
const status = practicePublishStatus(practice)
|
||||
return status === "DRAFT" || status === null
|
||||
}
|
||||
|
||||
export function draftPracticesForParent(
|
||||
practices: ParentContextPractice[],
|
||||
): ParentContextPractice[] {
|
||||
return practices.filter(isPracticeDraft)
|
||||
}
|
||||
27
src/lib/parseInviteEmails.ts
Normal file
27
src/lib/parseInviteEmails.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
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
|
||||
}
|
||||
51
src/lib/payments.ts
Normal file
51
src/lib/payments.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
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"
|
||||
}
|
||||
52
src/lib/personaDisplay.ts
Normal file
52
src/lib/personaDisplay.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import type {
|
||||
GetPersonasResponse,
|
||||
PersonaListItem,
|
||||
} from "../types/persona.types"
|
||||
|
||||
export type PersonaCardModel = {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
avatar: string
|
||||
}
|
||||
|
||||
/** Soft, professional palette aligned with the admin brand (slate, indigo, violet). */
|
||||
const PERSONA_FALLBACK_BACKGROUNDS = "f1f5f9,e0e7ff,ede9fe,fdf4ff,ecfeff"
|
||||
|
||||
/**
|
||||
* Default avatar when `profile_picture` is null: professional illustrated portrait
|
||||
* (DiceBear personas), not casual cartoon avataaars.
|
||||
*/
|
||||
export function personaAvatarUrl(
|
||||
profilePicture: string | null | undefined,
|
||||
name: string,
|
||||
personaId?: number | string,
|
||||
): string {
|
||||
const url = profilePicture?.trim()
|
||||
if (url) return url
|
||||
const params = new URLSearchParams({
|
||||
seed: personaId != null ? `yimaru-persona-${personaId}` : `yimaru-persona-${name}`,
|
||||
backgroundColor: PERSONA_FALLBACK_BACKGROUNDS,
|
||||
radius: "50",
|
||||
})
|
||||
return `https://api.dicebear.com/7.x/personas/svg?${params.toString()}`
|
||||
}
|
||||
|
||||
export function mapPersonaToCard(persona: PersonaListItem): PersonaCardModel {
|
||||
return {
|
||||
id: String(persona.id),
|
||||
name: persona.name,
|
||||
description: persona.description?.trim() ?? "",
|
||||
avatar: personaAvatarUrl(persona.profile_picture, persona.name, persona.id),
|
||||
}
|
||||
}
|
||||
|
||||
export function unwrapPersonasList(
|
||||
res: { data?: GetPersonasResponse & { Data?: GetPersonasResponse["data"] } },
|
||||
): PersonaListItem[] {
|
||||
const body = res.data
|
||||
if (!body) return []
|
||||
const data = body.data ?? body.Data
|
||||
const raw = data?.personas
|
||||
return Array.isArray(raw) ? raw : []
|
||||
}
|
||||
184
src/lib/practiceDynamicQuestionPayload.ts
Normal file
184
src/lib/practiceDynamicQuestionPayload.ts
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
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,
|
||||
),
|
||||
})),
|
||||
}
|
||||
}
|
||||
41
src/lib/schemaSlotLabel.ts
Normal file
41
src/lib/schemaSlotLabel.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
/** 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)
|
||||
}
|
||||
524
src/lib/selectMissingWordsSlotValue.ts
Normal file
524
src/lib/selectMissingWordsSlotValue.ts
Normal file
|
|
@ -0,0 +1,524 @@
|
|||
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
|
||||
}
|
||||
61
src/lib/subscriptionPlans.ts
Normal file
61
src/lib/subscriptionPlans.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
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",
|
||||
})
|
||||
}
|
||||
6
src/lib/tablePagination.ts
Normal file
6
src/lib/tablePagination.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
/** 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
|
||||
44
src/lib/teamInvitation.ts
Normal file
44
src/lib/teamInvitation.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
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."
|
||||
}
|
||||
43
src/lib/teamRoles.ts
Normal file
43
src/lib/teamRoles.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
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())
|
||||
}
|
||||
64
src/lib/theme.ts
Normal file
64
src/lib/theme.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
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)
|
||||
}
|
||||
|
|
@ -88,6 +88,19 @@ export function formatPreviewLength(totalSeconds: number): string {
|
|||
return `${totalSeconds} seconds`;
|
||||
}
|
||||
|
||||
/** Compact label for thumbnails (e.g. `3:02`, `1:05:07`). */
|
||||
export function formatVideoDurationLabel(totalSeconds: number): string {
|
||||
if (!Number.isFinite(totalSeconds) || totalSeconds <= 0) return "";
|
||||
const s = Math.round(totalSeconds);
|
||||
const h = Math.floor(s / 3600);
|
||||
const m = Math.floor((s % 3600) / 60);
|
||||
const sec = s % 60;
|
||||
if (h > 0) {
|
||||
return `${h}:${String(m).padStart(2, "0")}:${String(sec).padStart(2, "0")}`;
|
||||
}
|
||||
return `${m}:${String(sec).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* YouTube: `end` = stop after this many seconds from the start of the video.
|
||||
* Vimeo: time range in URL fragment (supported on many vimeo.com player links).
|
||||
|
|
|
|||
|
|
@ -3,11 +3,14 @@ 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>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
<ThemeProvider>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</ThemeProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import {
|
||||
// Activity,
|
||||
BadgeCheck,
|
||||
BookOpen,
|
||||
Video,
|
||||
// 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,15 +28,27 @@ import {
|
|||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts"
|
||||
import { RevenueTrendCard } from "../components/dashboard/RevenueTrendCard"
|
||||
import { StatCard } from "../components/dashboard/StatCard"
|
||||
import alertSrc from "../assets/Alert.svg"
|
||||
import { Badge } from "../components/ui/badge"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card"
|
||||
import { cn } from "../lib/utils"
|
||||
import { getTeamMemberById } from "../api/team.api"
|
||||
import { getDashboard } from "../api/analytics.api"
|
||||
import { getSubscriptionPlans } from "../api/subscription-plans.api"
|
||||
import { getRatings } from "../api/courses.api"
|
||||
import { useEffect, useState } from "react"
|
||||
import type { DashboardData } from "../types/analytics.types"
|
||||
import { AnalyticsTimeRangeFilter } from "../components/analytics/AnalyticsTimeRangeFilter"
|
||||
import {
|
||||
getPrimaryQuestionTypeSummary,
|
||||
getSeriesPeriodLabel,
|
||||
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 { Rating } from "../types/course.types"
|
||||
|
||||
const PIE_COLORS = ["#9E2891", "#FFD23F", "#1DE9B6", "#C26FC0", "#6366F1", "#F97316", "#14B8A6", "#EF4444"]
|
||||
|
|
@ -46,6 +58,8 @@ 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)
|
||||
|
|
@ -53,6 +67,9 @@ export function DashboardPage() {
|
|||
const [activeStatTab, setActiveStatTab] = useState<"primary" | "secondary">("primary")
|
||||
const [appRatings, setAppRatings] = useState<Rating[]>([])
|
||||
const [appRatingsLoading, setAppRatingsLoading] = useState(true)
|
||||
const [filters, setFilters] = useState<DashboardFilters>(DEFAULT_FILTERS)
|
||||
const [subscriptionPlans, setSubscriptionPlans] = useState<SubscriptionPlan[]>([])
|
||||
const [subscriptionPlansLoading, setSubscriptionPlansLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const fetchUser = async () => {
|
||||
|
|
@ -70,17 +87,10 @@ export function DashboardPage() {
|
|||
}
|
||||
}
|
||||
|
||||
const fetchDashboard = async () => {
|
||||
try {
|
||||
const res = await getDashboard()
|
||||
setDashboard(res.data as unknown as DashboardData)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
fetchUser()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const fetchAppRatings = async () => {
|
||||
try {
|
||||
const res = await getRatings({ target_type: "app", target_id: 1, limit: 5 })
|
||||
|
|
@ -92,23 +102,49 @@ 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,
|
||||
|
|
@ -123,9 +159,17 @@ 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 text-sm font-semibold text-grayScale-500">Dashboard</div>
|
||||
<div className="mb-2 flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="text-sm font-semibold text-grayScale-500">Dashboard</div>
|
||||
<AnalyticsTimeRangeFilter value={filters} onChange={setFilters} />
|
||||
</div>
|
||||
<div className="mb-5 text-2xl font-semibold tracking-tight">
|
||||
Welcome, {userFirstName || localStorage.getItem("user_first_name")}
|
||||
</div>
|
||||
|
|
@ -189,11 +233,11 @@ export function DashboardPage() {
|
|||
deltaPositive={dashboard.users.new_month > 0}
|
||||
/>
|
||||
<StatCard
|
||||
icon={BadgeCheck}
|
||||
label="Active Subscribers"
|
||||
value={dashboard.subscriptions.active_subscriptions.toLocaleString()}
|
||||
deltaLabel={`+${dashboard.subscriptions.new_month} this month`}
|
||||
deltaPositive={dashboard.subscriptions.new_month > 0}
|
||||
icon={CreditCard}
|
||||
label="Payments"
|
||||
value={dashboard.payments.total_payments.toLocaleString()}
|
||||
deltaLabel={`${dashboard.payments.successful_payments} successful`}
|
||||
deltaPositive={dashboard.payments.successful_payments > 0}
|
||||
/>
|
||||
<StatCard
|
||||
icon={DollarSign}
|
||||
|
|
@ -213,21 +257,49 @@ export function DashboardPage() {
|
|||
)}
|
||||
|
||||
{/* Secondary Stats */}
|
||||
{activeStatTab === "secondary" && (
|
||||
{activeStatTab === "secondary" && subscriptionMetrics && (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<StatCard
|
||||
icon={BookOpen}
|
||||
label="Courses"
|
||||
value={dashboard.courses.total_courses.toLocaleString()}
|
||||
deltaLabel={`${dashboard.courses.total_sub_courses} sub-modules, ${dashboard.courses.total_videos} videos`}
|
||||
deltaPositive
|
||||
icon={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}
|
||||
/>
|
||||
<StatCard
|
||||
icon={HelpCircle}
|
||||
label="Questions"
|
||||
value={dashboard.content.total_questions.toLocaleString()}
|
||||
deltaLabel={`${dashboard.content.total_question_sets} question sets`}
|
||||
deltaPositive
|
||||
deltaLabel={getPrimaryQuestionTypeSummary(dashboard.content.questions_by_type)}
|
||||
deltaPositive={dashboard.content.total_questions > 0}
|
||||
/>
|
||||
<StatCard
|
||||
icon={Bell}
|
||||
|
|
@ -261,7 +333,7 @@ export function DashboardPage() {
|
|||
</div>
|
||||
</div>
|
||||
<div className="rounded-full bg-grayScale-100 px-3 py-1 text-xs font-semibold text-grayScale-500">
|
||||
Last 30 Days
|
||||
{seriesPeriodLabel}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
|
@ -357,76 +429,69 @@ export function DashboardPage() {
|
|||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Revenue Chart */}
|
||||
<Card className="shadow-none">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Revenue Trend</CardTitle>
|
||||
<div className="mt-2 text-2xl font-semibold tracking-tight">
|
||||
ETB {dashboard.payments.total_revenue.toLocaleString()}
|
||||
</div>
|
||||
<div className="text-xs font-medium text-grayScale-500">Last 30 Days (ETB)</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="h-[220px] p-6 pt-2">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={revenueData} margin={{ left: 8, right: 8, top: 8 }}>
|
||||
<CartesianGrid vertical={false} stroke="#E0E0E0" strokeDasharray="4 4" />
|
||||
<XAxis dataKey="date" tickLine={false} axisLine={false} fontSize={12} />
|
||||
<YAxis tickLine={false} axisLine={false} fontSize={12} width={42} />
|
||||
<Tooltip
|
||||
formatter={(v) => [`${Number(v).toLocaleString()}`, "ETB"]}
|
||||
contentStyle={{
|
||||
borderRadius: 12,
|
||||
border: "1px solid #E0E0E0",
|
||||
boxShadow: "0 10px 30px rgba(0,0,0,0.08)",
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="revenue" radius={[10, 10, 0, 0]} fill="#9E2891" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<RevenueTrendCard />
|
||||
</div>
|
||||
|
||||
{/* Users by Role / Region / Knowledge Level */}
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
{[
|
||||
{ title: "Users by Role", data: dashboard.users.by_role },
|
||||
{ title: "Users by Region", data: dashboard.users.by_region },
|
||||
{ title: "Users by Knowledge Level", data: dashboard.users.by_knowledge_level },
|
||||
].map(({ title, data }) => (
|
||||
<Card key={title} className="shadow-none">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle>{title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-6 pt-2">
|
||||
{data.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{data.map((item, i) => (
|
||||
<div key={item.label} className="flex items-center justify-between gap-3 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="h-2.5 w-2.5 rounded-full"
|
||||
style={{ backgroundColor: PIE_COLORS[i % PIE_COLORS.length] }}
|
||||
/>
|
||||
<span className="text-grayScale-600">{item.label}</span>
|
||||
{/* Subscription plans (from catalog API) */}
|
||||
<Card className="shadow-none">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<CreditCard className="h-5 w-5 text-brand-500" />
|
||||
<CardTitle>Subscription plans</CardTitle>
|
||||
</div>
|
||||
<p className="text-sm text-grayScale-500">Available billing plans for learners.</p>
|
||||
</CardHeader>
|
||||
<CardContent className="p-6 pt-2">
|
||||
{subscriptionPlansLoading ? (
|
||||
<div className="flex items-center justify-center py-10">
|
||||
<img src={spinnerSrc} alt="" className="h-8 w-8 animate-spin" />
|
||||
</div>
|
||||
) : subscriptionPlans.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-10 text-sm text-grayScale-400">
|
||||
No subscription plans found
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{subscriptionPlans.map((plan) => (
|
||||
<div
|
||||
key={plan.id}
|
||||
className="flex flex-col rounded-xl border border-grayScale-200 bg-grayScale-50/50 p-4"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h3 className="font-semibold text-grayScale-700">{plan.name}</h3>
|
||||
<Badge variant={plan.is_active ? "success" : "secondary"}>
|
||||
{plan.is_active ? "Active" : "Inactive"}
|
||||
</Badge>
|
||||
</div>
|
||||
{plan.description ? (
|
||||
<p className="mt-2 line-clamp-2 text-sm text-grayScale-500">{plan.description}</p>
|
||||
) : null}
|
||||
<div className="mt-4 flex flex-wrap items-end justify-between gap-2 border-t border-grayScale-200 pt-4">
|
||||
<div>
|
||||
<div className="text-xs font-medium uppercase tracking-wide text-grayScale-400">Price</div>
|
||||
<div className="text-lg font-semibold text-brand-600">
|
||||
{plan.currency}{" "}
|
||||
{Number.isInteger(plan.price)
|
||||
? plan.price.toLocaleString()
|
||||
: plan.price.toLocaleString(undefined, {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 2,
|
||||
})}
|
||||
</div>
|
||||
<span className="font-semibold text-grayScale-600">{item.count.toLocaleString()}</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="text-right">
|
||||
<div className="text-xs font-medium uppercase tracking-wide text-grayScale-400">
|
||||
Billing
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-grayScale-600">{formatPlanDuration(plan)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center py-6 text-sm text-grayScale-400">
|
||||
No data available
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* App Ratings */}
|
||||
<Card className="shadow-none">
|
||||
|
|
|
|||
|
|
@ -1,11 +1,8 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
import {
|
||||
Bell,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Globe,
|
||||
KeyRound,
|
||||
Languages,
|
||||
Lock,
|
||||
Moon,
|
||||
Palette,
|
||||
|
|
@ -14,8 +11,7 @@ import {
|
|||
Sun,
|
||||
User,
|
||||
CreditCard,
|
||||
AlertTriangle,
|
||||
X,
|
||||
Smartphone,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Card,
|
||||
|
|
@ -26,228 +22,33 @@ 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", icon: CreditCard },
|
||||
{ id: "subscription", label: "Subscription packages", icon: CreditCard },
|
||||
{ id: "app-versions", label: "App versions", icon: Smartphone },
|
||||
{ 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);
|
||||
|
|
@ -363,17 +164,46 @@ function ProfileTab({ profile }: { profile: UserProfileData }) {
|
|||
);
|
||||
}
|
||||
|
||||
function SecurityTab() {
|
||||
function SecurityTab({ memberId }: { memberId: number }) {
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
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 new Promise((r) => setTimeout(r, 600));
|
||||
toast.success("Password updated successfully");
|
||||
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);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
|
|
@ -397,7 +227,11 @@ function SecurityTab() {
|
|||
<Input
|
||||
type={showCurrent ? "text" : "password"}
|
||||
placeholder="Enter current password"
|
||||
className="rounded-[6px]"
|
||||
className="rounded-[6px] pr-10"
|
||||
autoComplete="current-password"
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
disabled={saving}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -421,7 +255,11 @@ function SecurityTab() {
|
|||
<Input
|
||||
type={showNew ? "text" : "password"}
|
||||
placeholder="Enter new password"
|
||||
className="rounded-[6px]"
|
||||
className="rounded-[6px] pr-10"
|
||||
autoComplete="new-password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
disabled={saving}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -444,7 +282,11 @@ function SecurityTab() {
|
|||
<Input
|
||||
type={showConfirm ? "text" : "password"}
|
||||
placeholder="Confirm new password"
|
||||
className="rounded-[6px]"
|
||||
className="rounded-[6px] pr-10"
|
||||
autoComplete="new-password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
disabled={saving}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -532,52 +374,101 @@ function NotificationsTab() {
|
|||
}
|
||||
|
||||
function AppearanceTab() {
|
||||
const [theme, setTheme] = useState<"light" | "dark" | "system">("light");
|
||||
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,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -644,10 +535,36 @@ export function SettingsPage() {
|
|||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-8">
|
||||
{/* Content Area */}
|
||||
<main className="min-h-[400px]">
|
||||
{activeTab === "subscription" && <SubscriptionTab />}
|
||||
<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 />}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -39,7 +39,15 @@ import { Badge } from "../../components/ui/badge"
|
|||
import { Button } from "../../components/ui/button"
|
||||
import { cn } from "../../lib/utils"
|
||||
import { getDashboard } from "../../api/analytics.api"
|
||||
import type { DashboardData, LabelCount } from "../../types/analytics.types"
|
||||
import { AnalyticsTimeRangeFilter, getDashboardFilterLabel } from "../../components/analytics/AnalyticsTimeRangeFilter"
|
||||
import {
|
||||
getPrimaryQuestionTypeSummary,
|
||||
getSeriesPeriodLabel,
|
||||
formatAnalyticsLabel,
|
||||
getSubscriptionMetrics,
|
||||
getVideoLessonsSummary,
|
||||
} from "../../lib/analytics"
|
||||
import type { DashboardData, DashboardFilters, LabelCount } from "../../types/analytics.types"
|
||||
|
||||
const PIE_COLORS = ["#9E2891", "#FFD23F", "#1DE9B6", "#C26FC0", "#6366F1", "#F97316", "#14B8A6", "#EF4444", "#8B5CF6", "#EC4899", "#06B6D4", "#84CC16"]
|
||||
|
||||
|
|
@ -109,31 +117,43 @@ 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">
|
||||
{data.length > 0 ? (
|
||||
<div className="space-y-2.5">
|
||||
{data.map((item, i) => {
|
||||
{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) => {
|
||||
const pct = computedTotal > 0 ? (item.count / computedTotal) * 100 : 0
|
||||
const displayLabel = formatAnalyticsLabel(item.label)
|
||||
return (
|
||||
<div key={item.label}>
|
||||
<div className="mb-1 flex items-center justify-between text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<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">
|
||||
<span
|
||||
className="h-2 w-2 rounded-full"
|
||||
className="h-2 w-2 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: PIE_COLORS[i % PIE_COLORS.length] }}
|
||||
/>
|
||||
<span className="text-grayScale-600">{item.label}</span>
|
||||
<span className="truncate text-grayScale-600" title={displayLabel}>
|
||||
{displayLabel}
|
||||
</span>
|
||||
</div>
|
||||
<span className="font-semibold text-grayScale-700">
|
||||
{item.count.toLocaleString()}
|
||||
|
|
@ -285,18 +305,21 @@ function Section({
|
|||
)
|
||||
}
|
||||
|
||||
const DEFAULT_FILTERS: DashboardFilters = { mode: "all_time" }
|
||||
|
||||
export function AnalyticsPage() {
|
||||
const [dashboard, setDashboard] = useState<DashboardData | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(false)
|
||||
const [activeSummaryTab, setActiveSummaryTab] = useState<"key" | "content" | "operations">("key")
|
||||
const [filters, setFilters] = useState<DashboardFilters>(DEFAULT_FILTERS)
|
||||
|
||||
const fetchData = async () => {
|
||||
const fetchData = async (nextFilters: DashboardFilters = filters) => {
|
||||
setLoading(true)
|
||||
setError(false)
|
||||
try {
|
||||
const res = await getDashboard()
|
||||
setDashboard(res.data as unknown as DashboardData)
|
||||
const res = await getDashboard(nextFilters)
|
||||
setDashboard(res.data)
|
||||
} catch {
|
||||
setError(true)
|
||||
} finally {
|
||||
|
|
@ -305,10 +328,11 @@ export function AnalyticsPage() {
|
|||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
}, [])
|
||||
fetchData(filters)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [filters])
|
||||
|
||||
if (loading) {
|
||||
if (!dashboard && loading) {
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-[1280px] px-2 sm:px-4">
|
||||
<div className="mb-6 text-xs font-semibold uppercase tracking-wide text-grayScale-400">Analytics</div>
|
||||
|
|
@ -323,11 +347,14 @@ export function AnalyticsPage() {
|
|||
if (error || !dashboard) {
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-[1280px] px-2 sm:px-4">
|
||||
<div className="mb-6 text-xs font-semibold uppercase tracking-wide text-grayScale-400">Analytics</div>
|
||||
<div className="mb-6 flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-grayScale-400">Analytics</div>
|
||||
<AnalyticsTimeRangeFilter value={filters} onChange={setFilters} />
|
||||
</div>
|
||||
<div className="flex flex-col items-center justify-center gap-3 rounded-2xl border border-red-100 bg-red-50/30 py-24">
|
||||
<img src={alertSrc} alt="" className="h-12 w-12" />
|
||||
<span className="text-sm text-destructive">Failed to load analytics data.</span>
|
||||
<Button variant="outline" size="sm" onClick={fetchData}>
|
||||
<Button variant="outline" size="sm" onClick={() => fetchData(filters)}>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
Retry
|
||||
</Button>
|
||||
|
|
@ -337,6 +364,10 @@ 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),
|
||||
|
|
@ -387,15 +418,25 @@ export function AnalyticsPage() {
|
|||
<div className="mb-1 text-xs font-semibold uppercase tracking-wide text-grayScale-400">Analytics</div>
|
||||
<h1 className="text-3xl font-semibold tracking-tight text-grayScale-900">Platform Overview</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-grayScale-400">Generated {generatedAt}</span>
|
||||
<Button variant="outline" size="sm" onClick={fetchData}>
|
||||
<RefreshCw className="mr-2 h-3.5 w-3.5" />
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span className="text-xs text-grayScale-400">
|
||||
{getDashboardFilterLabel(filters)} · Generated {generatedAt}
|
||||
</span>
|
||||
<AnalyticsTimeRangeFilter value={filters} onChange={setFilters} />
|
||||
<Button variant="outline" size="sm" onClick={() => fetchData(filters)} disabled={loading}>
|
||||
<RefreshCw className={cn("mr-2 h-3.5 w-3.5", loading && "animate-spin")} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<div className="mb-4 flex items-center gap-2 rounded-lg border border-grayScale-100 bg-grayScale-50 px-3 py-2 text-xs text-grayScale-500">
|
||||
<img src={spinnerSrc} alt="" className="h-4 w-4 animate-spin" />
|
||||
Updating analytics for {getDashboardFilterLabel(filters)}…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Summary Tabs */}
|
||||
<div className="mb-6 rounded-2xl border border-grayScale-100 bg-white px-5 pt-4 shadow-sm">
|
||||
<div className="-mb-px flex gap-6">
|
||||
|
|
@ -452,10 +493,10 @@ export function AnalyticsPage() {
|
|||
trend={users.new_month > 0 ? "up" : "neutral"}
|
||||
/>
|
||||
<KpiCard
|
||||
icon={BadgeCheck}
|
||||
label="Active Subscriptions"
|
||||
value={formatNumber(subscriptions.active_subscriptions)}
|
||||
sub={`${subscriptions.total_subscriptions} total · +${subscriptions.new_month} this month`}
|
||||
icon={CreditCard}
|
||||
label="Total Subscriptions"
|
||||
value={formatNumber(subscriptionMetrics.total)}
|
||||
sub={`${subscriptionMetrics.active} active · ${subscriptionMetrics.inactive} inactive`}
|
||||
trend={subscriptions.new_month > 0 ? "up" : "neutral"}
|
||||
/>
|
||||
<KpiCard
|
||||
|
|
@ -483,7 +524,7 @@ export function AnalyticsPage() {
|
|||
<Section
|
||||
title="Content & Platform"
|
||||
icon={BookOpen}
|
||||
count={courses.total_courses + content.total_questions}
|
||||
count={courses.total_videos + content.total_questions}
|
||||
defaultOpen
|
||||
>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
|
|
@ -491,28 +532,29 @@ export function AnalyticsPage() {
|
|||
icon={FolderOpen}
|
||||
label="Categories"
|
||||
value={courses.total_categories.toLocaleString()}
|
||||
sub={`${courses.total_courses} courses`}
|
||||
sub={`${courses.total_courses} courses · ${courses.total_sub_courses} modules`}
|
||||
trend="neutral"
|
||||
/>
|
||||
<KpiCard
|
||||
icon={BookOpen}
|
||||
label="Sub-Courses"
|
||||
value={courses.total_sub_courses.toLocaleString()}
|
||||
sub={`across ${courses.total_courses} courses`}
|
||||
label="LMS Programs"
|
||||
value={(lms?.programs ?? 0).toLocaleString()}
|
||||
sub={`${lms?.courses ?? 0} courses · ${lms?.practices ?? 0} practices`}
|
||||
trend="neutral"
|
||||
/>
|
||||
<KpiCard
|
||||
icon={Video}
|
||||
label="Videos"
|
||||
value={courses.total_videos.toLocaleString()}
|
||||
trend="neutral"
|
||||
sub={getVideoLessonsSummary(lms?.lessons_with_video, examPrep?.lessons_with_video)}
|
||||
trend={courses.total_videos > 0 ? "up" : "neutral"}
|
||||
/>
|
||||
<KpiCard
|
||||
icon={HelpCircle}
|
||||
label="Questions"
|
||||
value={content.total_questions.toLocaleString()}
|
||||
sub={`${content.total_question_sets} question sets`}
|
||||
trend="neutral"
|
||||
sub={getPrimaryQuestionTypeSummary(content.questions_by_type)}
|
||||
trend={content.total_questions > 0 ? "up" : "neutral"}
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
|
|
@ -573,7 +615,7 @@ export function AnalyticsPage() {
|
|||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="secondary">Last 30 Days</Badge>
|
||||
<Badge variant="secondary">{seriesPeriodLabel}</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="h-[280px] p-6 pt-2">
|
||||
|
|
@ -601,12 +643,77 @@ export function AnalyticsPage() {
|
|||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="mt-4 grid items-start gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<BreakdownList title="Users by Role" data={users.by_role} total={users.total_users} />
|
||||
<BreakdownList title="Users by 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 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>
|
||||
</Section>
|
||||
|
||||
|
|
@ -625,7 +732,7 @@ export function AnalyticsPage() {
|
|||
+{subscriptions.new_today} today · +{subscriptions.new_week} this week
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="secondary">Last 30 Days</Badge>
|
||||
<Badge variant="secondary">{seriesPeriodLabel}</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="h-[240px] p-6 pt-2">
|
||||
|
|
@ -664,7 +771,7 @@ export function AnalyticsPage() {
|
|||
</div>
|
||||
<div className="text-xs text-grayScale-400">Daily revenue over last 30 days</div>
|
||||
</div>
|
||||
<Badge variant="secondary">Last 30 Days</Badge>
|
||||
<Badge variant="secondary">{seriesPeriodLabel}</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="h-[240px] p-6 pt-2">
|
||||
|
|
@ -728,6 +835,43 @@ export function AnalyticsPage() {
|
|||
</div>
|
||||
</Section>
|
||||
|
||||
{/* ─── Course Management ─── */}
|
||||
{(lms || examPrep) && (
|
||||
<Section title="Course Management" icon={BookOpen} count={courses.total_videos} defaultOpen={false}>
|
||||
<div className="grid items-start gap-4 lg:grid-cols-2">
|
||||
{lms && (
|
||||
<BreakdownList
|
||||
title="LMS"
|
||||
data={[
|
||||
{ label: "Programs", count: lms.programs },
|
||||
{ label: "Courses", count: lms.courses },
|
||||
{ label: "Modules", count: lms.modules },
|
||||
{ label: "Lessons", count: lms.lessons },
|
||||
{ label: "Lessons with video", count: lms.lessons_with_video },
|
||||
{ label: "Practices", count: lms.practices },
|
||||
{ label: "Practices at course", count: lms.practices_at_course },
|
||||
{ label: "Practices at module", count: lms.practices_at_module },
|
||||
{ label: "Practices at lesson", count: lms.practices_at_lesson },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
{examPrep && (
|
||||
<BreakdownList
|
||||
title="Exam prep"
|
||||
data={[
|
||||
{ label: "Catalog courses", count: examPrep.catalog_courses },
|
||||
{ label: "Units", count: examPrep.units },
|
||||
{ label: "Unit modules", count: examPrep.unit_modules },
|
||||
{ label: "Lessons", count: examPrep.lessons },
|
||||
{ label: "Lessons with video", count: examPrep.lessons_with_video },
|
||||
{ label: "Lesson practices", count: examPrep.lesson_practices },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* ─── Content Breakdown ─── */}
|
||||
<Section title="Content Breakdown" icon={HelpCircle} count={content.total_questions} defaultOpen={false}>
|
||||
<div className="grid items-start gap-4 sm:grid-cols-2">
|
||||
|
|
|
|||
467
src/pages/auth/AcceptInvitePage.tsx
Normal file
467
src/pages/auth/AcceptInvitePage.tsx
Normal file
|
|
@ -0,0 +1,467 @@
|
|||
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>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { Link, Navigate, useNavigate } from "react-router-dom";
|
||||
import { Link, Navigate, useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
|
||||
import { BrandLogo } from "../../components/brand/BrandLogo";
|
||||
|
|
@ -65,9 +65,18 @@ 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("");
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ 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"
|
||||
|
|
@ -12,7 +14,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"
|
||||
type QuestionType = "MCQ" | "TRUE_FALSE" | "SHORT" | "AUDIO" | "DYNAMIC"
|
||||
type DifficultyLevel = "EASY" | "MEDIUM" | "HARD"
|
||||
type ResultStatus = "success" | "error"
|
||||
|
||||
|
|
@ -35,6 +37,10 @@ interface Question {
|
|||
audioCorrectAnswerText: string
|
||||
shortAnswers: string[]
|
||||
imageUrl: string
|
||||
questionTypeDefinitionId: number | null
|
||||
dynamicStimulusRows: PracticeQuestionDynamicRow[]
|
||||
dynamicResponseRows: PracticeQuestionDynamicRow[]
|
||||
dynamicFieldValues: Record<string, string>
|
||||
}
|
||||
|
||||
const STEPS = [
|
||||
|
|
@ -63,6 +69,10 @@ function createEmptyQuestion(id: string): Question {
|
|||
audioCorrectAnswerText: "",
|
||||
shortAnswers: [],
|
||||
imageUrl: "",
|
||||
questionTypeDefinitionId: null,
|
||||
dynamicStimulusRows: [],
|
||||
dynamicResponseRows: [],
|
||||
dynamicFieldValues: {},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -104,6 +114,7 @@ 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"
|
||||
}
|
||||
|
||||
|
|
@ -224,6 +235,39 @@ 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) => ({
|
||||
|
|
@ -233,6 +277,15 @@ 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,
|
||||
|
|
@ -240,13 +293,22 @@ export function AddNewLessonPage() {
|
|||
points: q.points,
|
||||
tips: q.tips || undefined,
|
||||
explanation: q.explanation || undefined,
|
||||
status: "PUBLISHED",
|
||||
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,
|
||||
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,
|
||||
}
|
||||
: {}),
|
||||
})
|
||||
const questionId = qRes.data?.data?.id
|
||||
if (questionId) {
|
||||
|
|
@ -457,6 +519,10 @@ 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, {
|
||||
|
|
@ -472,6 +538,10 @@ 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}
|
||||
|
|
|
|||
|
|
@ -9,8 +9,6 @@ import {
|
|||
Plus,
|
||||
Trash2,
|
||||
GripVertical,
|
||||
Edit,
|
||||
Rocket,
|
||||
Loader2,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
|
|
@ -19,26 +17,24 @@ 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";
|
||||
type QuestionType = "MCQ" | "TRUE_FALSE" | "SHORT" | "AUDIO" | "DYNAMIC";
|
||||
type DifficultyLevel = "EASY" | "MEDIUM" | "HARD";
|
||||
|
||||
interface Persona {
|
||||
id: string;
|
||||
name: string;
|
||||
avatar: string;
|
||||
}
|
||||
|
||||
interface MCQOption {
|
||||
text: string;
|
||||
isCorrect: boolean;
|
||||
|
|
@ -58,51 +54,12 @@ 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" },
|
||||
|
|
@ -153,64 +110,6 @@ function isDirectVideoFile(url: string): boolean {
|
|||
return /\.(mp4|webm|ogg|mov|m4v)$/.test(clean);
|
||||
}
|
||||
|
||||
function escapeHtml(raw: string): string {
|
||||
return raw
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
function sanitizeAdminRichTextHtml(input: string): string {
|
||||
if (!input.trim()) return "";
|
||||
try {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(input, "text/html");
|
||||
const blockedTags = new Set([
|
||||
"script",
|
||||
"style",
|
||||
"iframe",
|
||||
"object",
|
||||
"embed",
|
||||
"link",
|
||||
"meta",
|
||||
]);
|
||||
doc.body.querySelectorAll("*").forEach((el) => {
|
||||
const tagName = el.tagName.toLowerCase();
|
||||
if (blockedTags.has(tagName)) {
|
||||
el.remove();
|
||||
return;
|
||||
}
|
||||
const attrs = [...el.attributes];
|
||||
attrs.forEach((attr) => {
|
||||
const name = attr.name.toLowerCase();
|
||||
const value = attr.value.trim().toLowerCase();
|
||||
if (name.startsWith("on")) {
|
||||
el.removeAttribute(attr.name);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
(name === "href" || name === "src") &&
|
||||
value.startsWith("javascript:")
|
||||
) {
|
||||
el.removeAttribute(attr.name);
|
||||
}
|
||||
});
|
||||
});
|
||||
return doc.body.innerHTML;
|
||||
} catch {
|
||||
return escapeHtml(input).replace(/\r?\n/g, "<br />");
|
||||
}
|
||||
}
|
||||
|
||||
function formatDescriptionForPreview(raw: string): string {
|
||||
if (!raw.trim()) return "";
|
||||
const hasHtml = /<\/?[a-z][\s\S]*>/i.test(raw);
|
||||
if (hasHtml) return sanitizeAdminRichTextHtml(raw);
|
||||
return escapeHtml(raw).replace(/\r?\n/g, "<br />");
|
||||
}
|
||||
|
||||
function createEmptyQuestion(id: string): Question {
|
||||
return {
|
||||
id,
|
||||
|
|
@ -231,6 +130,10 @@ function createEmptyQuestion(id: string): Question {
|
|||
audioCorrectAnswerText: "",
|
||||
shortAnswers: [],
|
||||
imageUrl: "",
|
||||
questionTypeDefinitionId: null,
|
||||
dynamicStimulusRows: [],
|
||||
dynamicResponseRows: [],
|
||||
dynamicFieldValues: {},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -272,6 +175,12 @@ export function AddNewPracticePage() {
|
|||
|
||||
// Step 2: Persona
|
||||
const [selectedPersona, setSelectedPersona] = useState<string | null>(null);
|
||||
const {
|
||||
personas,
|
||||
loading: personasLoading,
|
||||
error: personasError,
|
||||
reload: reloadPersonas,
|
||||
} = useActivePersonas();
|
||||
|
||||
// Step 3: Questions
|
||||
const [questions, setQuestions] = useState<Question[]>([
|
||||
|
|
@ -364,11 +273,6 @@ export function AddNewPracticePage() {
|
|||
return null;
|
||||
}, [introVideoUrl]);
|
||||
|
||||
const descriptionPreviewHtml = useMemo(
|
||||
() => formatDescriptionForPreview(practiceDescription),
|
||||
[practiceDescription],
|
||||
);
|
||||
|
||||
const addQuestion = () => {
|
||||
setQuestions([...questions, createEmptyQuestion(String(Date.now()))]);
|
||||
};
|
||||
|
|
@ -389,7 +293,12 @@ export function AddNewPracticePage() {
|
|||
setSaving(true);
|
||||
setSaveError(null);
|
||||
try {
|
||||
const persona = PERSONAS.find((p) => p.id === selectedPersona);
|
||||
if (!selectedPersona) {
|
||||
toast.error("Select a persona before saving.");
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
const persona = personas.find((p) => p.id === selectedPersona);
|
||||
const setRes = await createQuestionSet({
|
||||
title: practiceTitle || "Untitled Practice",
|
||||
set_type: "PRACTICE",
|
||||
|
|
@ -414,6 +323,38 @@ 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) => ({
|
||||
|
|
@ -423,22 +364,43 @@ export function AddNewPracticePage() {
|
|||
}))
|
||||
: [];
|
||||
|
||||
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 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 questionId = qRes.data?.data?.id;
|
||||
if (questionId) {
|
||||
|
|
@ -841,66 +803,17 @@ export function AddNewPracticePage() {
|
|||
practice.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-5 sm:p-8 lg:p-10">
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 sm:gap-4 lg:grid-cols-4 lg:gap-5">
|
||||
{PERSONAS.map((persona) => (
|
||||
<button
|
||||
key={persona.id}
|
||||
onClick={() => setSelectedPersona(persona.id)}
|
||||
className={`group relative flex flex-col items-center rounded-xl border-2 p-6 transition-all duration-200 ${
|
||||
selectedPersona === persona.id
|
||||
? "border-brand-500 bg-brand-50 shadow-md shadow-brand-100"
|
||||
: "border-grayScale-200 bg-white hover:border-brand-300 hover:shadow-sm"
|
||||
}`}
|
||||
>
|
||||
{selectedPersona === persona.id && (
|
||||
<div className="absolute right-2.5 top-2.5 flex h-6 w-6 items-center justify-center rounded-full bg-brand-500 text-white shadow-sm">
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`mb-3 h-20 w-20 overflow-hidden rounded-full bg-grayScale-100 ring-2 transition-all duration-200 ${
|
||||
selectedPersona === persona.id
|
||||
? "ring-brand-300 ring-offset-2"
|
||||
: "ring-transparent group-hover:ring-grayScale-200"
|
||||
}`}
|
||||
>
|
||||
<img
|
||||
src={persona.avatar}
|
||||
alt={persona.name}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
className={`text-sm font-semibold transition-colors ${
|
||||
selectedPersona === persona.id
|
||||
? "text-brand-600"
|
||||
: "text-grayScale-900"
|
||||
}`}
|
||||
>
|
||||
{persona.name}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col-reverse items-stretch justify-between gap-3 border-t border-grayScale-100 bg-grayScale-50/30 px-5 py-4 sm:flex-row sm:items-center sm:px-8 sm:py-5">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleBack}
|
||||
className="sm:w-auto"
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
className="w-full bg-brand-500 hover:bg-brand-600 sm:w-auto sm:min-w-[180px]"
|
||||
onClick={handleNext}
|
||||
>
|
||||
{getNextButtonLabel()}
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
<PersonaStep
|
||||
personas={personas}
|
||||
loading={personasLoading}
|
||||
error={personasError}
|
||||
onRetry={() => void reloadPersonas()}
|
||||
selectedPersona={selectedPersona}
|
||||
setSelectedPersona={setSelectedPersona}
|
||||
nextStep={handleNext}
|
||||
prevStep={handleBack}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
|
@ -912,7 +825,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, or Audio items. Use the full
|
||||
Add MCQ, True/False, Short Answer, Audio, or Dynamic (schema-driven) items. Use the full
|
||||
width for stems and options.
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -952,6 +865,10 @@ 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, {
|
||||
|
|
@ -970,6 +887,10 @@ export function AddNewPracticePage() {
|
|||
? [next.shortAnswer.trim()]
|
||||
: [],
|
||||
imageUrl: next.imageUrl,
|
||||
questionTypeDefinitionId: next.questionTypeDefinitionId,
|
||||
dynamicStimulusRows: next.dynamicStimulusRows,
|
||||
dynamicResponseRows: next.dynamicResponseRows,
|
||||
dynamicFieldValues: next.dynamicFieldValues,
|
||||
});
|
||||
}}
|
||||
mediaBusy={saving}
|
||||
|
|
@ -1009,259 +930,26 @@ export function AddNewPracticePage() {
|
|||
)}
|
||||
|
||||
{currentStep === 4 && (
|
||||
<div className="w-full space-y-6">
|
||||
<div className="rounded-2xl border border-grayScale-200/80 bg-gradient-to-r from-grayScale-50/80 to-white px-5 py-5 shadow-sm sm:px-8 sm:py-6">
|
||||
<h2 className="text-lg font-semibold tracking-tight text-grayScale-900 sm:text-xl">
|
||||
Step 4: Review & publish
|
||||
</h2>
|
||||
<p className="mt-1.5 max-w-3xl text-sm leading-relaxed text-grayScale-500">
|
||||
Confirm context, persona, and questions before saving or
|
||||
publishing.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2 lg:items-start lg:gap-8">
|
||||
{/* Basic Information Card */}
|
||||
<Card className="overflow-hidden border-grayScale-200/80 p-0 shadow-sm">
|
||||
<div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-4">
|
||||
<h3 className="font-semibold text-grayScale-900">
|
||||
Basic Information
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setCurrentStep(1)}
|
||||
className="flex items-center gap-1.5 rounded-md px-2.5 py-1 text-sm font-medium text-brand-500 transition-colors hover:bg-brand-50 hover:text-brand-600"
|
||||
>
|
||||
<Edit className="h-3.5 w-3.5" />
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
<div className="divide-y divide-grayScale-100">
|
||||
<div className="flex justify-between px-6 py-3.5 odd:bg-grayScale-50/50">
|
||||
<span className="text-sm text-grayScale-500">Title</span>
|
||||
<span className="text-sm font-medium text-grayScale-900">
|
||||
{practiceTitle || "Untitled Practice"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="bg-grayScale-50/50 px-6 py-4">
|
||||
<span className="text-sm text-grayScale-500">
|
||||
Description
|
||||
</span>
|
||||
{descriptionPreviewHtml ? (
|
||||
<div
|
||||
className="mt-2 rounded-lg border border-grayScale-200 bg-white px-4 py-3 text-sm leading-relaxed text-grayScale-800 [&_h1]:text-xl [&_h1]:font-semibold [&_h2]:text-lg [&_h2]:font-semibold [&_ol]:list-decimal [&_ol]:pl-6 [&_p]:mb-2 [&_strong]:font-semibold [&_ul]:list-disc [&_ul]:pl-6"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: descriptionPreviewHtml,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<p className="mt-2 text-sm text-grayScale-400">—</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-between px-6 py-3.5">
|
||||
<span className="text-sm text-grayScale-500">
|
||||
Intro video URL
|
||||
</span>
|
||||
<span className="max-w-[min(28rem,55%)] break-all text-right text-sm text-grayScale-700">
|
||||
{introVideoUrl.trim() || "—"}
|
||||
</span>
|
||||
</div>
|
||||
{introVideoPreview ? (
|
||||
<div className="bg-grayScale-50/50 px-6 py-4">
|
||||
<span className="text-sm text-grayScale-500">
|
||||
Intro video preview
|
||||
</span>
|
||||
<div className="mt-2 rounded-lg border border-grayScale-200 bg-white p-3">
|
||||
{introVideoPreview.kind === "vimeo" ? (
|
||||
<div className="overflow-hidden rounded-lg border border-grayScale-200 bg-black">
|
||||
<iframe
|
||||
src={introVideoPreview.url}
|
||||
title="Intro video preview"
|
||||
className="aspect-video w-full"
|
||||
allow="autoplay; fullscreen; picture-in-picture"
|
||||
allowFullScreen
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<video
|
||||
controls
|
||||
src={introVideoPreview.url}
|
||||
className="aspect-video w-full rounded-lg border border-grayScale-200 bg-black"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex justify-between bg-grayScale-50/50 px-6 py-3.5">
|
||||
<span className="text-sm text-grayScale-500">
|
||||
Passing Score
|
||||
</span>
|
||||
<span className="text-sm font-medium text-grayScale-900">
|
||||
{passingScore}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between px-6 py-3.5">
|
||||
<span className="text-sm text-grayScale-500">
|
||||
Time Limit
|
||||
</span>
|
||||
<span className="text-sm font-medium text-grayScale-900">
|
||||
{timeLimitMinutes} minutes
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between bg-grayScale-50/50 px-6 py-3.5">
|
||||
<span className="text-sm text-grayScale-500">
|
||||
Shuffle Questions
|
||||
</span>
|
||||
<span className="text-sm font-medium text-grayScale-900">
|
||||
{shuffleQuestions ? "Yes" : "No"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between px-6 py-3.5">
|
||||
<span className="text-sm text-grayScale-500">Persona</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedPersona && (
|
||||
<div className="h-6 w-6 overflow-hidden rounded-full bg-grayScale-100 ring-2 ring-brand-100">
|
||||
<img
|
||||
src={
|
||||
PERSONAS.find((p) => p.id === selectedPersona)
|
||||
?.avatar
|
||||
}
|
||||
alt="Persona"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<span className="text-sm font-medium text-brand-600">
|
||||
{PERSONAS.find((p) => p.id === selectedPersona)?.name ||
|
||||
"None selected"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Questions Review */}
|
||||
<Card className="overflow-hidden border-grayScale-200/80 p-0 shadow-sm lg:min-h-0">
|
||||
<div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-4">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<h3 className="font-semibold text-grayScale-900">
|
||||
Questions
|
||||
</h3>
|
||||
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-brand-100 text-xs font-semibold text-brand-600">
|
||||
{questions.length}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setCurrentStep(3)}
|
||||
className="flex items-center gap-1.5 rounded-md px-2.5 py-1 text-sm font-medium text-brand-500 transition-colors hover:bg-brand-50 hover:text-brand-600"
|
||||
>
|
||||
<Edit className="h-3.5 w-3.5" />
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
<div className="max-h-[min(70vh,52rem)] space-y-3 overflow-y-auto px-4 py-4 sm:px-6">
|
||||
{questions.map((question, index) => (
|
||||
<div
|
||||
key={question.id}
|
||||
className="rounded-xl border border-grayScale-200 bg-grayScale-50/20 p-4 transition-colors hover:border-grayScale-300 sm:p-4"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-brand-100 text-xs font-bold text-brand-600">
|
||||
{index + 1}
|
||||
</span>
|
||||
<div className="flex-1 space-y-2.5">
|
||||
<p className="text-sm font-medium leading-relaxed text-grayScale-900">
|
||||
{question.questionText}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="rounded-md bg-blue-50 px-2 py-0.5 text-xs font-medium text-blue-600">
|
||||
{question.questionType === "MCQ"
|
||||
? "Multiple Choice"
|
||||
: question.questionType === "TRUE_FALSE"
|
||||
? "True/False"
|
||||
: question.questionType === "AUDIO"
|
||||
? "Audio"
|
||||
: "Short Answer"}
|
||||
</span>
|
||||
<span className="rounded-md bg-purple-50 px-2 py-0.5 text-xs font-medium text-purple-600">
|
||||
{question.difficultyLevel}
|
||||
</span>
|
||||
<span className="rounded-md bg-grayScale-100 px-2 py-0.5 text-xs font-medium text-grayScale-600">
|
||||
{question.points} pt
|
||||
{question.points !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
{question.questionType === "MCQ" &&
|
||||
question.options.length > 0 && (
|
||||
<div className="mt-2 space-y-1">
|
||||
{question.options.map((opt, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`flex items-center gap-2 rounded-md px-2.5 py-1.5 text-sm ${
|
||||
opt.isCorrect
|
||||
? "bg-green-50 font-medium text-green-700"
|
||||
: "text-grayScale-600"
|
||||
}`}
|
||||
>
|
||||
{opt.isCorrect && (
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{opt.text || `Option ${i + 1}`}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{question.tips && (
|
||||
<p className="rounded-md bg-amber-50 px-2.5 py-1.5 text-xs text-amber-600">
|
||||
💡 Tip: {question.tips}
|
||||
</p>
|
||||
)}
|
||||
{question.explanation && (
|
||||
<p className="rounded-md bg-grayScale-50 px-2.5 py-1.5 text-xs text-grayScale-500">
|
||||
Explanation: {question.explanation}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{saveError && (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-3">
|
||||
<p className="text-sm font-medium text-red-600">{saveError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col-reverse items-stretch justify-between gap-3 rounded-2xl border border-grayScale-200/80 bg-grayScale-50/30 px-4 py-4 sm:flex-row sm:items-center sm:px-6 sm:py-5">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleBack}
|
||||
className="sm:w-auto"
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<div className="flex w-full flex-col gap-3 sm:w-auto sm:flex-row sm:justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleSaveAsDraft}
|
||||
disabled={saving}
|
||||
className="sm:min-w-[140px]"
|
||||
>
|
||||
{saving ? "Saving..." : "Save as Draft"}
|
||||
</Button>
|
||||
<Button
|
||||
className="bg-brand-500 hover:bg-brand-600 sm:min-w-[160px]"
|
||||
onClick={handlePublish}
|
||||
disabled={saving}
|
||||
>
|
||||
<Rocket className="mr-2 h-4 w-4" />
|
||||
{saving ? "Publishing..." : "Publish Now"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<AddNewPracticeReviewStep
|
||||
practiceTitle={practiceTitle}
|
||||
practiceDescription={practiceDescription}
|
||||
selectedProgram={selectedProgram}
|
||||
selectedCourse={selectedCourse}
|
||||
moduleLabel={
|
||||
subModuleId ? `Module ${subModuleId}` : "Current module"
|
||||
}
|
||||
selectedPersona={selectedPersona}
|
||||
personas={personas}
|
||||
introVideoPreview={introVideoPreview}
|
||||
questions={questions}
|
||||
saving={saving}
|
||||
saveError={saveError}
|
||||
onEditContext={() => setCurrentStep(1)}
|
||||
onEditQuestions={() => setCurrentStep(3)}
|
||||
onBack={handleBack}
|
||||
onSaveDraft={handleSaveAsDraft}
|
||||
onPublish={handlePublish}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Step 5: Result */}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Link,
|
||||
useNavigate,
|
||||
|
|
@ -6,70 +6,370 @@ 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 } = useParams<{ level: string }>();
|
||||
const {
|
||||
level,
|
||||
programType,
|
||||
courseId: routeCourseId,
|
||||
unitId: routeUnitId,
|
||||
moduleId: routeModuleId,
|
||||
} = useParams<{
|
||||
level?: string;
|
||||
programType?: string;
|
||||
courseId?: string;
|
||||
unitId?: string;
|
||||
moduleId?: string;
|
||||
}>();
|
||||
const [searchParams] = useSearchParams();
|
||||
const backTo = searchParams.get("backTo");
|
||||
const courseId = searchParams.get("courseId");
|
||||
const moduleId = searchParams.get("moduleId");
|
||||
const backToParam = searchParams.get("backTo");
|
||||
const lessonId = searchParams.get("lessonId");
|
||||
const lessonTitleRaw = searchParams.get("lessonTitle");
|
||||
|
||||
const isModuleContext = backTo === "module";
|
||||
const isCourseContext = backTo === "modules";
|
||||
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 backLabel =
|
||||
backTo === "module"
|
||||
effectiveBackTo === "module"
|
||||
? "Back to Module"
|
||||
: backTo === "modules"
|
||||
: effectiveBackTo === "modules"
|
||||
? "Back to Modules"
|
||||
: "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`;
|
||||
: effectiveBackTo === "courses"
|
||||
? "Back to Course"
|
||||
: isExamPrep
|
||||
? "Back to Program"
|
||||
: "Back to Courses";
|
||||
|
||||
const flowSteps = isModuleContext
|
||||
? ["Context", "Persona", "Questions", "Review"]
|
||||
: ["Context", "Scenario", "Persona", "Questions", "Review"];
|
||||
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 [currentStep, setCurrentStep] = useState(1);
|
||||
const [selectedPersona, setSelectedPersona] = useState<string | null>(
|
||||
"dawit",
|
||||
);
|
||||
const [isPublished, setIsPublished] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const [selectedPersona, setSelectedPersona] = useState<string | null>(null);
|
||||
const {
|
||||
personas,
|
||||
loading: personasLoading,
|
||||
error: personasError,
|
||||
reload: reloadPersonas,
|
||||
} = useActivePersonas();
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
program: "Intermediate",
|
||||
course: "A2",
|
||||
title: "",
|
||||
description: "",
|
||||
selectedVideo: "",
|
||||
tips: "Focus on using the present perfect continuous tense to describe an action that started in the past and continues now.",
|
||||
storyImageUrl: "",
|
||||
shuffleQuestions: false,
|
||||
tips: "",
|
||||
questions: [
|
||||
{
|
||||
id: "q1",
|
||||
text: "How long have you been studying English?",
|
||||
type: "Speaking",
|
||||
voicePrompt: "prompt_q1_en.mp3",
|
||||
sampleAnswer: "prompt_q1_en.mp3",
|
||||
questionTypeDefinitionId: null as number | null,
|
||||
text: "",
|
||||
dynamicFieldValues: {} as Record<string, string>,
|
||||
mcqOptions: [
|
||||
{ text: "", isCorrect: true },
|
||||
{ text: "", isCorrect: false },
|
||||
{ text: "", isCorrect: false },
|
||||
{ text: "", isCorrect: false },
|
||||
],
|
||||
trueFalseCorrect: true,
|
||||
shortAnswers: [""],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const [typeDefinitions, setTypeDefinitions] = useState<QuestionTypeDefinition[]>(
|
||||
[],
|
||||
);
|
||||
const [definitionsLoading, setDefinitionsLoading] = useState(true);
|
||||
const [definitionsError, setDefinitionsError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
setDefinitionsLoading(true);
|
||||
setDefinitionsError(null);
|
||||
try {
|
||||
const { 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, flowSteps.length));
|
||||
setCurrentStep((prev) => Math.min(prev + 1, STEP_LABELS.length));
|
||||
const prevStep = () => setCurrentStep((prev) => Math.max(prev - 1, 1));
|
||||
|
||||
if (isPublished) {
|
||||
|
|
@ -87,23 +387,47 @@ export function AddPracticeFlow() {
|
|||
Practice Published Successfully!
|
||||
</h1>
|
||||
<p className="text-grayScale-600 text-md mb-14 max-w-lg font-medium leading-relaxed">
|
||||
Your speaking practice is now active and available inside the module.
|
||||
{lessonId
|
||||
? "Your speaking practice is saved and linked to this lesson’s question set."
|
||||
: "Your speaking practice is saved for the linked course or module."}
|
||||
</p>
|
||||
<div className="flex flex-col gap-4 w-full max-w-[400px]">
|
||||
<Button
|
||||
onClick={() => navigate(backPath)}
|
||||
className="h-14 rounded-[6px] bg-[#9E2891] font-bold shadow-xl shadow-brand-500/20 text-[16px] text-white "
|
||||
>
|
||||
Go back to Module
|
||||
{backLabel}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setIsPublished(false);
|
||||
setCurrentStep(1);
|
||||
setSelectedPersona(null);
|
||||
setFormData({
|
||||
...formData,
|
||||
title: "",
|
||||
description: "",
|
||||
storyImageUrl: "",
|
||||
shuffleQuestions: false,
|
||||
tips: "",
|
||||
questions: [
|
||||
{
|
||||
id: "q1",
|
||||
questionTypeDefinitionId:
|
||||
typeDefinitions[0]?.id ?? (null as number | null),
|
||||
text: "",
|
||||
dynamicFieldValues: typeDefinitions[0]
|
||||
? emptyDynamicFieldValuesForDefinition(typeDefinitions[0])
|
||||
: {},
|
||||
mcqOptions: [
|
||||
{ text: "", isCorrect: true },
|
||||
{ text: "", isCorrect: false },
|
||||
{ text: "", isCorrect: false },
|
||||
{ text: "", isCorrect: false },
|
||||
],
|
||||
trueFalseCorrect: true,
|
||||
shortAnswers: [""],
|
||||
},
|
||||
],
|
||||
});
|
||||
}}
|
||||
variant="outline"
|
||||
|
|
@ -116,9 +440,8 @@ export function AddPracticeFlow() {
|
|||
);
|
||||
}
|
||||
|
||||
// Helper to map currentStep to the actual component for the module flow
|
||||
const renderStep = () => {
|
||||
if (!isModuleContext) {
|
||||
if (isModuleContext) {
|
||||
switch (currentStep) {
|
||||
case 1:
|
||||
return (
|
||||
|
|
@ -126,70 +449,19 @@ export function AddPracticeFlow() {
|
|||
formData={formData}
|
||||
setFormData={setFormData}
|
||||
nextStep={nextStep}
|
||||
navigate={navigate}
|
||||
level={level!}
|
||||
isModuleContext={isModuleContext}
|
||||
isCourseContext={isCourseContext}
|
||||
/>
|
||||
);
|
||||
case 2:
|
||||
return (
|
||||
<ScenarioStep
|
||||
formData={formData}
|
||||
setFormData={setFormData}
|
||||
nextStep={nextStep}
|
||||
prevStep={prevStep}
|
||||
/>
|
||||
);
|
||||
case 3:
|
||||
return (
|
||||
<PersonaStep
|
||||
selectedPersona={selectedPersona}
|
||||
setSelectedPersona={setSelectedPersona}
|
||||
nextStep={nextStep}
|
||||
prevStep={prevStep}
|
||||
/>
|
||||
);
|
||||
case 4:
|
||||
return (
|
||||
<QuestionsStep
|
||||
formData={formData}
|
||||
setFormData={setFormData}
|
||||
nextStep={nextStep}
|
||||
prevStep={prevStep}
|
||||
/>
|
||||
);
|
||||
case 5:
|
||||
return (
|
||||
<ReviewStep
|
||||
formData={formData}
|
||||
selectedPersona={selectedPersona}
|
||||
prevStep={prevStep}
|
||||
setIsPublished={setIsPublished}
|
||||
isModuleContext={isModuleContext}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
// Module Context Flow (Skips Scenario)
|
||||
switch (currentStep) {
|
||||
case 1:
|
||||
return (
|
||||
<ContextStep
|
||||
formData={formData}
|
||||
setFormData={setFormData}
|
||||
nextStep={nextStep}
|
||||
navigate={navigate}
|
||||
level={level!}
|
||||
isModuleContext={isModuleContext}
|
||||
isCourseContext={isCourseContext}
|
||||
onCancel={() => navigate(backPath)}
|
||||
isLessonPractice={isLearnEnglishLessonPractice}
|
||||
lessonTitle={lessonTitleDisplay}
|
||||
parentSummary={parentSummary}
|
||||
/>
|
||||
);
|
||||
case 2:
|
||||
return (
|
||||
<PersonaStep
|
||||
personas={personas}
|
||||
loading={personasLoading}
|
||||
error={personasError}
|
||||
onRetry={() => void reloadPersonas()}
|
||||
selectedPersona={selectedPersona}
|
||||
setSelectedPersona={setSelectedPersona}
|
||||
nextStep={nextStep}
|
||||
|
|
@ -203,6 +475,9 @@ export function AddPracticeFlow() {
|
|||
setFormData={setFormData}
|
||||
nextStep={nextStep}
|
||||
prevStep={prevStep}
|
||||
typeDefinitions={typeDefinitions}
|
||||
definitionsLoading={definitionsLoading}
|
||||
definitionsError={definitionsError}
|
||||
/>
|
||||
);
|
||||
case 4:
|
||||
|
|
@ -210,20 +485,92 @@ 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}
|
||||
setIsPublished={setIsPublished}
|
||||
isModuleContext={isModuleContext}
|
||||
onEditContext={() => setCurrentStep(1)}
|
||||
onEditQuestions={() => setCurrentStep(3)}
|
||||
parentSummary={parentSummary}
|
||||
typeDefinitions={typeDefinitions}
|
||||
canPublish={parentContext !== null}
|
||||
submitting={submitting}
|
||||
onSaveDraft={() => void submitPractice("DRAFT")}
|
||||
onPublish={() => void submitPractice("PUBLISHED")}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
switch (currentStep) {
|
||||
case 1:
|
||||
return (
|
||||
<ScenarioStep
|
||||
formData={formData}
|
||||
setFormData={setFormData}
|
||||
nextStep={nextStep}
|
||||
cancelHref={backPath}
|
||||
/>
|
||||
);
|
||||
case 2:
|
||||
return (
|
||||
<PersonaStep
|
||||
personas={personas}
|
||||
loading={personasLoading}
|
||||
error={personasError}
|
||||
onRetry={() => void reloadPersonas()}
|
||||
selectedPersona={selectedPersona}
|
||||
setSelectedPersona={setSelectedPersona}
|
||||
nextStep={nextStep}
|
||||
prevStep={prevStep}
|
||||
/>
|
||||
);
|
||||
case 3:
|
||||
return (
|
||||
<QuestionsStep
|
||||
formData={formData}
|
||||
setFormData={setFormData}
|
||||
nextStep={nextStep}
|
||||
prevStep={prevStep}
|
||||
typeDefinitions={typeDefinitions}
|
||||
definitionsLoading={definitionsLoading}
|
||||
definitionsError={definitionsError}
|
||||
/>
|
||||
);
|
||||
case 4:
|
||||
return (
|
||||
<ReviewStep
|
||||
formData={formData}
|
||||
selectedPersona={selectedPersona}
|
||||
personas={personas}
|
||||
isLessonPractice={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
|
||||
|
|
@ -249,16 +596,34 @@ export function AddPracticeFlow() {
|
|||
</Button>
|
||||
</div>
|
||||
<p className="text-grayScale-400 text-base">
|
||||
Create a new immersive practice session for students.
|
||||
Create a practice with story details, a persona, and questions from your question type library.
|
||||
</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={flowSteps} currentStep={currentStep} />
|
||||
<Stepper steps={[...STEP_LABELS]} currentStep={currentStep} />
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`mx-auto ${(!isModuleContext && currentStep === 3) || (isModuleContext && currentStep === 2) || currentStep === 5 ? "max-w-6xl" : "max-w-4xl"}`}
|
||||
className={`mx-auto ${currentStep === 3 || currentStep === 4 ? "max-w-6xl" : "max-w-4xl"}`}
|
||||
>
|
||||
{renderStep()}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -8,11 +8,21 @@ 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"
|
||||
type QuestionType = "MCQ" | "TRUE_FALSE" | "SHORT_ANSWER" | "AUDIO" | "DYNAMIC"
|
||||
type Difficulty = "EASY" | "MEDIUM" | "HARD"
|
||||
type QuestionStatus = "DRAFT" | "PUBLISHED" | "INACTIVE"
|
||||
|
||||
const defaultDynamicPayloadJson = `{
|
||||
"stimulus": [],
|
||||
"response": []
|
||||
}`
|
||||
|
||||
interface Question {
|
||||
id?: number
|
||||
question: string
|
||||
|
|
@ -27,6 +37,9 @@ interface Question {
|
|||
voicePrompt: string
|
||||
sampleAnswerVoicePrompt: string
|
||||
audioCorrectAnswerText: string
|
||||
/** Definition id as string for select value */
|
||||
questionTypeDefinitionId: string
|
||||
dynamicPayloadJson: string
|
||||
}
|
||||
|
||||
const initialForm: Question = {
|
||||
|
|
@ -42,6 +55,8 @@ const initialForm: Question = {
|
|||
voicePrompt: "",
|
||||
sampleAnswerVoicePrompt: "",
|
||||
audioCorrectAnswerText: "",
|
||||
questionTypeDefinitionId: "",
|
||||
dynamicPayloadJson: defaultDynamicPayloadJson,
|
||||
}
|
||||
|
||||
export function AddQuestionPage() {
|
||||
|
|
@ -52,6 +67,7 @@ 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 () => {
|
||||
|
|
@ -64,7 +80,8 @@ export function AddQuestionPage() {
|
|||
q.question_type === "MCQ" ||
|
||||
q.question_type === "TRUE_FALSE" ||
|
||||
q.question_type === "SHORT_ANSWER" ||
|
||||
q.question_type === "AUDIO"
|
||||
q.question_type === "AUDIO" ||
|
||||
q.question_type === "DYNAMIC"
|
||||
? q.question_type
|
||||
: "MCQ"
|
||||
const shortAnswer = Array.isArray(q.short_answers) && q.short_answers.length > 0
|
||||
|
|
@ -100,6 +117,14 @@ 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)
|
||||
|
|
@ -111,6 +136,22 @@ 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") {
|
||||
|
|
@ -120,6 +161,15 @@ 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,
|
||||
|
|
@ -200,6 +250,27 @@ 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)
|
||||
|
|
@ -221,6 +292,18 @@ 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,
|
||||
|
|
@ -236,6 +319,12 @@ 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)
|
||||
|
|
@ -257,68 +346,105 @@ export function AddQuestionPage() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Page Header */}
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-4">
|
||||
<div className="space-y-4 pb-6">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => navigate("/content/questions")}
|
||||
className="rounded-lg bg-grayScale-50 hover:bg-brand-500/10 hover:text-brand-500 transition-colors"
|
||||
className="h-9 w-9 shrink-0 rounded-lg bg-grayScale-50 hover:bg-brand-500/10 hover:text-brand-500"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight text-grayScale-600">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-lg font-bold tracking-tight text-grayScale-800 sm:text-xl">
|
||||
{isEditing ? "Edit Question" : "Add New Question"}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-grayScale-400">
|
||||
{isEditing ? "Update the question details below" : "Fill in the details to create a new question"}
|
||||
<p className="mt-0.5 text-xs text-grayScale-500 sm:text-sm">
|
||||
{isEditing ? "Update fields below" : "Create a bank question"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<div className="mx-auto max-w-2xl">
|
||||
{loading && (
|
||||
<Card className="mb-4 border border-grayScale-200">
|
||||
<CardContent className="py-4 text-sm text-grayScale-500">Loading question details...</CardContent>
|
||||
<Card className="mb-2 border border-grayScale-200">
|
||||
<CardContent className="py-2.5 text-xs text-grayScale-500">Loading…</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Card className="shadow-sm border border-grayScale-100 rounded-xl">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-lg font-semibold text-grayScale-600">Question Details</CardTitle>
|
||||
<Card className="rounded-lg border border-grayScale-100 shadow-sm">
|
||||
<CardHeader className="space-y-0 px-4 py-3 sm:px-5">
|
||||
<CardTitle className="text-base font-semibold text-grayScale-700">Question details</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-7">
|
||||
{/* Question Type */}
|
||||
<CardContent className="space-y-3 px-4 pb-4 pt-0 sm:px-5 sm:pb-5">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
|
||||
<label className="mb-1 block text-xs font-medium text-grayScale-600">
|
||||
Question Type
|
||||
</label>
|
||||
<Select
|
||||
value={formData.type}
|
||||
onChange={(e) => handleTypeChange(e.target.value as QuestionType)}
|
||||
className="h-9 text-sm"
|
||||
>
|
||||
<option value="MCQ">Multiple Choice</option>
|
||||
<option value="TRUE_FALSE">True/False</option>
|
||||
<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.5 block text-sm font-medium text-grayScale-500">
|
||||
Question
|
||||
<label htmlFor="question" className="mb-1 block text-xs font-medium text-grayScale-600">
|
||||
{formData.type === "DYNAMIC" ? "Title / stem" : "Question"}
|
||||
</label>
|
||||
<Textarea
|
||||
id="question"
|
||||
placeholder="Enter your question here..."
|
||||
value={formData.question}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, question: e.target.value }))}
|
||||
rows={3}
|
||||
rows={2}
|
||||
className="min-h-[72px] text-sm"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -326,13 +452,11 @@ export function AddQuestionPage() {
|
|||
{/* Options for Multiple Choice */}
|
||||
{(formData.type === "MCQ" || formData.type === "TRUE_FALSE") && (
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
|
||||
Options
|
||||
</label>
|
||||
<div className="space-y-3">
|
||||
<label className="mb-1 block text-xs font-medium text-grayScale-600">Options</label>
|
||||
<div className="space-y-1.5">
|
||||
{formData.options.map((option, index) => (
|
||||
<div key={index} className="flex items-center gap-2 group">
|
||||
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-grayScale-50 text-grayScale-400 text-xs font-medium flex items-center justify-center">
|
||||
<div key={index} className="group flex items-center gap-1.5">
|
||||
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-grayScale-100 text-[10px] font-medium text-grayScale-500">
|
||||
{index + 1}
|
||||
</span>
|
||||
<Input
|
||||
|
|
@ -340,6 +464,7 @@ export function AddQuestionPage() {
|
|||
onChange={(e) => handleOptionChange(index, e.target.value)}
|
||||
placeholder={`Option ${index + 1}`}
|
||||
disabled={formData.type === "TRUE_FALSE"}
|
||||
className="h-9 text-sm"
|
||||
required
|
||||
/>
|
||||
{formData.type === "MCQ" && formData.options.length > 2 && (
|
||||
|
|
@ -348,17 +473,22 @@ export function AddQuestionPage() {
|
|||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => removeOption(index)}
|
||||
className="opacity-0 group-hover:opacity-100 hover:bg-red-50 hover:text-red-500 transition-all"
|
||||
className="h-8 w-8 shrink-0 opacity-0 transition-all group-hover:opacity-100 hover:bg-red-50 hover:text-red-500"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{formData.type === "MCQ" && (
|
||||
<Button type="button" variant="outline" onClick={addOption} className="w-full mt-1 border-dashed border-grayScale-200 text-grayScale-400 hover:text-brand-500 hover:border-brand-500/30">
|
||||
<Plus className="h-4 w-4" />
|
||||
Add Option
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={addOption}
|
||||
className="mt-0.5 h-9 w-full border-dashed border-grayScale-200 text-xs text-grayScale-500 hover:border-brand-500/30 hover:text-brand-500"
|
||||
>
|
||||
<Plus className="mr-1 h-3.5 w-3.5" />
|
||||
Add option
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -368,9 +498,10 @@ export function AddQuestionPage() {
|
|||
<hr className="border-grayScale-100" />
|
||||
|
||||
{/* Correct Answer */}
|
||||
{formData.type !== "DYNAMIC" && (
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
|
||||
{formData.type === "AUDIO" ? "Audio Correct Answer Text" : "Correct Answer"}
|
||||
<label className="mb-1 block text-xs font-medium text-grayScale-600">
|
||||
{formData.type === "AUDIO" ? "Audio correct answer" : "Correct answer"}
|
||||
</label>
|
||||
{formData.type === "MCQ" || formData.type === "TRUE_FALSE" ? (
|
||||
<Select
|
||||
|
|
@ -378,6 +509,7 @@ export function AddQuestionPage() {
|
|||
onChange={(e) =>
|
||||
setFormData((prev) => ({ ...prev, correctAnswer: e.target.value }))
|
||||
}
|
||||
className="h-9 text-sm"
|
||||
required
|
||||
>
|
||||
<option value="">Select correct answer</option>
|
||||
|
|
@ -389,7 +521,7 @@ export function AddQuestionPage() {
|
|||
</Select>
|
||||
) : (
|
||||
<Textarea
|
||||
placeholder={formData.type === "AUDIO" ? "Enter audio correct answer text..." : "Enter the correct answer..."}
|
||||
placeholder={formData.type === "AUDIO" ? "Expected spoken answer…" : "Correct answer…"}
|
||||
value={formData.type === "AUDIO" ? formData.audioCorrectAnswerText : formData.correctAnswer}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) =>
|
||||
|
|
@ -399,24 +531,26 @@ 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 sm:grid-cols-2 gap-6">
|
||||
{/* Points */}
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-4">
|
||||
<div>
|
||||
<label htmlFor="points" className="mb-1.5 block text-sm font-medium text-grayScale-500">
|
||||
<label htmlFor="points" className="mb-1 block text-xs font-medium text-grayScale-600">
|
||||
Points
|
||||
</label>
|
||||
<Input
|
||||
id="points"
|
||||
type="number"
|
||||
min="1"
|
||||
className="h-9 text-sm"
|
||||
value={formData.points}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({ ...prev, points: parseInt(e.target.value) || 1 }))
|
||||
|
|
@ -425,14 +559,12 @@ export function AddQuestionPage() {
|
|||
/>
|
||||
</div>
|
||||
|
||||
{/* Difficulty */}
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
|
||||
Difficulty (Optional)
|
||||
</label>
|
||||
<label className="mb-1 block text-xs font-medium text-grayScale-600">Difficulty</label>
|
||||
<Select
|
||||
value={formData.difficulty}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, difficulty: e.target.value as Difficulty }))}
|
||||
className="h-9 text-sm"
|
||||
>
|
||||
<option value="EASY">Easy</option>
|
||||
<option value="MEDIUM">Medium</option>
|
||||
|
|
@ -443,12 +575,11 @@ export function AddQuestionPage() {
|
|||
|
||||
{/* Status */}
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
|
||||
Status
|
||||
</label>
|
||||
<label className="mb-1 block text-xs font-medium text-grayScale-600">Status</label>
|
||||
<Select
|
||||
value={formData.status}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, status: e.target.value as QuestionStatus }))}
|
||||
className="h-9 text-sm"
|
||||
>
|
||||
<option value="DRAFT">Draft</option>
|
||||
<option value="PUBLISHED">Published</option>
|
||||
|
|
@ -457,58 +588,71 @@ export function AddQuestionPage() {
|
|||
</div>
|
||||
|
||||
{(formData.type === "AUDIO" || formData.type === "SHORT_ANSWER") && (
|
||||
<>
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
|
||||
Voice Prompt{formData.type === "AUDIO" ? "" : " (Optional)"}
|
||||
<label className="mb-1 block text-xs font-medium text-grayScale-600">
|
||||
Voice prompt{formData.type === "AUDIO" ? "" : " (opt.)"}
|
||||
</label>
|
||||
<Textarea
|
||||
value={formData.voicePrompt}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, voicePrompt: e.target.value }))}
|
||||
rows={2}
|
||||
placeholder="Please say your answer..."
|
||||
placeholder="URL or key…"
|
||||
className="min-h-[60px] text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
|
||||
Sample Answer Voice Prompt{formData.type === "AUDIO" ? "" : " (Optional)"}
|
||||
<label className="mb-1 block text-xs font-medium text-grayScale-600">
|
||||
Sample answer (voice){formData.type === "AUDIO" ? "" : " (opt.)"}
|
||||
</label>
|
||||
<Textarea
|
||||
value={formData.sampleAnswerVoicePrompt}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, sampleAnswerVoicePrompt: e.target.value }))}
|
||||
rows={2}
|
||||
placeholder="Sample spoken answer..."
|
||||
placeholder="URL or key…"
|
||||
className="min-h-[60px] text-sm"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">Tips (Optional)</label>
|
||||
<Input
|
||||
value={formData.tips}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, tips: e.target.value }))}
|
||||
placeholder="Helpful tip for learners"
|
||||
/>
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-grayScale-600">Tips (opt.)</label>
|
||||
<Input
|
||||
value={formData.tips}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, tips: e.target.value }))}
|
||||
placeholder="Short tip"
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-grayScale-600">Explanation (opt.)</label>
|
||||
<Textarea
|
||||
value={formData.explanation}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, explanation: e.target.value }))}
|
||||
rows={2}
|
||||
placeholder="Why this answer"
|
||||
className="min-h-[60px] text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">Explanation (Optional)</label>
|
||||
<Textarea
|
||||
value={formData.explanation}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, explanation: e.target.value }))}
|
||||
rows={2}
|
||||
placeholder="Explain why the answer is correct"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-col-reverse sm:flex-row justify-end gap-3 pt-6 border-t border-grayScale-100">
|
||||
<Button type="button" variant="outline" onClick={() => navigate("/content/questions")} className="w-full sm:w-auto hover:bg-grayScale-50">
|
||||
<div className="flex flex-col-reverse gap-2 border-t border-grayScale-100 pt-3 sm:flex-row sm:justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => navigate("/content/questions")}
|
||||
className="h-9 w-full text-sm sm:w-auto"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={submitting || loading} className="bg-brand-500 hover:bg-brand-600 text-white w-full sm:w-auto shadow-sm hover:shadow-md transition-all">
|
||||
{isEditing ? "Update Question" : "Create Question"}
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={submitting || loading}
|
||||
className="h-9 w-full bg-brand-500 text-sm text-white hover:bg-brand-600 sm:w-auto"
|
||||
>
|
||||
{isEditing ? "Update" : "Create"}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { ArrowLeft } from "lucide-react";
|
|||
import { Button } from "../../components/ui/button";
|
||||
import { Stepper } from "../../components/ui/stepper";
|
||||
import { createModuleLesson } from "../../api/courses.api";
|
||||
import type { PracticePublishStatus } from "../../types/course.types";
|
||||
|
||||
import { VideoDetailStep } from "./components/video-steps/VideoDetailStep";
|
||||
import { ReviewPublishStep } from "./components/video-steps/ReviewPublishStep";
|
||||
|
|
@ -17,7 +18,7 @@ const STEPS = [
|
|||
|
||||
export type AddLessonFormData = {
|
||||
title: string;
|
||||
order: string;
|
||||
sortOrder: string;
|
||||
description: string;
|
||||
videoUrl: string;
|
||||
thumbnailUrl: string;
|
||||
|
|
@ -25,7 +26,7 @@ export type AddLessonFormData = {
|
|||
|
||||
const emptyForm = (): AddLessonFormData => ({
|
||||
title: "",
|
||||
order: "1",
|
||||
sortOrder: "0",
|
||||
description: "",
|
||||
videoUrl: "",
|
||||
thumbnailUrl: "",
|
||||
|
|
@ -51,6 +52,8 @@ export function AddVideoFlow() {
|
|||
}>();
|
||||
const [currentStep, setCurrentStep] = useState(1);
|
||||
const [isPublished, setIsPublished] = useState(false);
|
||||
const [lastCreatedPublishStatus, setLastCreatedPublishStatus] =
|
||||
useState<PracticePublishStatus>("PUBLISHED");
|
||||
const [formData, setFormData] = useState<AddLessonFormData>(emptyForm);
|
||||
const [publishing, setPublishing] = useState(false);
|
||||
const [formResetKey, setFormResetKey] = useState(0);
|
||||
|
|
@ -60,7 +63,7 @@ export function AddVideoFlow() {
|
|||
|
||||
const backPath = `/new-content/learn-english/${level}/courses/${courseId}/modules/${moduleId}`;
|
||||
|
||||
const handlePublish = async () => {
|
||||
const handleCreateLesson = async (publishStatus: PracticePublishStatus) => {
|
||||
const mid = Number(moduleId);
|
||||
if (!Number.isFinite(mid) || mid < 1) {
|
||||
toast.error("Invalid module");
|
||||
|
|
@ -86,6 +89,16 @@ export function AddVideoFlow() {
|
|||
toast.error("Description is required");
|
||||
return;
|
||||
}
|
||||
const sortOrderRaw = formData.sortOrder.trim();
|
||||
if (sortOrderRaw === "") {
|
||||
toast.error("Sort order is required");
|
||||
return;
|
||||
}
|
||||
const sort_order = Number(sortOrderRaw);
|
||||
if (!Number.isInteger(sort_order) || sort_order < 0) {
|
||||
toast.error("Sort order must be a whole number of 0 or greater");
|
||||
return;
|
||||
}
|
||||
setPublishing(true);
|
||||
try {
|
||||
await createModuleLesson(mid, {
|
||||
|
|
@ -93,8 +106,15 @@ export function AddVideoFlow() {
|
|||
video_url: videoUrl,
|
||||
thumbnail,
|
||||
description,
|
||||
sort_order,
|
||||
publish_status: publishStatus,
|
||||
});
|
||||
toast.success("Lesson created");
|
||||
setLastCreatedPublishStatus(publishStatus);
|
||||
toast.success(
|
||||
publishStatus === "DRAFT"
|
||||
? "Lesson saved as draft"
|
||||
: "Lesson published",
|
||||
);
|
||||
setIsPublished(true);
|
||||
} catch (e: unknown) {
|
||||
console.error(e);
|
||||
|
|
@ -123,10 +143,14 @@ export function AddVideoFlow() {
|
|||
</div>
|
||||
|
||||
<h1 className="text-[26px] font-bold text-grayScale-900 mb-4">
|
||||
Lesson created successfully
|
||||
{lastCreatedPublishStatus === "DRAFT"
|
||||
? "Lesson saved as draft"
|
||||
: "Lesson published successfully"}
|
||||
</h1>
|
||||
<p className="text-grayScale-600 text-base mb-14 max-w-lg font-medium leading-relaxed">
|
||||
Your lesson is now available in this module.
|
||||
{lastCreatedPublishStatus === "DRAFT"
|
||||
? "You can finish editing and publish it later from the module."
|
||||
: "Your lesson is now available in this module."}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col gap-4 w-full max-w-[400px]">
|
||||
|
|
@ -140,6 +164,7 @@ export function AddVideoFlow() {
|
|||
onClick={() => {
|
||||
setFormData(emptyForm());
|
||||
setFormResetKey((k) => k + 1);
|
||||
setLastCreatedPublishStatus("PUBLISHED");
|
||||
setIsPublished(false);
|
||||
setCurrentStep(1);
|
||||
}}
|
||||
|
|
@ -205,7 +230,7 @@ export function AddVideoFlow() {
|
|||
<ReviewPublishStep
|
||||
formData={formData}
|
||||
prevStep={prevStep}
|
||||
onPublish={() => void handlePublish()}
|
||||
onCreateLesson={(status) => void handleCreateLesson(status)}
|
||||
publishing={publishing}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ 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 }
|
||||
|
|
@ -422,7 +423,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"
|
||||
>
|
||||
{[10, 20, 50].map((size) => (
|
||||
{TABLE_PAGE_SIZE_OPTIONS.map((size) => (
|
||||
<option key={size} value={size}>
|
||||
{size}
|
||||
</option>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
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="flex items-center gap-3 mb-8">
|
||||
<div className="mb-8 flex items-center gap-3">
|
||||
<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]">
|
||||
|
|
@ -16,8 +15,6 @@ export function ContentManagementLayout() {
|
|||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ContentHierarchyList />
|
||||
</div>
|
||||
|
||||
<Outlet />
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Plus,
|
||||
|
|
@ -21,23 +21,33 @@ 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;
|
||||
|
||||
|
|
@ -145,7 +155,7 @@ export function CourseDetailPage() {
|
|||
const [editingModule, setEditingModule] =
|
||||
useState<TopLevelCourseModuleItem | null>(null);
|
||||
const [editModuleName, setEditModuleName] = useState("");
|
||||
const [editModuleDescription, setEditModuleDescription] = useState("");
|
||||
const [editModuleSortOrder, setEditModuleSortOrder] = useState("");
|
||||
const [editModuleIcon, setEditModuleIcon] = useState("");
|
||||
const [editModuleIconUploadBusy, setEditModuleIconUploadBusy] =
|
||||
useState(false);
|
||||
|
|
@ -155,10 +165,21 @@ 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 ?? "");
|
||||
setEditModuleDescription(module.description ?? "");
|
||||
setEditModuleSortOrder(String(module.sort_order ?? 0));
|
||||
setEditModuleIcon(module.icon?.trim() ?? "");
|
||||
setEditModuleIconUploadBusy(false);
|
||||
};
|
||||
|
|
@ -260,6 +281,91 @@ 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();
|
||||
|
|
@ -267,12 +373,23 @@ export function CourseDetailPage() {
|
|||
toast.error("Module name is required");
|
||||
return;
|
||||
}
|
||||
const sortOrderRaw = editModuleSortOrder.trim();
|
||||
if (!sortOrderRaw) {
|
||||
toast.error("Sort order is required");
|
||||
return;
|
||||
}
|
||||
const sort_order = Number(sortOrderRaw);
|
||||
if (!Number.isInteger(sort_order) || sort_order < 0) {
|
||||
toast.error("Sort order must be a whole number of 0 or greater");
|
||||
return;
|
||||
}
|
||||
setSavingModuleEdit(true);
|
||||
try {
|
||||
await updateTopLevelCourseModule(editingModule.id, {
|
||||
name,
|
||||
description: editModuleDescription.trim(),
|
||||
description: editingModule.description?.trim() ?? "",
|
||||
icon: editModuleIcon.trim(),
|
||||
sort_order,
|
||||
});
|
||||
toast.success("Module updated");
|
||||
setEditModuleIconUploadBusy(false);
|
||||
|
|
@ -380,20 +497,32 @@ export function CourseDetailPage() {
|
|||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<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 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>
|
||||
</div>
|
||||
|
||||
|
|
@ -412,18 +541,15 @@ export function CourseDetailPage() {
|
|||
if (!open) closeEditModule();
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogContent className="flex max-h-[min(90vh,calc(100dvh-2rem))] max-w-lg flex-col gap-0 overflow-hidden p-0">
|
||||
<DialogHeader className="shrink-0 space-y-1.5 border-b border-grayScale-100 px-6 pb-4 pt-6 pr-12">
|
||||
<DialogTitle>Edit module</DialogTitle>
|
||||
<DialogDescription>
|
||||
Update name, description, and icon (upload or URL). Saved with{" "}
|
||||
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px]">
|
||||
PUT /modules/:id
|
||||
</code>
|
||||
.
|
||||
Update name, sort order, and icon (upload or URL).
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-2">
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-6 py-4">
|
||||
<div className="grid gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-grayScale-700">
|
||||
Name
|
||||
|
|
@ -437,17 +563,27 @@ export function CourseDetailPage() {
|
|||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-grayScale-700">
|
||||
Description
|
||||
<label
|
||||
htmlFor="edit-module-sort-order"
|
||||
className="text-sm font-medium text-grayScale-700"
|
||||
>
|
||||
Sort Order
|
||||
</label>
|
||||
<Textarea
|
||||
value={editModuleDescription}
|
||||
onChange={(e) => setEditModuleDescription(e.target.value)}
|
||||
rows={4}
|
||||
className="min-h-[100px] resize-y rounded-xl"
|
||||
placeholder="Optional short description."
|
||||
disabled={savingModuleEdit}
|
||||
<Input
|
||||
id="edit-module-sort-order"
|
||||
type="number"
|
||||
min={0}
|
||||
step={1}
|
||||
inputMode="numeric"
|
||||
value={editModuleSortOrder}
|
||||
onChange={(e) => setEditModuleSortOrder(e.target.value)}
|
||||
className="rounded-xl"
|
||||
placeholder="e.g. 5"
|
||||
disabled={savingModuleEdit || editModuleIconUploadBusy}
|
||||
/>
|
||||
<p className="text-xs text-grayScale-500">
|
||||
Lower numbers appear first when modules are listed.
|
||||
</p>
|
||||
</div>
|
||||
<ModuleIconUploadField
|
||||
value={editModuleIcon}
|
||||
|
|
@ -456,7 +592,8 @@ export function CourseDetailPage() {
|
|||
onUploadBusyChange={setEditModuleIconUploadBusy}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
</div>
|
||||
<DialogFooter className="shrink-0 gap-2 border-t border-grayScale-100 bg-white px-6 py-4 sm:gap-0">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
|
|
@ -477,97 +614,184 @@ export function CourseDetailPage() {
|
|||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{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="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} />
|
||||
{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="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 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>
|
||||
)
|
||||
) : (
|
||||
<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>
|
||||
|
||||
<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>
|
||||
{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>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import { Link, useParams, useNavigate } from "react-router-dom";
|
|||
import {
|
||||
ArrowLeft,
|
||||
Plus,
|
||||
FileText,
|
||||
LayoutGrid,
|
||||
PlayCircle,
|
||||
ClipboardCheck,
|
||||
|
|
@ -15,7 +14,6 @@ import {
|
|||
} from "lucide-react";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import { Card } from "../../components/ui/card";
|
||||
import { Textarea } from "../../components/ui/textarea";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
|
|
@ -45,7 +43,7 @@ export function CourseManagementPage() {
|
|||
const catalogCourseId = Number(courseId);
|
||||
const [addUnitOpen, setAddUnitOpen] = useState(false);
|
||||
const [createName, setCreateName] = useState("");
|
||||
const [createDescription, setCreateDescription] = useState("");
|
||||
const [createSortOrder, setCreateSortOrder] = useState("");
|
||||
const [createThumbnail, setCreateThumbnail] = useState("");
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [uploadingThumbnail, setUploadingThumbnail] = useState(false);
|
||||
|
|
@ -66,7 +64,6 @@ export function CourseManagementPage() {
|
|||
const [unitsLoading, setUnitsLoading] = useState(false);
|
||||
const [editingUnitId, setEditingUnitId] = useState<number | null>(null);
|
||||
const [editName, setEditName] = useState("");
|
||||
const [editDescription, setEditDescription] = useState("");
|
||||
const [editThumbnail, setEditThumbnail] = useState("");
|
||||
const [editSortOrder, setEditSortOrder] = useState("1");
|
||||
const [savingEdit, setSavingEdit] = useState(false);
|
||||
|
|
@ -152,7 +149,7 @@ export function CourseManagementPage() {
|
|||
|
||||
const clearCreateUnitForm = () => {
|
||||
setCreateName("");
|
||||
setCreateDescription("");
|
||||
setCreateSortOrder("");
|
||||
setCreateThumbnail("");
|
||||
if (createThumbnailFileInputRef.current) {
|
||||
createThumbnailFileInputRef.current.value = "";
|
||||
|
|
@ -202,13 +199,24 @@ export function CourseManagementPage() {
|
|||
toast.error("Unit name is required");
|
||||
return;
|
||||
}
|
||||
const sortOrderRaw = createSortOrder.trim();
|
||||
if (!sortOrderRaw) {
|
||||
toast.error("Sort order is required");
|
||||
return;
|
||||
}
|
||||
const sort_order = Number(sortOrderRaw);
|
||||
if (!Number.isInteger(sort_order) || sort_order < 0) {
|
||||
toast.error("Sort order must be a whole number of 0 or greater");
|
||||
return;
|
||||
}
|
||||
setCreating(true);
|
||||
try {
|
||||
const minioThumbnail = await resolveThumbnailToMinioUrl(createThumbnail);
|
||||
const response = await createExamPrepCatalogUnit(catalogCourseId, {
|
||||
name,
|
||||
description: createDescription.trim() || null,
|
||||
description: null,
|
||||
thumbnail: minioThumbnail || null,
|
||||
sort_order,
|
||||
});
|
||||
void response;
|
||||
await loadUnits();
|
||||
|
|
@ -271,18 +279,16 @@ export function CourseManagementPage() {
|
|||
const openEditUnit = (unit: (typeof units)[number]) => {
|
||||
setEditingUnitId(unit.id);
|
||||
setEditName(unit.name ?? "");
|
||||
setEditDescription(unit.description ?? "");
|
||||
setEditThumbnail(unit.thumbnail ?? "");
|
||||
setEditSortOrder(String(unit.sortOrder ?? 1));
|
||||
setEditSortOrder(String(unit.sortOrder ?? 0));
|
||||
};
|
||||
|
||||
const closeEditUnit = () => {
|
||||
if (savingEdit || uploadingEditThumbnail) return;
|
||||
setEditingUnitId(null);
|
||||
setEditName("");
|
||||
setEditDescription("");
|
||||
setEditThumbnail("");
|
||||
setEditSortOrder("1");
|
||||
setEditSortOrder("");
|
||||
};
|
||||
|
||||
const handleEditUnitThumbnailFile = async (
|
||||
|
|
@ -320,20 +326,30 @@ export function CourseManagementPage() {
|
|||
toast.error("Unit name is required");
|
||||
return;
|
||||
}
|
||||
const sortOrderNum = Number(editSortOrder);
|
||||
if (!Number.isFinite(sortOrderNum) || sortOrderNum < 0) {
|
||||
toast.error("Sort order must be a valid number");
|
||||
const sortOrderRaw = editSortOrder.trim();
|
||||
if (!sortOrderRaw) {
|
||||
toast.error("Sort order is required");
|
||||
return;
|
||||
}
|
||||
const sort_order = Number(sortOrderRaw);
|
||||
if (!Number.isInteger(sort_order) || sort_order < 0) {
|
||||
toast.error("Sort order must be a whole number of 0 or greater");
|
||||
return;
|
||||
}
|
||||
|
||||
setSavingEdit(true);
|
||||
try {
|
||||
const existing = units.find((u) => u.id === editingUnitId);
|
||||
const preservedDescription =
|
||||
existing?.description && existing.description !== "—"
|
||||
? existing.description
|
||||
: null;
|
||||
const minioThumbnail = await resolveThumbnailToMinioUrl(editThumbnail);
|
||||
await updateExamPrepCatalogUnit(editingUnitId, {
|
||||
name,
|
||||
description: editDescription.trim() || null,
|
||||
description: preservedDescription,
|
||||
thumbnail: minioThumbnail || null,
|
||||
sort_order: sortOrderNum,
|
||||
sort_order,
|
||||
});
|
||||
await loadUnits();
|
||||
toast.success("Unit updated");
|
||||
|
|
@ -425,18 +441,29 @@ export function CourseManagementPage() {
|
|||
disabled={creating || uploadingThumbnail}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<label className="text-[15px] text-grayScale-800">
|
||||
Description
|
||||
<label
|
||||
htmlFor="create-unit-sort-order"
|
||||
className="text-[15px] text-grayScale-800"
|
||||
>
|
||||
Sort Order
|
||||
</label>
|
||||
<Textarea
|
||||
value={createDescription}
|
||||
onChange={(e) => setCreateDescription(e.target.value)}
|
||||
placeholder="Short unit description"
|
||||
rows={4}
|
||||
className="min-h-[96px] rounded-[8px] border-grayScale-400"
|
||||
<Input
|
||||
id="create-unit-sort-order"
|
||||
type="number"
|
||||
min={0}
|
||||
step={1}
|
||||
inputMode="numeric"
|
||||
value={createSortOrder}
|
||||
onChange={(e) => setCreateSortOrder(e.target.value)}
|
||||
placeholder="e.g. 0"
|
||||
className="h-12 border-grayScale-400 rounded-[8px] px-4 placeholder:text-grayScale-400 text-[15px] focus:ring-brand-500/20"
|
||||
disabled={creating || uploadingThumbnail}
|
||||
/>
|
||||
<p className="text-xs text-grayScale-500">
|
||||
Lower numbers appear first when units are listed.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
|
|
@ -528,17 +555,6 @@ 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>
|
||||
|
||||
|
|
@ -690,25 +706,27 @@ export function CourseManagementPage() {
|
|||
/>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<label className="text-[15px] text-grayScale-800">Description</label>
|
||||
<Textarea
|
||||
value={editDescription}
|
||||
onChange={(e) => setEditDescription(e.target.value)}
|
||||
rows={4}
|
||||
className="min-h-[96px] rounded-[8px] border-grayScale-400"
|
||||
disabled={savingEdit || uploadingEditThumbnail}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<label className="text-[15px] text-grayScale-800">Sort Order</label>
|
||||
<label
|
||||
htmlFor="edit-unit-sort-order"
|
||||
className="text-[15px] text-grayScale-800"
|
||||
>
|
||||
Sort Order
|
||||
</label>
|
||||
<Input
|
||||
id="edit-unit-sort-order"
|
||||
type="number"
|
||||
min={0}
|
||||
step={1}
|
||||
inputMode="numeric"
|
||||
value={editSortOrder}
|
||||
onChange={(e) => setEditSortOrder(e.target.value)}
|
||||
className="h-12 border-grayScale-400 rounded-[8px] px-4"
|
||||
placeholder="e.g. 0"
|
||||
className="h-12 border-grayScale-400 rounded-[8px] px-4 placeholder:text-grayScale-400 text-[15px] focus:ring-brand-500/20"
|
||||
disabled={savingEdit || uploadingEditThumbnail}
|
||||
/>
|
||||
<p className="text-xs text-grayScale-500">
|
||||
Lower numbers appear first when units are listed.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<label className="text-[15px] text-grayScale-800">Thumbnail</label>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { ArrowLeft, Plus, FileText, Pencil, Trash2 } from "lucide-react";
|
||||
import { ArrowLeft, Plus, FileText, Video } from "lucide-react";
|
||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
|
@ -23,8 +23,18 @@ 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 = [
|
||||
{
|
||||
|
|
@ -60,12 +70,17 @@ export function CourseModuleDetailPage() {
|
|||
id: number;
|
||||
title: string;
|
||||
videoUrl: string;
|
||||
description: string;
|
||||
description: string | null;
|
||||
thumbnail: string;
|
||||
sortOrder: number;
|
||||
gradient: string;
|
||||
publishStatus: PracticePublishStatus | string | null;
|
||||
durationSeconds: number | null;
|
||||
}>
|
||||
>([]);
|
||||
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("");
|
||||
|
|
@ -121,6 +136,7 @@ export function CourseModuleDetailPage() {
|
|||
return;
|
||||
}
|
||||
setLessonsLoading(true);
|
||||
setLessonsLoadError(null);
|
||||
try {
|
||||
const response = await getExamPrepModuleLessons(parsedModuleId, {
|
||||
limit: 20,
|
||||
|
|
@ -129,24 +145,27 @@ export function CourseModuleDetailPage() {
|
|||
const rows = response.data?.data?.lessons;
|
||||
const list = Array.isArray(rows) ? rows : [];
|
||||
setLessons(
|
||||
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%)",
|
||||
})),
|
||||
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,
|
||||
};
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error("Failed to load lessons");
|
||||
setLessonsLoadError("Failed to load lessons. Please try again.");
|
||||
setLessons([]);
|
||||
} finally {
|
||||
setLessonsLoading(false);
|
||||
|
|
@ -252,7 +271,7 @@ export function CourseModuleDetailPage() {
|
|||
}
|
||||
};
|
||||
|
||||
const handleCreateLesson = async () => {
|
||||
const handleCreateLesson = async (publishStatus: PracticePublishStatus) => {
|
||||
if (!Number.isFinite(parsedModuleId) || parsedModuleId < 1) {
|
||||
toast.error("Invalid module");
|
||||
return;
|
||||
|
|
@ -276,9 +295,14 @@ export function CourseModuleDetailPage() {
|
|||
video_url: videoUrl,
|
||||
thumbnail: minioThumbnail || null,
|
||||
description: createDescription.trim() || null,
|
||||
publish_status: publishStatus,
|
||||
});
|
||||
await loadLessons();
|
||||
toast.success("Lesson created");
|
||||
toast.success(
|
||||
publishStatus === "DRAFT"
|
||||
? "Lesson saved as draft"
|
||||
: "Lesson created",
|
||||
);
|
||||
clearCreateLessonForm();
|
||||
setCreateLessonOpen(false);
|
||||
} catch (error: unknown) {
|
||||
|
|
@ -448,6 +472,45 @@ 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 */}
|
||||
|
|
@ -476,12 +539,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}/unit/${unitId}/module/${moduleId}/attach-practice`,
|
||||
`/new-content/courses/${programType}/${courseId}/${unitId}/${moduleId}/add-practice`,
|
||||
)
|
||||
}
|
||||
>
|
||||
<FileText className="h-5 w-5" />
|
||||
Attach Practice
|
||||
Add Practice
|
||||
</Button>
|
||||
<Dialog
|
||||
open={createLessonOpen}
|
||||
|
|
@ -641,7 +704,7 @@ export function CourseModuleDetailPage() {
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 px-8 py-6 bg-grayScale-50/30 border-t border-grayScale-50 flex justify-end gap-3">
|
||||
<div className="shrink-0 px-8 py-6 bg-grayScale-50/30 border-t border-grayScale-50 flex flex-wrap justify-end gap-3">
|
||||
<DialogClose asChild>
|
||||
<Button
|
||||
type="button"
|
||||
|
|
@ -653,13 +716,22 @@ export function CourseModuleDetailPage() {
|
|||
Cancel
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-11 px-8 rounded-[8px] border-grayScale-200 text-grayScale-700 font-bold hover:bg-grayScale-50"
|
||||
disabled={creatingLesson || uploadingThumbnail || uploadingVideo}
|
||||
onClick={() => void handleCreateLesson("DRAFT")}
|
||||
>
|
||||
{creatingLesson ? "Saving…" : "Save as draft"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
className="h-11 px-8 rounded-[8px] bg-brand-500 text-white font-bold hover:bg-brand-600"
|
||||
disabled={creatingLesson || uploadingThumbnail || uploadingVideo}
|
||||
onClick={() => void handleCreateLesson()}
|
||||
onClick={() => void handleCreateLesson("PUBLISHED")}
|
||||
>
|
||||
{creatingLesson ? "Creating..." : "Create Lesson"}
|
||||
{creatingLesson ? "Creating..." : "Publish lesson"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -669,61 +741,101 @@ export function CourseModuleDetailPage() {
|
|||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<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 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>
|
||||
|
||||
{/* Grid of Content */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 pt-4">
|
||||
{/* Content */}
|
||||
<div className="mt-8">
|
||||
{activeTab === "video" ? (
|
||||
lessonsLoading ? (
|
||||
<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 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}
|
||||
/>
|
||||
))}
|
||||
</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="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>
|
||||
)
|
||||
) : (
|
||||
MOCK_PRACTICES.map((item) => <PracticeCard key={item.id} {...item} />)
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ 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 }>()
|
||||
|
|
@ -513,7 +514,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"
|
||||
>
|
||||
{[10, 20, 50].map((size) => (
|
||||
{TABLE_PAGE_SIZE_OPTIONS.map((size) => (
|
||||
<option key={size} value={size}>
|
||||
{size}
|
||||
</option>
|
||||
|
|
|
|||
|
|
@ -1,31 +1,281 @@
|
|||
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";
|
||||
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),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
export function CreateQuestionTypeFlow() {
|
||||
const navigate = useNavigate();
|
||||
const [currentStep, setCurrentStep] = useState(1);
|
||||
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 steps = [
|
||||
"Basic Info",
|
||||
"Input & Answer Configuration",
|
||||
"Versions",
|
||||
"Review & Publish",
|
||||
];
|
||||
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 handleNext = () =>
|
||||
setCurrentStep((prev) => Math.min(prev + 1, steps.length));
|
||||
const handleBack = () => setCurrentStep((prev) => Math.max(prev - 1, 1));
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen pb-20 overflow-x-hidden">
|
||||
{/* Header */}
|
||||
<div className=" border-b border-grayScale-100 sticky top-0 z-50">
|
||||
<div className="max-w-[1440px] mx-auto py-6">
|
||||
<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">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<Link
|
||||
to="/new-content/question-types"
|
||||
|
|
@ -36,16 +286,18 @@ export function CreateQuestionTypeFlow() {
|
|||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex flex-col lg:flex-row lg:items-start lg:justify-between gap-6">
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-[28px] font-bold text-grayScale-900 tracking-tight">
|
||||
Create Question Type
|
||||
{isEdit ? "Edit question type definition" : "Create question type definition"}
|
||||
</h1>
|
||||
<p className="text-grayScale-500 text-[14px] font-medium">
|
||||
Create a new immersive practice session for students.
|
||||
<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>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-4 shrink-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-10 px-8 rounded-[6px] border-grayScale-200 text-grayScale-900 font-medium hover:bg-grayScale-50"
|
||||
|
|
@ -53,7 +305,10 @@ 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">
|
||||
<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}
|
||||
>
|
||||
Save as Draft
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -65,23 +320,41 @@ export function CreateQuestionTypeFlow() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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 onNext={handleNext} onBack={handleBack} />
|
||||
<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}
|
||||
/>
|
||||
)}
|
||||
{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>
|
||||
{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}
|
||||
/>
|
||||
)}
|
||||
{currentStep === 3 && (
|
||||
<QuestionTypeValidatePreviewStep draft={draft} onNext={() => setCurrentStep(4)} onBack={handleBack} />
|
||||
)}
|
||||
{currentStep === 4 && (
|
||||
<QuestionTypeReviewPublishStep
|
||||
draft={draft}
|
||||
onBack={handleBack}
|
||||
editDefinitionId={editDefinitionId}
|
||||
isSystem={isSystemDefinition}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, D
|
|||
import { Input } from "../../components/ui/input"
|
||||
import { Select } from "../../components/ui/select"
|
||||
import { SpinnerIcon } from "../../components/ui/spinner-icon"
|
||||
import { Textarea } from "../../components/ui/textarea"
|
||||
import {
|
||||
createModule,
|
||||
deleteModule,
|
||||
|
|
@ -241,7 +240,6 @@ export function HumanLanguageHierarchyPage() {
|
|||
const [createModuleTitle, setCreateModuleTitle] = useState("")
|
||||
const [createModuleUseDefaultNaming, setCreateModuleUseDefaultNaming] = useState(false)
|
||||
const [createModuleDefaultTitle, setCreateModuleDefaultTitle] = useState("")
|
||||
const [createModuleDescription, setCreateModuleDescription] = useState("")
|
||||
const [createModuleIconSource, setCreateModuleIconSource] = useState<"url" | "file">("url")
|
||||
const [createModuleIconUrl, setCreateModuleIconUrl] = useState("")
|
||||
const [createModuleIconFile, setCreateModuleIconFile] = useState<File | null>(null)
|
||||
|
|
@ -253,7 +251,6 @@ export function HumanLanguageHierarchyPage() {
|
|||
const [editModuleSaving, setEditModuleSaving] = useState(false)
|
||||
const [editModuleTarget, setEditModuleTarget] = useState<EditModuleTarget | null>(null)
|
||||
const [editModuleTitle, setEditModuleTitle] = useState("")
|
||||
const [editModuleDescription, setEditModuleDescription] = useState("")
|
||||
const [editModuleDisplayOrder, setEditModuleDisplayOrder] = useState(0)
|
||||
const [editModuleIconSource, setEditModuleIconSource] = useState<"url" | "file">("url")
|
||||
const [editModuleIconUrl, setEditModuleIconUrl] = useState("")
|
||||
|
|
@ -467,7 +464,6 @@ export function HumanLanguageHierarchyPage() {
|
|||
setCreateModuleUseDefaultNaming(false)
|
||||
setCreateModuleDefaultTitle(getNextDefaultModuleName(level))
|
||||
setCreateModuleTitle("")
|
||||
setCreateModuleDescription("")
|
||||
setCreateModuleIconSource("url")
|
||||
setCreateModuleIconUrl("")
|
||||
setCreateModuleIconFile(null)
|
||||
|
|
@ -503,7 +499,6 @@ export function HumanLanguageHierarchyPage() {
|
|||
await createModule({
|
||||
level_id: createModuleLevelId,
|
||||
title,
|
||||
description: createModuleDescription.trim() || undefined,
|
||||
icon_url: uploadedIconUrl,
|
||||
display_order: createModuleDisplayOrder,
|
||||
is_active: true,
|
||||
|
|
@ -553,7 +548,6 @@ export function HumanLanguageHierarchyPage() {
|
|||
levelKey,
|
||||
})
|
||||
setEditModuleTitle(module.title)
|
||||
setEditModuleDescription("")
|
||||
setEditModuleDisplayOrder(moduleDisplayOrder)
|
||||
setEditModuleIconSource("url")
|
||||
setEditModuleIconUrl(existingIconUrl)
|
||||
|
|
@ -594,7 +588,6 @@ export function HumanLanguageHierarchyPage() {
|
|||
|
||||
await updateModule(editModuleTarget.moduleId, {
|
||||
title,
|
||||
description: editModuleDescription.trim() || undefined,
|
||||
icon_url: uploadedIconUrl,
|
||||
display_order: editModuleDisplayOrder,
|
||||
is_active: true,
|
||||
|
|
@ -794,7 +787,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">
|
||||
Powered by `GET /course-management/human-language/hierarchy` and `GET /course-management/courses/:courseId/hierarchy`.
|
||||
Choose a sub-category from the list to view and manage its course structure.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
|
@ -1026,7 +1019,7 @@ export function HumanLanguageHierarchyPage() {
|
|||
<DialogHeader>
|
||||
<DialogTitle>Create module</DialogTitle>
|
||||
<DialogDescription>
|
||||
Add a module to this level. This will call `POST /course-management/modules`.
|
||||
Add a module to this level.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
|
|
@ -1068,17 +1061,6 @@ export function HumanLanguageHierarchyPage() {
|
|||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-grayScale-600">Description (optional)</label>
|
||||
<Textarea
|
||||
rows={3}
|
||||
value={createModuleDescription}
|
||||
onChange={(event) => setCreateModuleDescription(event.target.value)}
|
||||
placeholder="Optional description"
|
||||
disabled={createModuleSaving}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-grayScale-600">Icon URL (optional)</label>
|
||||
<div className="mb-2 grid grid-cols-2 gap-2">
|
||||
|
|
@ -1158,7 +1140,7 @@ export function HumanLanguageHierarchyPage() {
|
|||
<DialogHeader>
|
||||
<DialogTitle>Update module</DialogTitle>
|
||||
<DialogDescription>
|
||||
Update this module using `PUT /course-management/modules/:moduleId`.
|
||||
Update this module's name, order, and settings.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
|
|
@ -1173,17 +1155,6 @@ export function HumanLanguageHierarchyPage() {
|
|||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-grayScale-600">Description</label>
|
||||
<Textarea
|
||||
rows={3}
|
||||
value={editModuleDescription}
|
||||
onChange={(event) => setEditModuleDescription(event.target.value)}
|
||||
placeholder="New description"
|
||||
disabled={editModuleSaving}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-grayScale-600">Display order</label>
|
||||
<Input
|
||||
|
|
|
|||
|
|
@ -1029,7 +1029,7 @@ export function HumanLanguageSubModulePage() {
|
|||
<DialogHeader>
|
||||
<DialogTitle>Lesson detail</DialogTitle>
|
||||
<DialogDescription>
|
||||
Loaded from `GET /course-management/sub-module-lessons/:lessonId`.
|
||||
View and edit lesson details.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
|
|
|
|||
|
|
@ -24,9 +24,26 @@ import {
|
|||
updateLearningProgram,
|
||||
deleteLearningProgram,
|
||||
} from "../../api/courses.api";
|
||||
import { uploadImageFile } from "../../api/files.api";
|
||||
import { refreshFileUrl, uploadImageFile } from "../../api/files.api";
|
||||
import type { LearningProgramListItem } from "../../types/course.types";
|
||||
|
||||
/** Presigned MinIO/S3 URLs and our storage hosts — safe to send to POST /files/refresh-url. */
|
||||
function looksLikeRefreshableFileUrl(url: string): boolean {
|
||||
const trimmed = url.trim();
|
||||
if (!trimmed.startsWith("http://") && !trimmed.startsWith("https://")) return false;
|
||||
try {
|
||||
const u = new URL(trimmed);
|
||||
const q = u.search.toLowerCase();
|
||||
if (q.includes("x-amz-")) return true;
|
||||
const h = u.hostname.toLowerCase();
|
||||
if (h.includes("yimaruacademy.com")) return true;
|
||||
if (h.includes("minio")) return true;
|
||||
return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function LearnEnglishPage() {
|
||||
const [programs, setPrograms] = useState<LearningProgramListItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
|
@ -36,6 +53,7 @@ export function LearnEnglishPage() {
|
|||
useState<LearningProgramListItem | null>(null);
|
||||
const [editName, setEditName] = useState("");
|
||||
const [editDescription, setEditDescription] = useState("");
|
||||
const [editSortOrder, setEditSortOrder] = useState("");
|
||||
const [editThumbnail, setEditThumbnail] = useState("");
|
||||
const [savingEdit, setSavingEdit] = useState(false);
|
||||
const [uploadingEditThumbnail, setUploadingEditThumbnail] = useState(false);
|
||||
|
|
@ -44,6 +62,7 @@ export function LearnEnglishPage() {
|
|||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [createName, setCreateName] = useState("");
|
||||
const [createDescription, setCreateDescription] = useState("");
|
||||
const [createSortOrder, setCreateSortOrder] = useState("");
|
||||
const [createThumbnail, setCreateThumbnail] = useState("");
|
||||
const [createSaving, setCreateSaving] = useState(false);
|
||||
const [createUploadingThumbnail, setCreateUploadingThumbnail] = useState(false);
|
||||
|
|
@ -57,6 +76,7 @@ export function LearnEnglishPage() {
|
|||
setEditingProgram(program);
|
||||
setEditName(program.name ?? "");
|
||||
setEditDescription(program.description?.trim() ?? "");
|
||||
setEditSortOrder(String(program.sort_order ?? 0));
|
||||
setEditThumbnail(program.thumbnail?.trim() ?? "");
|
||||
};
|
||||
|
||||
|
|
@ -64,6 +84,7 @@ export function LearnEnglishPage() {
|
|||
setEditingProgram(null);
|
||||
setEditName("");
|
||||
setEditDescription("");
|
||||
setEditSortOrder("");
|
||||
setEditThumbnail("");
|
||||
setUploadingEditThumbnail(false);
|
||||
if (editThumbnailFileInputRef.current) editThumbnailFileInputRef.current.value = "";
|
||||
|
|
@ -107,6 +128,7 @@ export function LearnEnglishPage() {
|
|||
const clearCreateFormFields = () => {
|
||||
setCreateName("");
|
||||
setCreateDescription("");
|
||||
setCreateSortOrder("");
|
||||
setCreateThumbnail("");
|
||||
if (createThumbnailFileInputRef.current) {
|
||||
createThumbnailFileInputRef.current.value = "";
|
||||
|
|
@ -160,12 +182,23 @@ export function LearnEnglishPage() {
|
|||
toast.error("Program name is required");
|
||||
return;
|
||||
}
|
||||
const sortOrderRaw = createSortOrder.trim();
|
||||
if (!sortOrderRaw) {
|
||||
toast.error("Sort order is required");
|
||||
return;
|
||||
}
|
||||
const sort_order = Number(sortOrderRaw);
|
||||
if (!Number.isInteger(sort_order) || sort_order < 0) {
|
||||
toast.error("Sort order must be a whole number of 0 or greater");
|
||||
return;
|
||||
}
|
||||
setCreateSaving(true);
|
||||
try {
|
||||
await createLearningProgram({
|
||||
name,
|
||||
description: createDescription.trim(),
|
||||
thumbnail: createThumbnail.trim(),
|
||||
sort_order,
|
||||
});
|
||||
toast.success("Program created");
|
||||
clearCreateFormFields();
|
||||
|
|
@ -189,12 +222,23 @@ export function LearnEnglishPage() {
|
|||
toast.error("Program name is required");
|
||||
return;
|
||||
}
|
||||
const sortOrderRaw = editSortOrder.trim();
|
||||
if (!sortOrderRaw) {
|
||||
toast.error("Sort order is required");
|
||||
return;
|
||||
}
|
||||
const sort_order = Number(sortOrderRaw);
|
||||
if (!Number.isInteger(sort_order) || sort_order < 0) {
|
||||
toast.error("Sort order must be a whole number of 0 or greater");
|
||||
return;
|
||||
}
|
||||
setSavingEdit(true);
|
||||
try {
|
||||
await updateLearningProgram(editingProgram.id, {
|
||||
name,
|
||||
description: editDescription.trim(),
|
||||
thumbnail: editThumbnail.trim(),
|
||||
sort_order,
|
||||
});
|
||||
toast.success("Program updated");
|
||||
closeEdit();
|
||||
|
|
@ -240,6 +284,35 @@ export function LearnEnglishPage() {
|
|||
(a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0),
|
||||
);
|
||||
setPrograms(sorted);
|
||||
|
||||
void (async () => {
|
||||
const results = await Promise.all(
|
||||
sorted.map(async (p) => {
|
||||
const ref = p.thumbnail?.trim();
|
||||
if (!ref || !looksLikeRefreshableFileUrl(ref)) return null;
|
||||
try {
|
||||
const res = await refreshFileUrl(ref);
|
||||
const url = res.data?.data?.url?.trim();
|
||||
if (!url) return null;
|
||||
return { id: p.id, url };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
);
|
||||
const map = new Map(
|
||||
results
|
||||
.filter((r): r is { id: number; url: string } => r != null)
|
||||
.map((r) => [r.id, r.url] as const),
|
||||
);
|
||||
if (map.size === 0) return;
|
||||
setPrograms((prev) =>
|
||||
prev.map((prog) => {
|
||||
const next = map.get(prog.id);
|
||||
return next ? { ...prog, thumbnail: next } : prog;
|
||||
}),
|
||||
);
|
||||
})();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setError("Failed to load programs");
|
||||
|
|
@ -283,15 +356,8 @@ export function LearnEnglishPage() {
|
|||
Add New Program
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-sm text-grayScale-400">
|
||||
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>
|
||||
.
|
||||
Create a new learning program. Add a thumbnail as an image URL or by uploading a
|
||||
file.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{/* Gradient Divider */}
|
||||
|
|
@ -348,6 +414,27 @@ export function LearnEnglishPage() {
|
|||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="create-program-sort-order" className="text-[15px] text-grayScale-700">
|
||||
Sort Order
|
||||
</label>
|
||||
<Input
|
||||
id="create-program-sort-order"
|
||||
type="number"
|
||||
min={0}
|
||||
step={1}
|
||||
inputMode="numeric"
|
||||
value={createSortOrder}
|
||||
onChange={(e) => setCreateSortOrder(e.target.value)}
|
||||
placeholder="e.g. 5"
|
||||
className="h-12 rounded-xl ring-0"
|
||||
disabled={createSaving || createUploadingThumbnail}
|
||||
/>
|
||||
<p className="text-xs text-grayScale-500">
|
||||
Lower numbers appear first when programs are listed.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-[15px] text-grayScale-700">
|
||||
Thumbnail
|
||||
|
|
@ -549,16 +636,17 @@ export function LearnEnglishPage() {
|
|||
if (!open) closeEdit();
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogContent className="flex max-h-[min(90vh,calc(100dvh-2rem))] max-w-lg flex-col gap-0 overflow-hidden p-0">
|
||||
<DialogHeader className="shrink-0 space-y-1.5 border-b border-grayScale-100 px-6 pb-4 pt-6 pr-12">
|
||||
<DialogTitle>Edit program</DialogTitle>
|
||||
<DialogDescription>
|
||||
Update name, description, and thumbnail. Upload an image from your
|
||||
computer (via file storage) or paste a URL. Changes are saved to the
|
||||
server.
|
||||
Update name, description, sort order, and thumbnail. Upload an image
|
||||
from your computer (via file storage) or paste a URL. Changes are
|
||||
saved to the server.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-2">
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-6 py-4">
|
||||
<div className="grid gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-grayScale-700">
|
||||
Name
|
||||
|
|
@ -584,6 +672,26 @@ export function LearnEnglishPage() {
|
|||
disabled={savingEdit || uploadingEditThumbnail}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="edit-program-sort-order" className="text-sm font-medium text-grayScale-700">
|
||||
Sort Order
|
||||
</label>
|
||||
<Input
|
||||
id="edit-program-sort-order"
|
||||
type="number"
|
||||
min={0}
|
||||
step={1}
|
||||
inputMode="numeric"
|
||||
value={editSortOrder}
|
||||
onChange={(e) => setEditSortOrder(e.target.value)}
|
||||
className="rounded-xl"
|
||||
placeholder="e.g. 5"
|
||||
disabled={savingEdit || uploadingEditThumbnail}
|
||||
/>
|
||||
<p className="text-xs text-grayScale-500">
|
||||
Lower numbers appear first when programs are listed.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-grayScale-700">
|
||||
Thumbnail
|
||||
|
|
@ -624,15 +732,12 @@ export function LearnEnglishPage() {
|
|||
disabled={savingEdit || uploadingEditThumbnail}
|
||||
/>
|
||||
<p className="text-xs text-grayScale-500">
|
||||
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.
|
||||
Uploaded images are stored and used as the program thumbnail.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
</div>
|
||||
<DialogFooter className="shrink-0 gap-2 border-t border-grayScale-100 bg-white px-6 py-4 sm:gap-0">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
|
|
|
|||
380
src/pages/content-management/LessonPracticesPage.tsx
Normal file
380
src/pages/content-management/LessonPracticesPage.tsx
Normal file
|
|
@ -0,0 +1,380 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,23 +1,27 @@
|
|||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Video,
|
||||
Calendar,
|
||||
Mic,
|
||||
Layers,
|
||||
Edit2,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { ArrowLeft, Video, Calendar, Trash2, X } from "lucide-react";
|
||||
import { Link, useLocation, useNavigate, useParams } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
deleteTopLevelModuleLesson,
|
||||
getModuleLessons,
|
||||
getPracticesByParentModule,
|
||||
getTopLevelCourseModules,
|
||||
publishParentLinkedPractice,
|
||||
publishTopLevelModuleLesson,
|
||||
updateParentLinkedPractice,
|
||||
updateTopLevelModuleLesson,
|
||||
} from "../../api/courses.api";
|
||||
import type { TopLevelModuleLessonItem } from "../../types/course.types";
|
||||
import type {
|
||||
ParentContextPractice,
|
||||
PracticePublishStatus,
|
||||
TopLevelModuleLessonItem,
|
||||
} from "../../types/course.types";
|
||||
import {
|
||||
isPracticeDraft,
|
||||
isPracticePublished,
|
||||
unwrapPracticesList,
|
||||
} from "../../lib/parentContextPractice";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
|
|
@ -32,6 +36,7 @@ import { Textarea } from "../../components/ui/textarea";
|
|||
import { resolveThumbnailForPreview } from "../../lib/videoPreview";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { LessonMediaUploadField } from "./components/LessonMediaUploadField";
|
||||
import { ModulePracticeCard } from "./components/ModulePracticeCard";
|
||||
import { VideoCard } from "./components/VideoCard";
|
||||
|
||||
const LESSON_THUMB_GRADIENTS = [
|
||||
|
|
@ -41,37 +46,6 @@ const LESSON_THUMB_GRADIENTS = [
|
|||
"from-[#FCE7F3] to-[#F9A8D4]",
|
||||
] as const;
|
||||
|
||||
const MOCK_PRACTICES = [
|
||||
{
|
||||
id: "p1",
|
||||
title: "Describe a Photo",
|
||||
level: "IELTS",
|
||||
variations: 12,
|
||||
status: "Draft",
|
||||
},
|
||||
{
|
||||
id: "p2",
|
||||
title: "Describe a Photo",
|
||||
level: "IELTS",
|
||||
variations: 12,
|
||||
status: "Draft",
|
||||
},
|
||||
{
|
||||
id: "p3",
|
||||
title: "Describe a Photo",
|
||||
level: "IELTS",
|
||||
variations: 12,
|
||||
status: "Draft",
|
||||
},
|
||||
{
|
||||
id: "p4",
|
||||
title: "Describe a Photo",
|
||||
level: "IELTS",
|
||||
variations: 12,
|
||||
status: "Draft",
|
||||
},
|
||||
];
|
||||
|
||||
type ModuleDetailState = {
|
||||
moduleName?: string;
|
||||
moduleDescription?: string;
|
||||
|
|
@ -87,13 +61,14 @@ export function ModuleDetailPage() {
|
|||
moduleId: string;
|
||||
}>();
|
||||
const [activeTab, setActiveTab] = useState<"video" | "practice">("video");
|
||||
const [activeFilter, setActiveFilter] = useState("Draft");
|
||||
const [activeFilter, setActiveFilter] = useState("All");
|
||||
const [lessons, setLessons] = useState<TopLevelModuleLessonItem[]>([]);
|
||||
const [lessonsLoading, setLessonsLoading] = useState(true);
|
||||
const [lessonsLoadError, setLessonsLoadError] = useState<string | null>(null);
|
||||
const [editingLesson, setEditingLesson] =
|
||||
useState<TopLevelModuleLessonItem | null>(null);
|
||||
const [editLessonTitle, setEditLessonTitle] = useState("");
|
||||
const [editLessonSortOrder, setEditLessonSortOrder] = useState("");
|
||||
const [editLessonVideoUrl, setEditLessonVideoUrl] = useState("");
|
||||
const [editLessonThumbnail, setEditLessonThumbnail] = useState("");
|
||||
const [editLessonDescription, setEditLessonDescription] = useState("");
|
||||
|
|
@ -104,7 +79,17 @@ export function ModuleDetailPage() {
|
|||
const [deletingLesson, setDeletingLesson] =
|
||||
useState<TopLevelModuleLessonItem | null>(null);
|
||||
const [deletingLessonInFlight, setDeletingLessonInFlight] = useState(false);
|
||||
const [practices] = useState(MOCK_PRACTICES);
|
||||
const [publishStatusLessonId, setPublishStatusLessonId] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
const [practices, setPractices] = useState<ParentContextPractice[]>([]);
|
||||
const [practicesLoading, setPracticesLoading] = useState(false);
|
||||
const [practicesLoadError, setPracticesLoadError] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [publishStatusPracticeId, setPublishStatusPracticeId] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
const [loadedModuleName, setLoadedModuleName] = useState<string | null>(null);
|
||||
const [loadedModuleDescription, setLoadedModuleDescription] = useState<
|
||||
string | null
|
||||
|
|
@ -233,9 +218,96 @@ export function ModuleDetailPage() {
|
|||
void loadModuleLessons({ showPageLoading: true });
|
||||
}, [loadModuleLessons]);
|
||||
|
||||
const loadModulePractices = useCallback(async () => {
|
||||
const mid = Number(moduleId);
|
||||
if (!Number.isFinite(mid) || mid < 1) {
|
||||
setPractices([]);
|
||||
setPracticesLoadError(null);
|
||||
setPracticesLoading(false);
|
||||
return;
|
||||
}
|
||||
setPracticesLoading(true);
|
||||
setPracticesLoadError(null);
|
||||
try {
|
||||
const res = await getPracticesByParentModule(mid, {
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
});
|
||||
setPractices(unwrapPracticesList(res));
|
||||
} catch {
|
||||
setPractices([]);
|
||||
setPracticesLoadError("Failed to load practices. Please try again.");
|
||||
} finally {
|
||||
setPracticesLoading(false);
|
||||
}
|
||||
}, [moduleId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab !== "practice") return;
|
||||
void loadModulePractices();
|
||||
}, [activeTab, loadModulePractices]);
|
||||
|
||||
const filteredPractices = useMemo(() => {
|
||||
if (activeFilter === "Published") {
|
||||
return practices.filter(isPracticePublished);
|
||||
}
|
||||
if (activeFilter === "Draft") {
|
||||
return practices.filter(isPracticeDraft);
|
||||
}
|
||||
if (activeFilter === "Archived") {
|
||||
return [];
|
||||
}
|
||||
return practices;
|
||||
}, [practices, activeFilter]);
|
||||
|
||||
const handlePublishPractice = async (practiceId: number) => {
|
||||
setPublishStatusPracticeId(practiceId);
|
||||
try {
|
||||
await publishParentLinkedPractice(practiceId);
|
||||
setPractices((prev) =>
|
||||
prev.map((p) =>
|
||||
p.id === practiceId ? { ...p, publish_status: "PUBLISHED" } : p,
|
||||
),
|
||||
);
|
||||
toast.success("Practice published");
|
||||
} catch (e: unknown) {
|
||||
console.error(e);
|
||||
const msg =
|
||||
(e as { response?: { data?: { message?: string } } })?.response?.data
|
||||
?.message ?? "Failed to publish practice";
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setPublishStatusPracticeId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSavePracticeAsDraft = async (practiceId: number) => {
|
||||
setPublishStatusPracticeId(practiceId);
|
||||
try {
|
||||
await updateParentLinkedPractice(practiceId, {
|
||||
publish_status: "DRAFT",
|
||||
});
|
||||
setPractices((prev) =>
|
||||
prev.map((p) =>
|
||||
p.id === practiceId ? { ...p, publish_status: "DRAFT" } : p,
|
||||
),
|
||||
);
|
||||
toast.success("Practice saved as draft");
|
||||
} catch (e: unknown) {
|
||||
console.error(e);
|
||||
const msg =
|
||||
(e as { response?: { data?: { message?: string } } })?.response?.data
|
||||
?.message ?? "Failed to save practice as draft";
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setPublishStatusPracticeId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const openEditLesson = (lesson: TopLevelModuleLessonItem) => {
|
||||
setEditingLesson(lesson);
|
||||
setEditLessonTitle(lesson.title ?? "");
|
||||
setEditLessonSortOrder(String(lesson.sort_order ?? 0));
|
||||
setEditLessonVideoUrl(lesson.video_url ?? "");
|
||||
setEditLessonThumbnail(lesson.thumbnail ?? "");
|
||||
setEditLessonDescription(lesson.description ?? "");
|
||||
|
|
@ -253,6 +325,16 @@ export function ModuleDetailPage() {
|
|||
toast.error("Title is required");
|
||||
return;
|
||||
}
|
||||
const sortOrderRaw = editLessonSortOrder.trim();
|
||||
if (sortOrderRaw === "") {
|
||||
toast.error("Sort order is required");
|
||||
return;
|
||||
}
|
||||
const sort_order = Number(sortOrderRaw);
|
||||
if (!Number.isInteger(sort_order) || sort_order < 0) {
|
||||
toast.error("Sort order must be a whole number of 0 or greater");
|
||||
return;
|
||||
}
|
||||
setSavingLessonEdit(true);
|
||||
try {
|
||||
await updateTopLevelModuleLesson(editingLesson.id, {
|
||||
|
|
@ -260,6 +342,7 @@ export function ModuleDetailPage() {
|
|||
video_url: editLessonVideoUrl.trim(),
|
||||
thumbnail: editLessonThumbnail.trim(),
|
||||
description: editLessonDescription.trim(),
|
||||
sort_order,
|
||||
});
|
||||
toast.success("Lesson updated");
|
||||
setEditingLesson(null);
|
||||
|
|
@ -275,6 +358,39 @@ export function ModuleDetailPage() {
|
|||
}
|
||||
};
|
||||
|
||||
const handleToggleLessonPublishStatus = async (
|
||||
lessonId: number,
|
||||
nextStatus: PracticePublishStatus,
|
||||
) => {
|
||||
setPublishStatusLessonId(lessonId);
|
||||
try {
|
||||
await publishTopLevelModuleLesson(lessonId, {
|
||||
publish_status: nextStatus,
|
||||
});
|
||||
setLessons((prev) =>
|
||||
prev.map((l) =>
|
||||
l.id === lessonId ? { ...l, publish_status: nextStatus } : l,
|
||||
),
|
||||
);
|
||||
toast.success(
|
||||
nextStatus === "PUBLISHED"
|
||||
? "Lesson published"
|
||||
: "Lesson saved as draft",
|
||||
);
|
||||
} catch (e: unknown) {
|
||||
console.error(e);
|
||||
const msg =
|
||||
(e as { response?: { data?: { message?: string } } })?.response?.data
|
||||
?.message ??
|
||||
(nextStatus === "PUBLISHED"
|
||||
? "Failed to publish lesson"
|
||||
: "Failed to save lesson as draft");
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setPublishStatusLessonId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmDeleteLesson = async () => {
|
||||
if (!deletingLesson) return;
|
||||
setDeletingLessonInFlight(true);
|
||||
|
|
@ -393,11 +509,34 @@ 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>
|
||||
|
|
@ -454,12 +593,66 @@ export function ModuleDetailPage() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Practice Cards Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{practices.map((practice) => (
|
||||
<PracticeCard key={practice.id} {...practice} />
|
||||
))}
|
||||
</div>
|
||||
{practicesLoading ? (
|
||||
<div className="flex flex-col items-center justify-center py-24 text-grayScale-500 text-[15px] font-medium">
|
||||
Loading practices…
|
||||
</div>
|
||||
) : practicesLoadError ? (
|
||||
<div className="rounded-2xl border border-amber-100 bg-amber-50/80 px-6 py-8 text-center text-sm text-amber-900 max-w-lg mx-auto">
|
||||
{practicesLoadError}
|
||||
</div>
|
||||
) : filteredPractices.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{filteredPractices.map((practice) => (
|
||||
<ModulePracticeCard
|
||||
key={practice.id}
|
||||
practice={practice}
|
||||
statusUpdating={publishStatusPracticeId === practice.id}
|
||||
onEdit={() =>
|
||||
navigate(
|
||||
`/content/practices?type=module&id=${moduleId}`,
|
||||
)
|
||||
}
|
||||
onPublish={() => void handlePublishPractice(practice.id)}
|
||||
onSaveAsDraft={() =>
|
||||
void handleSavePracticeAsDraft(practice.id)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-32 px-4 rounded-[40px] border-2 border-dashed border-[#F1F5F9] bg-white max-w-4xl mx-auto shadow-sm">
|
||||
<div className="h-20 w-20 rounded-full bg-[#FAF5FF] flex items-center justify-center mb-6">
|
||||
<div className="h-14 w-14 rounded-full bg-[#F5EBFF] flex items-center justify-center">
|
||||
<Calendar className="h-7 w-7 text-brand-500" />
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="text-2xl font-extrabold text-grayScale-900 mb-3">
|
||||
{practices.length === 0
|
||||
? "No practices in this module yet"
|
||||
: "No practices match this filter"}
|
||||
</h2>
|
||||
<p className="text-grayScale-400 font-medium text-[15px] text-center max-w-sm mb-10 leading-relaxed">
|
||||
{practices.length === 0
|
||||
? "Add a practice to give learners speaking exercises for this module."
|
||||
: "Try another status filter or add a new practice."}
|
||||
</p>
|
||||
{practices.length === 0 ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-12 px-8 rounded-xl border-brand-500 text-brand-500 font-bold hover:bg-brand-50 transition-all flex items-center gap-2"
|
||||
onClick={() =>
|
||||
navigate(
|
||||
`/new-content/learn-english/${level}/courses/add-practice?backTo=module&courseId=${courseId}&moduleId=${moduleId}`,
|
||||
)
|
||||
}
|
||||
>
|
||||
<Calendar className="h-5 w-5" />
|
||||
Add Practice
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -475,15 +668,7 @@ export function ModuleDetailPage() {
|
|||
<DialogHeader>
|
||||
<DialogTitle>Edit lesson</DialogTitle>
|
||||
<DialogDescription>
|
||||
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>
|
||||
.
|
||||
Update lesson details. Uploaded video and thumbnail files are stored automatically.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-2">
|
||||
|
|
@ -501,6 +686,28 @@ export function ModuleDetailPage() {
|
|||
disabled={savingLessonEdit}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
className="text-sm font-medium text-grayScale-700"
|
||||
htmlFor="edit-lesson-sort-order"
|
||||
>
|
||||
Sort order
|
||||
</label>
|
||||
<Input
|
||||
id="edit-lesson-sort-order"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={0}
|
||||
step={1}
|
||||
value={editLessonSortOrder}
|
||||
onChange={(e) => setEditLessonSortOrder(e.target.value)}
|
||||
disabled={savingLessonEdit}
|
||||
className="max-w-[200px]"
|
||||
/>
|
||||
<p className="text-xs text-grayScale-500">
|
||||
Whole number, 0 or greater.
|
||||
</p>
|
||||
</div>
|
||||
<LessonMediaUploadField
|
||||
kind="video"
|
||||
value={editLessonVideoUrl}
|
||||
|
|
@ -609,68 +816,3 @@ export function ModuleDetailPage() {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PracticeCard({
|
||||
title,
|
||||
level,
|
||||
variations,
|
||||
status,
|
||||
}: {
|
||||
title: string;
|
||||
level: string;
|
||||
variations: number;
|
||||
status: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-white rounded-[24px] border border-grayScale-50 shadow-sm overflow-hidden hover:shadow-xl hover:shadow-grayScale-400/5 transition-all group p-6 flex flex-col h-full min-h-[340px]">
|
||||
<div className="flex-1 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-[18px] font-bold text-grayScale-900 line-clamp-1">
|
||||
{title}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="bg-[#22C55E] text-white text-[11px] font-bold px-2 py-1 rounded-[4px]">
|
||||
{level}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5 text-grayScale-500">
|
||||
<Mic className="h-4 w-4" />
|
||||
<span className="text-[13px] font-bold">Speaking</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2.5 text-brand-400 w-fit py-2 rounded-xl">
|
||||
<Layers className="h-4 w-4" />
|
||||
<span className="text-[14px] font-bold">{variations} Variations</span>
|
||||
</div>
|
||||
|
||||
<div className="flex border-t border-grayScale-200 items-center justify-between pt-2">
|
||||
<div className="bg-grayScale-100 text-grayScale-400 text-[11px] font-bold px-3 py-1.5 rounded-[6px] tracking-wide uppercase">
|
||||
{status}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button className="h-8 w-8 rounded-lg flex items-center justify-center text-grayScale-400 hover:text-brand-500 hover:border-brand-100 transition-all">
|
||||
<Edit2 className="h-5 w-5" />
|
||||
</button>
|
||||
<button className="h-8 w-8 rounded-lg flex items-center justify-center text-grayScale-400 hover:text-red-500 hover:border-red-100 transition-all">
|
||||
<Trash2 className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 grid grid-cols-2 gap-3">
|
||||
<Button className="bg-brand-500 text-white rounded-xl h-11 text-[13px] font-bold shadow-md shadow-brand-500/10 hover:bg-brand-600 transition-all px-0">
|
||||
Publish Practice
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-brand-500 text-brand-500 rounded-xl h-11 text-[13px] font-bold bg-white hover:bg-brand-50 transition-all px-0"
|
||||
>
|
||||
Publish Video
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,14 +7,31 @@ export function NewContentPage() {
|
|||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Header section */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight text-grayScale-700">
|
||||
Content Management
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-grayScale-500">
|
||||
Upload, organize, and manage learning content across programs and
|
||||
courses
|
||||
</p>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight text-grayScale-700">
|
||||
Content Management
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-grayScale-500">
|
||||
Upload, organize, and manage learning content across programs and
|
||||
courses
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap items-center justify-end gap-3">
|
||||
<Link to="/new-content/question-types">
|
||||
<Button className="h-10 px-6 rounded-[6px] bg-brand-500 font-bold text-white shadow-sm hover:bg-brand-600 transition-all">
|
||||
Manage Question Types
|
||||
</Button>
|
||||
</Link>
|
||||
<Link to="/new-content/reorder">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-10 px-6 rounded-[6px] border-brand-500 font-bold text-brand-500 hover:bg-brand-50 transition-all"
|
||||
>
|
||||
Reorder Content
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Gradient Divider */}
|
||||
|
|
|
|||
|
|
@ -1093,9 +1093,6 @@ export function PracticeDetailsPage() {
|
|||
placeholder="Optional"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-grayScale-500">
|
||||
Uses <span className="font-mono">PUT /practices/{id}</span> with the fields above.
|
||||
</p>
|
||||
</div>
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button type="button" variant="outline" onClick={() => setEditOpen(false)} disabled={savePracticeLoading}>
|
||||
|
|
@ -1115,9 +1112,8 @@ export function PracticeDetailsPage() {
|
|||
<DialogTitle>Delete this practice?</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-sm text-grayScale-600">
|
||||
This will call <span className="font-mono">DELETE /practices/{id}</span> and remove the practice
|
||||
for this {parentTabCopy[parentTab].label.toLowerCase()}. The question set is not deleted unless your API
|
||||
cascades.
|
||||
This permanently removes the practice for this {parentTabCopy[parentTab].label.toLowerCase()}. The linked
|
||||
question set may remain unless you remove it separately.
|
||||
</p>
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ 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"
|
||||
|
|
@ -84,7 +85,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] = useState(10)
|
||||
const [pageSize, setPageSize] = useState(DEFAULT_TABLE_PAGE_SIZE)
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const [totalQuestions, setTotalQuestions] = useState(0)
|
||||
|
||||
|
|
@ -736,29 +737,56 @@ export function PracticeQuestionsPage() {
|
|||
))}
|
||||
</div>
|
||||
))}
|
||||
{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>
|
||||
{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>
|
||||
</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>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { ArrowLeft, Plus, FileText, Pencil, Trash2, X } from "lucide-react";
|
||||
import { ArrowLeft, Plus, Pencil, Trash2, X } from "lucide-react";
|
||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { Card, CardContent } from "../../components/ui/card";
|
||||
|
|
@ -14,7 +14,6 @@ import {
|
|||
DialogTrigger,
|
||||
} from "../../components/ui/dialog";
|
||||
import { Input } from "../../components/ui/input";
|
||||
import { Textarea } from "../../components/ui/textarea";
|
||||
import uploadIcon from "../../assets/icons/upload.png";
|
||||
import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg";
|
||||
import alertSrc from "../../assets/Alert.svg";
|
||||
|
|
@ -30,6 +29,7 @@ import type {
|
|||
LearningProgramListItem,
|
||||
ProgramCourseListItem,
|
||||
} from "../../types/course.types";
|
||||
import { PublishPracticeButton } from "./components/PublishPracticeButton";
|
||||
|
||||
export function ProgramCoursesPage() {
|
||||
const navigate = useNavigate();
|
||||
|
|
@ -51,7 +51,7 @@ export function ProgramCoursesPage() {
|
|||
null,
|
||||
);
|
||||
const [editName, setEditName] = useState("");
|
||||
const [editDescription, setEditDescription] = useState("");
|
||||
const [editSortOrder, setEditSortOrder] = useState("");
|
||||
const [editThumbnail, setEditThumbnail] = useState("");
|
||||
const [savingEdit, setSavingEdit] = useState(false);
|
||||
const [uploadingEditThumbnail, setUploadingEditThumbnail] = useState(false);
|
||||
|
|
@ -59,7 +59,7 @@ export function ProgramCoursesPage() {
|
|||
|
||||
const [createCourseOpen, setCreateCourseOpen] = useState(false);
|
||||
const [createName, setCreateName] = useState("");
|
||||
const [createDescription, setCreateDescription] = useState("");
|
||||
const [createSortOrder, setCreateSortOrder] = useState("");
|
||||
const [createThumbnail, setCreateThumbnail] = useState("");
|
||||
const [createSaving, setCreateSaving] = useState(false);
|
||||
const [createUploadingThumbnail, setCreateUploadingThumbnail] = useState(false);
|
||||
|
|
@ -133,16 +133,16 @@ export function ProgramCoursesPage() {
|
|||
const openEditCourse = (course: ProgramCourseListItem) => {
|
||||
setEditingCourse(course);
|
||||
setEditName(course.name ?? "");
|
||||
setEditDescription(course.description?.trim() ?? "");
|
||||
setEditThumbnail(
|
||||
course.thumbnail?.trim() || course.thumbnail_url?.trim() || "",
|
||||
);
|
||||
setEditSortOrder(String(course.sort_order ?? 0));
|
||||
};
|
||||
|
||||
const closeEditCourse = () => {
|
||||
setEditingCourse(null);
|
||||
setEditName("");
|
||||
setEditDescription("");
|
||||
setEditSortOrder("");
|
||||
setEditThumbnail("");
|
||||
setUploadingEditThumbnail(false);
|
||||
if (editThumbnailFileInputRef.current) {
|
||||
|
|
@ -192,12 +192,23 @@ export function ProgramCoursesPage() {
|
|||
toast.error("Course name is required");
|
||||
return;
|
||||
}
|
||||
const sortOrderRaw = editSortOrder.trim();
|
||||
if (!sortOrderRaw) {
|
||||
toast.error("Sort order is required");
|
||||
return;
|
||||
}
|
||||
const sort_order = Number(sortOrderRaw);
|
||||
if (!Number.isInteger(sort_order) || sort_order < 0) {
|
||||
toast.error("Sort order must be a whole number of 0 or greater");
|
||||
return;
|
||||
}
|
||||
setSavingEdit(true);
|
||||
try {
|
||||
await updateTopLevelCourse(editingCourse.id, {
|
||||
name,
|
||||
description: editDescription.trim(),
|
||||
description: editingCourse.description?.trim() ?? "",
|
||||
thumbnail: editThumbnail.trim(),
|
||||
sort_order,
|
||||
});
|
||||
toast.success("Course updated");
|
||||
closeEditCourse();
|
||||
|
|
@ -215,7 +226,7 @@ export function ProgramCoursesPage() {
|
|||
|
||||
const clearCreateCourseForm = () => {
|
||||
setCreateName("");
|
||||
setCreateDescription("");
|
||||
setCreateSortOrder("");
|
||||
setCreateThumbnail("");
|
||||
setCreateUploadingThumbnail(false);
|
||||
if (createThumbnailFileInputRef.current) {
|
||||
|
|
@ -271,12 +282,23 @@ export function ProgramCoursesPage() {
|
|||
toast.error("Course name is required");
|
||||
return;
|
||||
}
|
||||
const sortOrderRaw = createSortOrder.trim();
|
||||
if (!sortOrderRaw) {
|
||||
toast.error("Sort order is required");
|
||||
return;
|
||||
}
|
||||
const sort_order = Number(sortOrderRaw);
|
||||
if (!Number.isInteger(sort_order) || sort_order < 0) {
|
||||
toast.error("Sort order must be a whole number of 0 or greater");
|
||||
return;
|
||||
}
|
||||
setCreateSaving(true);
|
||||
try {
|
||||
await createProgramCourse(programId, {
|
||||
name,
|
||||
description: createDescription.trim(),
|
||||
description: "",
|
||||
thumbnail: createThumbnail.trim(),
|
||||
sort_order,
|
||||
});
|
||||
toast.success("Course created");
|
||||
clearCreateCourseForm();
|
||||
|
|
@ -337,18 +359,6 @@ export function ProgramCoursesPage() {
|
|||
<div className="flex gap-3">
|
||||
{programIdValid ? (
|
||||
<>
|
||||
<Link
|
||||
to={`/new-content/learn-english/${programIdParam}/courses/add-practice`}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="rounded-[6px] border-brand-500 text-brand-500 "
|
||||
>
|
||||
<FileText className="mr-2 h-4 w-4" />
|
||||
Add Practice
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Dialog
|
||||
open={createCourseOpen}
|
||||
onOpenChange={handleCreateCourseDialogOpenChange}
|
||||
|
|
@ -369,15 +379,8 @@ export function ProgramCoursesPage() {
|
|||
Add New Course
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-sm text-grayScale-400">
|
||||
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>
|
||||
.
|
||||
Add a new course to this program. Use an image URL or upload a file for the
|
||||
thumbnail.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
|
|
@ -422,17 +425,27 @@ export function ProgramCoursesPage() {
|
|||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-[15px] font-medium text-grayScale-700">
|
||||
Description
|
||||
<label
|
||||
htmlFor="create-course-sort-order"
|
||||
className="text-[15px] font-medium text-grayScale-700"
|
||||
>
|
||||
Sort Order
|
||||
</label>
|
||||
<Textarea
|
||||
value={createDescription}
|
||||
onChange={(e) => setCreateDescription(e.target.value)}
|
||||
placeholder="Short summary of the course"
|
||||
rows={3}
|
||||
className="min-h-[88px] resize-y rounded-xl"
|
||||
<Input
|
||||
id="create-course-sort-order"
|
||||
type="number"
|
||||
min={0}
|
||||
step={1}
|
||||
inputMode="numeric"
|
||||
value={createSortOrder}
|
||||
onChange={(e) => setCreateSortOrder(e.target.value)}
|
||||
placeholder="e.g. 5"
|
||||
className="h-12 rounded-xl"
|
||||
disabled={createSaving || createUploadingThumbnail}
|
||||
/>
|
||||
<p className="text-xs text-grayScale-500">
|
||||
Lower numbers appear first when courses are listed.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
|
|
@ -664,9 +677,11 @@ export function ProgramCoursesPage() {
|
|||
>
|
||||
View Detail
|
||||
</Button>
|
||||
<Button className="h-10 flex-1 rounded-[6px] bg-brand-500 text-[13px] font-semibold ">
|
||||
Publish Practice
|
||||
</Button>
|
||||
<PublishPracticeButton
|
||||
parentKind="COURSE"
|
||||
parentId={course.id}
|
||||
className="h-10 flex-1 rounded-[6px] bg-brand-500 text-[13px] font-semibold hover:bg-brand-600 disabled:opacity-60"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
@ -682,18 +697,15 @@ export function ProgramCoursesPage() {
|
|||
if (!open) closeEditCourse();
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogContent className="flex max-h-[min(90vh,calc(100dvh-2rem))] max-w-lg flex-col gap-0 overflow-hidden p-0">
|
||||
<DialogHeader className="shrink-0 space-y-1.5 border-b border-grayScale-100 px-6 pb-4 pt-6 pr-12">
|
||||
<DialogTitle>Edit course</DialogTitle>
|
||||
<DialogDescription>
|
||||
Update name, description, and thumbnail. Saved with{" "}
|
||||
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px]">
|
||||
PUT /courses/:id
|
||||
</code>
|
||||
.
|
||||
Update name, sort order, and thumbnail.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-2">
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-6 py-4">
|
||||
<div className="grid gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-grayScale-700">
|
||||
Name
|
||||
|
|
@ -707,17 +719,27 @@ export function ProgramCoursesPage() {
|
|||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-grayScale-700">
|
||||
Description
|
||||
<label
|
||||
htmlFor="edit-course-sort-order"
|
||||
className="text-sm font-medium text-grayScale-700"
|
||||
>
|
||||
Sort Order
|
||||
</label>
|
||||
<Textarea
|
||||
value={editDescription}
|
||||
onChange={(e) => setEditDescription(e.target.value)}
|
||||
rows={4}
|
||||
className="min-h-[100px] resize-y rounded-xl"
|
||||
placeholder="Short summary"
|
||||
<Input
|
||||
id="edit-course-sort-order"
|
||||
type="number"
|
||||
min={0}
|
||||
step={1}
|
||||
inputMode="numeric"
|
||||
value={editSortOrder}
|
||||
onChange={(e) => setEditSortOrder(e.target.value)}
|
||||
className="rounded-xl"
|
||||
placeholder="e.g. 5"
|
||||
disabled={savingEdit || uploadingEditThumbnail}
|
||||
/>
|
||||
<p className="text-xs text-grayScale-500">
|
||||
Lower numbers appear first when courses are listed.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-grayScale-700">
|
||||
|
|
@ -760,7 +782,8 @@ export function ProgramCoursesPage() {
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
</div>
|
||||
<DialogFooter className="shrink-0 gap-2 border-t border-grayScale-100 bg-white px-6 py-4 sm:gap-0">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ import {
|
|||
} from "lucide-react";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import { Card } from "../../components/ui/card";
|
||||
import { Textarea } from "../../components/ui/textarea";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
|
|
@ -39,7 +38,6 @@ export function ProgramDetailPage() {
|
|||
const { programType } = useParams<{ programType: string }>();
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [createName, setCreateName] = useState("");
|
||||
const [createDescription, setCreateDescription] = useState("");
|
||||
const [createThumbnail, setCreateThumbnail] = useState("");
|
||||
const [createThumbnailFromUpload, setCreateThumbnailFromUpload] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
|
@ -60,7 +58,6 @@ export function ProgramDetailPage() {
|
|||
const [catalogLoading, setCatalogLoading] = useState(false);
|
||||
const [editingCourseId, setEditingCourseId] = useState<number | null>(null);
|
||||
const [editName, setEditName] = useState("");
|
||||
const [editDescription, setEditDescription] = useState("");
|
||||
const [editThumbnail, setEditThumbnail] = useState("");
|
||||
const [editSortOrder, setEditSortOrder] = useState("1");
|
||||
const [savingEdit, setSavingEdit] = useState(false);
|
||||
|
|
@ -216,7 +213,7 @@ export function ProgramDetailPage() {
|
|||
|
||||
const response = await createExamPrepCatalogCourse({
|
||||
name,
|
||||
description: createDescription.trim() || null,
|
||||
description: null,
|
||||
thumbnail: thumbnailToSend,
|
||||
});
|
||||
const row = response.data?.data;
|
||||
|
|
@ -227,7 +224,7 @@ export function ProgramDetailPage() {
|
|||
{
|
||||
id: row.id,
|
||||
name: row.name ?? name,
|
||||
description: row.description?.trim() || createDescription.trim() || "—",
|
||||
description: row.description?.trim() || "—",
|
||||
thumbnail: row.thumbnail?.trim() || null,
|
||||
sortOrder: Number(row.sort_order ?? 0),
|
||||
unitsCount: Number(row.units_count ?? 0),
|
||||
|
|
@ -239,7 +236,6 @@ export function ProgramDetailPage() {
|
|||
await loadCatalogCourses();
|
||||
toast.success("Course created");
|
||||
setCreateName("");
|
||||
setCreateDescription("");
|
||||
setCreateThumbnail("");
|
||||
setCreateThumbnailFromUpload(false);
|
||||
setCreateOpen(false);
|
||||
|
|
@ -259,7 +255,6 @@ export function ProgramDetailPage() {
|
|||
if (!Number.isFinite(idNum)) return;
|
||||
setEditingCourseId(idNum);
|
||||
setEditName(String(course.name ?? ""));
|
||||
setEditDescription(String(course.description ?? ""));
|
||||
setEditThumbnail(String(course.thumbnail ?? ""));
|
||||
setEditSortOrder(String(course.sort_order ?? 1));
|
||||
};
|
||||
|
|
@ -268,7 +263,6 @@ export function ProgramDetailPage() {
|
|||
if (savingEdit || uploadingEditThumbnail) return;
|
||||
setEditingCourseId(null);
|
||||
setEditName("");
|
||||
setEditDescription("");
|
||||
setEditThumbnail("");
|
||||
setEditSortOrder("1");
|
||||
};
|
||||
|
|
@ -317,9 +311,14 @@ export function ProgramDetailPage() {
|
|||
setSavingEdit(true);
|
||||
try {
|
||||
const minioThumbnail = await resolveThumbnailToMinioUrl(editThumbnail);
|
||||
const existing = createdCourses.find((c) => c.id === editingCourseId);
|
||||
const preservedDescription =
|
||||
existing?.description && existing.description !== "—"
|
||||
? existing.description
|
||||
: null;
|
||||
const response = await updateExamPrepCatalogCourse(editingCourseId, {
|
||||
name,
|
||||
description: editDescription.trim() || null,
|
||||
description: preservedDescription,
|
||||
thumbnail: minioThumbnail || null,
|
||||
sort_order: sortOrderNum,
|
||||
});
|
||||
|
|
@ -330,7 +329,7 @@ export function ProgramDetailPage() {
|
|||
? {
|
||||
...course,
|
||||
name: row?.name ?? name,
|
||||
description: row?.description?.trim() || editDescription.trim() || "—",
|
||||
description: row?.description?.trim() || preservedDescription || "—",
|
||||
thumbnail: row?.thumbnail?.trim() || null,
|
||||
sortOrder: Number(row?.sort_order ?? sortOrderNum),
|
||||
unitsCount: Number(row?.units_count ?? course.unitsCount ?? 0),
|
||||
|
|
@ -467,20 +466,6 @@ export function ProgramDetailPage() {
|
|||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<label className="text-[15px] text-grayScale-800">
|
||||
Description
|
||||
</label>
|
||||
<Textarea
|
||||
value={createDescription}
|
||||
onChange={(e) => setCreateDescription(e.target.value)}
|
||||
placeholder="Optional description"
|
||||
rows={4}
|
||||
className="min-h-[96px] rounded-[8px] border-grayScale-400"
|
||||
disabled={creating}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<label className="text-[15px] text-grayScale-800">
|
||||
Thumbnail
|
||||
|
|
@ -572,11 +557,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}/attach-practice`)
|
||||
navigate(`/new-content/courses/${programType}/add-practice`)
|
||||
}
|
||||
>
|
||||
<FileText className="h-5 w-5" />
|
||||
Attach Practice
|
||||
Add Practice
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -735,17 +720,6 @@ export function ProgramDetailPage() {
|
|||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<label className="text-[15px] text-grayScale-800">Description</label>
|
||||
<Textarea
|
||||
value={editDescription}
|
||||
onChange={(e) => setEditDescription(e.target.value)}
|
||||
rows={4}
|
||||
className="min-h-[96px] rounded-[8px] border-grayScale-400"
|
||||
disabled={savingEdit || uploadingEditThumbnail}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<label className="text-[15px] text-grayScale-800">Sort Order</label>
|
||||
<Input
|
||||
|
|
|
|||
|
|
@ -1,26 +1,18 @@
|
|||
import { Link } from "react-router-dom";
|
||||
import { GraduationCap, Brain } from "lucide-react";
|
||||
import { Button } from "../../components/ui/button";
|
||||
|
||||
export function ProgramTypeSelectionPage() {
|
||||
return (
|
||||
<div className="space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||
{/* Header section */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="space-y-1.5 pt-2">
|
||||
<h1 className="text-[28px] font-bold tracking-tight text-grayScale-900">
|
||||
Courses
|
||||
</h1>
|
||||
<p className="max-w-2xl text-[15px] font-medium text-grayScale-500">
|
||||
Organize courses under skill-based learning or English proficiency
|
||||
exams. Select a program type to manage curriculum and modules.
|
||||
</p>
|
||||
</div>
|
||||
<Link to="/new-content/question-types">
|
||||
<Button className="h-10 px-6 rounded-[6px] bg-brand-500 font-bold text-white shadow-sm hover:bg-brand-600 transition-all flex items-center gap-2 mt-4">
|
||||
Manage Question Types
|
||||
</Button>
|
||||
</Link>
|
||||
<div className="space-y-1.5 pt-2">
|
||||
<h1 className="text-[28px] font-bold tracking-tight text-grayScale-900">
|
||||
Courses
|
||||
</h1>
|
||||
<p className="max-w-2xl text-[15px] font-medium text-grayScale-500">
|
||||
Organize courses under skill-based learning or English proficiency
|
||||
exams. Select a program type to manage curriculum and modules.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Gradient Divider */}
|
||||
|
|
|
|||
|
|
@ -1,127 +1,519 @@
|
|||
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";
|
||||
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
|
||||
|
||||
export function QuestionTypeLibraryPage() {
|
||||
const [activeTab, setActiveTab] = useState("All");
|
||||
const navigate = useNavigate()
|
||||
const [searchParams] = useSearchParams()
|
||||
const createdId = searchParams.get("created")
|
||||
const updatedId = searchParams.get("updated")
|
||||
|
||||
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,
|
||||
},
|
||||
];
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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/courses"
|
||||
to="/new-content"
|
||||
className="flex items-center gap-2 text-[15px] font-bold text-grayScale-600 transition-colors hover:text-brand-500 group w-fit"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5 transition-transform group-hover:-translate-x-1" />
|
||||
Back to Courses
|
||||
Back to Content Management
|
||||
</Link>
|
||||
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||
<div className="space-y-1">
|
||||
<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.
|
||||
<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.
|
||||
</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 Question Type
|
||||
Create definition
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
<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>
|
||||
|
||||
<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",
|
||||
)}
|
||||
<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()}
|
||||
>
|
||||
{tab}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<RefreshCw className={cn("mr-2 h-4 w-4", loading && "animate-spin")} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<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>
|
||||
) : (
|
||||
<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>
|
||||
</Card>
|
||||
|
||||
{/* Grid of Cards */}
|
||||
<div className="grid grid-cols-4 gap-6">
|
||||
{questionTypes.map((qt, index) => (
|
||||
<QuestionTypeCard key={index} {...qt} />
|
||||
))}
|
||||
</div>
|
||||
<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>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ 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"
|
||||
|
|
@ -558,7 +559,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"
|
||||
>
|
||||
{[10, 20, 50].map((size) => (
|
||||
{TABLE_PAGE_SIZE_OPTIONS.map((size) => (
|
||||
<option key={size} value={size}>
|
||||
{size}
|
||||
</option>
|
||||
|
|
|
|||
31
src/pages/content-management/ReorderContentPage.tsx
Normal file
31
src/pages/content-management/ReorderContentPage.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { Link } from "react-router-dom";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { ContentHierarchyList } from "./components/ContentHierarchyList";
|
||||
|
||||
export function ReorderContentPage() {
|
||||
return (
|
||||
<div className="space-y-8 animate-in fade-in duration-500 pb-20">
|
||||
<div className="space-y-6">
|
||||
<Link
|
||||
to="/new-content"
|
||||
className="flex items-center gap-2 text-[15px] font-bold text-grayScale-600 transition-colors hover:text-brand-500 group w-fit"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5 transition-transform group-hover:-translate-x-1" />
|
||||
Back to Content Management
|
||||
</Link>
|
||||
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-2xl font-bold tracking-tight text-grayScale-700">
|
||||
Reorder Content
|
||||
</h1>
|
||||
<p className="max-w-2xl text-sm text-grayScale-500">
|
||||
Drag and drop programs, courses, modules, and lessons to change
|
||||
their display order.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ContentHierarchyList />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -30,6 +30,7 @@ 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"])
|
||||
|
|
@ -149,7 +150,7 @@ export function SpeakingPage() {
|
|||
const [audioQuestions, setAudioQuestions] = useState<AudioListQuestion[]>([])
|
||||
const [audioTotalCount, setAudioTotalCount] = useState(0)
|
||||
const [audioPage, setAudioPage] = useState(1)
|
||||
const [audioPageSize] = useState(12)
|
||||
const [audioPageSize, setAudioPageSize] = useState(DEFAULT_TABLE_PAGE_SIZE)
|
||||
const [practiceOptions, setPracticeOptions] = useState<PracticeFilterOption[]>([])
|
||||
const [selectedPracticeId, setSelectedPracticeId] = useState<string>("")
|
||||
const [practiceFilterOpen, setPracticeFilterOpen] = useState(false)
|
||||
|
|
@ -1510,31 +1511,58 @@ export function SpeakingPage() {
|
|||
))}
|
||||
</div>
|
||||
))}
|
||||
{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>
|
||||
{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>
|
||||
</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>
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ import {
|
|||
} from "lucide-react";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import { Card } from "../../components/ui/card";
|
||||
import { Textarea } from "../../components/ui/textarea";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
|
|
@ -55,7 +54,6 @@ export function UnitManagementPage() {
|
|||
const parsedUnitId = Number(unitId);
|
||||
const [addModuleOpen, setAddModuleOpen] = useState(false);
|
||||
const [createName, setCreateName] = useState("");
|
||||
const [createDescription, setCreateDescription] = useState("");
|
||||
const [createThumbnail, setCreateThumbnail] = useState("");
|
||||
const [createIcon, setCreateIcon] = useState("");
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
|
@ -79,7 +77,6 @@ export function UnitManagementPage() {
|
|||
>([]);
|
||||
const [editingModuleId, setEditingModuleId] = useState<number | null>(null);
|
||||
const [editName, setEditName] = useState("");
|
||||
const [editDescription, setEditDescription] = useState("");
|
||||
const [editThumbnail, setEditThumbnail] = useState("");
|
||||
const [editIcon, setEditIcon] = useState("");
|
||||
const [editSortOrder, setEditSortOrder] = useState("1");
|
||||
|
|
@ -159,7 +156,6 @@ export function UnitManagementPage() {
|
|||
|
||||
const clearCreateModuleForm = () => {
|
||||
setCreateName("");
|
||||
setCreateDescription("");
|
||||
setCreateThumbnail("");
|
||||
setCreateIcon("");
|
||||
if (createThumbnailFileInputRef.current) {
|
||||
|
|
@ -264,7 +260,7 @@ export function UnitManagementPage() {
|
|||
const minioIcon = await resolveToMinioUrl(createIcon);
|
||||
await createExamPrepUnitModule(parsedUnitId, {
|
||||
name,
|
||||
description: createDescription.trim() || null,
|
||||
description: null,
|
||||
thumbnail: minioThumbnail || null,
|
||||
icon: minioIcon || null,
|
||||
});
|
||||
|
|
@ -286,7 +282,6 @@ export function UnitManagementPage() {
|
|||
const openEditModule = (module: (typeof modules)[number]) => {
|
||||
setEditingModuleId(module.id);
|
||||
setEditName(module.name ?? "");
|
||||
setEditDescription(module.description ?? "");
|
||||
setEditThumbnail(module.thumbnail ?? "");
|
||||
setEditIcon(module.icon ?? "");
|
||||
setEditSortOrder(String(module.sortOrder ?? 1));
|
||||
|
|
@ -296,7 +291,6 @@ export function UnitManagementPage() {
|
|||
if (savingEdit || uploadingEditThumbnail || uploadingEditIcon) return;
|
||||
setEditingModuleId(null);
|
||||
setEditName("");
|
||||
setEditDescription("");
|
||||
setEditThumbnail("");
|
||||
setEditIcon("");
|
||||
setEditSortOrder("1");
|
||||
|
|
@ -391,11 +385,16 @@ export function UnitManagementPage() {
|
|||
|
||||
setSavingEdit(true);
|
||||
try {
|
||||
const existing = modules.find((m) => m.id === editingModuleId);
|
||||
const preservedDescription =
|
||||
existing?.description && existing.description !== "—"
|
||||
? existing.description
|
||||
: null;
|
||||
const minioThumbnail = await resolveToMinioUrl(editThumbnail);
|
||||
const minioIcon = await resolveToMinioUrl(editIcon);
|
||||
await updateExamPrepUnitModule(editingModuleId, {
|
||||
name,
|
||||
description: editDescription.trim() || null,
|
||||
description: preservedDescription,
|
||||
thumbnail: minioThumbnail || null,
|
||||
icon: minioIcon || null,
|
||||
sort_order: sortOrderNum,
|
||||
|
|
@ -489,20 +488,6 @@ export function UnitManagementPage() {
|
|||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<label className="text-[15px] text-grayScale-800">
|
||||
Description
|
||||
</label>
|
||||
<Textarea
|
||||
value={createDescription}
|
||||
onChange={(e) => setCreateDescription(e.target.value)}
|
||||
placeholder="Optional module description"
|
||||
rows={4}
|
||||
className="min-h-[96px] rounded-[8px] border-grayScale-400"
|
||||
disabled={creating || uploadingThumbnail || uploadingIcon}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<label className="text-[15px] text-grayScale-800">Thumbnail</label>
|
||||
<input
|
||||
|
|
@ -812,16 +797,6 @@ export function UnitManagementPage() {
|
|||
disabled={savingEdit || uploadingEditThumbnail || uploadingEditIcon}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<label className="text-[15px] text-grayScale-800">Description</label>
|
||||
<Textarea
|
||||
value={editDescription}
|
||||
onChange={(e) => setEditDescription(e.target.value)}
|
||||
rows={4}
|
||||
className="min-h-[96px] rounded-[8px] border-grayScale-400"
|
||||
disabled={savingEdit || uploadingEditThumbnail || uploadingEditIcon}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<label className="text-[15px] text-grayScale-800">Sort Order</label>
|
||||
<Input
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ import {
|
|||
DialogClose,
|
||||
} from "../../../components/ui/dialog";
|
||||
import { Input } from "../../../components/ui/input";
|
||||
import { Textarea } from "../../../components/ui/textarea";
|
||||
import { toast } from "sonner";
|
||||
import { createTopLevelCourseModule } from "../../../api/courses.api";
|
||||
import { ModuleIconUploadField } from "./ModuleIconUploadField";
|
||||
|
|
@ -28,7 +27,7 @@ export function AddModuleModal({
|
|||
onCreated,
|
||||
}: AddModuleModalProps) {
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [sortOrder, setSortOrder] = useState("");
|
||||
const [icon, setIcon] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [iconUploadBusy, setIconUploadBusy] = useState(false);
|
||||
|
|
@ -36,7 +35,7 @@ export function AddModuleModal({
|
|||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setName("");
|
||||
setDescription("");
|
||||
setSortOrder("");
|
||||
setIcon("");
|
||||
setSubmitting(false);
|
||||
setIconUploadBusy(false);
|
||||
|
|
@ -45,7 +44,7 @@ export function AddModuleModal({
|
|||
|
||||
const resetAndClose = () => {
|
||||
setName("");
|
||||
setDescription("");
|
||||
setSortOrder("");
|
||||
setIcon("");
|
||||
setIconUploadBusy(false);
|
||||
onClose();
|
||||
|
|
@ -69,12 +68,23 @@ export function AddModuleModal({
|
|||
toast.error("Invalid course");
|
||||
return;
|
||||
}
|
||||
const sortOrderRaw = sortOrder.trim();
|
||||
if (!sortOrderRaw) {
|
||||
toast.error("Sort order is required");
|
||||
return;
|
||||
}
|
||||
const sort_order = Number(sortOrderRaw);
|
||||
if (!Number.isInteger(sort_order) || sort_order < 0) {
|
||||
toast.error("Sort order must be a whole number of 0 or greater");
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await createTopLevelCourseModule(courseId, {
|
||||
name: trimmedName,
|
||||
description: description.trim(),
|
||||
description: "",
|
||||
icon: icon.trim(),
|
||||
sort_order,
|
||||
});
|
||||
toast.success("Module created");
|
||||
if (onCreated) {
|
||||
|
|
@ -101,11 +111,7 @@ export function AddModuleModal({
|
|||
Add New Module
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-sm text-grayScale-400">
|
||||
Create a module with{" "}
|
||||
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px]">
|
||||
POST /courses/:courseId/modules
|
||||
</code>
|
||||
.
|
||||
Add a new module to this course.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
|
|
@ -144,17 +150,27 @@ export function AddModuleModal({
|
|||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-[15px] font-medium text-grayScale-700">
|
||||
Description
|
||||
<label
|
||||
htmlFor="create-module-sort-order"
|
||||
className="text-[15px] font-medium text-grayScale-700"
|
||||
>
|
||||
Sort Order
|
||||
</label>
|
||||
<Textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Learn to introduce yourself and talk about your life."
|
||||
className="min-h-[88px] resize-y rounded-xl"
|
||||
disabled={submitting}
|
||||
rows={3}
|
||||
<Input
|
||||
id="create-module-sort-order"
|
||||
type="number"
|
||||
min={0}
|
||||
step={1}
|
||||
inputMode="numeric"
|
||||
value={sortOrder}
|
||||
onChange={(e) => setSortOrder(e.target.value)}
|
||||
placeholder="e.g. 5"
|
||||
className="h-12 rounded-xl"
|
||||
disabled={submitting || iconUploadBusy}
|
||||
/>
|
||||
<p className="text-xs text-grayScale-500">
|
||||
Lower numbers appear first when modules are listed.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ModuleIconUploadField
|
||||
|
|
|
|||
|
|
@ -0,0 +1,104 @@
|
|||
import { useMemo } from "react";
|
||||
import {
|
||||
PracticeSequentialReview,
|
||||
type PracticeReviewQuestion,
|
||||
} from "./practice-steps/PracticeSequentialReview";
|
||||
import type { PersonaCardModel } from "../../../lib/personaDisplay";
|
||||
|
||||
type IntroVideoPreview =
|
||||
| { kind: "vimeo"; url: string }
|
||||
| { kind: "video"; url: string }
|
||||
| null;
|
||||
|
||||
function plainTextFromHtml(raw: string): string {
|
||||
if (!raw.trim()) return "";
|
||||
if (!/<\/?[a-z][\s\S]*>/i.test(raw)) return raw.trim();
|
||||
try {
|
||||
const doc = new DOMParser().parseFromString(raw, "text/html");
|
||||
return doc.body.textContent?.replace(/\s+/g, " ").trim() ?? "";
|
||||
} catch {
|
||||
return raw.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
}
|
||||
|
||||
export type AddNewPracticeReviewStepProps = {
|
||||
practiceTitle: string;
|
||||
practiceDescription: string;
|
||||
selectedProgram: string;
|
||||
selectedCourse: string;
|
||||
moduleLabel: string;
|
||||
selectedPersona: string | null;
|
||||
personas: PersonaCardModel[];
|
||||
introVideoPreview: IntroVideoPreview;
|
||||
questions: PracticeReviewQuestion[];
|
||||
saving: boolean;
|
||||
saveError: string | null;
|
||||
onEditContext: () => void;
|
||||
onEditQuestions: () => void;
|
||||
onBack: () => void;
|
||||
onSaveDraft: () => void;
|
||||
onPublish: () => void;
|
||||
};
|
||||
|
||||
export function AddNewPracticeReviewStep({
|
||||
practiceTitle,
|
||||
practiceDescription,
|
||||
selectedProgram,
|
||||
selectedCourse,
|
||||
moduleLabel,
|
||||
selectedPersona,
|
||||
personas,
|
||||
introVideoPreview,
|
||||
questions,
|
||||
saving,
|
||||
saveError,
|
||||
onEditContext,
|
||||
onEditQuestions,
|
||||
onBack,
|
||||
onSaveDraft,
|
||||
onPublish,
|
||||
}: AddNewPracticeReviewStepProps) {
|
||||
const persona = personas.find((p) => p.id === selectedPersona);
|
||||
|
||||
const guidanceText = useMemo(() => {
|
||||
const fromDescription = plainTextFromHtml(practiceDescription);
|
||||
if (fromDescription) return fromDescription;
|
||||
const tips = questions
|
||||
.map((q) => q.tips?.trim() ?? "")
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
return tips || "—";
|
||||
}, [practiceDescription, questions]);
|
||||
|
||||
const thumbnailKind =
|
||||
introVideoPreview?.kind === "video"
|
||||
? "video"
|
||||
: introVideoPreview?.kind === "vimeo"
|
||||
? "vimeo"
|
||||
: "gradient";
|
||||
|
||||
return (
|
||||
<PracticeSequentialReview
|
||||
practiceTitle={practiceTitle}
|
||||
thumbnailUrl={
|
||||
introVideoPreview?.kind === "video" ? introVideoPreview.url : null
|
||||
}
|
||||
thumbnailKind={thumbnailKind}
|
||||
persona={persona ?? null}
|
||||
metadata={[
|
||||
{ label: "Program", value: selectedProgram },
|
||||
{ label: "Course", value: selectedCourse },
|
||||
{ label: "Module", value: moduleLabel },
|
||||
]}
|
||||
guidanceText={guidanceText}
|
||||
questions={questions}
|
||||
saving={saving}
|
||||
saveError={saveError}
|
||||
onEditContext={onEditContext}
|
||||
onEditQuestions={onEditQuestions}
|
||||
onBack={onBack}
|
||||
onSaveDraft={onSaveDraft}
|
||||
onPublish={onPublish}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user