Compare commits
No commits in common. "main" and "el-ui" have entirely different histories.
21
index.html
21
index.html
|
|
@ -5,27 +5,6 @@
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>yimaru-admin</title>
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|
|
||||||
82
package-lock.json
generated
82
package-lock.json
generated
|
|
@ -26,7 +26,6 @@
|
||||||
"react-is": "^19.2.5",
|
"react-is": "^19.2.5",
|
||||||
"react-router-dom": "^7.10.1",
|
"react-router-dom": "^7.10.1",
|
||||||
"recharts": "^3.6.0",
|
"recharts": "^3.6.0",
|
||||||
"resend": "^6.12.3",
|
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"zustand": "^5.0.9"
|
"zustand": "^5.0.9"
|
||||||
|
|
@ -93,7 +92,6 @@
|
||||||
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.27.1",
|
"@babel/code-frame": "^7.27.1",
|
||||||
"@babel/generator": "^7.28.5",
|
"@babel/generator": "^7.28.5",
|
||||||
|
|
@ -362,7 +360,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||||
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/accessibility": "^3.1.1",
|
"@dnd-kit/accessibility": "^3.1.1",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
|
@ -2673,12 +2670,6 @@
|
||||||
"win32"
|
"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": {
|
"node_modules/@standard-schema/spec": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||||
|
|
@ -2819,7 +2810,6 @@
|
||||||
"integrity": "sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg==",
|
"integrity": "sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~7.16.0"
|
"undici-types": "~7.16.0"
|
||||||
}
|
}
|
||||||
|
|
@ -2830,7 +2820,6 @@
|
||||||
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
|
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
}
|
}
|
||||||
|
|
@ -2841,7 +2830,6 @@
|
||||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "^19.2.0"
|
"@types/react": "^19.2.0"
|
||||||
}
|
}
|
||||||
|
|
@ -2897,7 +2885,6 @@
|
||||||
"integrity": "sha512-6/cmF2piao+f6wSxUsJLZjck7OQsYyRtcOZS02k7XINSNlz93v6emM8WutDQSXnroG2xwYlEVHJI+cPA7CPM3Q==",
|
"integrity": "sha512-6/cmF2piao+f6wSxUsJLZjck7OQsYyRtcOZS02k7XINSNlz93v6emM8WutDQSXnroG2xwYlEVHJI+cPA7CPM3Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "8.50.0",
|
"@typescript-eslint/scope-manager": "8.50.0",
|
||||||
"@typescript-eslint/types": "8.50.0",
|
"@typescript-eslint/types": "8.50.0",
|
||||||
|
|
@ -3149,7 +3136,6 @@
|
||||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
|
|
@ -3388,7 +3374,6 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.9.0",
|
"baseline-browser-mapping": "^2.9.0",
|
||||||
"caniuse-lite": "^1.0.30001759",
|
"caniuse-lite": "^1.0.30001759",
|
||||||
|
|
@ -3965,7 +3950,6 @@
|
||||||
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
|
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.8.0",
|
"@eslint-community/eslint-utils": "^4.8.0",
|
||||||
"@eslint-community/regexpp": "^4.12.1",
|
"@eslint-community/regexpp": "^4.12.1",
|
||||||
|
|
@ -4201,12 +4185,6 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/fastq": {
|
||||||
"version": "1.19.1",
|
"version": "1.19.1",
|
||||||
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz",
|
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz",
|
||||||
|
|
@ -5086,7 +5064,6 @@
|
||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
|
|
@ -5114,12 +5091,6 @@
|
||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/postal-mime": {
|
|
||||||
"version": "2.7.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-2.7.4.tgz",
|
|
||||||
"integrity": "sha512-0WdnFQYUrPGGTFu1uOqD2s7omwua8xaeYGdO6rb88oD5yJ/4pPHDA4sdWqfD8wQVfCny563n/HQS7zTFft+f/g==",
|
|
||||||
"license": "MIT-0"
|
|
||||||
},
|
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.6",
|
"version": "8.5.6",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||||
|
|
@ -5140,7 +5111,6 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.11",
|
"nanoid": "^3.3.11",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
|
|
@ -5329,7 +5299,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
|
||||||
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
|
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
|
|
@ -5339,7 +5308,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
|
||||||
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
|
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"scheduler": "^0.27.0"
|
"scheduler": "^0.27.0"
|
||||||
},
|
},
|
||||||
|
|
@ -5351,15 +5319,13 @@
|
||||||
"version": "19.2.5",
|
"version": "19.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.5.tgz",
|
||||||
"integrity": "sha512-Dn0t8IQhCmeIT3wu+Apm1/YVsJXsGWi6k4sPdnBIdqMVtHtv0IGi6dcpNpNkNac0zB2uUAqNX3MHzN8c+z2rwQ==",
|
"integrity": "sha512-Dn0t8IQhCmeIT3wu+Apm1/YVsJXsGWi6k4sPdnBIdqMVtHtv0IGi6dcpNpNkNac0zB2uUAqNX3MHzN8c+z2rwQ==",
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/react-redux": {
|
"node_modules/react-redux": {
|
||||||
"version": "9.2.0",
|
"version": "9.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||||
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/use-sync-external-store": "^0.0.6",
|
"@types/use-sync-external-store": "^0.0.6",
|
||||||
"use-sync-external-store": "^1.4.0"
|
"use-sync-external-store": "^1.4.0"
|
||||||
|
|
@ -5565,8 +5531,7 @@
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/redux-thunk": {
|
"node_modules/redux-thunk": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
|
|
@ -5583,27 +5548,6 @@
|
||||||
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
|
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/resolve": {
|
||||||
"version": "1.22.11",
|
"version": "1.22.11",
|
||||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
||||||
|
|
@ -5777,16 +5721,6 @@
|
||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/strip-json-comments": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
|
||||||
|
|
@ -5849,15 +5783,6 @@
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/tailwind-merge": {
|
||||||
"version": "3.4.0",
|
"version": "3.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz",
|
||||||
|
|
@ -6010,7 +5935,6 @@
|
||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
|
|
@ -6178,7 +6102,6 @@
|
||||||
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
|
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.27.0",
|
"esbuild": "^0.27.0",
|
||||||
"fdir": "^6.5.0",
|
"fdir": "^6.5.0",
|
||||||
|
|
@ -6316,7 +6239,6 @@
|
||||||
"integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==",
|
"integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,6 @@
|
||||||
"react-is": "^19.2.5",
|
"react-is": "^19.2.5",
|
||||||
"react-router-dom": "^7.10.1",
|
"react-router-dom": "^7.10.1",
|
||||||
"recharts": "^3.6.0",
|
"recharts": "^3.6.0",
|
||||||
"resend": "^6.12.3",
|
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"zustand": "^5.0.9"
|
"zustand": "^5.0.9"
|
||||||
|
|
|
||||||
33
src/App.tsx
33
src/App.tsx
|
|
@ -1,29 +1,9 @@
|
||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import { Toaster } from 'sonner'
|
import { Toaster } from 'sonner'
|
||||||
import { AppRoutes } from './app/AppRoutes'
|
import { AppRoutes } from './app/AppRoutes'
|
||||||
import { useTheme } from './contexts/ThemeContext'
|
|
||||||
|
|
||||||
const SESSION_KEY = 'yimaru_session_active'
|
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() {
|
export default function App() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!sessionStorage.getItem(SESSION_KEY)) {
|
if (!sessionStorage.getItem(SESSION_KEY)) {
|
||||||
|
|
@ -38,7 +18,18 @@ export default function App() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<AppRoutes />
|
<AppRoutes />
|
||||||
<AppToaster />
|
<Toaster
|
||||||
|
position="top-center"
|
||||||
|
toastOptions={{
|
||||||
|
className: 'font-sans',
|
||||||
|
style: {
|
||||||
|
padding: '14px 20px',
|
||||||
|
borderRadius: '12px',
|
||||||
|
fontSize: '14px',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
richColors
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,177 +1,5 @@
|
||||||
import http from "./http";
|
import http from "./http";
|
||||||
import type {
|
import type { DashboardResponse } from "../types/analytics.types";
|
||||||
DashboardData,
|
|
||||||
DashboardFilters,
|
|
||||||
DashboardUsers,
|
|
||||||
DateCount,
|
|
||||||
LabelCount,
|
|
||||||
} from "../types/analytics.types";
|
|
||||||
|
|
||||||
function buildDashboardQueryParams(filters?: DashboardFilters): Record<string, string | number> {
|
export const getDashboard = () =>
|
||||||
if (!filters || filters.mode === "all_time") {
|
http.get<DashboardResponse>("/analytics/dashboard");
|
||||||
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),
|
|
||||||
}));
|
|
||||||
|
|
|
||||||
|
|
@ -1,118 +0,0 @@
|
||||||
import http from "./http"
|
|
||||||
import { DEFAULT_TABLE_PAGE_SIZE } from "../lib/tablePagination"
|
|
||||||
import type {
|
|
||||||
AppVersion,
|
|
||||||
AppVersionMutationResponse,
|
|
||||||
AppVersionsListData,
|
|
||||||
AppVersionsListResponse,
|
|
||||||
CreateAppVersionPayload,
|
|
||||||
UpdateAppVersionPayload,
|
|
||||||
} from "../types/app-version.types"
|
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
||||||
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeAppVersion(raw: unknown): AppVersion | null {
|
|
||||||
if (!isRecord(raw)) return null
|
|
||||||
const id = Number(raw.id)
|
|
||||||
if (!Number.isFinite(id)) return null
|
|
||||||
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
platform: String(raw.platform ?? ""),
|
|
||||||
version_name: String(raw.version_name ?? ""),
|
|
||||||
version_code: Number(raw.version_code ?? 0),
|
|
||||||
update_type: String(raw.update_type ?? ""),
|
|
||||||
release_notes: String(raw.release_notes ?? ""),
|
|
||||||
store_url: String(raw.store_url ?? ""),
|
|
||||||
min_supported_version_code: Number(raw.min_supported_version_code ?? 0),
|
|
||||||
status: String(raw.status ?? ""),
|
|
||||||
created_at: String(raw.created_at ?? ""),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseAppVersionsList(body: unknown): AppVersionsListData {
|
|
||||||
const empty: AppVersionsListData = { versions: [], total_count: 0 }
|
|
||||||
|
|
||||||
if (isRecord(body)) {
|
|
||||||
const data = body.data
|
|
||||||
if (isRecord(data) && Array.isArray(data.versions)) {
|
|
||||||
const versions = data.versions
|
|
||||||
.map(normalizeAppVersion)
|
|
||||||
.filter((v): v is AppVersion => v !== null)
|
|
||||||
const total_count = Number(data.total_count ?? versions.length)
|
|
||||||
return { versions, total_count: Number.isFinite(total_count) ? total_count : versions.length }
|
|
||||||
}
|
|
||||||
if (Array.isArray(data)) {
|
|
||||||
const versions = data.map(normalizeAppVersion).filter((v): v is AppVersion => v !== null)
|
|
||||||
return { versions, total_count: versions.length }
|
|
||||||
}
|
|
||||||
if (Array.isArray(body.versions)) {
|
|
||||||
const versions = body.versions
|
|
||||||
.map(normalizeAppVersion)
|
|
||||||
.filter((v): v is AppVersion => v !== null)
|
|
||||||
const total_count = Number(body.total_count ?? versions.length)
|
|
||||||
return { versions, total_count: Number.isFinite(total_count) ? total_count : versions.length }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Array.isArray(body)) {
|
|
||||||
const versions = body.map(normalizeAppVersion).filter((v): v is AppVersion => v !== null)
|
|
||||||
return { versions, total_count: versions.length }
|
|
||||||
}
|
|
||||||
|
|
||||||
return empty
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseAppVersionMutation(body: unknown): AppVersion | null {
|
|
||||||
if (isRecord(body) && body.data != null) {
|
|
||||||
return normalizeAppVersion(body.data)
|
|
||||||
}
|
|
||||||
return normalizeAppVersion(body)
|
|
||||||
}
|
|
||||||
|
|
||||||
export type GetAppVersionsParams = {
|
|
||||||
limit?: number
|
|
||||||
offset?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getAppVersions = (params: GetAppVersionsParams = {}) => {
|
|
||||||
const limit = params.limit ?? DEFAULT_TABLE_PAGE_SIZE
|
|
||||||
const offset = params.offset ?? 0
|
|
||||||
return http
|
|
||||||
.get<AppVersionsListResponse>("/admin/app-versions", { params: { limit, offset } })
|
|
||||||
.then((res) => {
|
|
||||||
const parsed = parseAppVersionsList(res.data)
|
|
||||||
return {
|
|
||||||
...res,
|
|
||||||
data: parsed,
|
|
||||||
message: isRecord(res.data) ? String(res.data.message ?? "") : undefined,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function mutationResult(res: { data: unknown }) {
|
|
||||||
const version = parseAppVersionMutation(res.data)
|
|
||||||
return {
|
|
||||||
...res,
|
|
||||||
data: version,
|
|
||||||
message: isRecord(res.data) ? String(res.data.message ?? "") : undefined,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const createAppVersion = (payload: CreateAppVersionPayload) =>
|
|
||||||
http
|
|
||||||
.post<AppVersionMutationResponse>("/admin/app-versions", payload)
|
|
||||||
.then(mutationResult)
|
|
||||||
|
|
||||||
export const updateAppVersion = (id: number, payload: UpdateAppVersionPayload) =>
|
|
||||||
http
|
|
||||||
.put<AppVersionMutationResponse>(`/admin/app-versions/${id}`, payload)
|
|
||||||
.then(mutationResult)
|
|
||||||
|
|
||||||
export const deleteAppVersion = (id: number) =>
|
|
||||||
http.delete<{ message?: string }>(`/admin/app-versions/${id}`).then((res) => ({
|
|
||||||
...res,
|
|
||||||
message: isRecord(res.data) ? String(res.data.message ?? "") : undefined,
|
|
||||||
}))
|
|
||||||
|
|
@ -97,9 +97,6 @@ import type {
|
||||||
CreateExamPrepModuleLessonResponse,
|
CreateExamPrepModuleLessonResponse,
|
||||||
UpdateExamPrepModuleLessonRequest,
|
UpdateExamPrepModuleLessonRequest,
|
||||||
UpdateExamPrepModuleLessonResponse,
|
UpdateExamPrepModuleLessonResponse,
|
||||||
PublishExamPrepModuleLessonRequest,
|
|
||||||
CreateExamPrepLessonPracticeRequest,
|
|
||||||
CreateExamPrepLessonPracticeResponse,
|
|
||||||
GetExamPrepModuleLessonsResponse,
|
GetExamPrepModuleLessonsResponse,
|
||||||
GetTopLevelModuleLessonsResponse,
|
GetTopLevelModuleLessonsResponse,
|
||||||
GetPracticesByParentContextResponse,
|
GetPracticesByParentContextResponse,
|
||||||
|
|
@ -107,9 +104,7 @@ import type {
|
||||||
CreateParentLinkedPracticeResponse,
|
CreateParentLinkedPracticeResponse,
|
||||||
UpdateParentLinkedPracticeRequest,
|
UpdateParentLinkedPracticeRequest,
|
||||||
UpdateParentLinkedPracticeResponse,
|
UpdateParentLinkedPracticeResponse,
|
||||||
PublishParentLinkedPracticeRequest,
|
|
||||||
UpdateTopLevelModuleLessonRequest,
|
UpdateTopLevelModuleLessonRequest,
|
||||||
PublishTopLevelModuleLessonRequest,
|
|
||||||
CreateTopLevelModuleLessonRequest,
|
CreateTopLevelModuleLessonRequest,
|
||||||
CreateTopLevelModuleLessonResponse,
|
CreateTopLevelModuleLessonResponse,
|
||||||
} from "../types/course.types"
|
} from "../types/course.types"
|
||||||
|
|
@ -590,26 +585,10 @@ export const updateExamPrepModuleLesson = (
|
||||||
data,
|
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 */
|
/** English proficiency lesson — DELETE /exam-prep/lessons/:lessonId */
|
||||||
export const deleteExamPrepModuleLesson = (lessonId: number) =>
|
export const deleteExamPrepModuleLesson = (lessonId: number) =>
|
||||||
http.delete(`/exam-prep/lessons/${lessonId}`)
|
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 */
|
/** Top-level course resource (Learn English track) — PUT /courses/:id */
|
||||||
export const updateTopLevelCourse = (courseId: number, data: UpdateTopLevelCourseRequest) =>
|
export const updateTopLevelCourse = (courseId: number, data: UpdateTopLevelCourseRequest) =>
|
||||||
http.put(`/courses/${courseId}`, data)
|
http.put(`/courses/${courseId}`, data)
|
||||||
|
|
@ -667,12 +646,6 @@ export const updateTopLevelModuleLesson = (
|
||||||
data: UpdateTopLevelModuleLessonRequest,
|
data: UpdateTopLevelModuleLessonRequest,
|
||||||
) => http.put(`/lessons/${lessonId}`, data)
|
) => 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 */
|
/** Learn English top-level module lesson — DELETE /lessons/:id */
|
||||||
export const deleteTopLevelModuleLesson = (lessonId: number) =>
|
export const deleteTopLevelModuleLesson = (lessonId: number) =>
|
||||||
http.delete(`/lessons/${lessonId}`)
|
http.delete(`/lessons/${lessonId}`)
|
||||||
|
|
@ -708,12 +681,6 @@ export const updateParentLinkedPractice = (
|
||||||
data: UpdateParentLinkedPracticeRequest,
|
data: UpdateParentLinkedPracticeRequest,
|
||||||
) => http.put<UpdateParentLinkedPracticeResponse>(`/practices/${practiceId}`, data)
|
) => 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 */
|
/** DELETE /practices/:id */
|
||||||
export const deleteParentLinkedPractice = (practiceId: number) =>
|
export const deleteParentLinkedPractice = (practiceId: number) =>
|
||||||
http.delete<{ message: string; success: boolean; status_code: number; metadata: unknown }>(
|
http.delete<{ message: string; success: boolean; status_code: number; metadata: unknown }>(
|
||||||
|
|
|
||||||
|
|
@ -1,69 +0,0 @@
|
||||||
import http from "./http"
|
|
||||||
import type {
|
|
||||||
CreateEmailTemplateRequest,
|
|
||||||
CreateEmailTemplateResponse,
|
|
||||||
DeleteEmailTemplateResponse,
|
|
||||||
EmailTemplate,
|
|
||||||
GetEmailTemplateBySlugResponse,
|
|
||||||
GetEmailTemplatesResponse,
|
|
||||||
UpdateEmailTemplateRequest,
|
|
||||||
UpdateEmailTemplateResponse,
|
|
||||||
} from "../types/emailTemplate.types"
|
|
||||||
|
|
||||||
/** GET /admin/email-templates — list all email templates. */
|
|
||||||
export const getEmailTemplates = () =>
|
|
||||||
http.get<GetEmailTemplatesResponse>("/admin/email-templates")
|
|
||||||
|
|
||||||
/** GET /admin/email-templates/slug/:slug — single template by slug. */
|
|
||||||
export const getEmailTemplateBySlug = (slug: string) =>
|
|
||||||
http.get<GetEmailTemplateBySlugResponse>(
|
|
||||||
`/admin/email-templates/slug/${encodeURIComponent(slug)}`,
|
|
||||||
)
|
|
||||||
|
|
||||||
function normalizeEmailTemplate(row: unknown): EmailTemplate | null {
|
|
||||||
if (!row || typeof row !== "object" || !("slug" in row)) return null
|
|
||||||
const t = row as EmailTemplate
|
|
||||||
return {
|
|
||||||
...t,
|
|
||||||
variables: Array.isArray(t.variables) ? t.variables : [],
|
|
||||||
status: t.status ?? "ACTIVE",
|
|
||||||
updated_at: t.updated_at ?? t.created_at ?? "",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseEmailTemplatesResponse(
|
|
||||||
response: Awaited<ReturnType<typeof getEmailTemplates>>,
|
|
||||||
): EmailTemplate[] {
|
|
||||||
const data = response.data?.data
|
|
||||||
const rows = Array.isArray(data)
|
|
||||||
? data
|
|
||||||
: Array.isArray(data?.templates)
|
|
||||||
? data.templates
|
|
||||||
: []
|
|
||||||
return rows
|
|
||||||
.map(normalizeEmailTemplate)
|
|
||||||
.filter((row): row is EmailTemplate => row != null)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** PUT /admin/email-templates/:id — update subject and bodies. */
|
|
||||||
export const updateEmailTemplate = (
|
|
||||||
id: number,
|
|
||||||
data: UpdateEmailTemplateRequest,
|
|
||||||
) => http.put<UpdateEmailTemplateResponse>(`/admin/email-templates/${id}`, data)
|
|
||||||
|
|
||||||
/** POST /admin/email-templates — create a custom template. */
|
|
||||||
export const createEmailTemplate = (data: CreateEmailTemplateRequest) =>
|
|
||||||
http.post<CreateEmailTemplateResponse>("/admin/email-templates", data)
|
|
||||||
|
|
||||||
/** DELETE /admin/email-templates/:id — delete a custom template. */
|
|
||||||
export const deleteEmailTemplate = (id: number) =>
|
|
||||||
http.delete<DeleteEmailTemplateResponse>(`/admin/email-templates/${id}`)
|
|
||||||
|
|
||||||
export function parseEmailTemplateResponse(
|
|
||||||
response:
|
|
||||||
| Awaited<ReturnType<typeof getEmailTemplateBySlug>>
|
|
||||||
| Awaited<ReturnType<typeof updateEmailTemplate>>
|
|
||||||
| Awaited<ReturnType<typeof createEmailTemplate>>,
|
|
||||||
): EmailTemplate | null {
|
|
||||||
return normalizeEmailTemplate(response.data?.data)
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import http from "./http"
|
import http from "./http"
|
||||||
|
|
||||||
export type UploadMediaType = "image" | "audio" | "video" | "pdf"
|
export type UploadMediaType = "image" | "audio" | "video"
|
||||||
export type UploadProvider = "MINIO" | "VIMEO"
|
export type UploadProvider = "MINIO" | "VIMEO"
|
||||||
|
|
||||||
export interface UploadMediaResponse {
|
export interface UploadMediaResponse {
|
||||||
|
|
@ -121,8 +121,6 @@ export const uploadVideoFile = (fileOrUrl: File | string, options?: UploadMediaO
|
||||||
})
|
})
|
||||||
: uploadMediaFile("video", fileOrUrl, options)
|
: uploadMediaFile("video", fileOrUrl, options)
|
||||||
|
|
||||||
export const uploadPdfFile = (file: File) => uploadMediaFile("pdf", file)
|
|
||||||
|
|
||||||
export const resolveFileUrl = (key: string) =>
|
export const resolveFileUrl = (key: string) =>
|
||||||
http.get<ResolveFileUrlResponse>("/files/url", {
|
http.get<ResolveFileUrlResponse>("/files/url", {
|
||||||
params: { key },
|
params: { key },
|
||||||
|
|
|
||||||
|
|
@ -59,9 +59,7 @@ const isAuthEndpointRequest = (url?: string) => {
|
||||||
return (
|
return (
|
||||||
url.includes("/team/login") ||
|
url.includes("/team/login") ||
|
||||||
url.includes("/team/google-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,125 +1,35 @@
|
||||||
import http from "./http"
|
import http from "./http";
|
||||||
import type {
|
import type { GetNotificationsResponse, UnreadCountResponse } from "../types/notification.types";
|
||||||
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) =>
|
export const getNotifications = (limit = 10, offset = 0) =>
|
||||||
http.get<unknown>("/notifications", { params: { limit, offset } }).then((res) => ({
|
http.get<GetNotificationsResponse>("/notifications", {
|
||||||
...res,
|
params: { limit, offset },
|
||||||
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 = () =>
|
export const getUnreadCount = () =>
|
||||||
http.get<unknown>("/notifications/unread").then((res) => ({
|
http.get<UnreadCountResponse>("/notifications/unread");
|
||||||
...res,
|
|
||||||
data: parseUnreadCount(res.data),
|
|
||||||
}))
|
|
||||||
|
|
||||||
export const markAsRead = (id: string) =>
|
export const markAsRead = (id: string) =>
|
||||||
http.patch(`/notifications/${id}/read`)
|
http.patch(`/notifications/${id}/read`);
|
||||||
|
|
||||||
export const markAsUnread = (id: string) =>
|
export const markAsUnread = (id: string) =>
|
||||||
http.patch(`/notifications/${id}/unread`)
|
http.patch(`/notifications/${id}/unread`);
|
||||||
|
|
||||||
export const markAllRead = () =>
|
export const markAllRead = () =>
|
||||||
http.post("/notifications/mark-all-read")
|
http.post("/notifications/mark-all-read");
|
||||||
|
|
||||||
export const markAllUnread = () =>
|
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 }) =>
|
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) =>
|
export const sendBulkEmail = (formData: FormData) =>
|
||||||
http.post("/notifications/bulk-email", formData, {
|
http.post("/notifications/bulk-email", formData, {
|
||||||
headers: { "Content-Type": "multipart/form-data" },
|
headers: { "Content-Type": "multipart/form-data" },
|
||||||
})
|
});
|
||||||
|
|
||||||
export const sendBulkPush = (formData: FormData) =>
|
export const sendBulkPush = (formData: FormData) =>
|
||||||
http.post("/notifications/bulk-push", formData, {
|
http.post("/notifications/bulk-push", formData, {
|
||||||
headers: { "Content-Type": "multipart/form-data" },
|
headers: { "Content-Type": "multipart/form-data" },
|
||||||
})
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,98 +0,0 @@
|
||||||
import http from "./http"
|
|
||||||
import type { GetPaymentsParams, Payment, PaymentsListData, PaymentsListResponse } from "../types/payment.types"
|
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
||||||
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizePayment(raw: unknown): Payment | null {
|
|
||||||
if (!isRecord(raw)) return null
|
|
||||||
const id = Number(raw.id)
|
|
||||||
if (!Number.isFinite(id)) return null
|
|
||||||
|
|
||||||
const paid_at = raw.paid_at
|
|
||||||
const expires_at = raw.expires_at
|
|
||||||
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
user_id: Number(raw.user_id ?? 0),
|
|
||||||
plan_id: Number(raw.plan_id ?? 0),
|
|
||||||
subscription_id: Number(raw.subscription_id ?? 0),
|
|
||||||
session_id: String(raw.session_id ?? ""),
|
|
||||||
transaction_id: String(raw.transaction_id ?? ""),
|
|
||||||
nonce: String(raw.nonce ?? ""),
|
|
||||||
amount: Number(raw.amount ?? 0),
|
|
||||||
currency: String(raw.currency ?? "ETB"),
|
|
||||||
payment_method: String(raw.payment_method ?? ""),
|
|
||||||
status: String(raw.status ?? ""),
|
|
||||||
payment_url: String(raw.payment_url ?? ""),
|
|
||||||
plan_name: String(raw.plan_name ?? ""),
|
|
||||||
plan_category: String(raw.plan_category ?? ""),
|
|
||||||
user_email: String(raw.user_email ?? ""),
|
|
||||||
user_first_name: String(raw.user_first_name ?? ""),
|
|
||||||
user_last_name: String(raw.user_last_name ?? ""),
|
|
||||||
paid_at: paid_at == null || paid_at === "" ? null : String(paid_at),
|
|
||||||
expires_at: expires_at == null || expires_at === "" ? null : String(expires_at),
|
|
||||||
created_at: String(raw.created_at ?? ""),
|
|
||||||
updated_at: String(raw.updated_at ?? ""),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parsePaymentsList(body: unknown): PaymentsListData {
|
|
||||||
const empty: PaymentsListData = {
|
|
||||||
payments: [],
|
|
||||||
total_count: 0,
|
|
||||||
limit: 0,
|
|
||||||
offset: 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isRecord(body)) {
|
|
||||||
const data = body.data
|
|
||||||
if (isRecord(data) && Array.isArray(data.payments)) {
|
|
||||||
const payments = data.payments
|
|
||||||
.map(normalizePayment)
|
|
||||||
.filter((p): p is Payment => p !== null)
|
|
||||||
const total_count = Number(data.total_count ?? payments.length)
|
|
||||||
const limit = Number(data.limit ?? payments.length)
|
|
||||||
const offset = Number(data.offset ?? 0)
|
|
||||||
return {
|
|
||||||
payments,
|
|
||||||
total_count: Number.isFinite(total_count) ? total_count : payments.length,
|
|
||||||
limit: Number.isFinite(limit) ? limit : payments.length,
|
|
||||||
offset: Number.isFinite(offset) ? offset : 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (Array.isArray(data)) {
|
|
||||||
const payments = data.map(normalizePayment).filter((p): p is Payment => p !== null)
|
|
||||||
return { payments, total_count: payments.length, limit: payments.length, offset: 0 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Array.isArray(body)) {
|
|
||||||
const payments = body.map(normalizePayment).filter((p): p is Payment => p !== null)
|
|
||||||
return { payments, total_count: payments.length, limit: payments.length, offset: 0 }
|
|
||||||
}
|
|
||||||
|
|
||||||
return empty
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildQueryParams(params: GetPaymentsParams): Record<string, string | number> {
|
|
||||||
const query: Record<string, string | number> = {
|
|
||||||
limit: Math.min(100, Math.max(1, params.limit ?? 20)),
|
|
||||||
offset: Math.max(0, params.offset ?? 0),
|
|
||||||
}
|
|
||||||
if (params.status?.trim()) query.status = params.status.trim()
|
|
||||||
if (params.provider?.trim()) query.provider = params.provider.trim()
|
|
||||||
if (params.plan_category?.trim()) query.plan_category = params.plan_category.trim()
|
|
||||||
return query
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getPayments = (params: GetPaymentsParams = {}) =>
|
|
||||||
http.get<PaymentsListResponse>("/admin/payments", { params: buildQueryParams(params) }).then((res) => {
|
|
||||||
const parsed = parsePaymentsList(res.data)
|
|
||||||
return {
|
|
||||||
...res,
|
|
||||||
data: parsed,
|
|
||||||
message: isRecord(res.data) ? String(res.data.message ?? "") : undefined,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
import http from "./http"
|
|
||||||
import type { GetPersonasParams, GetPersonasResponse } from "../types/persona.types"
|
|
||||||
|
|
||||||
/** GET /personas — list personas (filter active client-side when needed). */
|
|
||||||
export const getPersonas = (params?: GetPersonasParams) =>
|
|
||||||
http.get<GetPersonasResponse>("/personas", { params })
|
|
||||||
|
|
@ -1,360 +0,0 @@
|
||||||
import http from "./http"
|
|
||||||
import type {
|
|
||||||
DynamicElementDefinition,
|
|
||||||
QuestionComponentCatalog,
|
|
||||||
QuestionTypeDefinition,
|
|
||||||
QuestionTypeDefinitionCreatePayload,
|
|
||||||
QuestionTypeDefinitionUpdatePayload,
|
|
||||||
QuestionTypeDefinitionValidatePayload,
|
|
||||||
ValidateQuestionTypeDefinitionResult,
|
|
||||||
} from "../types/questionTypeDefinition.types"
|
|
||||||
|
|
||||||
interface ApiEnvelope<T> {
|
|
||||||
message?: string
|
|
||||||
data?: T
|
|
||||||
/** Some routes use PascalCase in JSON */
|
|
||||||
Data?: T
|
|
||||||
success?: boolean
|
|
||||||
status_code?: number
|
|
||||||
error?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reads the inner payload from a typical API envelope, or the raw body.
|
|
||||||
* Supports `data` / `Data` and list bodies where `res.data` is already an array
|
|
||||||
* (e.g. GET /questions/type-definitions → `data: [ { ID, Key, … }, … ]`).
|
|
||||||
*/
|
|
||||||
export function unwrapApiPayload(res: { data?: unknown }): unknown {
|
|
||||||
const body = res.data
|
|
||||||
if (body === null || body === undefined) return undefined
|
|
||||||
if (Array.isArray(body)) return body
|
|
||||||
if (typeof body !== "object") return body
|
|
||||||
const o = body as Record<string, unknown>
|
|
||||||
if ("data" in o || "Data" in o) {
|
|
||||||
const inner = o.data ?? o.Data
|
|
||||||
return inner
|
|
||||||
}
|
|
||||||
return body
|
|
||||||
}
|
|
||||||
|
|
||||||
function fromStringArray(arr: unknown): string[] {
|
|
||||||
return Array.isArray(arr)
|
|
||||||
? arr.filter((k): k is string => typeof k === "string" && k.length > 0)
|
|
||||||
: []
|
|
||||||
}
|
|
||||||
|
|
||||||
function sortUniqueStrings(values: string[]): string[] {
|
|
||||||
return [...new Set(values)].sort((a, b) => a.localeCompare(b))
|
|
||||||
}
|
|
||||||
|
|
||||||
const emptyCatalog = (): QuestionComponentCatalog => ({
|
|
||||||
stimulus_component_kinds: [],
|
|
||||||
response_component_kinds: [],
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse GET /questions/component-catalog body.
|
|
||||||
* Canonical shape: `data.stimulus_component_kinds` + `data.response_component_kinds`.
|
|
||||||
*/
|
|
||||||
export function parseComponentCatalog(payload: unknown): QuestionComponentCatalog {
|
|
||||||
if (!payload || typeof payload !== "object") return emptyCatalog()
|
|
||||||
const d = payload as Record<string, unknown>
|
|
||||||
|
|
||||||
if (
|
|
||||||
Array.isArray(d.stimulus_component_kinds) ||
|
|
||||||
Array.isArray(d.response_component_kinds)
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
stimulus_component_kinds: sortUniqueStrings(fromStringArray(d.stimulus_component_kinds)),
|
|
||||||
response_component_kinds: sortUniqueStrings(fromStringArray(d.response_component_kinds)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (d.data !== undefined && d.data !== null && typeof d.data === "object") {
|
|
||||||
const inner = parseComponentCatalog(d.data)
|
|
||||||
if (
|
|
||||||
inner.stimulus_component_kinds.length > 0 ||
|
|
||||||
inner.response_component_kinds.length > 0
|
|
||||||
) {
|
|
||||||
return inner
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const sk = fromStringArray(d.stimulus_kinds)
|
|
||||||
const rk = fromStringArray(d.response_kinds)
|
|
||||||
if (sk.length || rk.length) {
|
|
||||||
return {
|
|
||||||
stimulus_component_kinds: sortUniqueStrings(sk),
|
|
||||||
response_component_kinds: sortUniqueStrings(rk),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const mergedFlat = sortUniqueStrings([
|
|
||||||
...fromStringArray(d.kinds),
|
|
||||||
...fromStringArray(d.codes),
|
|
||||||
...fromStringArray(d.component_kinds),
|
|
||||||
])
|
|
||||||
if (mergedFlat.length) {
|
|
||||||
return {
|
|
||||||
stimulus_component_kinds: mergedFlat,
|
|
||||||
response_component_kinds: [...mergedFlat],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Array.isArray(payload)) {
|
|
||||||
const merged = sortUniqueStrings(fromStringArray(payload))
|
|
||||||
return {
|
|
||||||
stimulus_component_kinds: merged,
|
|
||||||
response_component_kinds: [...merged],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return emptyCatalog()
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getQuestionComponentCatalog(): Promise<QuestionComponentCatalog> {
|
|
||||||
const res = await http.get<ApiEnvelope<unknown>>("/questions/component-catalog")
|
|
||||||
const raw = unwrapApiPayload(res) ?? res.data
|
|
||||||
return parseComponentCatalog(raw)
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseInnerValidFlag(inner: unknown): boolean | undefined {
|
|
||||||
if (inner == null || typeof inner !== "object") return undefined
|
|
||||||
const o = inner as Record<string, unknown>
|
|
||||||
const v = o.valid ?? o.Valid
|
|
||||||
if (typeof v === "boolean") return v
|
|
||||||
if (v === "true" || v === 1) return true
|
|
||||||
if (v === "false" || v === 0) return false
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /questions/validate-question-type-definition
|
|
||||||
* Success: 200 with `data.valid` (envelope `success` may still be false).
|
|
||||||
* Invalid: 400 with `message` / `error` on the JSON body (axios throws).
|
|
||||||
*/
|
|
||||||
export async function validateQuestionTypeDefinition(
|
|
||||||
body: QuestionTypeDefinitionValidatePayload,
|
|
||||||
): Promise<ValidateQuestionTypeDefinitionResult> {
|
|
||||||
try {
|
|
||||||
const res = await http.post<ApiEnvelope<unknown>>("/questions/validate-question-type-definition", body)
|
|
||||||
const envelope = res.data as ApiEnvelope<unknown>
|
|
||||||
const inner = unwrapApiPayload(res)
|
|
||||||
const validFlag = parseInnerValidFlag(inner)
|
|
||||||
if (validFlag === true) {
|
|
||||||
return { valid: true, message: envelope?.message }
|
|
||||||
}
|
|
||||||
const envErr =
|
|
||||||
typeof envelope?.error === "string"
|
|
||||||
? envelope.error
|
|
||||||
: envelope && typeof envelope === "object" && "Error" in envelope
|
|
||||||
? String((envelope as { Error?: unknown }).Error)
|
|
||||||
: undefined
|
|
||||||
return {
|
|
||||||
valid: false,
|
|
||||||
message: envelope?.message,
|
|
||||||
error:
|
|
||||||
envErr ||
|
|
||||||
(validFlag === false ? "Definition is not valid." : "Validation response did not include a valid flag."),
|
|
||||||
}
|
|
||||||
} catch (e: unknown) {
|
|
||||||
const err = e as { response?: { data?: { message?: string; error?: string } } }
|
|
||||||
const d = err.response?.data
|
|
||||||
return {
|
|
||||||
valid: false,
|
|
||||||
message: d?.message,
|
|
||||||
error: d?.error || d?.message || "Validation request failed",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createQuestionTypeDefinition(body: QuestionTypeDefinitionCreatePayload) {
|
|
||||||
return http.post<ApiEnvelope<unknown>>("/questions/type-definitions", body)
|
|
||||||
}
|
|
||||||
|
|
||||||
function asStr(v: unknown): string {
|
|
||||||
if (v == null || v === undefined) return ""
|
|
||||||
return String(v)
|
|
||||||
}
|
|
||||||
|
|
||||||
function asStringArray(v: unknown): string[] {
|
|
||||||
if (!Array.isArray(v)) return []
|
|
||||||
return v.filter((x): x is string => typeof x === "string" && x.length > 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeSchemaRows(v: unknown): DynamicElementDefinition[] {
|
|
||||||
if (!Array.isArray(v)) return []
|
|
||||||
return v.map((row) => {
|
|
||||||
if (!row || typeof row !== "object") {
|
|
||||||
return { id: "", kind: "", required: false, label: undefined, config: undefined }
|
|
||||||
}
|
|
||||||
const r = row as Record<string, unknown>
|
|
||||||
const id = asStr(r.id ?? r.Id)
|
|
||||||
const kind = asStr(r.kind ?? r.Kind)
|
|
||||||
const labelRaw = r.label ?? r.Label
|
|
||||||
const config = (r.config ?? r.Config) as Record<string, unknown> | undefined
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
kind,
|
|
||||||
label: labelRaw != null && labelRaw !== "" ? asStr(labelRaw) : undefined,
|
|
||||||
required: Boolean(r.required ?? r.Required),
|
|
||||||
config: config && typeof config === "object" && !Array.isArray(config) ? config : undefined,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Maps GET/POST definition objects from PascalCase (ID, Key, StimulusSchema, …)
|
|
||||||
* or snake_case into {@link QuestionTypeDefinition}.
|
|
||||||
*/
|
|
||||||
export function normalizeTypeDefinitionFromApi(raw: unknown): QuestionTypeDefinition | null {
|
|
||||||
if (!raw || typeof raw !== "object") return null
|
|
||||||
const o = raw as Record<string, unknown>
|
|
||||||
const id = Number(o.ID ?? o.id)
|
|
||||||
if (!Number.isFinite(id)) return null
|
|
||||||
const statusRaw = asStr(o.Status ?? o.status).toUpperCase()
|
|
||||||
const status = statusRaw === "INACTIVE" ? "INACTIVE" : "ACTIVE"
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
key: asStr(o.Key ?? o.key),
|
|
||||||
display_name: asStr(
|
|
||||||
o.DisplayName ?? o.display_name ?? o.displayName ?? o.Display_Name,
|
|
||||||
),
|
|
||||||
description: (() => {
|
|
||||||
const d = o.Description ?? o.description
|
|
||||||
if (d == null) return null
|
|
||||||
const s = asStr(d)
|
|
||||||
return s === "" ? null : s
|
|
||||||
})(),
|
|
||||||
stimulus_component_kinds: asStringArray(o.StimulusComponentKinds ?? o.stimulus_component_kinds),
|
|
||||||
response_component_kinds: asStringArray(o.ResponseComponentKinds ?? o.response_component_kinds),
|
|
||||||
stimulus_schema: normalizeSchemaRows(o.StimulusSchema ?? o.stimulus_schema),
|
|
||||||
response_schema: normalizeSchemaRows(o.ResponseSchema ?? o.response_schema),
|
|
||||||
status,
|
|
||||||
is_system: Boolean(o.IsSystem ?? o.is_system),
|
|
||||||
created_at: o.CreatedAt != null ? asStr(o.CreatedAt) : o.created_at != null ? asStr(o.created_at) : undefined,
|
|
||||||
updated_at: o.UpdatedAt != null ? asStr(o.UpdatedAt) : o.updated_at != null ? asStr(o.updated_at) : undefined,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Label for selects: API `DisplayName` (stored as `display_name`), then key, then id. */
|
|
||||||
export function questionTypeDefinitionListLabel(def: QuestionTypeDefinition): string {
|
|
||||||
const name = def.display_name?.trim()
|
|
||||||
if (name) return name
|
|
||||||
const k = def.key?.trim()
|
|
||||||
if (k) return k
|
|
||||||
return `Type #${def.id}`
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Definition id from POST create or PUT update (`data.ID`, `data.id`, or PascalCase `Id`).
|
|
||||||
* Example update: `{ "data": { "id": 6 } }`.
|
|
||||||
*/
|
|
||||||
export function extractDefinitionMutationId(res: { data?: unknown }): number | undefined {
|
|
||||||
const data = unwrapApiPayload(res)
|
|
||||||
if (!data || typeof data !== "object" || Array.isArray(data)) return undefined
|
|
||||||
const o = data as Record<string, unknown>
|
|
||||||
const id = Number(o.ID ?? o.id ?? o.Id)
|
|
||||||
return Number.isFinite(id) && id > 0 ? id : undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @deprecated use extractDefinitionMutationId */
|
|
||||||
export const extractCreatedDefinitionId = extractDefinitionMutationId
|
|
||||||
|
|
||||||
export interface QuestionTypeDefinitionsListParams {
|
|
||||||
include_system?: boolean
|
|
||||||
status?: string
|
|
||||||
limit?: number
|
|
||||||
offset?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface QuestionTypeDefinitionsListResult {
|
|
||||||
definitions: QuestionTypeDefinition[]
|
|
||||||
total_count?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseListTotalCount(body: unknown): number | undefined {
|
|
||||||
if (!body || typeof body !== "object" || Array.isArray(body)) return undefined
|
|
||||||
const o = body as Record<string, unknown>
|
|
||||||
|
|
||||||
const direct = Number(o.total_count ?? o.TotalCount ?? o.totalCount)
|
|
||||||
if (Number.isFinite(direct) && direct >= 0) return direct
|
|
||||||
|
|
||||||
const meta = o.metadata ?? o.Metadata
|
|
||||||
if (meta && typeof meta === "object" && !Array.isArray(meta)) {
|
|
||||||
const m = meta as Record<string, unknown>
|
|
||||||
const fromMeta = Number(m.total_count ?? m.TotalCount ?? m.totalCount)
|
|
||||||
if (Number.isFinite(fromMeta) && fromMeta >= 0) return fromMeta
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = o.data ?? o.Data
|
|
||||||
if (data && typeof data === "object" && !Array.isArray(data)) {
|
|
||||||
return parseListTotalCount(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseDefinitionsList(payload: unknown): QuestionTypeDefinition[] {
|
|
||||||
if (!payload) return []
|
|
||||||
if (Array.isArray(payload)) {
|
|
||||||
return payload
|
|
||||||
.map((item) => normalizeTypeDefinitionFromApi(item))
|
|
||||||
.filter((x): x is QuestionTypeDefinition => x != null)
|
|
||||||
}
|
|
||||||
if (typeof payload === "object" && payload !== null) {
|
|
||||||
const o = payload as Record<string, unknown>
|
|
||||||
const inner =
|
|
||||||
o.question_type_definitions ??
|
|
||||||
o.QuestionTypeDefinitions ??
|
|
||||||
o.definitions ??
|
|
||||||
o.items ??
|
|
||||||
o.rows ??
|
|
||||||
o.Definitions
|
|
||||||
if (Array.isArray(inner)) return parseDefinitionsList(inner)
|
|
||||||
if (inner && typeof inner === "object" && !Array.isArray(inner)) return parseDefinitionsList(inner)
|
|
||||||
const data = o.data ?? o.Data
|
|
||||||
if (Array.isArray(data)) return parseDefinitionsList(data)
|
|
||||||
if (data && typeof data === "object") return parseDefinitionsList(data)
|
|
||||||
const single = normalizeTypeDefinitionFromApi(payload)
|
|
||||||
return single ? [single] : []
|
|
||||||
}
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getQuestionTypeDefinitions(
|
|
||||||
params?: QuestionTypeDefinitionsListParams,
|
|
||||||
): Promise<QuestionTypeDefinitionsListResult> {
|
|
||||||
const res = await http.get<ApiEnvelope<unknown>>("/questions/type-definitions", { params })
|
|
||||||
const raw = unwrapApiPayload(res) ?? res.data
|
|
||||||
const definitions = parseDefinitionsList(raw)
|
|
||||||
const total_count = parseListTotalCount(raw) ?? parseListTotalCount(res.data)
|
|
||||||
return total_count != null ? { definitions, total_count } : { definitions }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /questions/type-definitions/:id
|
|
||||||
*
|
|
||||||
* Typical success body (axios `res.data`): envelope with nested `data` or `Data` holding the definition.
|
|
||||||
* Definition fields are often PascalCase (`ID`, `Key`, `DisplayName`, `StimulusComponentKinds`, `StimulusSchema`,
|
|
||||||
* `ResponseSchema`, `IsSystem`, `Status`, `CreatedAt`, `UpdatedAt`). Envelope `success` may be false; parsing
|
|
||||||
* does not rely on it.
|
|
||||||
*/
|
|
||||||
export async function getQuestionTypeDefinitionById(id: number): Promise<QuestionTypeDefinition | undefined> {
|
|
||||||
const res = await http.get<ApiEnvelope<unknown>>(`/questions/type-definitions/${id}`)
|
|
||||||
const fromEnvelope = unwrapApiPayload(res)
|
|
||||||
return (
|
|
||||||
normalizeTypeDefinitionFromApi(fromEnvelope) ?? normalizeTypeDefinitionFromApi(res.data) ?? undefined
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateQuestionTypeDefinition(
|
|
||||||
id: number,
|
|
||||||
body: QuestionTypeDefinitionUpdatePayload,
|
|
||||||
) {
|
|
||||||
return http.put<ApiEnvelope<unknown>>(`/questions/type-definitions/${id}`, body)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteQuestionTypeDefinition(id: number) {
|
|
||||||
return http.delete<ApiEnvelope<unknown>>(`/questions/type-definitions/${id}`)
|
|
||||||
}
|
|
||||||
|
|
@ -8,8 +8,6 @@ import type {
|
||||||
DeleteRoleResponse,
|
DeleteRoleResponse,
|
||||||
SetRolePermissionsRequest,
|
SetRolePermissionsRequest,
|
||||||
GetPermissionsResponse,
|
GetPermissionsResponse,
|
||||||
BulkRoleDeactivateResponse,
|
|
||||||
BulkRoleReactivateResponse,
|
|
||||||
} from "../types/rbac.types"
|
} from "../types/rbac.types"
|
||||||
|
|
||||||
export const getRoles = (params?: GetRolesParams) =>
|
export const getRoles = (params?: GetRolesParams) =>
|
||||||
|
|
@ -32,11 +30,3 @@ export const getAllPermissions = () =>
|
||||||
|
|
||||||
export const deleteRole = (roleId: number) =>
|
export const deleteRole = (roleId: number) =>
|
||||||
http.delete<DeleteRoleResponse>(`/rbac/roles/${roleId}`)
|
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`, {})
|
|
||||||
|
|
|
||||||
|
|
@ -1,84 +0,0 @@
|
||||||
import http from "./http"
|
|
||||||
import type {
|
|
||||||
CreateSubscriptionPlanPayload,
|
|
||||||
SubscriptionPlan,
|
|
||||||
SubscriptionPlanMutationResponse,
|
|
||||||
SubscriptionPlansListResponse,
|
|
||||||
UpdateSubscriptionPlanPayload,
|
|
||||||
} from "../types/subscription.types"
|
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
||||||
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeSubscriptionPlan(raw: unknown): SubscriptionPlan | null {
|
|
||||||
if (!isRecord(raw)) return null
|
|
||||||
const id = Number(raw.id)
|
|
||||||
if (!Number.isFinite(id)) return null
|
|
||||||
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
name: String(raw.name ?? ""),
|
|
||||||
description: String(raw.description ?? ""),
|
|
||||||
category: String(raw.category ?? ""),
|
|
||||||
duration_value: Number(raw.duration_value ?? 0),
|
|
||||||
duration_unit: String(raw.duration_unit ?? "MONTH"),
|
|
||||||
price: Number(raw.price ?? 0),
|
|
||||||
currency: String(raw.currency ?? "ETB"),
|
|
||||||
is_active: Boolean(raw.is_active ?? true),
|
|
||||||
created_at: String(raw.created_at ?? ""),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseSubscriptionPlansList(body: unknown): SubscriptionPlan[] {
|
|
||||||
if (Array.isArray(body)) {
|
|
||||||
return body.map(normalizeSubscriptionPlan).filter((p): p is SubscriptionPlan => p !== null)
|
|
||||||
}
|
|
||||||
if (isRecord(body) && Array.isArray(body.data)) {
|
|
||||||
return body.data
|
|
||||||
.map(normalizeSubscriptionPlan)
|
|
||||||
.filter((p): p is SubscriptionPlan => p !== null)
|
|
||||||
}
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseSubscriptionPlanMutation(body: unknown): SubscriptionPlan | null {
|
|
||||||
if (isRecord(body) && body.data != null) {
|
|
||||||
return normalizeSubscriptionPlan(body.data)
|
|
||||||
}
|
|
||||||
return normalizeSubscriptionPlan(body)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getSubscriptionPlans = () =>
|
|
||||||
http.get<SubscriptionPlansListResponse | SubscriptionPlan[]>("/subscription-plans").then((res) => {
|
|
||||||
const plans = parseSubscriptionPlansList(res.data)
|
|
||||||
return {
|
|
||||||
...res,
|
|
||||||
data: plans,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
function mutationResult(res: { data: unknown }) {
|
|
||||||
const plan = parseSubscriptionPlanMutation(res.data)
|
|
||||||
return {
|
|
||||||
...res,
|
|
||||||
data: plan,
|
|
||||||
message: isRecord(res.data) ? String(res.data.message ?? "") : undefined,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const createSubscriptionPlan = (payload: CreateSubscriptionPlanPayload) =>
|
|
||||||
http
|
|
||||||
.post<SubscriptionPlanMutationResponse | SubscriptionPlan>("/subscription-plans", payload)
|
|
||||||
.then(mutationResult)
|
|
||||||
|
|
||||||
export const updateSubscriptionPlan = (id: number, payload: UpdateSubscriptionPlanPayload) =>
|
|
||||||
http
|
|
||||||
.put<SubscriptionPlanMutationResponse | SubscriptionPlan>(`/subscription-plans/${id}`, payload)
|
|
||||||
.then(mutationResult)
|
|
||||||
|
|
||||||
export const deleteSubscriptionPlan = (id: number) =>
|
|
||||||
http.delete<{ message?: string }>(`/subscription-plans/${id}`).then((res) => ({
|
|
||||||
...res,
|
|
||||||
message: isRecord(res.data) ? String(res.data.message ?? "") : undefined,
|
|
||||||
}))
|
|
||||||
|
|
@ -1,14 +1,5 @@
|
||||||
import http from "./http"
|
import http from "./http"
|
||||||
import type {
|
import type {
|
||||||
AcceptInvitationRequest,
|
|
||||||
AcceptInvitationResponse,
|
|
||||||
InviteTeamMemberRequest,
|
|
||||||
InviteTeamMemberResponse,
|
|
||||||
VerifyInvitationResponse,
|
|
||||||
} from "../types/teamInvitation.types"
|
|
||||||
import type {
|
|
||||||
ChangeTeamMemberPasswordRequest,
|
|
||||||
ChangeTeamMemberPasswordResponse,
|
|
||||||
GetTeamMembersResponse,
|
GetTeamMembersResponse,
|
||||||
GetTeamMemberResponse,
|
GetTeamMemberResponse,
|
||||||
CreateTeamMemberRequest,
|
CreateTeamMemberRequest,
|
||||||
|
|
@ -34,31 +25,3 @@ export const updateTeamMemberStatus = (id: number, status: string) =>
|
||||||
|
|
||||||
export const updateTeamMember = (id: number, data: UpdateTeamMemberRequest) =>
|
export const updateTeamMember = (id: number, data: UpdateTeamMemberRequest) =>
|
||||||
http.put(`/team/members/${id}`, data)
|
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,46 +6,23 @@ import {
|
||||||
type UserSummaryResponse,
|
type UserSummaryResponse,
|
||||||
type GetDeletionRequestsParams,
|
type GetDeletionRequestsParams,
|
||||||
type GetDeletionRequestsResponse,
|
type GetDeletionRequestsResponse,
|
||||||
type UserRecentActivityResponse,
|
|
||||||
} from "../types/user.types";
|
} from "../types/user.types";
|
||||||
|
|
||||||
/** Query params for GET /users (RFC3339 for created_*; subscription_status: ACTIVE | PENDING | Unsubscribed). */
|
export const getUsers = (
|
||||||
export interface GetUsersParams {
|
page?: number,
|
||||||
page?: number
|
pageSize?: number,
|
||||||
page_size?: number
|
role?: string,
|
||||||
role?: string
|
status?: string,
|
||||||
status?: string
|
query?: 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", {
|
http.get<GetUsersResponse>("/users", {
|
||||||
params: buildGetUsersQuery(params),
|
params: {
|
||||||
|
role,
|
||||||
|
status,
|
||||||
|
query,
|
||||||
|
page,
|
||||||
|
page_size: pageSize,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export type UserStatus = "ACTIVE" | "DEACTIVATED" | "SUSPENDED" | "PENDING";
|
export type UserStatus = "ACTIVE" | "DEACTIVATED" | "SUSPENDED" | "PENDING";
|
||||||
|
|
@ -61,9 +38,6 @@ export const updateUserStatus = (payload: UpdateUserStatusRequest) =>
|
||||||
export const getUserById = (id: number) =>
|
export const getUserById = (id: number) =>
|
||||||
http.get<UserProfileResponse>(`/user/single/${id}`);
|
http.get<UserProfileResponse>(`/user/single/${id}`);
|
||||||
|
|
||||||
export const getUserRecentActivity = (userId: number) =>
|
|
||||||
http.get<UserRecentActivityResponse>(`/admin/users/${userId}/recent-activity`);
|
|
||||||
|
|
||||||
export const getMyProfile = () =>
|
export const getMyProfile = () =>
|
||||||
http.get<UserProfileResponse>("/team/me");
|
http.get<UserProfileResponse>("/team/me");
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,15 +15,15 @@ import { SpeakingPage } from "../pages/content-management/SpeakingPage";
|
||||||
import { AddVideoPage } from "../pages/content-management/AddVideoPage";
|
import { AddVideoPage } from "../pages/content-management/AddVideoPage";
|
||||||
import { AddPracticePage } from "../pages/content-management/AddPracticePage";
|
import { AddPracticePage } from "../pages/content-management/AddPracticePage";
|
||||||
import { NewContentPage } from "../pages/content-management/NewContentPage";
|
import { NewContentPage } from "../pages/content-management/NewContentPage";
|
||||||
import { ReorderContentPage } from "../pages/content-management/ReorderContentPage";
|
|
||||||
import { LearnEnglishPage } from "../pages/content-management/LearnEnglishPage";
|
import { LearnEnglishPage } from "../pages/content-management/LearnEnglishPage";
|
||||||
import { ProgramCoursesPage } from "../pages/content-management/ProgramCoursesPage";
|
import { ProgramCoursesPage } from "../pages/content-management/ProgramCoursesPage";
|
||||||
import { CourseDetailPage } from "../pages/content-management/CourseDetailPage";
|
import { CourseDetailPage } from "../pages/content-management/CourseDetailPage";
|
||||||
import { LessonPracticesPage } from "../pages/content-management/LessonPracticesPage";
|
|
||||||
import { ModuleDetailPage } from "../pages/content-management/ModuleDetailPage";
|
import { ModuleDetailPage } from "../pages/content-management/ModuleDetailPage";
|
||||||
import { AddVideoFlow } from "../pages/content-management/AddVideoFlow";
|
import { AddVideoFlow } from "../pages/content-management/AddVideoFlow";
|
||||||
import { AddPracticeFlow } from "../pages/content-management/AddPracticeFlow";
|
import { AddPracticeFlow } from "../pages/content-management/AddPracticeFlow";
|
||||||
import { CourseModuleDetailPage } from "../pages/content-management/CourseModuleDetailPage";
|
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 { ProgramTypeSelectionPage } from "../pages/content-management/ProgramTypeSelectionPage";
|
||||||
import { ProgramDetailPage } from "../pages/content-management/ProgramDetailPage";
|
import { ProgramDetailPage } from "../pages/content-management/ProgramDetailPage";
|
||||||
import { CourseManagementPage } from "../pages/content-management/CourseManagementPage";
|
import { CourseManagementPage } from "../pages/content-management/CourseManagementPage";
|
||||||
|
|
@ -33,12 +33,10 @@ import { CreateQuestionTypeFlow } from "../pages/content-management/CreateQuesti
|
||||||
import { NotFoundPage } from "../pages/NotFoundPage";
|
import { NotFoundPage } from "../pages/NotFoundPage";
|
||||||
import { NotificationsPage } from "../pages/notifications/NotificationsPage";
|
import { NotificationsPage } from "../pages/notifications/NotificationsPage";
|
||||||
import { CreateNotificationPage } from "../pages/notifications/CreateNotificationPage";
|
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 { UserDetailPage } from "../pages/user-management/UserDetailPage";
|
||||||
import { UserManagementLayout } from "../pages/user-management/UserManagementLayout";
|
import { UserManagementLayout } from "../pages/user-management/UserManagementLayout";
|
||||||
import { UsersListPage } from "../pages/user-management/UsersListPage";
|
import { UsersListPage } from "../pages/user-management/UsersListPage";
|
||||||
|
import { UserManagementDashboard } from "../pages/user-management/UserManagementDashboard";
|
||||||
import { UserGroupsPage } from "../pages/user-management/UserGroupsPage";
|
import { UserGroupsPage } from "../pages/user-management/UserGroupsPage";
|
||||||
import { DeletionRequestsPage } from "../pages/user-management/DeletionRequestsPage";
|
import { DeletionRequestsPage } from "../pages/user-management/DeletionRequestsPage";
|
||||||
import { RoleManagementLayout } from "../pages/role-management/RoleManagementLayout";
|
import { RoleManagementLayout } from "../pages/role-management/RoleManagementLayout";
|
||||||
|
|
@ -52,7 +50,6 @@ import { HumanLanguageHierarchyPage } from "../pages/content-management/HumanLan
|
||||||
import { HumanLanguageSubModulePage } from "../pages/content-management/HumanLanguageSubModulePage";
|
import { HumanLanguageSubModulePage } from "../pages/content-management/HumanLanguageSubModulePage";
|
||||||
import { UserLogPage } from "../pages/user-log/UserLogPage";
|
import { UserLogPage } from "../pages/user-log/UserLogPage";
|
||||||
import { IssuesPage } from "../pages/issues/IssuesPage";
|
import { IssuesPage } from "../pages/issues/IssuesPage";
|
||||||
import { PaymentsPage } from "../pages/payments/PaymentsPage";
|
|
||||||
import { ProfilePage } from "../pages/ProfilePage";
|
import { ProfilePage } from "../pages/ProfilePage";
|
||||||
import { SettingsPage } from "../pages/SettingsPage";
|
import { SettingsPage } from "../pages/SettingsPage";
|
||||||
import { TeamManagementPage } from "../pages/team/TeamManagementPage";
|
import { TeamManagementPage } from "../pages/team/TeamManagementPage";
|
||||||
|
|
@ -61,7 +58,6 @@ import { TeamMemberDetailPage } from "../pages/team/TeamMemberDetailPage";
|
||||||
import { LoginPage } from "../pages/auth/LoginPage";
|
import { LoginPage } from "../pages/auth/LoginPage";
|
||||||
import { ForgotPasswordPage } from "../pages/auth/ForgotPasswordPage";
|
import { ForgotPasswordPage } from "../pages/auth/ForgotPasswordPage";
|
||||||
import { VerificationPage } from "../pages/auth/VerificationPage";
|
import { VerificationPage } from "../pages/auth/VerificationPage";
|
||||||
import { AcceptInvitePage } from "../pages/auth/AcceptInvitePage";
|
|
||||||
import { AboutPage } from "../pages/AboutPage";
|
import { AboutPage } from "../pages/AboutPage";
|
||||||
import { TermsPage } from "../pages/TermsPage";
|
import { TermsPage } from "../pages/TermsPage";
|
||||||
import { PrivacyPage } from "../pages/PrivacyPage";
|
import { PrivacyPage } from "../pages/PrivacyPage";
|
||||||
|
|
@ -73,7 +69,6 @@ export function AppRoutes() {
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
|
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
|
||||||
<Route path="/verification" element={<VerificationPage />} />
|
<Route path="/verification" element={<VerificationPage />} />
|
||||||
<Route path="/accept-invite" element={<AcceptInvitePage />} />
|
|
||||||
<Route path="/about" element={<AboutPage />} />
|
<Route path="/about" element={<AboutPage />} />
|
||||||
<Route path="/terms" element={<TermsPage />} />
|
<Route path="/terms" element={<TermsPage />} />
|
||||||
<Route path="/privacy" element={<PrivacyPage />} />
|
<Route path="/privacy" element={<PrivacyPage />} />
|
||||||
|
|
@ -82,7 +77,7 @@ export function AppRoutes() {
|
||||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||||
<Route path="/dashboard" element={<DashboardPage />} />
|
<Route path="/dashboard" element={<DashboardPage />} />
|
||||||
<Route path="/users" element={<UserManagementLayout />}>
|
<Route path="/users" element={<UserManagementLayout />}>
|
||||||
<Route index element={<Navigate to="list" replace />} />
|
<Route index element={<UserManagementDashboard />} />
|
||||||
<Route path="list" element={<UsersListPage />} />
|
<Route path="list" element={<UsersListPage />} />
|
||||||
<Route path="deletion-requests" element={<DeletionRequestsPage />} />
|
<Route path="deletion-requests" element={<DeletionRequestsPage />} />
|
||||||
<Route path="groups" element={<UserGroupsPage />} />
|
<Route path="groups" element={<UserGroupsPage />} />
|
||||||
|
|
@ -167,7 +162,6 @@ export function AppRoutes() {
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
<Route path="/new-content" element={<NewContentPage />} />
|
<Route path="/new-content" element={<NewContentPage />} />
|
||||||
<Route path="/new-content/reorder" element={<ReorderContentPage />} />
|
|
||||||
<Route
|
<Route
|
||||||
path="/new-content/courses"
|
path="/new-content/courses"
|
||||||
element={<ProgramTypeSelectionPage />}
|
element={<ProgramTypeSelectionPage />}
|
||||||
|
|
@ -176,10 +170,6 @@ export function AppRoutes() {
|
||||||
path="/new-content/question-types"
|
path="/new-content/question-types"
|
||||||
element={<QuestionTypeLibraryPage />}
|
element={<QuestionTypeLibraryPage />}
|
||||||
/>
|
/>
|
||||||
<Route
|
|
||||||
path="/new-content/question-types/:definitionId/edit"
|
|
||||||
element={<CreateQuestionTypeFlow />}
|
|
||||||
/>
|
|
||||||
<Route
|
<Route
|
||||||
path="/new-content/question-types/create"
|
path="/new-content/question-types/create"
|
||||||
element={<CreateQuestionTypeFlow />}
|
element={<CreateQuestionTypeFlow />}
|
||||||
|
|
@ -189,16 +179,12 @@ export function AppRoutes() {
|
||||||
element={<ProgramDetailPage />}
|
element={<ProgramDetailPage />}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/new-content/courses/:programType/add-practice"
|
path="/new-content/courses/:programType/attach-practice"
|
||||||
element={<AddPracticeFlow />}
|
element={<AttachProgramPracticeFlow />}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/new-content/courses/:programType/:courseId/add-practice"
|
path="/new-content/courses/:programType/:courseId/unit/:unitId/module/:moduleId/attach-practice"
|
||||||
element={<AddPracticeFlow />}
|
element={<AttachPracticeFlow />}
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="/new-content/courses/:programType/:courseId/:unitId/:moduleId/add-practice"
|
|
||||||
element={<AddPracticeFlow />}
|
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/new-content/courses/:programType/:courseId"
|
path="/new-content/courses/:programType/:courseId"
|
||||||
|
|
@ -212,10 +198,6 @@ export function AppRoutes() {
|
||||||
path="/new-content/courses/:programType/:courseId/:unitId/:moduleId"
|
path="/new-content/courses/:programType/:courseId/:unitId/:moduleId"
|
||||||
element={<CourseModuleDetailPage />}
|
element={<CourseModuleDetailPage />}
|
||||||
/>
|
/>
|
||||||
<Route
|
|
||||||
path="/new-content/courses/:programType/:courseId/:unitId/:moduleId/lessons/:lessonId/practices"
|
|
||||||
element={<LessonPracticesPage />}
|
|
||||||
/>
|
|
||||||
<Route
|
<Route
|
||||||
path="/new-content/learn-english"
|
path="/new-content/learn-english"
|
||||||
element={<LearnEnglishPage />}
|
element={<LearnEnglishPage />}
|
||||||
|
|
@ -236,33 +218,16 @@ export function AppRoutes() {
|
||||||
path="/new-content/learn-english/:level/courses/:courseId/modules/:moduleId/add-video"
|
path="/new-content/learn-english/:level/courses/:courseId/modules/:moduleId/add-video"
|
||||||
element={<AddVideoFlow />}
|
element={<AddVideoFlow />}
|
||||||
/>
|
/>
|
||||||
<Route
|
|
||||||
path="/new-content/learn-english/:level/courses/:courseId/modules/:moduleId/lessons/:lessonId/practices"
|
|
||||||
element={<LessonPracticesPage />}
|
|
||||||
/>
|
|
||||||
<Route
|
<Route
|
||||||
path="/new-content/learn-english/:level/courses/add-practice"
|
path="/new-content/learn-english/:level/courses/add-practice"
|
||||||
element={<AddPracticeFlow />}
|
element={<AddPracticeFlow />}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Route path="/notifications" element={<NotificationsPage />} />
|
<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
|
<Route
|
||||||
path="/notifications/create"
|
path="/notifications/create"
|
||||||
element={<CreateNotificationPage />}
|
element={<CreateNotificationPage />}
|
||||||
/>
|
/>
|
||||||
<Route path="/payments" element={<PaymentsPage />} />
|
|
||||||
<Route path="/user-log" element={<UserLogPage />} />
|
<Route path="/user-log" element={<UserLogPage />} />
|
||||||
<Route path="/issues" element={<IssuesPage />} />
|
<Route path="/issues" element={<IssuesPage />} />
|
||||||
<Route path="/analytics" element={<AnalyticsPage />} />
|
<Route path="/analytics" element={<AnalyticsPage />} />
|
||||||
|
|
|
||||||
|
|
@ -1,272 +0,0 @@
|
||||||
import { useEffect, useRef, useState } from "react"
|
|
||||||
import { ChevronDown } from "lucide-react"
|
|
||||||
import { cn } from "../../lib/utils"
|
|
||||||
import { Input } from "../ui/input"
|
|
||||||
import { Button } from "../ui/button"
|
|
||||||
import type { DashboardFilters } from "../../types/analytics.types"
|
|
||||||
|
|
||||||
const MONTH_LABELS = [
|
|
||||||
"January",
|
|
||||||
"February",
|
|
||||||
"March",
|
|
||||||
"April",
|
|
||||||
"May",
|
|
||||||
"June",
|
|
||||||
"July",
|
|
||||||
"August",
|
|
||||||
"September",
|
|
||||||
"October",
|
|
||||||
"November",
|
|
||||||
"December",
|
|
||||||
] as const
|
|
||||||
|
|
||||||
const MIN_SELECTABLE_YEAR = 2000
|
|
||||||
|
|
||||||
export function getYearOptions(): number[] {
|
|
||||||
const currentYear = new Date().getFullYear()
|
|
||||||
const years: number[] = []
|
|
||||||
for (let year = currentYear; year >= MIN_SELECTABLE_YEAR; year--) {
|
|
||||||
years.push(year)
|
|
||||||
}
|
|
||||||
return years
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getDashboardFilterLabel(filters: DashboardFilters): string {
|
|
||||||
if (filters.mode === "year" && filters.year != null) {
|
|
||||||
return String(filters.year)
|
|
||||||
}
|
|
||||||
if (filters.mode === "year_month" && filters.year != null && filters.month != null) {
|
|
||||||
return `${MONTH_LABELS[filters.month - 1]} ${filters.year}`
|
|
||||||
}
|
|
||||||
if (filters.mode === "custom" && filters.from && filters.to) {
|
|
||||||
const from = new Date(`${filters.from}T00:00:00`)
|
|
||||||
const to = new Date(`${filters.to}T00:00:00`)
|
|
||||||
const opts: Intl.DateTimeFormatOptions = { month: "short", day: "numeric", year: "numeric" }
|
|
||||||
return `${from.toLocaleDateString("en-US", opts)} – ${to.toLocaleDateString("en-US", opts)}`
|
|
||||||
}
|
|
||||||
return "All Time"
|
|
||||||
}
|
|
||||||
|
|
||||||
type AnalyticsTimeRangeFilterProps = {
|
|
||||||
value: DashboardFilters
|
|
||||||
onChange: (filters: DashboardFilters) => void
|
|
||||||
className?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AnalyticsTimeRangeFilter({ value, onChange, className }: AnalyticsTimeRangeFilterProps) {
|
|
||||||
const [open, setOpen] = useState(false)
|
|
||||||
const [yearOpen, setYearOpen] = useState(true)
|
|
||||||
const [monthOpen, setMonthOpen] = useState(false)
|
|
||||||
const [customOpen, setCustomOpen] = useState(false)
|
|
||||||
const [contextYear, setContextYear] = useState(() => value.year ?? new Date().getFullYear())
|
|
||||||
const [customFrom, setCustomFrom] = useState(value.from ?? "")
|
|
||||||
const [customTo, setCustomTo] = useState(value.to ?? "")
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
|
||||||
|
|
||||||
const years = getYearOptions()
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (value.year != null) {
|
|
||||||
setContextYear(value.year)
|
|
||||||
}
|
|
||||||
}, [value.year])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (value.mode === "custom") {
|
|
||||||
setCustomFrom(value.from ?? "")
|
|
||||||
setCustomTo(value.to ?? "")
|
|
||||||
}
|
|
||||||
}, [value.from, value.mode, value.to])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return
|
|
||||||
|
|
||||||
const handlePointerDown = (event: MouseEvent) => {
|
|
||||||
if (!containerRef.current?.contains(event.target as Node)) {
|
|
||||||
setOpen(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener("mousedown", handlePointerDown)
|
|
||||||
return () => document.removeEventListener("mousedown", handlePointerDown)
|
|
||||||
}, [open])
|
|
||||||
|
|
||||||
const selectAllTime = () => {
|
|
||||||
onChange({ mode: "all_time" })
|
|
||||||
setOpen(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectYear = (year: number) => {
|
|
||||||
setContextYear(year)
|
|
||||||
onChange({ mode: "year", year })
|
|
||||||
setOpen(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectMonth = (month: number) => {
|
|
||||||
onChange({ mode: "year_month", year: contextYear, month })
|
|
||||||
setOpen(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const applyCustomRange = () => {
|
|
||||||
if (!customFrom || !customTo) return
|
|
||||||
onChange({ mode: "custom", from: customFrom, to: customTo })
|
|
||||||
setOpen(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={containerRef} className={cn("relative", className)}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setOpen((prev) => !prev)}
|
|
||||||
className="inline-flex items-center gap-2 rounded-lg border border-grayScale-200 bg-white px-4 py-2 text-sm font-medium text-grayScale-700 shadow-sm transition-colors hover:bg-grayScale-50"
|
|
||||||
>
|
|
||||||
Time Range
|
|
||||||
<ChevronDown className={cn("h-4 w-4 text-grayScale-400 transition-transform", open && "rotate-180")} />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{open && (
|
|
||||||
<div className="absolute right-0 z-50 mt-2 w-[220px] overflow-hidden rounded-xl border border-grayScale-100 bg-white py-2 shadow-lg">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={selectAllTime}
|
|
||||||
className={cn(
|
|
||||||
"flex w-full px-4 py-2.5 text-left text-sm transition-colors hover:bg-grayScale-50",
|
|
||||||
value.mode === "all_time" ? "font-semibold text-grayScale-900" : "text-grayScale-700",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
All Time
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className="border-t border-grayScale-100">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setYearOpen((prev) => !prev)}
|
|
||||||
className="flex w-full items-center justify-between px-4 py-2.5 text-left text-sm font-medium text-grayScale-800 hover:bg-grayScale-50"
|
|
||||||
>
|
|
||||||
Year
|
|
||||||
<ChevronDown
|
|
||||||
className={cn("h-4 w-4 text-grayScale-400 transition-transform", yearOpen && "rotate-180")}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
{yearOpen && (
|
|
||||||
<div className="max-h-[220px] overflow-y-auto pb-1">
|
|
||||||
{years.map((year) => (
|
|
||||||
<button
|
|
||||||
key={year}
|
|
||||||
type="button"
|
|
||||||
onClick={() => selectYear(year)}
|
|
||||||
className={cn(
|
|
||||||
"flex w-full px-6 py-2 text-left text-sm transition-colors hover:bg-grayScale-50",
|
|
||||||
value.mode === "year" && value.year === year
|
|
||||||
? "font-semibold text-brand-600"
|
|
||||||
: "text-grayScale-600",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{year}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border-t border-grayScale-100">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setMonthOpen((prev) => !prev)}
|
|
||||||
className="flex w-full items-center justify-between px-4 py-2.5 text-left text-sm font-medium text-grayScale-800 hover:bg-grayScale-50"
|
|
||||||
>
|
|
||||||
Month
|
|
||||||
<ChevronDown
|
|
||||||
className={cn("h-4 w-4 text-grayScale-400 transition-transform", monthOpen && "rotate-180")}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
{monthOpen && (
|
|
||||||
<div className="max-h-[260px] overflow-y-auto pb-1">
|
|
||||||
<div className="flex max-h-[88px] flex-wrap gap-1 overflow-y-auto px-4 pb-2">
|
|
||||||
{years.map((year) => (
|
|
||||||
<button
|
|
||||||
key={year}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setContextYear(year)}
|
|
||||||
className={cn(
|
|
||||||
"rounded-md px-2 py-0.5 text-[11px] font-medium transition-colors",
|
|
||||||
contextYear === year
|
|
||||||
? "bg-brand-100 text-brand-700"
|
|
||||||
: "text-grayScale-500 hover:bg-grayScale-100",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{year}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{MONTH_LABELS.map((label, index) => {
|
|
||||||
const month = index + 1
|
|
||||||
const isSelected =
|
|
||||||
value.mode === "year_month" && value.year === contextYear && value.month === month
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={label}
|
|
||||||
type="button"
|
|
||||||
onClick={() => selectMonth(month)}
|
|
||||||
className={cn(
|
|
||||||
"flex w-full px-6 py-2 text-left text-sm transition-colors hover:bg-grayScale-50",
|
|
||||||
isSelected ? "font-semibold text-brand-600" : "text-grayScale-600",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border-t border-grayScale-100">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setCustomOpen((prev) => !prev)}
|
|
||||||
className="flex w-full items-center justify-between px-4 py-2.5 text-left text-sm font-medium text-grayScale-800 hover:bg-grayScale-50"
|
|
||||||
>
|
|
||||||
Date Range
|
|
||||||
<ChevronDown
|
|
||||||
className={cn("h-4 w-4 text-grayScale-400 transition-transform", customOpen && "rotate-180")}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
{customOpen && (
|
|
||||||
<div className="space-y-2 px-4 pb-3">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<label className="text-[11px] font-medium text-grayScale-500">From</label>
|
|
||||||
<Input
|
|
||||||
type="date"
|
|
||||||
value={customFrom}
|
|
||||||
onChange={(e) => setCustomFrom(e.target.value)}
|
|
||||||
className="h-8 text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<label className="text-[11px] font-medium text-grayScale-500">To</label>
|
|
||||||
<Input
|
|
||||||
type="date"
|
|
||||||
value={customTo}
|
|
||||||
onChange={(e) => setCustomTo(e.target.value)}
|
|
||||||
className="h-8 text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
className="h-8 w-full text-xs"
|
|
||||||
disabled={!customFrom || !customTo}
|
|
||||||
onClick={applyCustomRange}
|
|
||||||
>
|
|
||||||
Apply
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,241 +0,0 @@
|
||||||
import { Plus, Trash2 } from "lucide-react"
|
|
||||||
import { Button } from "../ui/button"
|
|
||||||
import { Input } from "../ui/input"
|
|
||||||
import {
|
|
||||||
addMatchingInputRow,
|
|
||||||
addMatchingPair,
|
|
||||||
defaultMatchingAnswerFromInputs,
|
|
||||||
MATCHING_MIN_ITEMS,
|
|
||||||
parseMatchingAnswerSlotValue,
|
|
||||||
parseMatchingInputsSlotValue,
|
|
||||||
removeMatchingInputRow,
|
|
||||||
removeMatchingPair,
|
|
||||||
serializeMatchingAnswerSlotValue,
|
|
||||||
serializeMatchingInputsSlotValue,
|
|
||||||
type MatchingAnswerSlotValue,
|
|
||||||
type MatchingInputsSlotValue,
|
|
||||||
} from "../../lib/matchingSlotValue"
|
|
||||||
|
|
||||||
export function DynamicMatchingInputsSlot({
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
disabled,
|
|
||||||
slotLabel,
|
|
||||||
}: {
|
|
||||||
value: string
|
|
||||||
onChange: (next: string) => void
|
|
||||||
disabled: boolean
|
|
||||||
slotLabel: string
|
|
||||||
}) {
|
|
||||||
const parsed = parseMatchingInputsSlotValue(value)
|
|
||||||
|
|
||||||
const updateValue = (next: MatchingInputsSlotValue) => {
|
|
||||||
onChange(serializeMatchingInputsSlotValue(next))
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateSide = (
|
|
||||||
side: "left" | "right",
|
|
||||||
index: number,
|
|
||||||
text: string,
|
|
||||||
) => {
|
|
||||||
const next = {
|
|
||||||
left: [...parsed.left],
|
|
||||||
right: [...parsed.right],
|
|
||||||
}
|
|
||||||
next[side][index] = { ...next[side][index], text }
|
|
||||||
updateValue(next)
|
|
||||||
}
|
|
||||||
|
|
||||||
const rowCount = Math.max(parsed.left.length, parsed.right.length)
|
|
||||||
const canRemove = rowCount > MATCHING_MIN_ITEMS
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
||||||
<label className="text-sm font-medium text-grayScale-700">{slotLabel}</label>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
disabled={disabled}
|
|
||||||
className="h-8 gap-1.5 rounded-lg border-brand-200 text-brand-600 hover:bg-brand-50"
|
|
||||||
onClick={() => updateValue(addMatchingInputRow(parsed))}
|
|
||||||
>
|
|
||||||
<Plus className="h-3.5 w-3.5" />
|
|
||||||
Add row
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
{Array.from({ length: rowCount }).map((_, index) => (
|
|
||||||
<div
|
|
||||||
key={`matching-row-${index}`}
|
|
||||||
className="grid gap-2 rounded-lg border border-grayScale-200 bg-grayScale-50/50 p-3 md:grid-cols-[1fr_1fr_auto]"
|
|
||||||
>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<p className="text-[10px] font-bold uppercase tracking-wide text-grayScale-400">
|
|
||||||
Left {parsed.left[index]?.id ?? `l${index + 1}`}
|
|
||||||
</p>
|
|
||||||
<Input
|
|
||||||
value={parsed.left[index]?.text ?? ""}
|
|
||||||
onChange={(e) => updateSide("left", index, e.target.value)}
|
|
||||||
placeholder={`Left item ${index + 1}`}
|
|
||||||
className="rounded-lg border-grayScale-200 bg-white"
|
|
||||||
disabled={disabled}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<p className="text-[10px] font-bold uppercase tracking-wide text-grayScale-400">
|
|
||||||
Right {parsed.right[index]?.id ?? `r${index + 1}`}
|
|
||||||
</p>
|
|
||||||
<Input
|
|
||||||
value={parsed.right[index]?.text ?? ""}
|
|
||||||
onChange={(e) => updateSide("right", index, e.target.value)}
|
|
||||||
placeholder={`Right item ${index + 1}`}
|
|
||||||
className="rounded-lg border-grayScale-200 bg-white"
|
|
||||||
disabled={disabled}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-end justify-end">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
disabled={disabled || !canRemove}
|
|
||||||
className="h-9 w-9 text-grayScale-400 hover:text-red-600 disabled:opacity-40"
|
|
||||||
aria-label={`Remove row ${index + 1}`}
|
|
||||||
onClick={() => updateValue(removeMatchingInputRow(parsed, index))}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-[11px] text-grayScale-500">
|
|
||||||
Minimum {MATCHING_MIN_ITEMS} rows on each side. Whitespace in text is preserved.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DynamicMatchingAnswerSlot({
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
disabled,
|
|
||||||
slotLabel,
|
|
||||||
matchingInputs,
|
|
||||||
}: {
|
|
||||||
value: string
|
|
||||||
onChange: (next: string) => void
|
|
||||||
disabled: boolean
|
|
||||||
slotLabel: string
|
|
||||||
matchingInputs: MatchingInputsSlotValue | null
|
|
||||||
}) {
|
|
||||||
const parsed = parseMatchingAnswerSlotValue(value, matchingInputs)
|
|
||||||
|
|
||||||
const updateValue = (next: MatchingAnswerSlotValue) => {
|
|
||||||
onChange(serializeMatchingAnswerSlotValue(next))
|
|
||||||
}
|
|
||||||
|
|
||||||
const leftOptions = matchingInputs?.left ?? []
|
|
||||||
const rightOptions = matchingInputs?.right ?? []
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
||||||
<label className="text-sm font-medium text-grayScale-700">{slotLabel}</label>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{matchingInputs ? (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
disabled={disabled}
|
|
||||||
className="h-8 rounded-lg"
|
|
||||||
onClick={() =>
|
|
||||||
updateValue(defaultMatchingAnswerFromInputs(matchingInputs))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Reset from inputs
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
disabled={disabled}
|
|
||||||
className="h-8 gap-1.5 rounded-lg border-brand-200 text-brand-600 hover:bg-brand-50"
|
|
||||||
onClick={() => updateValue(addMatchingPair(parsed, matchingInputs))}
|
|
||||||
>
|
|
||||||
<Plus className="h-3.5 w-3.5" />
|
|
||||||
Add pair
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!matchingInputs ? (
|
|
||||||
<p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-900">
|
|
||||||
Fill in matching inputs first so answer pairs can reference left and right ids.
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
{parsed.pairs.map((pair, index) => (
|
|
||||||
<div
|
|
||||||
key={`pair-${index}-${pair.left_id}-${pair.right_id}`}
|
|
||||||
className="flex flex-wrap items-center gap-2"
|
|
||||||
>
|
|
||||||
<select
|
|
||||||
value={pair.left_id}
|
|
||||||
disabled={disabled || leftOptions.length === 0}
|
|
||||||
onChange={(e) => {
|
|
||||||
const pairs = [...parsed.pairs]
|
|
||||||
pairs[index] = { ...pairs[index], left_id: e.target.value }
|
|
||||||
updateValue({ pairs })
|
|
||||||
}}
|
|
||||||
className="h-10 min-w-[120px] rounded-lg border border-grayScale-200 bg-white px-3 text-sm"
|
|
||||||
>
|
|
||||||
{leftOptions.map((item) => (
|
|
||||||
<option key={item.id} value={item.id}>
|
|
||||||
{item.id}
|
|
||||||
{item.text ? `: ${item.text.slice(0, 40)}` : ""}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<span className="text-sm text-grayScale-400">→</span>
|
|
||||||
<select
|
|
||||||
value={pair.right_id}
|
|
||||||
disabled={disabled || rightOptions.length === 0}
|
|
||||||
onChange={(e) => {
|
|
||||||
const pairs = [...parsed.pairs]
|
|
||||||
pairs[index] = { ...pairs[index], right_id: e.target.value }
|
|
||||||
updateValue({ pairs })
|
|
||||||
}}
|
|
||||||
className="h-10 min-w-[120px] rounded-lg border border-grayScale-200 bg-white px-3 text-sm"
|
|
||||||
>
|
|
||||||
{rightOptions.map((item) => (
|
|
||||||
<option key={item.id} value={item.id}>
|
|
||||||
{item.id}
|
|
||||||
{item.text ? `: ${item.text.slice(0, 40)}` : ""}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
disabled={disabled || parsed.pairs.length <= 1}
|
|
||||||
className="h-8 w-8 shrink-0 text-grayScale-400 hover:text-red-600 disabled:opacity-40"
|
|
||||||
aria-label={`Remove pair ${index + 1}`}
|
|
||||||
onClick={() => updateValue(removeMatchingPair(parsed, index))}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,275 +0,0 @@
|
||||||
import { Plus, Trash2 } from "lucide-react"
|
|
||||||
import { Button } from "../ui/button"
|
|
||||||
import { Input } from "../ui/input"
|
|
||||||
import { Textarea } from "../ui/textarea"
|
|
||||||
import {
|
|
||||||
addBlankSegment,
|
|
||||||
addTextSegment,
|
|
||||||
addWordBankItem,
|
|
||||||
defaultSelectMissingWordsResponseFromStimulus,
|
|
||||||
parseSelectMissingWordsResponseSlotValue,
|
|
||||||
parseSelectMissingWordsStimulusSlotValue,
|
|
||||||
removeSegment,
|
|
||||||
removeWordBankItem,
|
|
||||||
SELECT_MISSING_WORDS_MIN_BANK,
|
|
||||||
serializeSelectMissingWordsResponseSlotValue,
|
|
||||||
serializeSelectMissingWordsStimulusSlotValue,
|
|
||||||
setAllowReuse,
|
|
||||||
syncResponseBlanksWithStimulus,
|
|
||||||
updateTextSegment,
|
|
||||||
updateWordBankText,
|
|
||||||
type SelectMissingWordsStimulusValue,
|
|
||||||
} from "../../lib/selectMissingWordsSlotValue"
|
|
||||||
|
|
||||||
export function DynamicSelectMissingWordsStimulusSlot({
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
disabled,
|
|
||||||
slotLabel,
|
|
||||||
}: {
|
|
||||||
value: string
|
|
||||||
onChange: (next: string) => void
|
|
||||||
disabled: boolean
|
|
||||||
slotLabel: string
|
|
||||||
}) {
|
|
||||||
const parsed = parseSelectMissingWordsStimulusSlotValue(value)
|
|
||||||
|
|
||||||
const updateValue = (next: SelectMissingWordsStimulusValue) => {
|
|
||||||
onChange(serializeSelectMissingWordsStimulusSlotValue(next))
|
|
||||||
}
|
|
||||||
|
|
||||||
const canRemoveWord = parsed.word_bank.length > SELECT_MISSING_WORDS_MIN_BANK
|
|
||||||
const canRemoveSegment = parsed.segments.length > 1
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-5">
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
||||||
<label className="text-sm font-medium text-grayScale-700">{slotLabel}</label>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
disabled={disabled}
|
|
||||||
className="h-8 gap-1.5 rounded-lg border-brand-200 text-brand-600 hover:bg-brand-50"
|
|
||||||
onClick={() => updateValue(addTextSegment(parsed))}
|
|
||||||
>
|
|
||||||
<Plus className="h-3.5 w-3.5" />
|
|
||||||
Add text
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
disabled={disabled}
|
|
||||||
className="h-8 gap-1.5 rounded-lg border-brand-200 text-brand-600 hover:bg-brand-50"
|
|
||||||
onClick={() => updateValue(addBlankSegment(parsed))}
|
|
||||||
>
|
|
||||||
<Plus className="h-3.5 w-3.5" />
|
|
||||||
Add blank
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<p className="text-[10px] font-bold uppercase tracking-wide text-grayScale-400">
|
|
||||||
Passage segments
|
|
||||||
</p>
|
|
||||||
{parsed.segments.map((segment, index) => (
|
|
||||||
<div
|
|
||||||
key={`segment-${index}-${segment.type === "blank" ? segment.id : "text"}`}
|
|
||||||
className="flex flex-wrap items-start gap-2 rounded-lg border border-grayScale-200 bg-grayScale-50/50 p-3"
|
|
||||||
>
|
|
||||||
{segment.type === "text" ? (
|
|
||||||
<div className="min-w-0 flex-1 space-y-1">
|
|
||||||
<p className="text-[10px] font-bold uppercase tracking-wide text-grayScale-400">
|
|
||||||
Text
|
|
||||||
</p>
|
|
||||||
<Textarea
|
|
||||||
rows={2}
|
|
||||||
value={segment.value}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateValue(updateTextSegment(parsed, index, e.target.value))
|
|
||||||
}
|
|
||||||
placeholder="Text before or after a blank"
|
|
||||||
className="min-h-[56px] resize-y rounded-lg border-grayScale-200 bg-white font-mono text-sm"
|
|
||||||
disabled={disabled}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex min-h-[56px] flex-1 items-center rounded-lg border border-dashed border-brand-200 bg-brand-50/40 px-3">
|
|
||||||
<span className="text-sm font-medium text-brand-700">
|
|
||||||
Blank {segment.id}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
disabled={disabled || !canRemoveSegment}
|
|
||||||
className="h-9 w-9 shrink-0 text-grayScale-400 hover:text-red-600 disabled:opacity-40"
|
|
||||||
aria-label={`Remove segment ${index + 1}`}
|
|
||||||
onClick={() => updateValue(removeSegment(parsed, index))}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
||||||
<p className="text-[10px] font-bold uppercase tracking-wide text-grayScale-400">
|
|
||||||
Word bank
|
|
||||||
</p>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
disabled={disabled}
|
|
||||||
className="h-8 gap-1.5 rounded-lg border-brand-200 text-brand-600 hover:bg-brand-50"
|
|
||||||
onClick={() => updateValue(addWordBankItem(parsed))}
|
|
||||||
>
|
|
||||||
<Plus className="h-3.5 w-3.5" />
|
|
||||||
Add word
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{parsed.word_bank.map((item, index) => (
|
|
||||||
<div
|
|
||||||
key={`word-${item.id}`}
|
|
||||||
className="flex flex-wrap items-center gap-2"
|
|
||||||
>
|
|
||||||
<span className="w-10 text-xs font-medium text-grayScale-500">{item.id}</span>
|
|
||||||
<Input
|
|
||||||
value={item.text}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateValue(updateWordBankText(parsed, index, e.target.value))
|
|
||||||
}
|
|
||||||
placeholder={`Word ${index + 1}`}
|
|
||||||
className="h-10 flex-1 rounded-lg border-grayScale-200 bg-white"
|
|
||||||
disabled={disabled}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
disabled={disabled || !canRemoveWord}
|
|
||||||
className="h-9 w-9 shrink-0 text-grayScale-400 hover:text-red-600 disabled:opacity-40"
|
|
||||||
aria-label={`Remove word ${item.id}`}
|
|
||||||
onClick={() => updateValue(removeWordBankItem(parsed, index))}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label className="flex items-center gap-2 text-sm text-grayScale-700">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={parsed.allow_reuse}
|
|
||||||
disabled={disabled}
|
|
||||||
onChange={(e) => updateValue(setAllowReuse(parsed, e.target.checked))}
|
|
||||||
className="h-4 w-4 rounded border-grayScale-300"
|
|
||||||
/>
|
|
||||||
Allow word reuse across blanks
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<p className="text-[11px] text-grayScale-500">
|
|
||||||
Minimum {SELECT_MISSING_WORDS_MIN_BANK} words in the bank and at least one blank.
|
|
||||||
Whitespace in text is preserved.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DynamicSelectMissingWordsAnswerSlot({
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
disabled,
|
|
||||||
slotLabel,
|
|
||||||
stimulus,
|
|
||||||
}: {
|
|
||||||
value: string
|
|
||||||
onChange: (next: string) => void
|
|
||||||
disabled: boolean
|
|
||||||
slotLabel: string
|
|
||||||
stimulus: SelectMissingWordsStimulusValue | null
|
|
||||||
}) {
|
|
||||||
const parsed = parseSelectMissingWordsResponseSlotValue(value, stimulus)
|
|
||||||
const synced = stimulus
|
|
||||||
? syncResponseBlanksWithStimulus(parsed, stimulus)
|
|
||||||
: parsed
|
|
||||||
|
|
||||||
const updateValue = (next: ReturnType<typeof parseSelectMissingWordsResponseSlotValue>) => {
|
|
||||||
onChange(serializeSelectMissingWordsResponseSlotValue(next))
|
|
||||||
}
|
|
||||||
|
|
||||||
const wordOptions =
|
|
||||||
stimulus?.word_bank.filter((item) => item.text.length > 0) ?? []
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
||||||
<label className="text-sm font-medium text-grayScale-700">{slotLabel}</label>
|
|
||||||
{stimulus ? (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
disabled={disabled}
|
|
||||||
className="h-8 rounded-lg"
|
|
||||||
onClick={() =>
|
|
||||||
updateValue(defaultSelectMissingWordsResponseFromStimulus(stimulus))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Reset from blanks
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!stimulus ? (
|
|
||||||
<p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-900">
|
|
||||||
Fill in the cloze passage and word bank first so answers can reference blank and word ids.
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
{synced.blanks.map((blank, index) => (
|
|
||||||
<div
|
|
||||||
key={`blank-answer-${blank.blank_id}`}
|
|
||||||
className="flex flex-wrap items-center gap-2 rounded-lg border border-grayScale-200 bg-grayScale-50/50 p-3"
|
|
||||||
>
|
|
||||||
<span className="w-10 text-xs font-medium text-grayScale-500">
|
|
||||||
{blank.blank_id}
|
|
||||||
</span>
|
|
||||||
<select
|
|
||||||
value={blank.word_id}
|
|
||||||
disabled={disabled || wordOptions.length === 0}
|
|
||||||
onChange={(e) => {
|
|
||||||
const selected = wordOptions.find((item) => item.id === e.target.value)
|
|
||||||
const blanks = [...synced.blanks]
|
|
||||||
blanks[index] = {
|
|
||||||
blank_id: blank.blank_id,
|
|
||||||
word_id: e.target.value,
|
|
||||||
text: selected?.text ?? "",
|
|
||||||
}
|
|
||||||
updateValue({ blanks })
|
|
||||||
}}
|
|
||||||
className="h-10 min-w-[160px] flex-1 rounded-lg border border-grayScale-200 bg-white px-3 text-sm"
|
|
||||||
>
|
|
||||||
<option value="">Select word…</option>
|
|
||||||
{wordOptions.map((item) => (
|
|
||||||
<option key={item.id} value={item.id}>
|
|
||||||
{item.id}
|
|
||||||
{item.text ? `: ${item.text}` : ""}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,250 +0,0 @@
|
||||||
import { useMemo } from "react"
|
|
||||||
import { Plus, Trash2 } from "lucide-react"
|
|
||||||
import { Button } from "../ui/button"
|
|
||||||
import { Input } from "../ui/input"
|
|
||||||
import { cn } from "../../lib/utils"
|
|
||||||
import {
|
|
||||||
createEmptyTable,
|
|
||||||
parseTableSlotValue,
|
|
||||||
serializeTableSlotValue,
|
|
||||||
type DynamicTableValue,
|
|
||||||
} from "../../lib/dynamicTableValue"
|
|
||||||
|
|
||||||
export type DynamicTableBuilderProps = {
|
|
||||||
value: string
|
|
||||||
onChange: (next: string) => void
|
|
||||||
disabled?: boolean
|
|
||||||
slotLabel: string
|
|
||||||
slotMeta: string
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeTable(table: DynamicTableValue): DynamicTableValue {
|
|
||||||
const columns =
|
|
||||||
table.columns.length > 0
|
|
||||||
? table.columns.map((c, i) => c.trim() || `Column ${i + 1}`)
|
|
||||||
: ["Column 1"]
|
|
||||||
const colCount = columns.length
|
|
||||||
const rows =
|
|
||||||
table.rows.length > 0
|
|
||||||
? table.rows.map((row) => {
|
|
||||||
const cells = [...row]
|
|
||||||
while (cells.length < colCount) cells.push("")
|
|
||||||
return cells.slice(0, colCount)
|
|
||||||
})
|
|
||||||
: [Array(colCount).fill("")]
|
|
||||||
return { columns, rows }
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DynamicTableBuilder({
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
disabled = false,
|
|
||||||
slotLabel,
|
|
||||||
slotMeta,
|
|
||||||
}: DynamicTableBuilderProps) {
|
|
||||||
const table = useMemo(() => normalizeTable(parseTableSlotValue(value)), [value])
|
|
||||||
|
|
||||||
const commit = (next: DynamicTableValue) => {
|
|
||||||
onChange(serializeTableSlotValue(normalizeTable(next)))
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateColumn = (colIndex: number, text: string) => {
|
|
||||||
const columns = [...table.columns]
|
|
||||||
columns[colIndex] = text
|
|
||||||
commit({ columns, rows: table.rows })
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateCell = (rowIndex: number, colIndex: number, text: string) => {
|
|
||||||
const rows = table.rows.map((r) => [...r])
|
|
||||||
rows[rowIndex][colIndex] = text
|
|
||||||
commit({ columns: table.columns, rows })
|
|
||||||
}
|
|
||||||
|
|
||||||
const addColumn = () => {
|
|
||||||
const columns = [...table.columns, `Column ${table.columns.length + 1}`]
|
|
||||||
const rows = table.rows.map((row) => [...row, ""])
|
|
||||||
commit({ columns, rows })
|
|
||||||
}
|
|
||||||
|
|
||||||
const removeColumn = (colIndex: number) => {
|
|
||||||
if (table.columns.length <= 1) return
|
|
||||||
const columns = table.columns.filter((_, i) => i !== colIndex)
|
|
||||||
const rows = table.rows.map((row) => row.filter((_, i) => i !== colIndex))
|
|
||||||
commit({ columns, rows })
|
|
||||||
}
|
|
||||||
|
|
||||||
const addRow = () => {
|
|
||||||
const rows = [...table.rows, Array(table.columns.length).fill("")]
|
|
||||||
commit({ columns: table.columns, rows })
|
|
||||||
}
|
|
||||||
|
|
||||||
const removeRow = (rowIndex: number) => {
|
|
||||||
if (table.rows.length <= 1) return
|
|
||||||
const rows = table.rows.filter((_, i) => i !== rowIndex)
|
|
||||||
commit({ columns: table.columns, rows })
|
|
||||||
}
|
|
||||||
|
|
||||||
const resetTable = () => {
|
|
||||||
commit(createEmptyTable(2, 1))
|
|
||||||
}
|
|
||||||
|
|
||||||
const previewColumns = table.columns.map((c, i) => c.trim() || `Column ${i + 1}`)
|
|
||||||
const previewRows = table.rows.map((row) =>
|
|
||||||
row.map((cell, ci) => cell.trim() || ""),
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex flex-wrap items-end justify-between gap-2">
|
|
||||||
<label className="text-sm font-medium text-grayScale-700">{slotLabel}</label>
|
|
||||||
<span className="text-[11px] font-mono text-grayScale-500">{slotMeta}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-xs text-grayScale-500">
|
|
||||||
Build the reference table learners will see with the question.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="overflow-x-auto rounded-xl border border-grayScale-200 bg-white">
|
|
||||||
<table className="w-full min-w-[320px] border-collapse text-sm">
|
|
||||||
<thead>
|
|
||||||
<tr className="bg-grayScale-50/90">
|
|
||||||
{table.columns.map((col, colIndex) => (
|
|
||||||
<th
|
|
||||||
key={`col-${colIndex}`}
|
|
||||||
className="border-b border-r border-grayScale-200 p-1.5 align-top last:border-r-0"
|
|
||||||
>
|
|
||||||
<div className="flex min-w-[100px] items-start gap-1">
|
|
||||||
<Input
|
|
||||||
value={col}
|
|
||||||
disabled={disabled}
|
|
||||||
onChange={(e) => updateColumn(colIndex, e.target.value)}
|
|
||||||
placeholder={`Column ${colIndex + 1}`}
|
|
||||||
className="h-9 border-grayScale-200 bg-white text-xs font-semibold"
|
|
||||||
/>
|
|
||||||
{table.columns.length > 1 ? (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
disabled={disabled}
|
|
||||||
className="h-9 w-9 shrink-0 p-0 text-grayScale-400 hover:text-red-600"
|
|
||||||
aria-label={`Remove column ${colIndex + 1}`}
|
|
||||||
onClick={() => removeColumn(colIndex)}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</th>
|
|
||||||
))}
|
|
||||||
<th className="w-10 border-b border-grayScale-200 bg-grayScale-50/90 p-1" />
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{table.rows.map((row, rowIndex) => (
|
|
||||||
<tr key={`row-${rowIndex}`} className="group">
|
|
||||||
{row.map((cell, colIndex) => (
|
|
||||||
<td
|
|
||||||
key={`cell-${rowIndex}-${colIndex}`}
|
|
||||||
className="border-b border-r border-grayScale-100 p-1.5 last:border-r-0"
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
value={cell}
|
|
||||||
disabled={disabled}
|
|
||||||
onChange={(e) => updateCell(rowIndex, colIndex, e.target.value)}
|
|
||||||
placeholder="Cell value"
|
|
||||||
className="h-9 border-grayScale-200 bg-[#F8FAFC] text-sm"
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
))}
|
|
||||||
<td className="border-b border-grayScale-100 p-1 align-middle">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
disabled={disabled || table.rows.length <= 1}
|
|
||||||
className="h-9 w-9 p-0 text-grayScale-400 hover:text-red-600 disabled:opacity-30"
|
|
||||||
aria-label={`Remove row ${rowIndex + 1}`}
|
|
||||||
onClick={() => removeRow(rowIndex)}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
|
||||||
</Button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
disabled={disabled}
|
|
||||||
className="gap-1.5"
|
|
||||||
onClick={addColumn}
|
|
||||||
>
|
|
||||||
<Plus className="h-3.5 w-3.5" />
|
|
||||||
Add column
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
disabled={disabled}
|
|
||||||
className="gap-1.5"
|
|
||||||
onClick={addRow}
|
|
||||||
>
|
|
||||||
<Plus className="h-3.5 w-3.5" />
|
|
||||||
Add row
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
disabled={disabled}
|
|
||||||
onClick={resetTable}
|
|
||||||
>
|
|
||||||
Reset table
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-xl border border-dashed border-grayScale-200 bg-grayScale-50/60 p-4">
|
|
||||||
<p className="mb-2 text-[10px] font-bold uppercase tracking-wide text-grayScale-400">
|
|
||||||
Learner preview
|
|
||||||
</p>
|
|
||||||
<div className="overflow-x-auto rounded-lg border border-grayScale-200 bg-white">
|
|
||||||
<table className={cn("w-full min-w-[240px] border-collapse text-sm text-grayScale-800")}>
|
|
||||||
<thead>
|
|
||||||
<tr className="bg-brand-50/80">
|
|
||||||
{previewColumns.map((col, i) => (
|
|
||||||
<th
|
|
||||||
key={`preview-h-${i}`}
|
|
||||||
className="border border-grayScale-200 px-3 py-2 text-left text-xs font-bold uppercase tracking-wide text-grayScale-700"
|
|
||||||
>
|
|
||||||
{col}
|
|
||||||
</th>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{previewRows.map((row, ri) => (
|
|
||||||
<tr key={`preview-r-${ri}`} className={ri % 2 === 0 ? "bg-white" : "bg-grayScale-50/50"}>
|
|
||||||
{row.map((cell, ci) => (
|
|
||||||
<td
|
|
||||||
key={`preview-c-${ri}-${ci}`}
|
|
||||||
className="border border-grayScale-100 px-3 py-2 text-sm"
|
|
||||||
>
|
|
||||||
{cell || <span className="text-grayScale-300">—</span>}
|
|
||||||
</td>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -9,23 +9,15 @@ import { Check, Image as ImageIcon, Mic, Plus, Upload, X } from "lucide-react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { resolveMediaPreviewUrl } from "../../lib/practiceMedia"
|
import { resolveMediaPreviewUrl } from "../../lib/practiceMedia"
|
||||||
import { uploadAudioFile, uploadImageFile } from "../../api/files.api"
|
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 { Input } from "../ui/input"
|
||||||
import { Textarea } from "../ui/textarea"
|
|
||||||
import { Select } from "../ui/select"
|
import { Select } from "../ui/select"
|
||||||
import { Button } from "../ui/button"
|
import { Button } from "../ui/button"
|
||||||
import { SpinnerIcon } from "../ui/spinner-icon"
|
import { SpinnerIcon } from "../ui/spinner-icon"
|
||||||
import { cn } from "../../lib/utils"
|
import { cn } from "../../lib/utils"
|
||||||
import { ResolvedAudio } from "../media/ResolvedAudio"
|
import { ResolvedAudio } from "../media/ResolvedAudio"
|
||||||
import { ResolvedImage } from "../media/ResolvedImage"
|
import { ResolvedImage } from "../media/ResolvedImage"
|
||||||
import { DynamicSchemaSlotField } from "./DynamicSchemaSlotField"
|
|
||||||
|
|
||||||
export type PracticeQuestionEditorType = "MCQ" | "TRUE_FALSE" | "SHORT" | "AUDIO" | "DYNAMIC"
|
export type PracticeQuestionEditorType = "MCQ" | "TRUE_FALSE" | "SHORT" | "AUDIO"
|
||||||
export type PracticeQuestionEditorDifficulty = "EASY" | "MEDIUM" | "HARD"
|
export type PracticeQuestionEditorDifficulty = "EASY" | "MEDIUM" | "HARD"
|
||||||
|
|
||||||
export interface PracticeQuestionOptionDraft {
|
export interface PracticeQuestionOptionDraft {
|
||||||
|
|
@ -40,13 +32,6 @@ const ALLOWED_AUDIO_EXTENSIONS = new Set(["mp3", "wav", "ogg", "m4a", "aac", "we
|
||||||
const MAX_IMAGE_SIZE_BYTES = 10 * 1024 * 1024
|
const MAX_IMAGE_SIZE_BYTES = 10 * 1024 * 1024
|
||||||
const ALLOWED_IMAGE_EXTENSIONS = new Set(["jpg", "jpeg", "png", "webp", "gif"])
|
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 {
|
export interface PracticeQuestionEditorValue {
|
||||||
questionText: string
|
questionText: string
|
||||||
questionType: PracticeQuestionEditorType
|
questionType: PracticeQuestionEditorType
|
||||||
|
|
@ -61,12 +46,6 @@ export interface PracticeQuestionEditorValue {
|
||||||
shortAnswer: string
|
shortAnswer: string
|
||||||
/** Stored URL or object key; same semantics as Speaking practice editor */
|
/** Stored URL or object key; same semantics as Speaking practice editor */
|
||||||
imageUrl: string
|
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 {
|
export function createEmptyPracticeQuestionDraft(): PracticeQuestionEditorValue {
|
||||||
|
|
@ -88,10 +67,6 @@ export function createEmptyPracticeQuestionDraft(): PracticeQuestionEditorValue
|
||||||
audioCorrectAnswerText: "",
|
audioCorrectAnswerText: "",
|
||||||
shortAnswer: "",
|
shortAnswer: "",
|
||||||
imageUrl: "",
|
imageUrl: "",
|
||||||
questionTypeDefinitionId: null,
|
|
||||||
dynamicStimulusRows: [],
|
|
||||||
dynamicResponseRows: [],
|
|
||||||
dynamicFieldValues: {},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -109,9 +84,6 @@ function defaultOptionsForType(
|
||||||
previousType: PracticeQuestionEditorType,
|
previousType: PracticeQuestionEditorType,
|
||||||
current: PracticeQuestionOptionDraft[],
|
current: PracticeQuestionOptionDraft[],
|
||||||
): PracticeQuestionOptionDraft[] {
|
): PracticeQuestionOptionDraft[] {
|
||||||
if (type === "DYNAMIC") {
|
|
||||||
return current
|
|
||||||
}
|
|
||||||
if (type === "TRUE_FALSE") {
|
if (type === "TRUE_FALSE") {
|
||||||
if (previousType === "TRUE_FALSE" && current.length >= 2) {
|
if (previousType === "TRUE_FALSE" && current.length >= 2) {
|
||||||
return current.map((o, i) => ({
|
return current.map((o, i) => ({
|
||||||
|
|
@ -174,18 +146,6 @@ export function PracticeQuestionEditorFields({
|
||||||
|
|
||||||
const setType = (questionType: PracticeQuestionEditorType) => {
|
const setType = (questionType: PracticeQuestionEditorType) => {
|
||||||
const options = defaultOptionsForType(questionType, value.questionType, value.options)
|
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 })
|
onChange({ ...value, questionType, options })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -626,97 +586,11 @@ export function PracticeQuestionEditorFields({
|
||||||
|
|
||||||
const controlsDisabled = mediaBusy
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="mt-3 space-y-3">
|
<div className="mt-5 space-y-5">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-2">
|
||||||
<label className="text-[11px] font-medium uppercase tracking-wide text-grayScale-500">Question Text</label>
|
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">Question Text</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={value.questionText}
|
value={value.questionText}
|
||||||
onChange={(e) => patch({ questionText: e.target.value })}
|
onChange={(e) => patch({ questionText: e.target.value })}
|
||||||
|
|
@ -733,40 +607,35 @@ export function PracticeQuestionEditorFields({
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3 lg:gap-3">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 lg:gap-5">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-2">
|
||||||
<label className="text-[11px] font-medium uppercase tracking-wide text-grayScale-500">Type</label>
|
<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)} className="h-9 text-sm">
|
<Select value={value.questionType} onChange={(e) => setType(e.target.value as PracticeQuestionEditorType)}>
|
||||||
<option value="MCQ">Multiple Choice</option>
|
<option value="MCQ">Multiple Choice</option>
|
||||||
<option value="TRUE_FALSE">True/False</option>
|
<option value="TRUE_FALSE">True/False</option>
|
||||||
<option value="SHORT">Short Answer</option>
|
<option value="SHORT">Short Answer</option>
|
||||||
<option value="AUDIO">Audio</option>
|
<option value="AUDIO">Audio</option>
|
||||||
<option value="DYNAMIC">Dynamic (schema-driven)</option>
|
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-2">
|
||||||
<label className="text-[11px] font-medium uppercase tracking-wide text-grayScale-500">Difficulty</label>
|
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">Difficulty</label>
|
||||||
<Select
|
<Select
|
||||||
value={value.difficultyLevel}
|
value={value.difficultyLevel}
|
||||||
onChange={(e) => patch({ difficultyLevel: e.target.value as PracticeQuestionEditorDifficulty })}
|
onChange={(e) => patch({ difficultyLevel: e.target.value as PracticeQuestionEditorDifficulty })}
|
||||||
className="h-9 text-sm"
|
|
||||||
>
|
>
|
||||||
<option value="EASY">Easy</option>
|
<option value="EASY">Easy</option>
|
||||||
<option value="MEDIUM">Medium</option>
|
<option value="MEDIUM">Medium</option>
|
||||||
<option value="HARD">Hard</option>
|
<option value="HARD">Hard</option>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-2">
|
||||||
<label className="text-[11px] font-medium uppercase tracking-wide text-grayScale-500">Points</label>
|
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">Points</label>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
value={value.points}
|
value={value.points}
|
||||||
onChange={(e) => patch({ points: Number(e.target.value) || 1 })}
|
onChange={(e) => patch({ points: Number(e.target.value) || 1 })}
|
||||||
min={1}
|
min={1}
|
||||||
className={cn(
|
className={cn(showFieldErrors && fieldErrors.points ? "border-red-300 ring-1 ring-red-200" : undefined)}
|
||||||
"h-9 text-sm",
|
|
||||||
showFieldErrors && fieldErrors.points ? "border-red-300 ring-1 ring-red-200" : undefined,
|
|
||||||
)}
|
|
||||||
aria-invalid={Boolean(showFieldErrors && fieldErrors.points)}
|
aria-invalid={Boolean(showFieldErrors && fieldErrors.points)}
|
||||||
/>
|
/>
|
||||||
{showFieldErrors && fieldErrors.points ? (
|
{showFieldErrors && fieldErrors.points ? (
|
||||||
|
|
@ -775,82 +644,8 @@ export function PracticeQuestionEditorFields({
|
||||||
</div>
|
</div>
|
||||||
</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" && (
|
{value.questionType === "MCQ" && (
|
||||||
<div className="space-y-2 rounded-lg bg-grayScale-50/50 p-3">
|
<div className="space-y-3 rounded-lg bg-grayScale-50/50 p-4">
|
||||||
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">Options</label>
|
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">Options</label>
|
||||||
<div className="space-y-2.5">
|
<div className="space-y-2.5">
|
||||||
{value.options.map((option, optIdx) => (
|
{value.options.map((option, optIdx) => (
|
||||||
|
|
@ -963,7 +758,7 @@ export function PracticeQuestionEditorFields({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-2 lg:grid-cols-2 lg:gap-4">
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2 lg:gap-6">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">Tips (Optional)</label>
|
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">Tips (Optional)</label>
|
||||||
<Input
|
<Input
|
||||||
|
|
@ -982,8 +777,6 @@ export function PracticeQuestionEditorFields({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{value.questionType !== "DYNAMIC" ? (
|
|
||||||
<>
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">Voice Prompt (Optional)</label>
|
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">Voice Prompt (Optional)</label>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
|
|
@ -1115,8 +908,6 @@ export function PracticeQuestionEditorFields({
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{recordingModal ? (
|
{recordingModal ? (
|
||||||
|
|
|
||||||
|
|
@ -1,130 +0,0 @@
|
||||||
import { useEffect, useMemo, useState } from "react"
|
|
||||||
import { Bar, CartesianGrid, Cell, ComposedChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"
|
|
||||||
import { getDashboard } from "../../api/analytics.api"
|
|
||||||
import { getYearOptions } from "../analytics/AnalyticsTimeRangeFilter"
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "../ui/card"
|
|
||||||
import { Select } from "../ui/select"
|
|
||||||
import { aggregateRevenueByMonth, formatRevenueAxisTick } from "../../lib/analytics"
|
|
||||||
import type { DateRevenue } from "../../types/analytics.types"
|
|
||||||
import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg"
|
|
||||||
|
|
||||||
const TRACK_COLOR = "#E8E8E8"
|
|
||||||
const BAR_COLOR = "#9E2891"
|
|
||||||
|
|
||||||
export function RevenueTrendCard() {
|
|
||||||
const currentYear = new Date().getFullYear()
|
|
||||||
const [year, setYear] = useState(currentYear)
|
|
||||||
const [totalRevenue, setTotalRevenue] = useState(0)
|
|
||||||
const [dailyRevenue, setDailyRevenue] = useState<DateRevenue[]>([])
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
|
|
||||||
const years = useMemo(() => getYearOptions(), [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false
|
|
||||||
|
|
||||||
const fetchRevenueTrend = async () => {
|
|
||||||
setLoading(true)
|
|
||||||
try {
|
|
||||||
const res = await getDashboard({ mode: "year", year })
|
|
||||||
if (cancelled) return
|
|
||||||
setTotalRevenue(res.data.payments.total_revenue)
|
|
||||||
setDailyRevenue(res.data.payments.revenue_last_30_days)
|
|
||||||
} catch {
|
|
||||||
if (!cancelled) {
|
|
||||||
setTotalRevenue(0)
|
|
||||||
setDailyRevenue([])
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (!cancelled) setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchRevenueTrend()
|
|
||||||
return () => {
|
|
||||||
cancelled = true
|
|
||||||
}
|
|
||||||
}, [year])
|
|
||||||
|
|
||||||
const chartData = useMemo(() => {
|
|
||||||
const monthly = aggregateRevenueByMonth(dailyRevenue, year)
|
|
||||||
const peak = Math.max(...monthly.map((point) => point.revenue), 1)
|
|
||||||
const trackMax = peak * 1.15
|
|
||||||
|
|
||||||
return monthly.map((point) => ({
|
|
||||||
month: point.month,
|
|
||||||
revenue: point.revenue,
|
|
||||||
track: trackMax,
|
|
||||||
}))
|
|
||||||
}, [dailyRevenue, year])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card className="shadow-none">
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<div className="flex items-start justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<CardTitle>Revenue Trend</CardTitle>
|
|
||||||
<div className="mt-2 text-2xl font-semibold tracking-tight">
|
|
||||||
ETB {totalRevenue.toLocaleString()}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs font-medium text-grayScale-500">Monthly · {year} (ETB)</div>
|
|
||||||
</div>
|
|
||||||
<Select
|
|
||||||
value={String(year)}
|
|
||||||
onChange={(e) => setYear(Number(e.target.value))}
|
|
||||||
className="h-9 w-[96px] shrink-0 rounded-lg py-1 text-sm font-medium"
|
|
||||||
aria-label="Revenue trend year"
|
|
||||||
>
|
|
||||||
{years.map((optionYear) => (
|
|
||||||
<option key={optionYear} value={optionYear}>
|
|
||||||
{optionYear}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="h-[240px] p-6 pt-2">
|
|
||||||
{loading ? (
|
|
||||||
<div className="flex h-full items-center justify-center">
|
|
||||||
<img src={spinnerSrc} alt="" className="h-8 w-8 animate-spin" />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
|
||||||
<ComposedChart data={chartData} margin={{ left: 4, right: 8, top: 8, bottom: 0 }} barGap={-28}>
|
|
||||||
<CartesianGrid vertical={false} stroke="#E0E0E0" strokeDasharray="4 4" />
|
|
||||||
<XAxis dataKey="month" tickLine={false} axisLine={false} fontSize={12} />
|
|
||||||
<YAxis
|
|
||||||
tickLine={false}
|
|
||||||
axisLine={false}
|
|
||||||
fontSize={12}
|
|
||||||
width={44}
|
|
||||||
tickFormatter={formatRevenueAxisTick}
|
|
||||||
/>
|
|
||||||
<Tooltip
|
|
||||||
formatter={(value, name) => {
|
|
||||||
if (name !== "revenue") return null
|
|
||||||
return [`ETB ${Number(value).toLocaleString()}`, "Revenue"]
|
|
||||||
}}
|
|
||||||
contentStyle={{
|
|
||||||
borderRadius: 12,
|
|
||||||
border: "1px solid #E0E0E0",
|
|
||||||
boxShadow: "0 10px 30px rgba(0,0,0,0.08)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Bar dataKey="track" barSize={28} radius={[8, 8, 0, 0]} isAnimationActive={false}>
|
|
||||||
{chartData.map((entry) => (
|
|
||||||
<Cell key={`track-${entry.month}`} fill={TRACK_COLOR} />
|
|
||||||
))}
|
|
||||||
</Bar>
|
|
||||||
<Bar dataKey="revenue" barSize={28} radius={[8, 8, 0, 0]}>
|
|
||||||
{chartData.map((entry) => (
|
|
||||||
<Cell key={`revenue-${entry.month}`} fill={BAR_COLOR} />
|
|
||||||
))}
|
|
||||||
</Bar>
|
|
||||||
</ComposedChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,169 +0,0 @@
|
||||||
import { Badge } from "../ui/badge"
|
|
||||||
import { Button } from "../ui/button"
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "../ui/dialog"
|
|
||||||
import { SpinnerIcon } from "../ui/spinner-icon"
|
|
||||||
import {
|
|
||||||
DEFAULT_NOTIFICATION_TYPE_CONFIG,
|
|
||||||
formatNotificationDateTime,
|
|
||||||
formatNotificationTimestamp,
|
|
||||||
formatNotificationTypeLabel,
|
|
||||||
getNotificationLevelBadge,
|
|
||||||
isMeaningfulExpiry,
|
|
||||||
NOTIFICATION_TYPE_CONFIG,
|
|
||||||
} from "../../lib/notificationDisplay"
|
|
||||||
import { getNotificationMessage, getNotificationTitle, type Notification } from "../../types/notification.types"
|
|
||||||
|
|
||||||
type NotificationDetailDialogProps = {
|
|
||||||
open: boolean
|
|
||||||
onOpenChange: (open: boolean) => void
|
|
||||||
notification: Notification | null
|
|
||||||
loading?: boolean
|
|
||||||
error?: boolean
|
|
||||||
onRetry?: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function NotificationDetailDialog({
|
|
||||||
open,
|
|
||||||
onOpenChange,
|
|
||||||
notification,
|
|
||||||
loading = false,
|
|
||||||
error = false,
|
|
||||||
onRetry,
|
|
||||||
}: NotificationDetailDialogProps) {
|
|
||||||
const config = notification
|
|
||||||
? NOTIFICATION_TYPE_CONFIG[notification.type] ?? DEFAULT_NOTIFICATION_TYPE_CONFIG
|
|
||||||
: DEFAULT_NOTIFICATION_TYPE_CONFIG
|
|
||||||
const Icon = config.icon
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
||||||
<DialogContent className="max-w-lg">
|
|
||||||
{loading ? (
|
|
||||||
<div className="flex flex-col items-center justify-center gap-3 py-12">
|
|
||||||
<SpinnerIcon className="h-8 w-8 text-brand-500" />
|
|
||||||
<p className="text-sm text-grayScale-500">Loading notification…</p>
|
|
||||||
</div>
|
|
||||||
) : error ? (
|
|
||||||
<div className="flex flex-col items-center justify-center gap-3 py-12 text-center">
|
|
||||||
<p className="text-sm font-medium text-grayScale-700">Could not load notification</p>
|
|
||||||
{onRetry ? (
|
|
||||||
<Button variant="outline" size="sm" onClick={onRetry}>
|
|
||||||
Try again
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
) : notification ? (
|
|
||||||
<>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="flex items-center gap-2">
|
|
||||||
<span
|
|
||||||
className={`inline-flex h-8 w-8 items-center justify-center rounded-lg ${config.bg} ${config.color}`}
|
|
||||||
>
|
|
||||||
<Icon className="h-4 w-4" />
|
|
||||||
</span>
|
|
||||||
<span className="truncate text-base">
|
|
||||||
{getNotificationTitle(notification) || "Notification"}
|
|
||||||
</span>
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Sent via {notification.delivery_channel || "in-app"} ·{" "}
|
|
||||||
{formatNotificationTimestamp(notification.timestamp)}
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
{notification.image ? (
|
|
||||||
<div className="overflow-hidden rounded-lg border border-grayScale-200 bg-grayScale-50">
|
|
||||||
<img
|
|
||||||
src={notification.image}
|
|
||||||
alt=""
|
|
||||||
className="max-h-48 w-full object-cover"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="rounded-lg bg-grayScale-50 p-3">
|
|
||||||
<p className="text-sm leading-relaxed text-grayScale-600">
|
|
||||||
{getNotificationMessage(notification) || "No message content."}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-3 text-xs text-grayScale-500 sm:grid-cols-2">
|
|
||||||
<div>
|
|
||||||
<p className="text-grayScale-400">Type</p>
|
|
||||||
<p className="mt-0.5 font-medium text-grayScale-700">
|
|
||||||
{formatNotificationTypeLabel(notification.type)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-grayScale-400">Level</p>
|
|
||||||
<div className="mt-0.5">
|
|
||||||
<Badge variant={getNotificationLevelBadge(notification.level)} className="text-[10px]">
|
|
||||||
{notification.level || "—"}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-grayScale-400">Channel</p>
|
|
||||||
<p className="mt-0.5 font-medium capitalize text-grayScale-700">
|
|
||||||
{notification.delivery_channel || "—"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-grayScale-400">Delivery status</p>
|
|
||||||
<p className="mt-0.5 font-medium text-grayScale-700">
|
|
||||||
{notification.delivery_status || "—"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-grayScale-400">Read status</p>
|
|
||||||
<p className="mt-0.5 font-medium text-grayScale-700">
|
|
||||||
{notification.is_read ? "Read" : "Unread"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{notification.receiver_type ? (
|
|
||||||
<div>
|
|
||||||
<p className="text-grayScale-400">Receiver</p>
|
|
||||||
<p className="mt-0.5 font-medium capitalize text-grayScale-700">
|
|
||||||
{notification.receiver_type}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<div className="sm:col-span-2">
|
|
||||||
<p className="text-grayScale-400">Sent at</p>
|
|
||||||
<p className="mt-0.5 font-medium text-grayScale-700">
|
|
||||||
{formatNotificationDateTime(notification.timestamp)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{isMeaningfulExpiry(notification.expires) ? (
|
|
||||||
<div className="sm:col-span-2">
|
|
||||||
<p className="text-grayScale-400">Expires</p>
|
|
||||||
<p className="mt-0.5 font-medium text-grayScale-700">
|
|
||||||
{formatNotificationDateTime(notification.expires)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{notification.payload.tags && notification.payload.tags.length > 0 ? (
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{notification.payload.tags.map((tag) => (
|
|
||||||
<Badge key={tag} variant="secondary" className="text-[10px]">
|
|
||||||
{tag}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -6,11 +6,12 @@ import {
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
CircleAlert,
|
CircleAlert,
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
CreditCard,
|
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
LogOut,
|
LogOut,
|
||||||
|
Shield,
|
||||||
UserCircle2,
|
UserCircle2,
|
||||||
Users,
|
Users,
|
||||||
|
Users2,
|
||||||
Settings,
|
Settings,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
@ -19,86 +20,27 @@ import { NavLink } from "react-router-dom";
|
||||||
import { cn } from "../../lib/utils";
|
import { cn } from "../../lib/utils";
|
||||||
import { BrandLogo } from "../brand/BrandLogo";
|
import { BrandLogo } from "../brand/BrandLogo";
|
||||||
import { getUnreadCount } from "../../api/notifications.api";
|
import { getUnreadCount } from "../../api/notifications.api";
|
||||||
import { SidebarNavGroup } from "./SidebarNavGroup";
|
|
||||||
|
|
||||||
type NavLinkItem = {
|
type NavItem = {
|
||||||
kind: "link";
|
|
||||||
label: string;
|
label: string;
|
||||||
to: string;
|
to: string;
|
||||||
icon: ComponentType<{ className?: string }>;
|
icon: ComponentType<{ className?: string }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type NavGroupItem = {
|
const navItems: NavItem[] = [
|
||||||
kind: "group";
|
{ label: "Dashboard", to: "/dashboard", icon: LayoutDashboard },
|
||||||
label: string;
|
{ label: "User Management", to: "/users", icon: Users },
|
||||||
basePath: string;
|
{ label: "Role Management", to: "/roles", icon: Shield },
|
||||||
activePaths?: string[];
|
{ label: "Content Management", to: "/content", icon: BookOpen },
|
||||||
icon: ComponentType<{ className?: string }>;
|
{ label: "New Content", to: "/new-content", icon: BookOpen },
|
||||||
children: { label: string; to: string; end?: boolean }[];
|
|
||||||
};
|
|
||||||
|
|
||||||
type NavSectionItem = {
|
{ label: "Notifications", to: "/notifications", icon: Bell },
|
||||||
kind: "section";
|
{ label: "User Log", to: "/user-log", icon: ClipboardList },
|
||||||
label: string;
|
{ label: "Issue Reports", to: "/issues", icon: CircleAlert },
|
||||||
};
|
{ label: "Analytics", to: "/analytics", icon: BarChart3 },
|
||||||
|
{ label: "Team Management", to: "/team", icon: Users2 },
|
||||||
type NavEntry = NavLinkItem | NavGroupItem | NavSectionItem;
|
{ label: "Profile", to: "/profile", icon: UserCircle2 },
|
||||||
|
{ label: "Settings", to: "/settings", icon: Settings },
|
||||||
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 = {
|
type SidebarProps = {
|
||||||
|
|
@ -133,18 +75,9 @@ export function Sidebar({
|
||||||
window.removeEventListener("notifications-updated", fetchUnread);
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{/* Mobile overlay */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed inset-0 z-40 bg-black/50 transition-opacity lg:hidden",
|
"fixed inset-0 z-40 bg-black/50 transition-opacity lg:hidden",
|
||||||
|
|
@ -154,6 +87,7 @@ export function Sidebar({
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Sidebar panel */}
|
||||||
<aside
|
<aside
|
||||||
className={cn(
|
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",
|
"group fixed left-0 top-0 z-50 flex h-screen flex-col border-r bg-grayScale-50 py-5 transition-all duration-300",
|
||||||
|
|
@ -200,59 +134,13 @@ export function Sidebar({
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="mt-6 flex-1 space-y-0.5 overflow-y-auto">
|
<nav className="mt-6 flex-1 space-y-1 overflow-y-auto">
|
||||||
{navEntries.map((entry, index) => {
|
{navItems.map((item) => {
|
||||||
if (entry.kind === "section") {
|
const Icon = item.icon;
|
||||||
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 (
|
return (
|
||||||
<NavLink
|
<NavLink
|
||||||
key={entry.to}
|
key={item.to}
|
||||||
to={entry.to}
|
to={item.to}
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
cn(
|
cn(
|
||||||
|
|
@ -263,22 +151,41 @@ export function Sidebar({
|
||||||
"bg-brand-100/40 text-brand-600 shadow-[0_1px_0_rgba(0,0,0,0.02)] ring-1 ring-brand-100",
|
"bg-brand-100/40 text-brand-600 shadow-[0_1px_0_rgba(0,0,0,0.02)] ring-1 ring-brand-100",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
title={isCollapsed ? entry.label : undefined}
|
title={isCollapsed ? item.label : undefined}
|
||||||
>
|
>
|
||||||
{({ isActive }) => (
|
{({ isActive }) => (
|
||||||
<>
|
<>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"grid h-8 w-8 place-items-center rounded-lg bg-grayScale-100 text-grayScale-500 transition group-hover:bg-brand-100 group-hover:text-brand-600",
|
"relative grid h-8 w-8 place-items-center rounded-lg bg-grayScale-100 text-grayScale-500 transition group-hover:bg-brand-100 group-hover:text-brand-600",
|
||||||
isActive && "bg-brand-500/90 text-white",
|
isActive && "bg-brand-500/90 text-white",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon className="h-4 w-4" />
|
<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>
|
</span>
|
||||||
{!isCollapsed && (
|
{!isCollapsed && (
|
||||||
<span className="truncate">{entry.label}</span>
|
<span className="truncate">{item.label}</span>
|
||||||
)}
|
)}
|
||||||
{!isCollapsed && isActive ? (
|
{!isCollapsed &&
|
||||||
|
item.to === "/notifications" &&
|
||||||
|
unreadCount > 0 && (
|
||||||
|
<span className="ml-auto flex h-5 min-w-[20px] items-center justify-center rounded-full bg-destructive px-1.5 text-[10px] font-bold text-white">
|
||||||
|
{unreadCount > 99 ? "99+" : unreadCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{!isCollapsed &&
|
||||||
|
item.to !== "/notifications" &&
|
||||||
|
isActive ? (
|
||||||
|
<span className="ml-auto h-6 w-1 rounded-full bg-brand-500/80" />
|
||||||
|
) : !isCollapsed &&
|
||||||
|
item.to === "/notifications" &&
|
||||||
|
unreadCount === 0 &&
|
||||||
|
isActive ? (
|
||||||
<span className="ml-auto h-6 w-1 rounded-full bg-brand-500/80" />
|
<span className="ml-auto h-6 w-1 rounded-full bg-brand-500/80" />
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -1,139 +0,0 @@
|
||||||
import { ChevronDown } from "lucide-react";
|
|
||||||
import { type ComponentType, type ReactNode, useEffect, useId, useState } from "react";
|
|
||||||
import { NavLink, useLocation } from "react-router-dom";
|
|
||||||
import { cn } from "../../lib/utils";
|
|
||||||
|
|
||||||
export type SidebarNavChild = {
|
|
||||||
label: string;
|
|
||||||
to: string;
|
|
||||||
end?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
type SidebarNavGroupProps = {
|
|
||||||
label: string;
|
|
||||||
icon: ComponentType<{ className?: string }>;
|
|
||||||
basePath: string;
|
|
||||||
/** When set, any matching prefix marks the group active (e.g. `/content` and `/new-content`). */
|
|
||||||
activePaths?: string[];
|
|
||||||
children: SidebarNavChild[];
|
|
||||||
isCollapsed: boolean;
|
|
||||||
onNavigate?: () => void;
|
|
||||||
trailing?: ReactNode;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function SidebarNavGroup({
|
|
||||||
label,
|
|
||||||
icon: Icon,
|
|
||||||
basePath,
|
|
||||||
activePaths,
|
|
||||||
children,
|
|
||||||
isCollapsed,
|
|
||||||
onNavigate,
|
|
||||||
trailing,
|
|
||||||
}: SidebarNavGroupProps) {
|
|
||||||
const location = useLocation();
|
|
||||||
const panelId = useId();
|
|
||||||
const paths = activePaths?.length ? activePaths : [basePath];
|
|
||||||
const isSectionActive = paths.some((path) => location.pathname.startsWith(path));
|
|
||||||
const [expanded, setExpanded] = useState(isSectionActive);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isSectionActive) {
|
|
||||||
setExpanded(true);
|
|
||||||
}
|
|
||||||
}, [isSectionActive]);
|
|
||||||
|
|
||||||
if (isCollapsed) {
|
|
||||||
return (
|
|
||||||
<NavLink
|
|
||||||
to={children[0]?.to ?? basePath}
|
|
||||||
onClick={onNavigate}
|
|
||||||
className={({ isActive }) =>
|
|
||||||
cn(
|
|
||||||
"group flex items-center justify-center rounded-lg px-2 py-2.5 text-sm font-medium text-grayScale-600 transition",
|
|
||||||
"hover:bg-grayScale-100 hover:text-brand-600",
|
|
||||||
isActive &&
|
|
||||||
"bg-brand-100/40 text-brand-600 shadow-[0_1px_0_rgba(0,0,0,0.02)] ring-1 ring-brand-100",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
title={label}
|
|
||||||
>
|
|
||||||
{({ isActive }) => (
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"relative grid h-8 w-8 place-items-center rounded-lg bg-grayScale-100 text-grayScale-500 transition group-hover:bg-brand-100 group-hover:text-brand-600",
|
|
||||||
isActive && "bg-brand-500/90 text-white",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Icon className="h-4 w-4" />
|
|
||||||
{trailing}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</NavLink>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-expanded={expanded}
|
|
||||||
aria-controls={panelId}
|
|
||||||
onClick={() => setExpanded((open) => !open)}
|
|
||||||
className={cn(
|
|
||||||
"group flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left text-sm font-medium text-grayScale-600 transition",
|
|
||||||
"hover:bg-grayScale-100 hover:text-brand-600",
|
|
||||||
isSectionActive && "text-brand-600",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"grid h-8 w-8 shrink-0 place-items-center rounded-lg bg-grayScale-100 text-grayScale-500 transition group-hover:bg-brand-100 group-hover:text-brand-600",
|
|
||||||
isSectionActive && "bg-brand-500/90 text-white",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Icon className="h-4 w-4" />
|
|
||||||
</span>
|
|
||||||
<span className="min-w-0 flex-1 truncate">{label}</span>
|
|
||||||
{trailing}
|
|
||||||
<ChevronDown
|
|
||||||
className={cn(
|
|
||||||
"h-4 w-4 shrink-0 text-grayScale-400 transition-transform duration-300 ease-in-out",
|
|
||||||
expanded && "rotate-180",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div
|
|
||||||
id={panelId}
|
|
||||||
className={cn(
|
|
||||||
"grid transition-[grid-template-rows] duration-300 ease-in-out",
|
|
||||||
expanded ? "grid-rows-[1fr]" : "grid-rows-[0fr]",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="overflow-hidden">
|
|
||||||
<div className="ml-4 space-y-0.5 border-l border-grayScale-200 pl-2 pt-0.5 pb-0.5">
|
|
||||||
{children.map((child) => (
|
|
||||||
<NavLink
|
|
||||||
key={child.to}
|
|
||||||
to={child.to}
|
|
||||||
end={child.end ?? false}
|
|
||||||
onClick={onNavigate}
|
|
||||||
className={({ isActive }) =>
|
|
||||||
cn(
|
|
||||||
"block rounded-lg px-3 py-2 text-sm font-medium transition",
|
|
||||||
isActive
|
|
||||||
? "bg-brand-100/40 text-brand-600"
|
|
||||||
: "text-grayScale-500 hover:bg-grayScale-100 hover:text-brand-600",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{child.label}
|
|
||||||
</NavLink>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,32 +1,74 @@
|
||||||
import { useCallback, useEffect, useRef, useState } from "react"
|
import { useEffect, useRef, useState } from "react"
|
||||||
import { useNavigate } from "react-router-dom"
|
import { useNavigate } from "react-router-dom"
|
||||||
import { Bell, BellOff, CheckCheck, Mail, MailOpen } from "lucide-react"
|
import {
|
||||||
import { toast } from "sonner"
|
Bell,
|
||||||
|
BellOff,
|
||||||
|
Info,
|
||||||
|
AlertCircle,
|
||||||
|
CheckCircle2,
|
||||||
|
Megaphone,
|
||||||
|
UserPlus,
|
||||||
|
CreditCard,
|
||||||
|
BookOpen,
|
||||||
|
Video,
|
||||||
|
ShieldAlert,
|
||||||
|
MailOpen,
|
||||||
|
Mail,
|
||||||
|
CheckCheck,
|
||||||
|
} from "lucide-react"
|
||||||
import { Badge } from "../ui/badge"
|
import { Badge } from "../ui/badge"
|
||||||
import { cn } from "../../lib/utils"
|
import { cn } from "../../lib/utils"
|
||||||
import { SpinnerIcon } from "../ui/spinner-icon"
|
import { SpinnerIcon } from "../ui/spinner-icon"
|
||||||
import { getNotificationById } from "../../api/notifications.api"
|
|
||||||
import { useNotifications } from "../../hooks/useNotifications"
|
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"
|
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({
|
function NotificationItem({
|
||||||
notification,
|
notification,
|
||||||
onOpen,
|
|
||||||
onMarkRead,
|
onMarkRead,
|
||||||
onMarkUnread,
|
onMarkUnread,
|
||||||
}: {
|
}: {
|
||||||
notification: Notification
|
notification: Notification
|
||||||
onOpen: (notification: Notification) => void
|
|
||||||
onMarkRead: (id: string) => void
|
onMarkRead: (id: string) => void
|
||||||
onMarkUnread: (id: string) => void
|
onMarkUnread: (id: string) => void
|
||||||
}) {
|
}) {
|
||||||
const cfg = NOTIFICATION_TYPE_CONFIG[notification.type] ?? DEFAULT_NOTIFICATION_TYPE_CONFIG
|
const cfg = TYPE_CONFIG[notification.type] ?? DEFAULT_TYPE_CONFIG
|
||||||
const Icon = cfg.icon
|
const Icon = cfg.icon
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -35,26 +77,31 @@ function NotificationItem({
|
||||||
className={cn(
|
className={cn(
|
||||||
"group relative flex w-full gap-3 rounded-lg px-3 py-3 text-left transition-colors hover:bg-grayScale-100",
|
"group relative flex w-full gap-3 rounded-lg px-3 py-3 text-left transition-colors hover:bg-grayScale-100",
|
||||||
)}
|
)}
|
||||||
onClick={() => onOpen(notification)}
|
onClick={() => {
|
||||||
|
if (!notification.is_read) onMarkRead(notification.id)
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
|
{/* Unread dot */}
|
||||||
{!notification.is_read && (
|
{!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" />
|
<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
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"ml-3 grid h-9 w-9 shrink-0 place-items-center rounded-lg",
|
"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)} />
|
<Icon className={cn("h-4 w-4", cfg.color)} />
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p
|
<p
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-sm leading-snug text-grayScale-900",
|
"text-sm leading-snug text-grayScale-900",
|
||||||
!notification.is_read && "font-semibold",
|
!notification.is_read && "font-semibold"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{getNotificationTitle(notification) || "Notification"}
|
{getNotificationTitle(notification) || "Notification"}
|
||||||
|
|
@ -63,10 +110,11 @@ function NotificationItem({
|
||||||
{getNotificationMessage(notification) || "No preview text available."}
|
{getNotificationMessage(notification) || "No preview text available."}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-[11px] text-grayScale-600">
|
<p className="mt-1 text-[11px] text-grayScale-600">
|
||||||
{formatNotificationTimestamp(notification.timestamp)}
|
{formatTimestamp(notification.timestamp)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Read / Unread toggle */}
|
||||||
<button
|
<button
|
||||||
type="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"
|
className="hidden shrink-0 self-center rounded-md p-1.5 text-grayScale-400 hover:bg-grayScale-200 hover:text-grayScale-600 group-hover:block"
|
||||||
|
|
@ -92,11 +140,6 @@ function NotificationItem({
|
||||||
|
|
||||||
export function NotificationDropdown() {
|
export function NotificationDropdown() {
|
||||||
const [open, setOpen] = useState(false)
|
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 containerRef = useRef<HTMLDivElement>(null)
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const {
|
const {
|
||||||
|
|
@ -108,40 +151,7 @@ export function NotificationDropdown() {
|
||||||
markAllAsRead,
|
markAllAsRead,
|
||||||
} = useNotifications()
|
} = useNotifications()
|
||||||
|
|
||||||
const loadNotificationDetail = useCallback(async (id: string, markReadIfNeeded: boolean) => {
|
// Click-outside handler
|
||||||
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(() => {
|
useEffect(() => {
|
||||||
function handleMouseDown(e: MouseEvent) {
|
function handleMouseDown(e: MouseEvent) {
|
||||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||||
|
|
@ -155,98 +165,89 @@ export function NotificationDropdown() {
|
||||||
}, [open])
|
}, [open])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div ref={containerRef} className="relative">
|
||||||
<div ref={containerRef} className="relative">
|
{/* Bell button */}
|
||||||
<button
|
<button
|
||||||
type="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"
|
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"
|
aria-label="Notifications"
|
||||||
onClick={() => setOpen((prev) => !prev)}
|
onClick={() => setOpen((prev) => !prev)}
|
||||||
>
|
>
|
||||||
<Bell className="h-5 w-5" />
|
<Bell className="h-5 w-5" />
|
||||||
{unreadCount > 0 && (
|
{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">
|
<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}
|
{unreadCount > 99 ? "99+" : unreadCount}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{open && (
|
{/* Dropdown panel */}
|
||||||
<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">
|
{open && (
|
||||||
<div className="flex items-center justify-between border-b px-4 py-3">
|
<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 gap-2">
|
{/* Header */}
|
||||||
<h3 className="text-sm font-semibold text-grayScale-800">Notifications</h3>
|
<div className="flex items-center justify-between border-b px-4 py-3">
|
||||||
{unreadCount > 0 && (
|
<div className="flex items-center gap-2">
|
||||||
<Badge variant="default" className="px-1.5 py-0 text-[10px]">
|
<h3 className="text-sm font-semibold text-grayScale-800">
|
||||||
{unreadCount}
|
Notifications
|
||||||
</Badge>
|
</h3>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{unreadCount > 0 && (
|
{unreadCount > 0 && (
|
||||||
<button
|
<Badge variant="default" className="px-1.5 py-0 text-[10px]">
|
||||||
type="button"
|
{unreadCount}
|
||||||
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"
|
</Badge>
|
||||||
onClick={markAllAsRead}
|
|
||||||
>
|
|
||||||
<CheckCheck className="h-3.5 w-3.5" />
|
|
||||||
Mark all read
|
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</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
|
<button
|
||||||
type="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"
|
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={() => {
|
onClick={markAllAsRead}
|
||||||
setOpen(false)
|
|
||||||
navigate("/notifications")
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
View all notifications
|
<CheckCheck className="h-3.5 w-3.5" />
|
||||||
|
Mark all read
|
||||||
</button>
|
</button>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<NotificationDetailDialog
|
{/* Body */}
|
||||||
open={detailOpen}
|
<div className="max-h-[480px] overflow-y-auto">
|
||||||
onOpenChange={setDetailOpen}
|
{loading ? (
|
||||||
notification={selectedNotification}
|
<div className="flex items-center justify-center py-12">
|
||||||
loading={detailLoading}
|
<SpinnerIcon className="h-6 w-6" />
|
||||||
error={detailError}
|
</div>
|
||||||
onRetry={
|
) : notifications.length === 0 ? (
|
||||||
selectedNotificationId
|
<div className="flex flex-col items-center justify-center gap-2 py-12 text-grayScale-400">
|
||||||
? () => void loadNotificationDetail(selectedNotificationId, false)
|
<BellOff className="h-8 w-8" />
|
||||||
: undefined
|
<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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,7 @@ const buttonVariants = cva(
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: "bg-primary text-white hover:bg-brand-600 hover:text-white",
|
default: "bg-primary text-primary-foreground hover:bg-brand-600",
|
||||||
brand: "bg-brand-500 text-white hover:bg-brand-600 hover:text-white",
|
|
||||||
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
outline: "border bg-background hover:bg-grayScale-100",
|
outline: "border bg-background hover:bg-grayScale-100",
|
||||||
ghost: "hover:bg-grayScale-100",
|
ghost: "hover:bg-grayScale-100",
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ const DialogContent = React.forwardRef<
|
||||||
<DialogPrimitive.Content
|
<DialogPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-border bg-card p-6 text-card-foreground shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] rounded-lg",
|
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-white p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] rounded-lg",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ export const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||||
<input
|
<input
|
||||||
type={type}
|
type={type}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-10 w-full rounded-[6px] border border-input bg-grayScale-50 px-3 py-2 text-sm text-foreground ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-grayScale-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
"flex h-10 w-full rounded-[6px] border bg-white px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-grayScale-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ export const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<select
|
<select
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-11 w-full appearance-none rounded-xl border border-input bg-grayScale-50 px-3 py-2 pr-8 text-sm text-foreground shadow-sm ring-offset-background transition hover:bg-grayScale-100 focus-visible:border-brand-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-200 disabled:cursor-not-allowed disabled:opacity-50",
|
"flex h-11 w-full appearance-none rounded-xl border border-grayScale-200 bg-white px-3 py-2 pr-8 text-sm text-grayScale-600 shadow-sm ring-offset-background transition hover:bg-grayScale-50 focus-visible:border-brand-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-200 disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ export const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||||
return (
|
return (
|
||||||
<textarea
|
<textarea
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex min-h-[80px] w-full rounded-lg border border-input bg-grayScale-50 px-3 py-2 text-sm text-foreground ring-offset-background placeholder:text-grayScale-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
"flex min-h-[80px] w-full rounded-lg border bg-white px-3 py-2 text-sm ring-offset-background placeholder:text-grayScale-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|
|
||||||
|
|
@ -1,76 +0,0 @@
|
||||||
import {
|
|
||||||
createContext,
|
|
||||||
useCallback,
|
|
||||||
useContext,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useState,
|
|
||||||
type ReactNode,
|
|
||||||
} from "react"
|
|
||||||
import {
|
|
||||||
applyTheme,
|
|
||||||
getStoredTheme,
|
|
||||||
getSystemTheme,
|
|
||||||
resolveTheme,
|
|
||||||
THEME_STORAGE_KEY,
|
|
||||||
watchSystemTheme,
|
|
||||||
type ResolvedTheme,
|
|
||||||
type ThemeMode,
|
|
||||||
} from "../lib/theme"
|
|
||||||
|
|
||||||
type ThemeContextValue = {
|
|
||||||
theme: ThemeMode
|
|
||||||
resolvedTheme: ResolvedTheme
|
|
||||||
systemTheme: ResolvedTheme
|
|
||||||
setTheme: (mode: ThemeMode) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const ThemeContext = createContext<ThemeContextValue | null>(null)
|
|
||||||
|
|
||||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
|
||||||
const [theme, setThemeState] = useState<ThemeMode>(() => getStoredTheme())
|
|
||||||
const [resolvedTheme, setResolvedTheme] = useState<ResolvedTheme>(() =>
|
|
||||||
resolveTheme(getStoredTheme()),
|
|
||||||
)
|
|
||||||
const [systemTheme, setSystemTheme] = useState<ResolvedTheme>(() => getSystemTheme())
|
|
||||||
|
|
||||||
const setTheme = useCallback((mode: ThemeMode) => {
|
|
||||||
localStorage.setItem(THEME_STORAGE_KEY, mode)
|
|
||||||
setThemeState(mode)
|
|
||||||
setResolvedTheme(applyTheme(mode))
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setResolvedTheme(applyTheme(theme))
|
|
||||||
}, [theme])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return watchSystemTheme((next) => {
|
|
||||||
setSystemTheme(next)
|
|
||||||
if (theme === "system") {
|
|
||||||
setResolvedTheme(applyTheme("system"))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}, [theme])
|
|
||||||
|
|
||||||
const value = useMemo(
|
|
||||||
() => ({ theme, resolvedTheme, systemTheme, setTheme }),
|
|
||||||
[theme, resolvedTheme, systemTheme, setTheme],
|
|
||||||
)
|
|
||||||
|
|
||||||
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useTheme(): ThemeContextValue {
|
|
||||||
const ctx = useContext(ThemeContext)
|
|
||||||
if (!ctx) {
|
|
||||||
throw new Error("useTheme must be used within ThemeProvider")
|
|
||||||
}
|
|
||||||
return ctx
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useThemeOptional(): ThemeContextValue | null {
|
|
||||||
return useContext(ThemeContext)
|
|
||||||
}
|
|
||||||
|
|
||||||
export { getSystemTheme }
|
|
||||||
|
|
@ -1,228 +0,0 @@
|
||||||
/**
|
|
||||||
* 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]
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
import { useCallback, useEffect, useState } from "react"
|
|
||||||
import { getPersonas } from "../api/personas.api"
|
|
||||||
import {
|
|
||||||
mapPersonaToCard,
|
|
||||||
unwrapPersonasList,
|
|
||||||
type PersonaCardModel,
|
|
||||||
} from "../lib/personaDisplay"
|
|
||||||
|
|
||||||
type UseActivePersonasOptions = {
|
|
||||||
limit?: number
|
|
||||||
offset?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useActivePersonas(options: UseActivePersonasOptions = {}) {
|
|
||||||
const { limit = 50, offset = 0 } = options
|
|
||||||
const [personas, setPersonas] = useState<PersonaCardModel[]>([])
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
|
||||||
setLoading(true)
|
|
||||||
setError(null)
|
|
||||||
try {
|
|
||||||
const res = await getPersonas({ limit, offset })
|
|
||||||
const list = unwrapPersonasList(res).filter((p) => p.is_active)
|
|
||||||
setPersonas(list.map(mapPersonaToCard))
|
|
||||||
} catch (e: unknown) {
|
|
||||||
const msg =
|
|
||||||
(e as { response?: { data?: { message?: string } } })?.response?.data
|
|
||||||
?.message ?? "Failed to load personas"
|
|
||||||
setError(msg)
|
|
||||||
setPersonas([])
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}, [limit, offset])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void load()
|
|
||||||
}, [load])
|
|
||||||
|
|
||||||
return { personas, loading, error, reload: load }
|
|
||||||
}
|
|
||||||
|
|
@ -3,13 +3,12 @@ import { getNotifications, getUnreadCount, markAsRead, markAsUnread, markAllRead
|
||||||
import type { Notification } from "../types/notification.types"
|
import type { Notification } from "../types/notification.types"
|
||||||
|
|
||||||
const MAX_DROPDOWN = 5
|
const MAX_DROPDOWN = 5
|
||||||
const RECONNECT_MS = 5000
|
|
||||||
|
|
||||||
function getWsUrl() {
|
function getWsUrl() {
|
||||||
const base = import.meta.env.VITE_API_BASE_URL as string
|
const base = import.meta.env.VITE_API_BASE_URL as string
|
||||||
const wsBase = base.replace(/^https/, "wss").replace(/^http/, "ws")
|
const wsBase = base.replace(/^https/, "wss").replace(/^http/, "ws")
|
||||||
const token = localStorage.getItem("access_token") ?? ""
|
const token = localStorage.getItem("access_token") ?? ""
|
||||||
return `${wsBase}/ws/connect?token=${encodeURIComponent(token)}`
|
return `${wsBase}/ws/connect?token=${token}`
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useNotifications() {
|
export function useNotifications() {
|
||||||
|
|
@ -19,8 +18,6 @@ export function useNotifications() {
|
||||||
const wsRef = useRef<WebSocket | null>(null)
|
const wsRef = useRef<WebSocket | null>(null)
|
||||||
const reconnectTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
const reconnectTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
const mountedRef = useRef(true)
|
const mountedRef = useRef(true)
|
||||||
const intentionalCloseRef = useRef(false)
|
|
||||||
const connectAttemptRef = useRef(0)
|
|
||||||
|
|
||||||
const dispatchUpdate = () => {
|
const dispatchUpdate = () => {
|
||||||
window.dispatchEvent(new Event("notifications-updated"))
|
window.dispatchEvent(new Event("notifications-updated"))
|
||||||
|
|
@ -43,37 +40,11 @@ export function useNotifications() {
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const clearReconnectTimer = useCallback(() => {
|
|
||||||
if (reconnectTimer.current) {
|
|
||||||
clearTimeout(reconnectTimer.current)
|
|
||||||
reconnectTimer.current = null
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const disconnectWs = useCallback(
|
|
||||||
(intentional: boolean) => {
|
|
||||||
intentionalCloseRef.current = intentional
|
|
||||||
clearReconnectTimer()
|
|
||||||
const ws = wsRef.current
|
|
||||||
wsRef.current = null
|
|
||||||
if (!ws) return
|
|
||||||
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
|
|
||||||
ws.close()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[clearReconnectTimer],
|
|
||||||
)
|
|
||||||
|
|
||||||
const connectWs = useCallback(() => {
|
const connectWs = useCallback(() => {
|
||||||
if (!mountedRef.current) return
|
if (wsRef.current) {
|
||||||
|
wsRef.current.close()
|
||||||
|
}
|
||||||
|
|
||||||
const token = localStorage.getItem("access_token")?.trim()
|
|
||||||
if (!token) return
|
|
||||||
|
|
||||||
disconnectWs(true)
|
|
||||||
intentionalCloseRef.current = false
|
|
||||||
|
|
||||||
const attempt = ++connectAttemptRef.current
|
|
||||||
const ws = new WebSocket(getWsUrl())
|
const ws = new WebSocket(getWsUrl())
|
||||||
wsRef.current = ws
|
wsRef.current = ws
|
||||||
|
|
||||||
|
|
@ -107,45 +78,47 @@ export function useNotifications() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ws.onerror = () => {
|
||||||
|
ws.close()
|
||||||
|
}
|
||||||
|
|
||||||
ws.onclose = () => {
|
ws.onclose = () => {
|
||||||
if (connectAttemptRef.current !== attempt) return
|
if (!mountedRef.current) return
|
||||||
if (wsRef.current === ws) wsRef.current = null
|
|
||||||
if (!mountedRef.current || intentionalCloseRef.current) return
|
|
||||||
clearReconnectTimer()
|
|
||||||
reconnectTimer.current = setTimeout(() => {
|
reconnectTimer.current = setTimeout(() => {
|
||||||
if (mountedRef.current) connectWs()
|
if (mountedRef.current) connectWs()
|
||||||
}, RECONNECT_MS)
|
}, 5000)
|
||||||
}
|
}
|
||||||
}, [clearReconnectTimer, disconnectWs])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
mountedRef.current = true
|
mountedRef.current = true
|
||||||
intentionalCloseRef.current = false
|
|
||||||
fetchData()
|
fetchData()
|
||||||
connectWs()
|
connectWs()
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
mountedRef.current = false
|
mountedRef.current = false
|
||||||
disconnectWs(true)
|
wsRef.current?.close()
|
||||||
|
if (reconnectTimer.current) clearTimeout(reconnectTimer.current)
|
||||||
}
|
}
|
||||||
}, [fetchData, connectWs, disconnectWs])
|
}, [fetchData, connectWs])
|
||||||
|
|
||||||
const markOneRead = useCallback(async (id: string) => {
|
const markOneRead = useCallback(async (id: string) => {
|
||||||
setNotifications((prev) =>
|
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))
|
setUnreadCount((prev) => Math.max(0, prev - 1))
|
||||||
dispatchUpdate()
|
dispatchUpdate()
|
||||||
try {
|
try {
|
||||||
await markAsRead(id)
|
await markAsRead(id)
|
||||||
} catch {
|
} catch {
|
||||||
|
// revert on failure
|
||||||
await fetchData()
|
await fetchData()
|
||||||
}
|
}
|
||||||
}, [fetchData])
|
}, [fetchData])
|
||||||
|
|
||||||
const markOneUnread = useCallback(async (id: string) => {
|
const markOneUnread = useCallback(async (id: string) => {
|
||||||
setNotifications((prev) =>
|
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)
|
setUnreadCount((prev) => prev + 1)
|
||||||
dispatchUpdate()
|
dispatchUpdate()
|
||||||
|
|
|
||||||
109
src/index.css
109
src/index.css
|
|
@ -5,17 +5,7 @@
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
html {
|
:root {
|
||||||
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%;
|
--background: 0 0% 100%;
|
||||||
--foreground: 222.2 84% 4.9%;
|
--foreground: 222.2 84% 4.9%;
|
||||||
|
|
||||||
|
|
@ -48,46 +38,6 @@
|
||||||
--radius: 14px;
|
--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;
|
@apply border-border;
|
||||||
}
|
}
|
||||||
|
|
@ -99,61 +49,6 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@apply bg-grayScale-100 text-foreground font-sans antialiased transition-colors duration-200;
|
@apply bg-grayScale-100 text-foreground font-sans antialiased;
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,143 +0,0 @@
|
||||||
import { getTeamMemberById } from "../api/team.api"
|
|
||||||
import { getUserById } from "../api/users.api"
|
|
||||||
import { TEAM_ROLE_OPTIONS, formatTeamRoleLabel } from "./teamRoles"
|
|
||||||
import type { TeamMember } from "../types/team.types"
|
|
||||||
import type { UserProfileData } from "../types/user.types"
|
|
||||||
|
|
||||||
const TEAM_ROLE_VALUES = new Set(
|
|
||||||
TEAM_ROLE_OPTIONS.map((o) => o.value.toUpperCase()),
|
|
||||||
)
|
|
||||||
|
|
||||||
const APP_USER_ROLES = new Set(["STUDENT", "LEARNER", "USER", "SUBSCRIBER"])
|
|
||||||
|
|
||||||
export type ActorProfileKind = "team" | "user"
|
|
||||||
|
|
||||||
export type ActorProfile =
|
|
||||||
| {
|
|
||||||
kind: "team"
|
|
||||||
id: number
|
|
||||||
name: string
|
|
||||||
email: string
|
|
||||||
roleLabel: string
|
|
||||||
status: string
|
|
||||||
emailVerified: boolean
|
|
||||||
createdAt: string
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
kind: "user"
|
|
||||||
id: number
|
|
||||||
name: string
|
|
||||||
email: string
|
|
||||||
roleLabel: string
|
|
||||||
status: string
|
|
||||||
emailVerified: boolean
|
|
||||||
country: string
|
|
||||||
region: string
|
|
||||||
lastLogin: string | null
|
|
||||||
subscriptionStatus: string
|
|
||||||
createdAt: string
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeRole(role: string): string {
|
|
||||||
return role.trim().toUpperCase().replace(/[\s-]+/g, "_")
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Choose API from activity log `actor_role` (team_role vs learner role). */
|
|
||||||
export function resolveActorKind(actorRole: string | null | undefined): ActorProfileKind | null {
|
|
||||||
if (!actorRole?.trim()) return null
|
|
||||||
const upper = normalizeRole(actorRole)
|
|
||||||
if (TEAM_ROLE_VALUES.has(upper)) return "team"
|
|
||||||
if (APP_USER_ROLES.has(upper)) return "user"
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
function teamMemberToProfile(member: TeamMember): ActorProfile {
|
|
||||||
return {
|
|
||||||
kind: "team",
|
|
||||||
id: member.id,
|
|
||||||
name: [member.first_name, member.last_name].filter(Boolean).join(" ") || "—",
|
|
||||||
email: member.email || "—",
|
|
||||||
roleLabel: formatTeamRoleLabel(member.team_role),
|
|
||||||
status: member.status || "—",
|
|
||||||
emailVerified: Boolean(member.email_verified),
|
|
||||||
createdAt: member.created_at,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function userToProfile(user: UserProfileData): ActorProfile {
|
|
||||||
return {
|
|
||||||
kind: "user",
|
|
||||||
id: user.id,
|
|
||||||
name: [user.first_name, user.last_name].filter(Boolean).join(" ") || "—",
|
|
||||||
email: user.email || "—",
|
|
||||||
roleLabel: user.role
|
|
||||||
? user.role.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())
|
|
||||||
: "—",
|
|
||||||
status: user.status || "—",
|
|
||||||
emailVerified: Boolean(user.email_verified),
|
|
||||||
country: user.country || "—",
|
|
||||||
region: user.region || "—",
|
|
||||||
lastLogin: user.last_login,
|
|
||||||
subscriptionStatus: user.subscription_status?.trim() || "—",
|
|
||||||
createdAt: user.created_at,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const profileCache = new Map<string, ActorProfile | "error">()
|
|
||||||
|
|
||||||
function cacheKey(actorId: number, kind: ActorProfileKind): string {
|
|
||||||
return `${kind}:${actorId}`
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchTeamProfile(actorId: number): Promise<ActorProfile> {
|
|
||||||
const res = await getTeamMemberById(actorId)
|
|
||||||
return teamMemberToProfile(res.data.data)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchUserProfile(actorId: number): Promise<ActorProfile> {
|
|
||||||
const res = await getUserById(actorId)
|
|
||||||
return userToProfile(res.data.data)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchActorProfile(
|
|
||||||
actorId: number,
|
|
||||||
actorRole: string | null | undefined,
|
|
||||||
): Promise<ActorProfile> {
|
|
||||||
const kind = resolveActorKind(actorRole)
|
|
||||||
|
|
||||||
const load = async (target: ActorProfileKind): Promise<ActorProfile> => {
|
|
||||||
const key = cacheKey(actorId, target)
|
|
||||||
const cached = profileCache.get(key)
|
|
||||||
if (cached && cached !== "error") return cached
|
|
||||||
if (cached === "error") throw new Error("Actor not found")
|
|
||||||
|
|
||||||
try {
|
|
||||||
const profile =
|
|
||||||
target === "team" ? await fetchTeamProfile(actorId) : await fetchUserProfile(actorId)
|
|
||||||
profileCache.set(key, profile)
|
|
||||||
return profile
|
|
||||||
} catch (e) {
|
|
||||||
profileCache.set(key, "error")
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (kind === "team") return load("team")
|
|
||||||
if (kind === "user") return load("user")
|
|
||||||
|
|
||||||
try {
|
|
||||||
return await load("team")
|
|
||||||
} catch {
|
|
||||||
return load("user")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatActorDate(iso: string): string {
|
|
||||||
const d = new Date(iso)
|
|
||||||
if (Number.isNaN(d.getTime())) return iso
|
|
||||||
return d.toLocaleDateString(undefined, {
|
|
||||||
year: "numeric",
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
@ -1,140 +0,0 @@
|
||||||
import type {
|
|
||||||
DashboardDateFilter,
|
|
||||||
DashboardSubscriptions,
|
|
||||||
DateRevenue,
|
|
||||||
LabelCount,
|
|
||||||
} from "../types/analytics.types"
|
|
||||||
|
|
||||||
const INACTIVE_SUBSCRIPTION_STATUSES = new Set([
|
|
||||||
"INACTIVE",
|
|
||||||
"CANCELLED",
|
|
||||||
"CANCELED",
|
|
||||||
"EXPIRED",
|
|
||||||
"PAUSED",
|
|
||||||
"SUSPENDED",
|
|
||||||
])
|
|
||||||
|
|
||||||
export interface SubscriptionMetrics {
|
|
||||||
total: number
|
|
||||||
active: number
|
|
||||||
inactive: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Derives inactive count from by_status when present, else total − active. */
|
|
||||||
export function getSubscriptionMetrics(
|
|
||||||
subscriptions: DashboardSubscriptions,
|
|
||||||
): SubscriptionMetrics {
|
|
||||||
const total = subscriptions.total_subscriptions ?? 0
|
|
||||||
const active = subscriptions.active_subscriptions ?? 0
|
|
||||||
|
|
||||||
let inactiveFromStatus = 0
|
|
||||||
if (subscriptions.by_status?.length) {
|
|
||||||
inactiveFromStatus = subscriptions.by_status
|
|
||||||
.filter((s) => INACTIVE_SUBSCRIPTION_STATUSES.has(s.label.toUpperCase()))
|
|
||||||
.reduce((sum, s) => sum + s.count, 0)
|
|
||||||
|
|
||||||
if (inactiveFromStatus === 0) {
|
|
||||||
inactiveFromStatus = subscriptions.by_status
|
|
||||||
.filter((s) => s.label.toUpperCase() !== "ACTIVE")
|
|
||||||
.reduce((sum, s) => sum + s.count, 0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const inactive =
|
|
||||||
inactiveFromStatus > 0 ? inactiveFromStatus : Math.max(0, total - active)
|
|
||||||
|
|
||||||
return { total, active, inactive }
|
|
||||||
}
|
|
||||||
|
|
||||||
const MONTH_SHORT = [
|
|
||||||
"Jan",
|
|
||||||
"Feb",
|
|
||||||
"Mar",
|
|
||||||
"Apr",
|
|
||||||
"May",
|
|
||||||
"Jun",
|
|
||||||
"Jul",
|
|
||||||
"Aug",
|
|
||||||
"Sep",
|
|
||||||
"Oct",
|
|
||||||
"Nov",
|
|
||||||
"Dec",
|
|
||||||
] as const
|
|
||||||
|
|
||||||
function formatShortDate(iso: string) {
|
|
||||||
const d = new Date(iso)
|
|
||||||
return d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatBreakdownLabel(label: string) {
|
|
||||||
return label.replace(/_/g, " ").toLowerCase()
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getPrimaryQuestionTypeSummary(questionsByType: LabelCount[]): string {
|
|
||||||
if (questionsByType.length === 0) return "No question types"
|
|
||||||
const top = [...questionsByType].sort((a, b) => b.count - a.count)[0]
|
|
||||||
return `${top.count.toLocaleString()} ${formatBreakdownLabel(top.label)}`
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getVideoLessonsSummary(lmsLessonsWithVideo = 0, examPrepLessonsWithVideo = 0): string {
|
|
||||||
return `${lmsLessonsWithVideo.toLocaleString()} LMS · ${examPrepLessonsWithVideo.toLocaleString()} exam prep lessons`
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MonthlyRevenuePoint {
|
|
||||||
month: string
|
|
||||||
monthIndex: number
|
|
||||||
revenue: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export function aggregateRevenueByMonth(daily: DateRevenue[], year: number): MonthlyRevenuePoint[] {
|
|
||||||
const monthly = Array.from({ length: 12 }, (_, monthIndex) => ({
|
|
||||||
month: MONTH_SHORT[monthIndex],
|
|
||||||
monthIndex,
|
|
||||||
revenue: 0,
|
|
||||||
}))
|
|
||||||
|
|
||||||
for (const { date, revenue } of daily) {
|
|
||||||
const parsed = new Date(date)
|
|
||||||
if (parsed.getUTCFullYear() !== year) continue
|
|
||||||
monthly[parsed.getUTCMonth()].revenue += revenue
|
|
||||||
}
|
|
||||||
|
|
||||||
return monthly
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatRevenueAxisTick(value: number): string {
|
|
||||||
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(0)}M`
|
|
||||||
if (value >= 1_000) return `${Math.round(value / 1_000)}K`
|
|
||||||
return String(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getSeriesPeriodLabel(dateFilter?: DashboardDateFilter): string {
|
|
||||||
if (!dateFilter) return "Last 30 Days"
|
|
||||||
|
|
||||||
switch (dateFilter.mode) {
|
|
||||||
case "all_time":
|
|
||||||
return "Last 30 Days"
|
|
||||||
case "year":
|
|
||||||
return dateFilter.year != null ? String(dateFilter.year) : "Selected year"
|
|
||||||
case "year_month":
|
|
||||||
if (dateFilter.year != null && dateFilter.month != null) {
|
|
||||||
return `${MONTH_SHORT[dateFilter.month - 1]} ${dateFilter.year}`
|
|
||||||
}
|
|
||||||
return "Selected month"
|
|
||||||
case "custom":
|
|
||||||
if (dateFilter.from && dateFilter.to) {
|
|
||||||
return `${formatShortDate(dateFilter.from)} – ${formatShortDate(dateFilter.to)}`
|
|
||||||
}
|
|
||||||
return "Custom range"
|
|
||||||
default:
|
|
||||||
return "Selected period"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Display label for dashboard breakdown rows (regions, enums, free text). */
|
|
||||||
export function formatAnalyticsLabel(label: string): string {
|
|
||||||
const text = label?.trim() ?? ""
|
|
||||||
if (!text || text.toLowerCase() === "unknown") return "Unknown"
|
|
||||||
if (text.includes("_")) return text.replace(/_/g, " ")
|
|
||||||
return text
|
|
||||||
}
|
|
||||||
|
|
@ -1,79 +0,0 @@
|
||||||
import type {
|
|
||||||
AppPlatform,
|
|
||||||
AppUpdateType,
|
|
||||||
AppVersion,
|
|
||||||
AppVersionStatus,
|
|
||||||
} from "../types/app-version.types"
|
|
||||||
|
|
||||||
export const APP_PLATFORMS: { value: AppPlatform; label: string }[] = [
|
|
||||||
{ value: "ANDROID", label: "Android" },
|
|
||||||
{ value: "IOS", label: "iOS" },
|
|
||||||
]
|
|
||||||
|
|
||||||
export const APP_UPDATE_TYPES: { value: AppUpdateType; label: string; description: string }[] = [
|
|
||||||
{
|
|
||||||
value: "FORCE",
|
|
||||||
label: "Force update",
|
|
||||||
description: "Users must update before continuing",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "SOFT",
|
|
||||||
label: "Soft update",
|
|
||||||
description: "Recommended update; users can dismiss",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "OPTIONAL",
|
|
||||||
label: "Optional",
|
|
||||||
description: "Informational prompt only",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
export const APP_VERSION_STATUSES: { value: AppVersionStatus; label: string }[] = [
|
|
||||||
{ value: "ACTIVE", label: "Active" },
|
|
||||||
{ value: "INACTIVE", label: "Inactive" },
|
|
||||||
{ value: "DRAFT", label: "Draft" },
|
|
||||||
]
|
|
||||||
|
|
||||||
export const DEFAULT_STORE_URLS: Record<string, string> = {
|
|
||||||
ANDROID: "https://play.google.com/store/apps/details?id=com.yimaru.app",
|
|
||||||
IOS: "https://apps.apple.com/app/id000000000",
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatAppPlatform(platform: string): string {
|
|
||||||
const match = APP_PLATFORMS.find((p) => p.value === platform)
|
|
||||||
if (match) return match.label
|
|
||||||
return platform.replace(/_/g, " ").toLowerCase().replace(/\b\w/g, (c) => c.toUpperCase())
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatUpdateType(updateType: string): string {
|
|
||||||
const match = APP_UPDATE_TYPES.find((t) => t.value === updateType)
|
|
||||||
if (match) return match.label
|
|
||||||
return updateType.replace(/_/g, " ").toLowerCase().replace(/\b\w/g, (c) => c.toUpperCase())
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatVersionStatus(status: string): string {
|
|
||||||
const match = APP_VERSION_STATUSES.find((s) => s.value === status)
|
|
||||||
if (match) return match.label
|
|
||||||
return status.replace(/_/g, " ").toLowerCase().replace(/\b\w/g, (c) => c.toUpperCase())
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatAppVersionCreatedAt(raw: string): string {
|
|
||||||
if (!raw) return "—"
|
|
||||||
const normalized = raw.replace(" +0000 UTC", "Z").replace(/^(\d{4}-\d{2}-\d{2}) /, "$1T")
|
|
||||||
const d = new Date(normalized)
|
|
||||||
if (Number.isNaN(d.getTime())) {
|
|
||||||
const datePart = raw.split(" ")[0]
|
|
||||||
return datePart || raw
|
|
||||||
}
|
|
||||||
return d.toLocaleString(undefined, {
|
|
||||||
year: "numeric",
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function versionLabel(version: Pick<AppVersion, "version_name" | "version_code">): string {
|
|
||||||
return `v${version.version_name} (${version.version_code})`
|
|
||||||
}
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
/** Clear session tokens and redirect to login (full page navigation). */
|
|
||||||
export function logoutToLogin(options?: { passwordChanged?: boolean }) {
|
|
||||||
localStorage.removeItem("access_token")
|
|
||||||
localStorage.removeItem("refresh_token")
|
|
||||||
localStorage.removeItem("member_id")
|
|
||||||
localStorage.removeItem("role")
|
|
||||||
const search = options?.passwordChanged ? "?password_changed=1" : ""
|
|
||||||
window.location.href = `/login${search}`
|
|
||||||
}
|
|
||||||
|
|
@ -1,57 +0,0 @@
|
||||||
export type DynamicTableValue = {
|
|
||||||
columns: string[]
|
|
||||||
rows: string[][]
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULT_TABLE: DynamicTableValue = {
|
|
||||||
columns: ["Column 1", "Column 2"],
|
|
||||||
rows: [["", ""]],
|
|
||||||
}
|
|
||||||
|
|
||||||
function isRecord(v: unknown): v is Record<string, unknown> {
|
|
||||||
return v !== null && typeof v === "object" && !Array.isArray(v)
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeRows(columns: string[], rows: unknown): string[][] {
|
|
||||||
const colCount = Math.max(1, columns.length)
|
|
||||||
if (!Array.isArray(rows)) return [Array(colCount).fill("")]
|
|
||||||
return rows.map((row) => {
|
|
||||||
if (!Array.isArray(row)) return Array(colCount).fill("")
|
|
||||||
const cells = row.map((c) => String(c ?? ""))
|
|
||||||
while (cells.length < colCount) cells.push("")
|
|
||||||
return cells.slice(0, colCount)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseTableSlotValue(raw: string | undefined): DynamicTableValue {
|
|
||||||
const t = (raw ?? "").trim()
|
|
||||||
if (!t) return { ...DEFAULT_TABLE, columns: [...DEFAULT_TABLE.columns], rows: DEFAULT_TABLE.rows.map((r) => [...r]) }
|
|
||||||
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(t) as unknown
|
|
||||||
if (isRecord(parsed) && Array.isArray(parsed.columns)) {
|
|
||||||
const columns = parsed.columns.map((c) => String(c ?? "").trim() || "Column")
|
|
||||||
if (columns.length === 0) columns.push("Column 1")
|
|
||||||
const rows = normalizeRows(columns, parsed.rows)
|
|
||||||
return { columns, rows: rows.length > 0 ? rows : [Array(columns.length).fill("")] }
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
/* fall through */
|
|
||||||
}
|
|
||||||
|
|
||||||
return { ...DEFAULT_TABLE, columns: [...DEFAULT_TABLE.columns], rows: DEFAULT_TABLE.rows.map((r) => [...r]) }
|
|
||||||
}
|
|
||||||
|
|
||||||
export function serializeTableSlotValue(table: DynamicTableValue): string {
|
|
||||||
const columns = table.columns.map((c) => c.trim() || "Column")
|
|
||||||
const rows = normalizeRows(columns, table.rows).map((row) =>
|
|
||||||
row.map((cell) => cell.trim()),
|
|
||||||
)
|
|
||||||
return JSON.stringify({ columns, rows })
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createEmptyTable(columnCount = 2, rowCount = 1): DynamicTableValue {
|
|
||||||
const columns = Array.from({ length: Math.max(1, columnCount) }, (_, i) => `Column ${i + 1}`)
|
|
||||||
const rows = Array.from({ length: Math.max(1, rowCount) }, () => Array(columns.length).fill(""))
|
|
||||||
return { columns, rows }
|
|
||||||
}
|
|
||||||
|
|
@ -1,56 +0,0 @@
|
||||||
const SAMPLE_VALUES: Record<string, string> = {
|
|
||||||
OTP: "123456",
|
|
||||||
FirstName: "Alex",
|
|
||||||
ExpiresMinutes: "10",
|
|
||||||
ResetLink: "https://app.yimaruacademy.com/reset?token=sample",
|
|
||||||
InviteLink: "https://app.yimaruacademy.com/invite?token=sample",
|
|
||||||
InviterName: "Jordan Admin",
|
|
||||||
LoginURL: "https://app.yimaruacademy.com/login",
|
|
||||||
Subject: "Sample announcement subject",
|
|
||||||
Message:
|
|
||||||
"This is sample body text shown in the admin preview. Replace variables when sending real emails.",
|
|
||||||
}
|
|
||||||
|
|
||||||
function sampleForVariable(name: string) {
|
|
||||||
return SAMPLE_VALUES[name] ?? `[${name}]`
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Best-effort preview: substitutes `{{.Var}}` and unwraps simple `{{if .Var}}...{{end}}` blocks. */
|
|
||||||
export function renderEmailTemplatePreview(
|
|
||||||
source: string,
|
|
||||||
variables: string[],
|
|
||||||
): string {
|
|
||||||
let result = source
|
|
||||||
for (const variable of variables) {
|
|
||||||
const sample = sampleForVariable(variable)
|
|
||||||
result = result.split(`{{.${variable}}}`).join(sample)
|
|
||||||
const ifBlock = new RegExp(
|
|
||||||
`\\{\\{if \\.${variable}\\}\\}([\\s\\S]*?)\\{\\{end\\}\\}`,
|
|
||||||
"g",
|
|
||||||
)
|
|
||||||
result = result.replace(ifBlock, "$1")
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatEmailTemplateDate(raw: string | null | undefined) {
|
|
||||||
if (raw == null || String(raw).trim() === "") {
|
|
||||||
return "—"
|
|
||||||
}
|
|
||||||
const text = String(raw)
|
|
||||||
const parsed = new Date(text)
|
|
||||||
if (Number.isNaN(parsed.getTime())) {
|
|
||||||
return text.split(" +")[0]?.trim() || text
|
|
||||||
}
|
|
||||||
return parsed.toLocaleString(undefined, {
|
|
||||||
dateStyle: "medium",
|
|
||||||
timeStyle: "short",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function emailTemplateStatusBadgeVariant(status: string) {
|
|
||||||
const normalized = status.toUpperCase()
|
|
||||||
if (normalized === "ACTIVE") return "success" as const
|
|
||||||
if (normalized === "INACTIVE") return "secondary" as const
|
|
||||||
return "info" as const
|
|
||||||
}
|
|
||||||
|
|
@ -1,548 +0,0 @@
|
||||||
import type { CreateQuestionRequest, QuestionOption } from "../types/course.types"
|
|
||||||
import type { QuestionTypeDefinition } from "../types/questionTypeDefinition.types"
|
|
||||||
import { createEmptyTable, serializeTableSlotValue } from "./dynamicTableValue"
|
|
||||||
import { buildDynamicQuestionPayload } from "./practiceDynamicQuestionPayload"
|
|
||||||
import {
|
|
||||||
parseMultipleChoiceSlotValue,
|
|
||||||
serializeMultipleChoiceSlotValue,
|
|
||||||
defaultMultipleChoiceSlotValue,
|
|
||||||
validateMultipleChoiceSlotValue,
|
|
||||||
multipleChoiceSlotHasContent,
|
|
||||||
} from "./multipleChoiceSlotValue"
|
|
||||||
import {
|
|
||||||
defaultMatchingInputsSlotValue,
|
|
||||||
findMatchingInputsInFieldValues,
|
|
||||||
matchingAnswerSlotHasContent,
|
|
||||||
matchingInputsSlotHasContent,
|
|
||||||
parseMatchingAnswerSlotValue,
|
|
||||||
parseMatchingInputsSlotValue,
|
|
||||||
serializeMatchingAnswerSlotValue,
|
|
||||||
serializeMatchingInputsSlotValue,
|
|
||||||
validateMatchingAnswerSlotValue,
|
|
||||||
validateMatchingInputsSlotValue,
|
|
||||||
} from "./matchingSlotValue"
|
|
||||||
import {
|
|
||||||
defaultSelectMissingWordsStimulusSlotValue,
|
|
||||||
findSelectMissingWordsStimulusInFieldValues,
|
|
||||||
parseSelectMissingWordsResponseSlotValue,
|
|
||||||
parseSelectMissingWordsStimulusSlotValue,
|
|
||||||
selectMissingWordsResponseHasContent,
|
|
||||||
selectMissingWordsStimulusHasContent,
|
|
||||||
serializeSelectMissingWordsResponseSlotValue,
|
|
||||||
serializeSelectMissingWordsStimulusSlotValue,
|
|
||||||
validateSelectMissingWordsResponseSlotValue,
|
|
||||||
validateSelectMissingWordsStimulusSlotValue,
|
|
||||||
} from "./selectMissingWordsSlotValue"
|
|
||||||
|
|
||||||
function isMultipleChoiceKind(kind: string): boolean {
|
|
||||||
const u = kind.trim().toUpperCase()
|
|
||||||
return u === "MULTIPLE_CHOICE" || u === "OPTION"
|
|
||||||
}
|
|
||||||
|
|
||||||
function isMatchingInputsKind(kind: string): boolean {
|
|
||||||
return kind.trim().toUpperCase() === "MATCHING_INPUTS"
|
|
||||||
}
|
|
||||||
|
|
||||||
function isMatchingAnswerKind(kind: string): boolean {
|
|
||||||
return kind.trim().toUpperCase() === "MATCHING_ANSWER"
|
|
||||||
}
|
|
||||||
|
|
||||||
function isSelectMissingWordsKind(kind: string): boolean {
|
|
||||||
return kind.trim().toUpperCase() === "SELECT_MISSING_WORDS"
|
|
||||||
}
|
|
||||||
|
|
||||||
function isStructuredDynamicSlotKind(kind: string): boolean {
|
|
||||||
return (
|
|
||||||
isMultipleChoiceKind(kind) ||
|
|
||||||
isMatchingInputsKind(kind) ||
|
|
||||||
isMatchingAnswerKind(kind) ||
|
|
||||||
isSelectMissingWordsKind(kind)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function defaultValueForSchemaSlot(
|
|
||||||
kind: string,
|
|
||||||
side: "stimulus" | "response",
|
|
||||||
): string {
|
|
||||||
const u = kind.trim().toUpperCase()
|
|
||||||
if (u === "TABLE") {
|
|
||||||
return serializeTableSlotValue(createEmptyTable(2, 1))
|
|
||||||
}
|
|
||||||
if (isMultipleChoiceKind(kind)) {
|
|
||||||
return serializeMultipleChoiceSlotValue(defaultMultipleChoiceSlotValue())
|
|
||||||
}
|
|
||||||
if (isMatchingInputsKind(kind)) {
|
|
||||||
return serializeMatchingInputsSlotValue(defaultMatchingInputsSlotValue())
|
|
||||||
}
|
|
||||||
if (isMatchingAnswerKind(kind)) {
|
|
||||||
return serializeMatchingAnswerSlotValue({ pairs: [] })
|
|
||||||
}
|
|
||||||
if (isSelectMissingWordsKind(kind)) {
|
|
||||||
if (side === "stimulus") {
|
|
||||||
return serializeSelectMissingWordsStimulusSlotValue(
|
|
||||||
defaultSelectMissingWordsStimulusSlotValue(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return serializeSelectMissingWordsResponseSlotValue({ blanks: [] })
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
export function definitionUsesDynamicPayload(def: QuestionTypeDefinition): boolean {
|
|
||||||
return def.stimulus_schema.length > 0 || def.response_schema.length > 0
|
|
||||||
}
|
|
||||||
|
|
||||||
export function emptyDynamicFieldValuesForDefinition(
|
|
||||||
def: QuestionTypeDefinition,
|
|
||||||
): Record<string, string> {
|
|
||||||
const o: Record<string, string> = {}
|
|
||||||
for (const r of def.stimulus_schema) {
|
|
||||||
o[`stimulus:${r.id}`] = defaultValueForSchemaSlot(r.kind, "stimulus")
|
|
||||||
}
|
|
||||||
for (const r of def.response_schema) {
|
|
||||||
o[`response:${r.id}`] = defaultValueForSchemaSlot(r.kind, "response")
|
|
||||||
}
|
|
||||||
return o
|
|
||||||
}
|
|
||||||
|
|
||||||
const PROMPT_STIMULUS_KINDS = new Set(["QUESTION_TEXT", "INSTRUCTION", "TEXT_PASSAGE"])
|
|
||||||
|
|
||||||
/** First stimulus slot used for a plain-text prompt shortcut in the practice UI. */
|
|
||||||
export function primaryPromptStimulusRow(
|
|
||||||
def: QuestionTypeDefinition,
|
|
||||||
): { id: string; kind: string } | null {
|
|
||||||
for (const kind of ["QUESTION_TEXT", "INSTRUCTION", "TEXT_PASSAGE"] as const) {
|
|
||||||
const row = def.stimulus_schema.find((r) => r.kind === kind)
|
|
||||||
if (row) return { id: row.id, kind: row.kind }
|
|
||||||
}
|
|
||||||
return def.stimulus_schema[0] ? { id: def.stimulus_schema[0].id, kind: def.stimulus_schema[0].kind } : null
|
|
||||||
}
|
|
||||||
|
|
||||||
export function mergePromptIntoDynamicFieldValues(
|
|
||||||
def: QuestionTypeDefinition,
|
|
||||||
questionText: string,
|
|
||||||
fieldValues: Record<string, string>,
|
|
||||||
): Record<string, string> {
|
|
||||||
const merged = { ...fieldValues }
|
|
||||||
const prompt = questionText.trim()
|
|
||||||
if (!prompt) return merged
|
|
||||||
const slot = primaryPromptStimulusRow(def)
|
|
||||||
if (!slot) return merged
|
|
||||||
const key = `stimulus:${slot.id}`
|
|
||||||
if (!merged[key]?.trim()) merged[key] = prompt
|
|
||||||
return merged
|
|
||||||
}
|
|
||||||
|
|
||||||
export function dynamicPromptFromFieldValues(
|
|
||||||
def: QuestionTypeDefinition,
|
|
||||||
fieldValues: Record<string, string>,
|
|
||||||
): string {
|
|
||||||
for (const row of def.stimulus_schema) {
|
|
||||||
if (!PROMPT_STIMULUS_KINDS.has(row.kind)) continue
|
|
||||||
const v = fieldValues[`stimulus:${row.id}`]?.trim()
|
|
||||||
if (v) return v
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* System definitions with empty schema map to classic POST /questions types.
|
|
||||||
* Returns null when the payload must be DYNAMIC (schema-driven or unknown).
|
|
||||||
*/
|
|
||||||
export function legacyQuestionTypeFromDefinition(
|
|
||||||
def: QuestionTypeDefinition,
|
|
||||||
): "MCQ" | "TRUE_FALSE" | "SHORT_ANSWER" | null {
|
|
||||||
if (definitionUsesDynamicPayload(def)) return null
|
|
||||||
const k = def.key.toLowerCase()
|
|
||||||
if (k === "multiple_choice") return "MCQ"
|
|
||||||
if (k === "true_false") return "TRUE_FALSE"
|
|
||||||
if (k === "short_answer" || k === "fill_in_the_blank") return "SHORT_ANSWER"
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LearnEnglishDefinitionQuestionInput {
|
|
||||||
questionText: string
|
|
||||||
questionTypeDefinitionId: number
|
|
||||||
dynamicFieldValues: Record<string, string>
|
|
||||||
mcqOptions?: { option_text: string; is_correct: boolean }[]
|
|
||||||
trueFalseAnswerIsTrue?: boolean
|
|
||||||
shortAnswers?: string[]
|
|
||||||
voicePromptUrl?: string
|
|
||||||
sampleAnswerVoiceUrl?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function questionRowHasContent(
|
|
||||||
q: LearnEnglishDefinitionQuestionInput,
|
|
||||||
def: QuestionTypeDefinition,
|
|
||||||
): boolean {
|
|
||||||
if (!definitionUsesDynamicPayload(def)) {
|
|
||||||
return Boolean(q.questionText.trim())
|
|
||||||
}
|
|
||||||
if (q.questionText.trim()) return true
|
|
||||||
const fv = q.dynamicFieldValues ?? {}
|
|
||||||
for (const row of def.stimulus_schema) {
|
|
||||||
if (isMultipleChoiceKind(row.kind)) {
|
|
||||||
if (
|
|
||||||
multipleChoiceSlotHasContent(
|
|
||||||
parseMultipleChoiceSlotValue(fv[`stimulus:${row.id}`]),
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (isMatchingInputsKind(row.kind)) {
|
|
||||||
if (
|
|
||||||
matchingInputsSlotHasContent(
|
|
||||||
parseMatchingInputsSlotValue(fv[`stimulus:${row.id}`]),
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (isMatchingAnswerKind(row.kind)) {
|
|
||||||
if (
|
|
||||||
matchingAnswerSlotHasContent(
|
|
||||||
parseMatchingAnswerSlotValue(fv[`stimulus:${row.id}`]),
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (isSelectMissingWordsKind(row.kind)) {
|
|
||||||
if (
|
|
||||||
selectMissingWordsStimulusHasContent(
|
|
||||||
parseSelectMissingWordsStimulusSlotValue(fv[`stimulus:${row.id}`]),
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (fv[`stimulus:${row.id}`]?.trim()) return true
|
|
||||||
}
|
|
||||||
for (const row of def.response_schema) {
|
|
||||||
if (isMultipleChoiceKind(row.kind)) {
|
|
||||||
if (
|
|
||||||
multipleChoiceSlotHasContent(
|
|
||||||
parseMultipleChoiceSlotValue(fv[`response:${row.id}`]),
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (isMatchingInputsKind(row.kind)) {
|
|
||||||
if (
|
|
||||||
matchingInputsSlotHasContent(
|
|
||||||
parseMatchingInputsSlotValue(fv[`response:${row.id}`]),
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (isMatchingAnswerKind(row.kind)) {
|
|
||||||
if (
|
|
||||||
matchingAnswerSlotHasContent(
|
|
||||||
parseMatchingAnswerSlotValue(fv[`response:${row.id}`]),
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (isSelectMissingWordsKind(row.kind)) {
|
|
||||||
if (
|
|
||||||
selectMissingWordsResponseHasContent(
|
|
||||||
parseSelectMissingWordsResponseSlotValue(fv[`response:${row.id}`]),
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (fv[`response:${row.id}`]?.trim()) return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildCreateQuestionFromDefinition(
|
|
||||||
def: QuestionTypeDefinition,
|
|
||||||
q: LearnEnglishDefinitionQuestionInput,
|
|
||||||
status: "DRAFT" | "PUBLISHED",
|
|
||||||
): CreateQuestionRequest {
|
|
||||||
const difficulty = "EASY"
|
|
||||||
const points = 1
|
|
||||||
const question_text = q.questionText.trim()
|
|
||||||
|
|
||||||
if (definitionUsesDynamicPayload(def)) {
|
|
||||||
const fieldValues = mergePromptIntoDynamicFieldValues(
|
|
||||||
def,
|
|
||||||
q.questionText,
|
|
||||||
q.dynamicFieldValues ?? {},
|
|
||||||
)
|
|
||||||
const payload = buildDynamicQuestionPayload({
|
|
||||||
stimulusRows: def.stimulus_schema.map((r) => ({ id: r.id, kind: r.kind })),
|
|
||||||
responseRows: def.response_schema.map((r) => ({ id: r.id, kind: r.kind })),
|
|
||||||
fieldValues,
|
|
||||||
mcqOptions: q.mcqOptions,
|
|
||||||
})
|
|
||||||
return {
|
|
||||||
question_type: "DYNAMIC",
|
|
||||||
question_type_definition_id: def.id,
|
|
||||||
difficulty_level: difficulty,
|
|
||||||
points,
|
|
||||||
status,
|
|
||||||
dynamic_payload: payload,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const legacy = legacyQuestionTypeFromDefinition(def)
|
|
||||||
if (legacy === "MCQ") {
|
|
||||||
const options: QuestionOption[] = (q.mcqOptions ?? [])
|
|
||||||
.filter((o) => o.option_text.trim())
|
|
||||||
.map((o, idx) => ({
|
|
||||||
option_order: idx + 1,
|
|
||||||
option_text: o.option_text.trim(),
|
|
||||||
is_correct: o.is_correct,
|
|
||||||
}))
|
|
||||||
return {
|
|
||||||
question_text,
|
|
||||||
question_type: "MCQ",
|
|
||||||
difficulty_level: difficulty,
|
|
||||||
points,
|
|
||||||
status,
|
|
||||||
options,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (legacy === "TRUE_FALSE") {
|
|
||||||
const trueCorrect = q.trueFalseAnswerIsTrue !== false
|
|
||||||
const options: QuestionOption[] = [
|
|
||||||
{ option_order: 1, option_text: "True", is_correct: trueCorrect },
|
|
||||||
{ option_order: 2, option_text: "False", is_correct: !trueCorrect },
|
|
||||||
]
|
|
||||||
return {
|
|
||||||
question_text,
|
|
||||||
question_type: "TRUE_FALSE",
|
|
||||||
difficulty_level: difficulty,
|
|
||||||
points,
|
|
||||||
status,
|
|
||||||
options,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (legacy === "SHORT_ANSWER") {
|
|
||||||
const short_answers = (q.shortAnswers ?? [])
|
|
||||||
.map((s) => s.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
.map((acceptable_answer) => ({
|
|
||||||
acceptable_answer,
|
|
||||||
match_type: "CASE_INSENSITIVE" as const,
|
|
||||||
}))
|
|
||||||
return {
|
|
||||||
question_text,
|
|
||||||
question_type: "SHORT_ANSWER",
|
|
||||||
difficulty_level: difficulty,
|
|
||||||
points,
|
|
||||||
status,
|
|
||||||
short_answers,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
question_type: "DYNAMIC",
|
|
||||||
question_type_definition_id: def.id,
|
|
||||||
difficulty_level: difficulty,
|
|
||||||
points,
|
|
||||||
status,
|
|
||||||
dynamic_payload: { stimulus: [], response: [] },
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function validateDefinitionQuestion(
|
|
||||||
def: QuestionTypeDefinition,
|
|
||||||
q: LearnEnglishDefinitionQuestionInput,
|
|
||||||
index1Based: number,
|
|
||||||
): string | null {
|
|
||||||
const n = index1Based
|
|
||||||
|
|
||||||
if (definitionUsesDynamicPayload(def)) {
|
|
||||||
const fieldValues = mergePromptIntoDynamicFieldValues(
|
|
||||||
def,
|
|
||||||
q.questionText,
|
|
||||||
q.dynamicFieldValues ?? {},
|
|
||||||
)
|
|
||||||
const hasPrompt =
|
|
||||||
Boolean(q.questionText.trim()) || Boolean(dynamicPromptFromFieldValues(def, fieldValues))
|
|
||||||
const promptRow = def.stimulus_schema.find((r) => PROMPT_STIMULUS_KINDS.has(r.kind) && r.required)
|
|
||||||
if (promptRow && !hasPrompt) {
|
|
||||||
return `Question ${n}: enter prompt text (${promptRow.label || promptRow.id}).`
|
|
||||||
}
|
|
||||||
for (const row of def.stimulus_schema) {
|
|
||||||
if (isStructuredDynamicSlotKind(row.kind)) continue
|
|
||||||
if (!row.required) continue
|
|
||||||
const v = fieldValues[`stimulus:${row.id}`]?.trim()
|
|
||||||
if (!v)
|
|
||||||
return `Question ${n}: fill required stimulus "${row.label || row.id}" (${row.kind}).`
|
|
||||||
}
|
|
||||||
for (const row of def.response_schema) {
|
|
||||||
if (isStructuredDynamicSlotKind(row.kind)) continue
|
|
||||||
if (!row.required) continue
|
|
||||||
const v = fieldValues[`response:${row.id}`]?.trim()
|
|
||||||
if (!v)
|
|
||||||
return `Question ${n}: fill required response "${row.label || row.id}" (${row.kind}).`
|
|
||||||
}
|
|
||||||
const matchingInputs = findMatchingInputsInFieldValues(
|
|
||||||
fieldValues,
|
|
||||||
def.stimulus_schema,
|
|
||||||
def.response_schema,
|
|
||||||
)
|
|
||||||
const clozeStimulus = findSelectMissingWordsStimulusInFieldValues(
|
|
||||||
fieldValues,
|
|
||||||
def.stimulus_schema,
|
|
||||||
)
|
|
||||||
for (const row of def.stimulus_schema) {
|
|
||||||
if (isMultipleChoiceKind(row.kind)) {
|
|
||||||
const val = parseMultipleChoiceSlotValue(fieldValues[`stimulus:${row.id}`])
|
|
||||||
if (!multipleChoiceSlotHasContent(val)) {
|
|
||||||
if (row.required) {
|
|
||||||
return `Question ${n}: add choices for stimulus "${row.label || row.id}".`
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
const mcqErr = validateMultipleChoiceSlotValue(val)
|
|
||||||
if (mcqErr) {
|
|
||||||
return `Question ${n} (stimulus "${row.label || row.id}"): ${mcqErr}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (isMatchingInputsKind(row.kind)) {
|
|
||||||
const val = parseMatchingInputsSlotValue(fieldValues[`stimulus:${row.id}`])
|
|
||||||
if (!matchingInputsSlotHasContent(val)) {
|
|
||||||
if (row.required) {
|
|
||||||
return `Question ${n}: add matching inputs for stimulus "${row.label || row.id}".`
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
const err = validateMatchingInputsSlotValue(val)
|
|
||||||
if (err) {
|
|
||||||
return `Question ${n} (stimulus "${row.label || row.id}"): ${err}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (isMatchingAnswerKind(row.kind)) {
|
|
||||||
const val = parseMatchingAnswerSlotValue(
|
|
||||||
fieldValues[`stimulus:${row.id}`],
|
|
||||||
matchingInputs,
|
|
||||||
)
|
|
||||||
if (!matchingAnswerSlotHasContent(val)) {
|
|
||||||
if (row.required) {
|
|
||||||
return `Question ${n}: add matching pairs for stimulus "${row.label || row.id}".`
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
const err = validateMatchingAnswerSlotValue(val, matchingInputs)
|
|
||||||
if (err) {
|
|
||||||
return `Question ${n} (stimulus "${row.label || row.id}"): ${err}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (isSelectMissingWordsKind(row.kind)) {
|
|
||||||
const val = parseSelectMissingWordsStimulusSlotValue(
|
|
||||||
fieldValues[`stimulus:${row.id}`],
|
|
||||||
)
|
|
||||||
if (!selectMissingWordsStimulusHasContent(val)) {
|
|
||||||
if (row.required) {
|
|
||||||
return `Question ${n}: add cloze passage for stimulus "${row.label || row.id}".`
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
const err = validateSelectMissingWordsStimulusSlotValue(val)
|
|
||||||
if (err) {
|
|
||||||
return `Question ${n} (stimulus "${row.label || row.id}"): ${err}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const row of def.response_schema) {
|
|
||||||
if (isMultipleChoiceKind(row.kind)) {
|
|
||||||
const val = parseMultipleChoiceSlotValue(fieldValues[`response:${row.id}`])
|
|
||||||
if (!multipleChoiceSlotHasContent(val)) {
|
|
||||||
if (row.required) {
|
|
||||||
return `Question ${n}: add choices for response "${row.label || row.id}".`
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
const mcqErr = validateMultipleChoiceSlotValue(val)
|
|
||||||
if (mcqErr) {
|
|
||||||
return `Question ${n} (response "${row.label || row.id}"): ${mcqErr}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (isMatchingInputsKind(row.kind)) {
|
|
||||||
const val = parseMatchingInputsSlotValue(fieldValues[`response:${row.id}`])
|
|
||||||
if (!matchingInputsSlotHasContent(val)) {
|
|
||||||
if (row.required) {
|
|
||||||
return `Question ${n}: add matching inputs for response "${row.label || row.id}".`
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
const err = validateMatchingInputsSlotValue(val)
|
|
||||||
if (err) {
|
|
||||||
return `Question ${n} (response "${row.label || row.id}"): ${err}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (isMatchingAnswerKind(row.kind)) {
|
|
||||||
const val = parseMatchingAnswerSlotValue(
|
|
||||||
fieldValues[`response:${row.id}`],
|
|
||||||
matchingInputs,
|
|
||||||
)
|
|
||||||
if (!matchingAnswerSlotHasContent(val)) {
|
|
||||||
if (row.required) {
|
|
||||||
return `Question ${n}: add matching pairs for response "${row.label || row.id}".`
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
const err = validateMatchingAnswerSlotValue(val, matchingInputs)
|
|
||||||
if (err) {
|
|
||||||
return `Question ${n} (response "${row.label || row.id}"): ${err}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (isSelectMissingWordsKind(row.kind)) {
|
|
||||||
const val = parseSelectMissingWordsResponseSlotValue(
|
|
||||||
fieldValues[`response:${row.id}`],
|
|
||||||
clozeStimulus,
|
|
||||||
)
|
|
||||||
if (!selectMissingWordsResponseHasContent(val)) {
|
|
||||||
if (row.required) {
|
|
||||||
return `Question ${n}: select words for each blank in response "${row.label || row.id}".`
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
const err = validateSelectMissingWordsResponseSlotValue(val, clozeStimulus)
|
|
||||||
if (err) {
|
|
||||||
return `Question ${n} (response "${row.label || row.id}"): ${err}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!q.questionText.trim()) return `Question ${n}: enter question text.`
|
|
||||||
|
|
||||||
const legacy = legacyQuestionTypeFromDefinition(def)
|
|
||||||
if (legacy === "MCQ") {
|
|
||||||
const opts = (q.mcqOptions ?? []).filter((o) => o.option_text.trim())
|
|
||||||
if (opts.length < 2)
|
|
||||||
return `Question ${n} (${def.display_name}): add at least two choices with text.`
|
|
||||||
if (!opts.some((o) => o.is_correct))
|
|
||||||
return `Question ${n} (${def.display_name}): mark one correct choice.`
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
if (legacy === "TRUE_FALSE") return null
|
|
||||||
if (legacy === "SHORT_ANSWER") {
|
|
||||||
const answers = (q.shortAnswers ?? []).map((s) => s.trim()).filter(Boolean)
|
|
||||||
if (answers.length < 1)
|
|
||||||
return `Question ${n} (${def.display_name}): add at least one acceptable answer.`
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
@ -1,164 +0,0 @@
|
||||||
import type { AxiosError } from "axios"
|
|
||||||
import {
|
|
||||||
addQuestionToSet,
|
|
||||||
createExamPrepLessonPractice,
|
|
||||||
createParentLinkedPractice,
|
|
||||||
createQuestion,
|
|
||||||
createQuestionSet,
|
|
||||||
} from "../api/courses.api"
|
|
||||||
import type { PracticeParentKind } from "../types/course.types"
|
|
||||||
import type { QuestionTypeDefinition } from "../types/questionTypeDefinition.types"
|
|
||||||
import {
|
|
||||||
buildCreateQuestionFromDefinition,
|
|
||||||
questionRowHasContent,
|
|
||||||
validateDefinitionQuestion,
|
|
||||||
type LearnEnglishDefinitionQuestionInput,
|
|
||||||
} from "./learnEnglishDefinitionQuestion"
|
|
||||||
|
|
||||||
export type { LearnEnglishDefinitionQuestionInput } from "./learnEnglishDefinitionQuestion"
|
|
||||||
|
|
||||||
export function learnEnglishPracticeApiErrorMessage(err: unknown): string {
|
|
||||||
const ax = err as AxiosError<{ message?: string; error?: string }>
|
|
||||||
const data = ax.response?.data
|
|
||||||
if (data && typeof data === "object") {
|
|
||||||
const m = data.message ?? data.error
|
|
||||||
if (typeof m === "string" && m.trim()) return m.trim()
|
|
||||||
}
|
|
||||||
if (err instanceof Error && err.message) return err.message
|
|
||||||
return "Request failed"
|
|
||||||
}
|
|
||||||
|
|
||||||
export function validateLearnEnglishQuestionsWithDefinitions(
|
|
||||||
questions: LearnEnglishDefinitionQuestionInput[],
|
|
||||||
definitions: QuestionTypeDefinition[],
|
|
||||||
): string | null {
|
|
||||||
const byId = new Map(definitions.map((d) => [d.id, d]))
|
|
||||||
const filled = questions.filter((q) => {
|
|
||||||
const def = byId.get(q.questionTypeDefinitionId)
|
|
||||||
return def ? questionRowHasContent(q, def) : false
|
|
||||||
})
|
|
||||||
if (filled.length === 0) return "Add at least one question with content."
|
|
||||||
for (let i = 0; i < filled.length; i++) {
|
|
||||||
const q = filled[i]
|
|
||||||
if (!Number.isFinite(q.questionTypeDefinitionId) || q.questionTypeDefinitionId <= 0) {
|
|
||||||
return `Question ${i + 1}: select a question type from the list.`
|
|
||||||
}
|
|
||||||
const def = byId.get(q.questionTypeDefinitionId)
|
|
||||||
if (!def) {
|
|
||||||
return `Question ${i + 1}: type definition #${q.questionTypeDefinitionId} was not found. Refresh and try again.`
|
|
||||||
}
|
|
||||||
const err = validateDefinitionQuestion(def, q, i + 1)
|
|
||||||
if (err) return err
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Learn English parent-linked practice: create PRACTICE question set,
|
|
||||||
* create questions from GET /questions/type-definitions entries, attach them, POST /practices.
|
|
||||||
*/
|
|
||||||
export async function executeLearnEnglishPracticeCreation(opts: {
|
|
||||||
parentKind: PracticeParentKind
|
|
||||||
parentId: number
|
|
||||||
status: "DRAFT" | "PUBLISHED"
|
|
||||||
questionSetTitle: string
|
|
||||||
questionSetDescription?: string | null
|
|
||||||
shuffleQuestions: boolean
|
|
||||||
practiceTitle: string
|
|
||||||
storyDescription: string
|
|
||||||
storyImage: string
|
|
||||||
quickTips: string
|
|
||||||
personaName?: string | null
|
|
||||||
/** Selected persona from step 2 — sent as `persona_id` on POST /practices. */
|
|
||||||
personaId: number
|
|
||||||
questions: LearnEnglishDefinitionQuestionInput[]
|
|
||||||
definitions: QuestionTypeDefinition[]
|
|
||||||
/** When set, links practice via POST /exam-prep/lessons/:id/practices instead of POST /practices. */
|
|
||||||
examPrepLessonId?: number
|
|
||||||
}): Promise<{ questionSetId: number; practiceId: number }> {
|
|
||||||
const err = validateLearnEnglishQuestionsWithDefinitions(
|
|
||||||
opts.questions,
|
|
||||||
opts.definitions,
|
|
||||||
)
|
|
||||||
if (err) throw new Error(err)
|
|
||||||
|
|
||||||
if (!Number.isFinite(opts.personaId) || opts.personaId < 1) {
|
|
||||||
throw new Error("persona_id is required. Select a persona before saving.")
|
|
||||||
}
|
|
||||||
|
|
||||||
const byId = new Map(opts.definitions.map((d) => [d.id, d]))
|
|
||||||
|
|
||||||
const setRes = await createQuestionSet({
|
|
||||||
title: opts.questionSetTitle.trim() || "Practice question set",
|
|
||||||
description: opts.questionSetDescription?.trim() || null,
|
|
||||||
set_type: "PRACTICE",
|
|
||||||
owner_type: opts.parentKind,
|
|
||||||
owner_id: opts.parentId,
|
|
||||||
shuffle_questions: opts.shuffleQuestions,
|
|
||||||
status: opts.status,
|
|
||||||
...(opts.personaName?.trim() ? { persona: opts.personaName.trim() } : {}),
|
|
||||||
})
|
|
||||||
|
|
||||||
const setId = setRes.data?.data?.id
|
|
||||||
if (!setId) {
|
|
||||||
throw new Error(
|
|
||||||
(setRes.data as { message?: string } | undefined)?.message ??
|
|
||||||
"Could not create question set",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const toCreate = opts.questions.filter((q) => {
|
|
||||||
const def = byId.get(q.questionTypeDefinitionId)
|
|
||||||
return def ? questionRowHasContent(q, def) : false
|
|
||||||
})
|
|
||||||
let displayOrder = 0
|
|
||||||
for (const q of toCreate) {
|
|
||||||
const def = byId.get(q.questionTypeDefinitionId)
|
|
||||||
if (!def) throw new Error(`Missing definition #${q.questionTypeDefinitionId}`)
|
|
||||||
displayOrder += 1
|
|
||||||
const payload = buildCreateQuestionFromDefinition(def, q, opts.status)
|
|
||||||
const qRes = await createQuestion(payload)
|
|
||||||
const questionId = qRes.data?.data?.id
|
|
||||||
if (!questionId) {
|
|
||||||
throw new Error(
|
|
||||||
(qRes.data as { message?: string } | undefined)?.message ??
|
|
||||||
"Could not create question",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
await addQuestionToSet(setId, {
|
|
||||||
question_id: questionId,
|
|
||||||
display_order: displayOrder,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const practiceRes = opts.examPrepLessonId
|
|
||||||
? await createExamPrepLessonPractice(opts.examPrepLessonId, {
|
|
||||||
title: opts.practiceTitle.trim(),
|
|
||||||
story_description: opts.storyDescription.trim(),
|
|
||||||
story_image: opts.storyImage.trim(),
|
|
||||||
persona_id: opts.personaId,
|
|
||||||
question_set_id: setId,
|
|
||||||
quick_tips: opts.quickTips.trim(),
|
|
||||||
})
|
|
||||||
: await createParentLinkedPractice({
|
|
||||||
parent_kind: opts.parentKind,
|
|
||||||
parent_id: opts.parentId,
|
|
||||||
title: opts.practiceTitle.trim(),
|
|
||||||
story_description: opts.storyDescription.trim(),
|
|
||||||
story_image: opts.storyImage.trim(),
|
|
||||||
question_set_id: setId,
|
|
||||||
quick_tips: opts.quickTips.trim(),
|
|
||||||
publish_status: opts.status,
|
|
||||||
persona_id: opts.personaId,
|
|
||||||
})
|
|
||||||
|
|
||||||
const practiceId = practiceRes.data?.data?.id
|
|
||||||
if (!practiceId) {
|
|
||||||
throw new Error(
|
|
||||||
(practiceRes.data as { message?: string } | undefined)?.message ??
|
|
||||||
"Could not create practice",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return { questionSetId: setId, practiceId }
|
|
||||||
}
|
|
||||||
|
|
@ -1,354 +0,0 @@
|
||||||
export interface MatchingSideItem {
|
|
||||||
id: string
|
|
||||||
text: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MatchingInputsSlotValue {
|
|
||||||
left: MatchingSideItem[]
|
|
||||||
right: MatchingSideItem[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MatchingPair {
|
|
||||||
left_id: string
|
|
||||||
right_id: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MatchingAnswerSlotValue {
|
|
||||||
pairs: MatchingPair[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export const MATCHING_MIN_ITEMS = 2
|
|
||||||
|
|
||||||
const DEFAULT_INPUT_COUNT = 4
|
|
||||||
|
|
||||||
function reindexSide(
|
|
||||||
items: MatchingSideItem[],
|
|
||||||
prefix: "l" | "r",
|
|
||||||
): MatchingSideItem[] {
|
|
||||||
return items.map((item, index) => ({
|
|
||||||
id: `${prefix}${index + 1}`,
|
|
||||||
text: item.text,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
export function defaultMatchingInputsSlotValue(
|
|
||||||
count = DEFAULT_INPUT_COUNT,
|
|
||||||
): MatchingInputsSlotValue {
|
|
||||||
return {
|
|
||||||
left: Array.from({ length: count }, (_, index) => ({
|
|
||||||
id: `l${index + 1}`,
|
|
||||||
text: "",
|
|
||||||
})),
|
|
||||||
right: Array.from({ length: count }, (_, index) => ({
|
|
||||||
id: `r${index + 1}`,
|
|
||||||
text: "",
|
|
||||||
})),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function defaultMatchingAnswerFromInputs(
|
|
||||||
inputs: MatchingInputsSlotValue,
|
|
||||||
): MatchingAnswerSlotValue {
|
|
||||||
const count = Math.min(inputs.left.length, inputs.right.length)
|
|
||||||
return {
|
|
||||||
pairs: Array.from({ length: count }, (_, index) => ({
|
|
||||||
left_id: inputs.left[index]?.id ?? `l${index + 1}`,
|
|
||||||
right_id: inputs.right[index]?.id ?? `r${index + 1}`,
|
|
||||||
})),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function serializeMatchingInputsSlotValue(
|
|
||||||
value: MatchingInputsSlotValue,
|
|
||||||
): string {
|
|
||||||
return JSON.stringify(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function serializeMatchingAnswerSlotValue(
|
|
||||||
value: MatchingAnswerSlotValue,
|
|
||||||
): string {
|
|
||||||
return JSON.stringify(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function matchingItemHasValue(text: string): boolean {
|
|
||||||
return text.length > 0
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeSideItem(raw: unknown, index: number, prefix: "l" | "r"): MatchingSideItem {
|
|
||||||
if (raw && typeof raw === "object") {
|
|
||||||
const record = raw as Record<string, unknown>
|
|
||||||
return {
|
|
||||||
id: String(record.id ?? `${prefix}${index + 1}`),
|
|
||||||
text: String(record.text ?? ""),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
id: `${prefix}${index + 1}`,
|
|
||||||
text: String(raw ?? ""),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeMatchingInputsValue(raw: unknown): MatchingInputsSlotValue {
|
|
||||||
if (raw && typeof raw === "object") {
|
|
||||||
const record = raw as Record<string, unknown>
|
|
||||||
const left = Array.isArray(record.left)
|
|
||||||
? record.left.map((item, index) => normalizeSideItem(item, index, "l"))
|
|
||||||
: []
|
|
||||||
const right = Array.isArray(record.right)
|
|
||||||
? record.right.map((item, index) => normalizeSideItem(item, index, "r"))
|
|
||||||
: []
|
|
||||||
if (left.length > 0 || right.length > 0) {
|
|
||||||
return ensureMinMatchingInputs({ left, right })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof raw === "string" && raw.trim()) {
|
|
||||||
try {
|
|
||||||
return normalizeMatchingInputsValue(JSON.parse(raw) as unknown)
|
|
||||||
} catch {
|
|
||||||
return defaultMatchingInputsSlotValue()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return defaultMatchingInputsSlotValue()
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizePair(raw: unknown, index: number): MatchingPair {
|
|
||||||
if (raw && typeof raw === "object") {
|
|
||||||
const record = raw as Record<string, unknown>
|
|
||||||
return {
|
|
||||||
left_id: String(record.left_id ?? record.leftId ?? `l${index + 1}`),
|
|
||||||
right_id: String(record.right_id ?? record.rightId ?? `r${index + 1}`),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
left_id: `l${index + 1}`,
|
|
||||||
right_id: `r${index + 1}`,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeMatchingAnswerValue(raw: unknown): MatchingAnswerSlotValue {
|
|
||||||
if (raw && typeof raw === "object") {
|
|
||||||
const record = raw as Record<string, unknown>
|
|
||||||
if (Array.isArray(record.pairs)) {
|
|
||||||
return {
|
|
||||||
pairs: record.pairs.map((pair, index) => normalizePair(pair, index)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof raw === "string" && raw.trim()) {
|
|
||||||
try {
|
|
||||||
return normalizeMatchingAnswerValue(JSON.parse(raw) as unknown)
|
|
||||||
} catch {
|
|
||||||
return { pairs: [] }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { pairs: [] }
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ensureMinMatchingInputs(
|
|
||||||
value: MatchingInputsSlotValue,
|
|
||||||
): MatchingInputsSlotValue {
|
|
||||||
const left = [...value.left]
|
|
||||||
const right = [...value.right]
|
|
||||||
while (left.length < MATCHING_MIN_ITEMS) {
|
|
||||||
left.push({ id: `l${left.length + 1}`, text: "" })
|
|
||||||
}
|
|
||||||
while (right.length < MATCHING_MIN_ITEMS) {
|
|
||||||
right.push({ id: `r${right.length + 1}`, text: "" })
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
left: reindexSide(left, "l"),
|
|
||||||
right: reindexSide(right, "r"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseMatchingInputsSlotValue(
|
|
||||||
raw: string | undefined,
|
|
||||||
): MatchingInputsSlotValue {
|
|
||||||
const trimmed = (raw ?? "").trim()
|
|
||||||
if (!trimmed) return defaultMatchingInputsSlotValue()
|
|
||||||
try {
|
|
||||||
return ensureMinMatchingInputs(
|
|
||||||
normalizeMatchingInputsValue(JSON.parse(trimmed) as unknown),
|
|
||||||
)
|
|
||||||
} catch {
|
|
||||||
return defaultMatchingInputsSlotValue()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseMatchingAnswerSlotValue(
|
|
||||||
raw: string | undefined,
|
|
||||||
inputs?: MatchingInputsSlotValue | null,
|
|
||||||
): MatchingAnswerSlotValue {
|
|
||||||
const trimmed = (raw ?? "").trim()
|
|
||||||
if (!trimmed) {
|
|
||||||
return inputs ? defaultMatchingAnswerFromInputs(inputs) : { pairs: [] }
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const parsed = normalizeMatchingAnswerValue(JSON.parse(trimmed) as unknown)
|
|
||||||
if (parsed.pairs.length > 0) return parsed
|
|
||||||
return inputs ? defaultMatchingAnswerFromInputs(inputs) : parsed
|
|
||||||
} catch {
|
|
||||||
return inputs ? defaultMatchingAnswerFromInputs(inputs) : { pairs: [] }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function matchingInputsSlotHasContent(
|
|
||||||
value: MatchingInputsSlotValue,
|
|
||||||
): boolean {
|
|
||||||
return (
|
|
||||||
value.left.some((item) => matchingItemHasValue(item.text)) ||
|
|
||||||
value.right.some((item) => matchingItemHasValue(item.text))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function matchingAnswerSlotHasContent(
|
|
||||||
value: MatchingAnswerSlotValue,
|
|
||||||
): boolean {
|
|
||||||
return value.pairs.some(
|
|
||||||
(pair) => pair.left_id.trim().length > 0 && pair.right_id.trim().length > 0,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function finalizeMatchingInputsPayload(
|
|
||||||
value: MatchingInputsSlotValue,
|
|
||||||
): MatchingInputsSlotValue {
|
|
||||||
return {
|
|
||||||
left: value.left
|
|
||||||
.filter((item) => matchingItemHasValue(item.text))
|
|
||||||
.map((item) => ({ id: item.id, text: item.text })),
|
|
||||||
right: value.right
|
|
||||||
.filter((item) => matchingItemHasValue(item.text))
|
|
||||||
.map((item) => ({ id: item.id, text: item.text })),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function finalizeMatchingAnswerPayload(
|
|
||||||
value: MatchingAnswerSlotValue,
|
|
||||||
): MatchingAnswerSlotValue {
|
|
||||||
return {
|
|
||||||
pairs: value.pairs
|
|
||||||
.filter(
|
|
||||||
(pair) => pair.left_id.trim().length > 0 && pair.right_id.trim().length > 0,
|
|
||||||
)
|
|
||||||
.map((pair) => ({
|
|
||||||
left_id: pair.left_id,
|
|
||||||
right_id: pair.right_id,
|
|
||||||
})),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function addMatchingInputRow(
|
|
||||||
value: MatchingInputsSlotValue,
|
|
||||||
): MatchingInputsSlotValue {
|
|
||||||
return ensureMinMatchingInputs({
|
|
||||||
left: [...value.left, { id: `l${value.left.length + 1}`, text: "" }],
|
|
||||||
right: [...value.right, { id: `r${value.right.length + 1}`, text: "" }],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function removeMatchingInputRow(
|
|
||||||
value: MatchingInputsSlotValue,
|
|
||||||
index: number,
|
|
||||||
): MatchingInputsSlotValue {
|
|
||||||
if (value.left.length <= MATCHING_MIN_ITEMS) return value
|
|
||||||
return ensureMinMatchingInputs({
|
|
||||||
left: value.left.filter((_, i) => i !== index),
|
|
||||||
right: value.right.filter((_, i) => i !== index),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function addMatchingPair(
|
|
||||||
value: MatchingAnswerSlotValue,
|
|
||||||
inputs?: MatchingInputsSlotValue | null,
|
|
||||||
): MatchingAnswerSlotValue {
|
|
||||||
const left = inputs?.left ?? []
|
|
||||||
const right = inputs?.right ?? []
|
|
||||||
const nextIndex = value.pairs.length
|
|
||||||
return {
|
|
||||||
pairs: [
|
|
||||||
...value.pairs,
|
|
||||||
{
|
|
||||||
left_id: left[nextIndex]?.id ?? left[0]?.id ?? "l1",
|
|
||||||
right_id: right[nextIndex]?.id ?? right[0]?.id ?? "r1",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function removeMatchingPair(
|
|
||||||
value: MatchingAnswerSlotValue,
|
|
||||||
index: number,
|
|
||||||
): MatchingAnswerSlotValue {
|
|
||||||
if (value.pairs.length <= 1) return value
|
|
||||||
return {
|
|
||||||
pairs: value.pairs.filter((_, i) => i !== index),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function validateMatchingInputsSlotValue(
|
|
||||||
value: MatchingInputsSlotValue,
|
|
||||||
): string | null {
|
|
||||||
if (
|
|
||||||
value.left.length < MATCHING_MIN_ITEMS ||
|
|
||||||
value.right.length < MATCHING_MIN_ITEMS
|
|
||||||
) {
|
|
||||||
return `Add at least ${MATCHING_MIN_ITEMS} items on each side.`
|
|
||||||
}
|
|
||||||
const filledLeft = value.left.filter((item) => matchingItemHasValue(item.text))
|
|
||||||
const filledRight = value.right.filter((item) => matchingItemHasValue(item.text))
|
|
||||||
if (filledLeft.length < MATCHING_MIN_ITEMS) {
|
|
||||||
return `Add at least ${MATCHING_MIN_ITEMS} left-side items with text.`
|
|
||||||
}
|
|
||||||
if (filledRight.length < MATCHING_MIN_ITEMS) {
|
|
||||||
return `Add at least ${MATCHING_MIN_ITEMS} right-side items with text.`
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
export function validateMatchingAnswerSlotValue(
|
|
||||||
value: MatchingAnswerSlotValue,
|
|
||||||
inputs?: MatchingInputsSlotValue | null,
|
|
||||||
): string | null {
|
|
||||||
const pairs = value.pairs.filter(
|
|
||||||
(pair) => pair.left_id.trim() && pair.right_id.trim(),
|
|
||||||
)
|
|
||||||
if (pairs.length < 1) return "Add at least one matching pair."
|
|
||||||
const leftIds = new Set(inputs?.left.map((item) => item.id) ?? [])
|
|
||||||
const rightIds = new Set(inputs?.right.map((item) => item.id) ?? [])
|
|
||||||
const usedLeft = new Set<string>()
|
|
||||||
for (const pair of pairs) {
|
|
||||||
if (inputs && leftIds.size > 0 && !leftIds.has(pair.left_id)) {
|
|
||||||
return `Unknown left id "${pair.left_id}" in matching answer.`
|
|
||||||
}
|
|
||||||
if (inputs && rightIds.size > 0 && !rightIds.has(pair.right_id)) {
|
|
||||||
return `Unknown right id "${pair.right_id}" in matching answer.`
|
|
||||||
}
|
|
||||||
if (usedLeft.has(pair.left_id)) {
|
|
||||||
return `Duplicate left id "${pair.left_id}" in matching answer.`
|
|
||||||
}
|
|
||||||
usedLeft.add(pair.left_id)
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
export function findMatchingInputsInFieldValues(
|
|
||||||
fieldValues: Record<string, string>,
|
|
||||||
stimulusSchema: { id: string; kind: string }[],
|
|
||||||
responseSchema: { id: string; kind: string }[],
|
|
||||||
): MatchingInputsSlotValue | null {
|
|
||||||
for (const row of stimulusSchema) {
|
|
||||||
if (row.kind.trim().toUpperCase() !== "MATCHING_INPUTS") continue
|
|
||||||
const parsed = parseMatchingInputsSlotValue(fieldValues[`stimulus:${row.id}`])
|
|
||||||
if (matchingInputsSlotHasContent(parsed)) return parsed
|
|
||||||
}
|
|
||||||
for (const row of responseSchema) {
|
|
||||||
if (row.kind.trim().toUpperCase() !== "MATCHING_INPUTS") continue
|
|
||||||
const parsed = parseMatchingInputsSlotValue(fieldValues[`response:${row.id}`])
|
|
||||||
if (matchingInputsSlotHasContent(parsed)) return parsed
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
@ -1,197 +0,0 @@
|
||||||
export interface MultipleChoiceOptionValue {
|
|
||||||
id: string
|
|
||||||
text: string
|
|
||||||
is_correct: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MultipleChoiceSlotValue {
|
|
||||||
options: MultipleChoiceOptionValue[]
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULT_OPTION_IDS = ["a", "b", "c", "d", "e", "f", "g", "h"] as const
|
|
||||||
|
|
||||||
export const MULTIPLE_CHOICE_MIN_OPTIONS = 2
|
|
||||||
|
|
||||||
export function defaultMultipleChoiceSlotValue(
|
|
||||||
count = MULTIPLE_CHOICE_MIN_OPTIONS,
|
|
||||||
): MultipleChoiceSlotValue {
|
|
||||||
return {
|
|
||||||
options: Array.from({ length: count }, (_, index) => ({
|
|
||||||
id: DEFAULT_OPTION_IDS[index] ?? String(index + 1),
|
|
||||||
text: "",
|
|
||||||
is_correct: index === 0,
|
|
||||||
})),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function serializeMultipleChoiceSlotValue(
|
|
||||||
value: MultipleChoiceSlotValue,
|
|
||||||
): string {
|
|
||||||
return JSON.stringify(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function nextMultipleChoiceOptionId(
|
|
||||||
existing: MultipleChoiceOptionValue[],
|
|
||||||
): string {
|
|
||||||
const used = new Set(existing.map((option) => option.id))
|
|
||||||
for (const id of DEFAULT_OPTION_IDS) {
|
|
||||||
if (!used.has(id)) return id
|
|
||||||
}
|
|
||||||
return String(existing.length + 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function addMultipleChoiceOption(
|
|
||||||
value: MultipleChoiceSlotValue,
|
|
||||||
): MultipleChoiceSlotValue {
|
|
||||||
return {
|
|
||||||
options: [
|
|
||||||
...value.options,
|
|
||||||
{
|
|
||||||
id: nextMultipleChoiceOptionId(value.options),
|
|
||||||
text: "",
|
|
||||||
is_correct: false,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function removeMultipleChoiceOption(
|
|
||||||
value: MultipleChoiceSlotValue,
|
|
||||||
index: number,
|
|
||||||
): MultipleChoiceSlotValue {
|
|
||||||
if (value.options.length <= MULTIPLE_CHOICE_MIN_OPTIONS) return value
|
|
||||||
const removed = value.options[index]
|
|
||||||
let options = value.options.filter((_, i) => i !== index)
|
|
||||||
if (
|
|
||||||
removed?.is_correct &&
|
|
||||||
options.length > 0 &&
|
|
||||||
!options.some((option) => option.is_correct)
|
|
||||||
) {
|
|
||||||
options = options.map((option, i) => ({
|
|
||||||
...option,
|
|
||||||
is_correct: i === 0,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
return { options }
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ensureMinMultipleChoiceOptions(
|
|
||||||
value: MultipleChoiceSlotValue,
|
|
||||||
): MultipleChoiceSlotValue {
|
|
||||||
if (value.options.length >= MULTIPLE_CHOICE_MIN_OPTIONS) return value
|
|
||||||
const options = [...value.options]
|
|
||||||
while (options.length < MULTIPLE_CHOICE_MIN_OPTIONS) {
|
|
||||||
options.push({
|
|
||||||
id: nextMultipleChoiceOptionId(options),
|
|
||||||
text: "",
|
|
||||||
is_correct: options.length === 0,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return { options }
|
|
||||||
}
|
|
||||||
|
|
||||||
export function multipleChoiceOptionHasValue(text: string): boolean {
|
|
||||||
return text.length > 0
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseMultipleChoiceSlotValue(
|
|
||||||
raw: string | undefined,
|
|
||||||
): MultipleChoiceSlotValue {
|
|
||||||
const trimmed = (raw ?? "").trim()
|
|
||||||
if (!trimmed) return defaultMultipleChoiceSlotValue()
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(trimmed) as unknown
|
|
||||||
return ensureMinMultipleChoiceOptions(normalizeMultipleChoiceValue(parsed))
|
|
||||||
} catch {
|
|
||||||
return defaultMultipleChoiceSlotValue()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeMultipleChoiceValue(
|
|
||||||
raw: unknown,
|
|
||||||
mcqOptions?: { option_text?: string; text?: string; is_correct?: boolean; isCorrect?: boolean }[],
|
|
||||||
): MultipleChoiceSlotValue {
|
|
||||||
if (mcqOptions?.some((o) => multipleChoiceOptionHasValue(o.option_text ?? o.text ?? ""))) {
|
|
||||||
return {
|
|
||||||
options: mcqOptions
|
|
||||||
.map((option, index) => ({
|
|
||||||
id: DEFAULT_OPTION_IDS[index] ?? String(index + 1),
|
|
||||||
text: option.option_text ?? option.text ?? "",
|
|
||||||
is_correct: Boolean(option.is_correct ?? option.isCorrect),
|
|
||||||
}))
|
|
||||||
.filter((option) => multipleChoiceOptionHasValue(option.text)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (raw && typeof raw === "object") {
|
|
||||||
const record = raw as Record<string, unknown>
|
|
||||||
if (Array.isArray(record.options)) {
|
|
||||||
return {
|
|
||||||
options: record.options.map((option, index) =>
|
|
||||||
normalizeMultipleChoiceOption(option, index),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Array.isArray(raw)) {
|
|
||||||
return {
|
|
||||||
options: raw.map((option, index) =>
|
|
||||||
normalizeMultipleChoiceOption(option, index),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof raw === "string" && raw.trim()) {
|
|
||||||
try {
|
|
||||||
return normalizeMultipleChoiceValue(JSON.parse(raw) as unknown)
|
|
||||||
} catch {
|
|
||||||
return defaultMultipleChoiceSlotValue()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return defaultMultipleChoiceSlotValue()
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeMultipleChoiceOption(
|
|
||||||
raw: unknown,
|
|
||||||
index: number,
|
|
||||||
): MultipleChoiceOptionValue {
|
|
||||||
if (raw && typeof raw === "object") {
|
|
||||||
const record = raw as Record<string, unknown>
|
|
||||||
return {
|
|
||||||
id: String(record.id ?? DEFAULT_OPTION_IDS[index] ?? index + 1),
|
|
||||||
text: String(record.text ?? record.option_text ?? ""),
|
|
||||||
is_correct: Boolean(record.is_correct ?? record.isCorrect),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
id: DEFAULT_OPTION_IDS[index] ?? String(index + 1),
|
|
||||||
text: String(raw ?? ""),
|
|
||||||
is_correct: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function multipleChoiceSlotHasContent(
|
|
||||||
value: MultipleChoiceSlotValue,
|
|
||||||
): boolean {
|
|
||||||
return value.options.some((option) => multipleChoiceOptionHasValue(option.text))
|
|
||||||
}
|
|
||||||
|
|
||||||
export function validateMultipleChoiceSlotValue(
|
|
||||||
value: MultipleChoiceSlotValue,
|
|
||||||
): string | null {
|
|
||||||
if (value.options.length < MULTIPLE_CHOICE_MIN_OPTIONS) {
|
|
||||||
return `Add at least ${MULTIPLE_CHOICE_MIN_OPTIONS} choices.`
|
|
||||||
}
|
|
||||||
const filled = value.options.filter((option) =>
|
|
||||||
multipleChoiceOptionHasValue(option.text),
|
|
||||||
)
|
|
||||||
if (filled.length < MULTIPLE_CHOICE_MIN_OPTIONS) {
|
|
||||||
return `Add at least ${MULTIPLE_CHOICE_MIN_OPTIONS} choices with text.`
|
|
||||||
}
|
|
||||||
if (!filled.some((option) => option.is_correct)) {
|
|
||||||
return "Mark one choice as correct."
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
@ -1,101 +0,0 @@
|
||||||
import {
|
|
||||||
Bell,
|
|
||||||
Info,
|
|
||||||
AlertCircle,
|
|
||||||
CheckCircle2,
|
|
||||||
Megaphone,
|
|
||||||
UserPlus,
|
|
||||||
CreditCard,
|
|
||||||
BookOpen,
|
|
||||||
Video,
|
|
||||||
ShieldAlert,
|
|
||||||
} from "lucide-react"
|
|
||||||
|
|
||||||
export const NOTIFICATION_TYPE_CONFIG: Record<
|
|
||||||
string,
|
|
||||||
{ icon: React.ElementType; color: string; bg: string }
|
|
||||||
> = {
|
|
||||||
announcement: { icon: Megaphone, color: "text-brand-600", bg: "bg-brand-100" },
|
|
||||||
system_alert: { icon: ShieldAlert, color: "text-amber-600", bg: "bg-amber-50" },
|
|
||||||
issue_created: { icon: AlertCircle, color: "text-red-500", bg: "bg-red-50" },
|
|
||||||
issue_status_updated: { icon: CheckCircle2, color: "text-sky-600", bg: "bg-sky-50" },
|
|
||||||
course_created: { icon: BookOpen, color: "text-indigo-600", bg: "bg-indigo-50" },
|
|
||||||
course_enrolled: { icon: BookOpen, color: "text-teal-600", bg: "bg-teal-50" },
|
|
||||||
sub_course_created: { icon: BookOpen, color: "text-violet-600", bg: "bg-violet-50" },
|
|
||||||
video_added: { icon: Video, color: "text-pink-600", bg: "bg-pink-50" },
|
|
||||||
user_deleted: { icon: UserPlus, color: "text-red-600", bg: "bg-red-50" },
|
|
||||||
admin_created: { icon: UserPlus, color: "text-brand-600", bg: "bg-brand-100" },
|
|
||||||
team_member_created: { icon: UserPlus, color: "text-emerald-600", bg: "bg-emerald-50" },
|
|
||||||
subscription_activated: { icon: CreditCard, color: "text-green-600", bg: "bg-green-50" },
|
|
||||||
payment_verified: { icon: CreditCard, color: "text-green-600", bg: "bg-green-50" },
|
|
||||||
knowledge_level_update: { icon: Info, color: "text-sky-600", bg: "bg-sky-50" },
|
|
||||||
assessment_assigned: { icon: BookOpen, color: "text-orange-600", bg: "bg-orange-50" },
|
|
||||||
}
|
|
||||||
|
|
||||||
export const DEFAULT_NOTIFICATION_TYPE_CONFIG = {
|
|
||||||
icon: Bell,
|
|
||||||
color: "text-grayScale-500",
|
|
||||||
bg: "bg-grayScale-100",
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getNotificationLevelBadge(level: string) {
|
|
||||||
switch (level) {
|
|
||||||
case "error":
|
|
||||||
case "critical":
|
|
||||||
return "destructive" as const
|
|
||||||
case "warning":
|
|
||||||
return "warning" as const
|
|
||||||
case "success":
|
|
||||||
return "success" as const
|
|
||||||
case "info":
|
|
||||||
default:
|
|
||||||
return "info" as const
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatNotificationTimestamp(ts: string) {
|
|
||||||
const date = new Date(ts)
|
|
||||||
if (Number.isNaN(date.getTime())) return "—"
|
|
||||||
const now = new Date()
|
|
||||||
const diffMs = now.getTime() - date.getTime()
|
|
||||||
const diffMin = Math.floor(diffMs / 60_000)
|
|
||||||
const diffHr = Math.floor(diffMs / 3_600_000)
|
|
||||||
const diffDay = Math.floor(diffMs / 86_400_000)
|
|
||||||
|
|
||||||
if (diffMin < 1) return "Just now"
|
|
||||||
if (diffMin < 60) return `${diffMin}m ago`
|
|
||||||
if (diffHr < 24) return `${diffHr}h ago`
|
|
||||||
if (diffDay < 7) return `${diffDay}d ago`
|
|
||||||
|
|
||||||
return date.toLocaleDateString("en-US", {
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
year: date.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatNotificationTypeLabel(type: string) {
|
|
||||||
return type
|
|
||||||
.split("_")
|
|
||||||
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
||||||
.join(" ")
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatNotificationDateTime(ts: string) {
|
|
||||||
const date = new Date(ts)
|
|
||||||
if (Number.isNaN(date.getTime())) return "—"
|
|
||||||
return date.toLocaleString(undefined, {
|
|
||||||
year: "numeric",
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isMeaningfulExpiry(expires: string) {
|
|
||||||
if (!expires) return false
|
|
||||||
const date = new Date(expires)
|
|
||||||
if (Number.isNaN(date.getTime())) return false
|
|
||||||
return date.getFullYear() > 1
|
|
||||||
}
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
import type {
|
|
||||||
GetPracticesByParentContextResponse,
|
|
||||||
ParentContextPractice,
|
|
||||||
PracticePublishStatus,
|
|
||||||
} from "../types/course.types"
|
|
||||||
|
|
||||||
export function unwrapPracticesList(
|
|
||||||
res: {
|
|
||||||
data?: GetPracticesByParentContextResponse & {
|
|
||||||
Data?: GetPracticesByParentContextResponse["data"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
): ParentContextPractice[] {
|
|
||||||
const body = res.data
|
|
||||||
if (!body) return []
|
|
||||||
const data = body.data ?? body.Data
|
|
||||||
const raw = data?.practices
|
|
||||||
return Array.isArray(raw) ? raw : []
|
|
||||||
}
|
|
||||||
|
|
||||||
export function practicePublishStatus(
|
|
||||||
practice: ParentContextPractice,
|
|
||||||
): PracticePublishStatus | null {
|
|
||||||
const raw = practice.publish_status
|
|
||||||
if (raw === "DRAFT" || raw === "PUBLISHED") return raw
|
|
||||||
if (typeof raw === "string") {
|
|
||||||
const upper = raw.toUpperCase()
|
|
||||||
if (upper === "DRAFT" || upper === "PUBLISHED") {
|
|
||||||
return upper as PracticePublishStatus
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isPracticePublished(practice: ParentContextPractice): boolean {
|
|
||||||
return practicePublishStatus(practice) === "PUBLISHED"
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isPracticeDraft(practice: ParentContextPractice): boolean {
|
|
||||||
const status = practicePublishStatus(practice)
|
|
||||||
return status === "DRAFT" || status === null
|
|
||||||
}
|
|
||||||
|
|
||||||
export function draftPracticesForParent(
|
|
||||||
practices: ParentContextPractice[],
|
|
||||||
): ParentContextPractice[] {
|
|
||||||
return practices.filter(isPracticeDraft)
|
|
||||||
}
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
|
||||||
|
|
||||||
/** Parse one or more emails from newline-, comma-, or semicolon-separated input. */
|
|
||||||
export function parseInviteEmails(text: string): string[] {
|
|
||||||
const seen = new Set<string>()
|
|
||||||
const result: string[] = []
|
|
||||||
|
|
||||||
for (const part of text.split(/[\n,;]+/)) {
|
|
||||||
const email = part.trim().toLowerCase()
|
|
||||||
if (!email || seen.has(email)) continue
|
|
||||||
seen.add(email)
|
|
||||||
result.push(email)
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isValidInviteEmail(email: string): boolean {
|
|
||||||
return EMAIL_PATTERN.test(email)
|
|
||||||
}
|
|
||||||
|
|
||||||
export type InviteEmailSendResult = {
|
|
||||||
email: string
|
|
||||||
success: boolean
|
|
||||||
message: string
|
|
||||||
invitationId?: number
|
|
||||||
}
|
|
||||||
|
|
@ -1,51 +0,0 @@
|
||||||
import { formatPlanCategory } from "./subscriptionPlans"
|
|
||||||
import type { Payment } from "../types/payment.types"
|
|
||||||
|
|
||||||
export function formatPaymentAmount(payment: Pick<Payment, "amount" | "currency">): string {
|
|
||||||
const amount = Number(payment.amount)
|
|
||||||
const formatted = Number.isFinite(amount)
|
|
||||||
? amount.toLocaleString(undefined, { minimumFractionDigits: 0, maximumFractionDigits: 2 })
|
|
||||||
: String(payment.amount)
|
|
||||||
return `${formatted} ${payment.currency || "ETB"}`
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatPaymentDate(iso: string | null | undefined): string {
|
|
||||||
if (!iso) return "—"
|
|
||||||
const d = new Date(iso)
|
|
||||||
if (Number.isNaN(d.getTime())) return iso
|
|
||||||
return d.toLocaleString(undefined, {
|
|
||||||
year: "numeric",
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatPaymentStatus(status: string): string {
|
|
||||||
return status.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatPaymentMethod(method: string): string {
|
|
||||||
if (!method) return "—"
|
|
||||||
return method.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())
|
|
||||||
}
|
|
||||||
|
|
||||||
export function paymentCustomerName(payment: Payment): string {
|
|
||||||
const name = [payment.user_first_name, payment.user_last_name].filter(Boolean).join(" ")
|
|
||||||
return name || payment.user_email || `User #${payment.user_id}`
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatPaymentPlanCategory(category: string): string {
|
|
||||||
return formatPlanCategory(category)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function paymentStatusBadgeVariant(
|
|
||||||
status: string,
|
|
||||||
): "success" | "warning" | "destructive" | "secondary" | "info" {
|
|
||||||
const s = status.toUpperCase()
|
|
||||||
if (s === "SUCCESS" || s === "COMPLETED" || s === "PAID") return "success"
|
|
||||||
if (s === "PENDING" || s === "PROCESSING") return "warning"
|
|
||||||
if (s === "FAILED" || s === "CANCELLED" || s === "EXPIRED") return "destructive"
|
|
||||||
return "secondary"
|
|
||||||
}
|
|
||||||
|
|
@ -1,52 +0,0 @@
|
||||||
import type {
|
|
||||||
GetPersonasResponse,
|
|
||||||
PersonaListItem,
|
|
||||||
} from "../types/persona.types"
|
|
||||||
|
|
||||||
export type PersonaCardModel = {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
description: string
|
|
||||||
avatar: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Soft, professional palette aligned with the admin brand (slate, indigo, violet). */
|
|
||||||
const PERSONA_FALLBACK_BACKGROUNDS = "f1f5f9,e0e7ff,ede9fe,fdf4ff,ecfeff"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Default avatar when `profile_picture` is null: professional illustrated portrait
|
|
||||||
* (DiceBear personas), not casual cartoon avataaars.
|
|
||||||
*/
|
|
||||||
export function personaAvatarUrl(
|
|
||||||
profilePicture: string | null | undefined,
|
|
||||||
name: string,
|
|
||||||
personaId?: number | string,
|
|
||||||
): string {
|
|
||||||
const url = profilePicture?.trim()
|
|
||||||
if (url) return url
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
seed: personaId != null ? `yimaru-persona-${personaId}` : `yimaru-persona-${name}`,
|
|
||||||
backgroundColor: PERSONA_FALLBACK_BACKGROUNDS,
|
|
||||||
radius: "50",
|
|
||||||
})
|
|
||||||
return `https://api.dicebear.com/7.x/personas/svg?${params.toString()}`
|
|
||||||
}
|
|
||||||
|
|
||||||
export function mapPersonaToCard(persona: PersonaListItem): PersonaCardModel {
|
|
||||||
return {
|
|
||||||
id: String(persona.id),
|
|
||||||
name: persona.name,
|
|
||||||
description: persona.description?.trim() ?? "",
|
|
||||||
avatar: personaAvatarUrl(persona.profile_picture, persona.name, persona.id),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function unwrapPersonasList(
|
|
||||||
res: { data?: GetPersonasResponse & { Data?: GetPersonasResponse["data"] } },
|
|
||||||
): PersonaListItem[] {
|
|
||||||
const body = res.data
|
|
||||||
if (!body) return []
|
|
||||||
const data = body.data ?? body.Data
|
|
||||||
const raw = data?.personas
|
|
||||||
return Array.isArray(raw) ? raw : []
|
|
||||||
}
|
|
||||||
|
|
@ -1,184 +0,0 @@
|
||||||
import type { DynamicQuestionPayload } from "../types/questionTypeDefinition.types"
|
|
||||||
import {
|
|
||||||
finalizeMatchingAnswerPayload,
|
|
||||||
finalizeMatchingInputsPayload,
|
|
||||||
findMatchingInputsInFieldValues,
|
|
||||||
matchingAnswerSlotHasContent,
|
|
||||||
matchingInputsSlotHasContent,
|
|
||||||
parseMatchingAnswerSlotValue,
|
|
||||||
parseMatchingInputsSlotValue,
|
|
||||||
} from "./matchingSlotValue"
|
|
||||||
import {
|
|
||||||
finalizeSelectMissingWordsResponsePayload,
|
|
||||||
finalizeSelectMissingWordsStimulusPayload,
|
|
||||||
findSelectMissingWordsStimulusInFieldValues,
|
|
||||||
parseSelectMissingWordsResponseSlotValue,
|
|
||||||
parseSelectMissingWordsStimulusSlotValue,
|
|
||||||
selectMissingWordsResponseHasContent,
|
|
||||||
selectMissingWordsStimulusHasContent,
|
|
||||||
} from "./selectMissingWordsSlotValue"
|
|
||||||
import {
|
|
||||||
multipleChoiceOptionHasValue,
|
|
||||||
multipleChoiceSlotHasContent,
|
|
||||||
normalizeMultipleChoiceValue,
|
|
||||||
parseMultipleChoiceSlotValue,
|
|
||||||
} from "./multipleChoiceSlotValue"
|
|
||||||
|
|
||||||
/** Parse a single slot value: plain string/URL, or JSON object/array when input looks like JSON. */
|
|
||||||
export function parseDynamicSlotValue(raw: string | undefined): unknown {
|
|
||||||
const t = (raw ?? "").trim()
|
|
||||||
if (!t) return ""
|
|
||||||
if ((t.startsWith("{") && t.endsWith("}")) || (t.startsWith("[") && t.endsWith("]"))) {
|
|
||||||
try {
|
|
||||||
return JSON.parse(t) as unknown
|
|
||||||
} catch {
|
|
||||||
return t
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return t
|
|
||||||
}
|
|
||||||
|
|
||||||
const PLAIN_TEXT_STIMULUS_KINDS = new Set([
|
|
||||||
"INSTRUCTION",
|
|
||||||
"QUESTION_TEXT",
|
|
||||||
"TEXT_PASSAGE",
|
|
||||||
"TEXT",
|
|
||||||
"TEXT_INPUT",
|
|
||||||
])
|
|
||||||
|
|
||||||
function isMultipleChoiceKind(kind: string): boolean {
|
|
||||||
const upper = kind.trim().toUpperCase()
|
|
||||||
return upper === "MULTIPLE_CHOICE" || upper === "OPTION"
|
|
||||||
}
|
|
||||||
|
|
||||||
function isMatchingInputsKind(kind: string): boolean {
|
|
||||||
return kind.trim().toUpperCase() === "MATCHING_INPUTS"
|
|
||||||
}
|
|
||||||
|
|
||||||
function isMatchingAnswerKind(kind: string): boolean {
|
|
||||||
return kind.trim().toUpperCase() === "MATCHING_ANSWER"
|
|
||||||
}
|
|
||||||
|
|
||||||
function isSelectMissingWordsKind(kind: string): boolean {
|
|
||||||
return kind.trim().toUpperCase() === "SELECT_MISSING_WORDS"
|
|
||||||
}
|
|
||||||
|
|
||||||
function slotValueForRow(
|
|
||||||
row: { id: string; kind: string },
|
|
||||||
side: "stimulus" | "response",
|
|
||||||
fieldValues: Record<string, string>,
|
|
||||||
mcqOptions: { option_text: string; is_correct: boolean }[] | undefined,
|
|
||||||
mcqOptionsConsumed: { current: boolean },
|
|
||||||
stimulusRows: { id: string; kind: string }[],
|
|
||||||
responseRows: { id: string; kind: string }[],
|
|
||||||
): unknown {
|
|
||||||
const fieldKey = `${side}:${row.id}`
|
|
||||||
const rawField = fieldValues[fieldKey]
|
|
||||||
|
|
||||||
if (isMultipleChoiceKind(row.kind)) {
|
|
||||||
const fromField = parseMultipleChoiceSlotValue(rawField)
|
|
||||||
if (multipleChoiceSlotHasContent(fromField)) {
|
|
||||||
return {
|
|
||||||
options: fromField.options
|
|
||||||
.filter((option) => multipleChoiceOptionHasValue(option.text))
|
|
||||||
.map((option) => ({
|
|
||||||
id: option.id,
|
|
||||||
text: option.text,
|
|
||||||
is_correct: option.is_correct,
|
|
||||||
})),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (mcqOptions && !mcqOptionsConsumed.current) {
|
|
||||||
mcqOptionsConsumed.current = true
|
|
||||||
return normalizeMultipleChoiceValue(undefined, mcqOptions)
|
|
||||||
}
|
|
||||||
return { options: [] }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isMatchingInputsKind(row.kind)) {
|
|
||||||
const fromField = parseMatchingInputsSlotValue(rawField)
|
|
||||||
if (matchingInputsSlotHasContent(fromField)) {
|
|
||||||
return finalizeMatchingInputsPayload(fromField)
|
|
||||||
}
|
|
||||||
return { left: [], right: [] }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isMatchingAnswerKind(row.kind)) {
|
|
||||||
const matchingInputs = findMatchingInputsInFieldValues(
|
|
||||||
fieldValues,
|
|
||||||
stimulusRows,
|
|
||||||
responseRows,
|
|
||||||
)
|
|
||||||
const fromField = parseMatchingAnswerSlotValue(rawField, matchingInputs)
|
|
||||||
if (matchingAnswerSlotHasContent(fromField)) {
|
|
||||||
return finalizeMatchingAnswerPayload(fromField)
|
|
||||||
}
|
|
||||||
return { pairs: [] }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isSelectMissingWordsKind(row.kind)) {
|
|
||||||
if (side === "stimulus") {
|
|
||||||
const fromField = parseSelectMissingWordsStimulusSlotValue(rawField)
|
|
||||||
if (selectMissingWordsStimulusHasContent(fromField)) {
|
|
||||||
return finalizeSelectMissingWordsStimulusPayload(fromField)
|
|
||||||
}
|
|
||||||
return { segments: [], word_bank: [], allow_reuse: false }
|
|
||||||
}
|
|
||||||
const clozeStimulus = findSelectMissingWordsStimulusInFieldValues(
|
|
||||||
fieldValues,
|
|
||||||
stimulusRows,
|
|
||||||
)
|
|
||||||
const fromField = parseSelectMissingWordsResponseSlotValue(
|
|
||||||
rawField,
|
|
||||||
clozeStimulus,
|
|
||||||
)
|
|
||||||
if (selectMissingWordsResponseHasContent(fromField)) {
|
|
||||||
return finalizeSelectMissingWordsResponsePayload(fromField)
|
|
||||||
}
|
|
||||||
return { blanks: [] }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (side === "stimulus" && PLAIN_TEXT_STIMULUS_KINDS.has(row.kind.trim().toUpperCase())) {
|
|
||||||
return (rawField ?? "").trim()
|
|
||||||
}
|
|
||||||
|
|
||||||
return parseDynamicSlotValue(rawField)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildDynamicQuestionPayload(input: {
|
|
||||||
stimulusRows: { id: string; kind: string }[]
|
|
||||||
responseRows: { id: string; kind: string }[]
|
|
||||||
fieldValues: Record<string, string>
|
|
||||||
mcqOptions?: { option_text: string; is_correct: boolean }[]
|
|
||||||
}): DynamicQuestionPayload {
|
|
||||||
const mcqOptionsConsumed = { current: false }
|
|
||||||
|
|
||||||
return {
|
|
||||||
stimulus: input.stimulusRows.map((row) => ({
|
|
||||||
id: row.id,
|
|
||||||
kind: row.kind,
|
|
||||||
value: slotValueForRow(
|
|
||||||
row,
|
|
||||||
"stimulus",
|
|
||||||
input.fieldValues,
|
|
||||||
input.mcqOptions,
|
|
||||||
mcqOptionsConsumed,
|
|
||||||
input.stimulusRows,
|
|
||||||
input.responseRows,
|
|
||||||
),
|
|
||||||
})),
|
|
||||||
response: input.responseRows.map((row) => ({
|
|
||||||
id: row.id,
|
|
||||||
kind: row.kind,
|
|
||||||
value: slotValueForRow(
|
|
||||||
row,
|
|
||||||
"response",
|
|
||||||
input.fieldValues,
|
|
||||||
input.mcqOptions,
|
|
||||||
mcqOptionsConsumed,
|
|
||||||
input.stimulusRows,
|
|
||||||
input.responseRows,
|
|
||||||
),
|
|
||||||
})),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,41 +0,0 @@
|
||||||
/** Author-facing default labels for dynamic schema slots. */
|
|
||||||
const KIND_DEFAULT_LABELS: Record<string, string> = {
|
|
||||||
QUESTION_TEXT: "Question prompt",
|
|
||||||
PREP_TIME: "Preparation time (seconds)",
|
|
||||||
INSTRUCTION: "Instructions",
|
|
||||||
AUDIO_PROMPT: "Audio",
|
|
||||||
TEXT_PASSAGE: "Reading passage",
|
|
||||||
IMAGE: "Image",
|
|
||||||
MATCHING_INPUTS: "Matching inputs",
|
|
||||||
SELECT_MISSING_WORDS: "Select missing words",
|
|
||||||
TABLE: "Reference table",
|
|
||||||
PDF_ATTACHMENT: "PDF document",
|
|
||||||
AUDIO_RESPONSE: "Audio response",
|
|
||||||
TEXT_INPUT: "Text input",
|
|
||||||
SHORT_ANSWER: "Short answer",
|
|
||||||
MULTIPLE_CHOICE: "Multiple choice",
|
|
||||||
OPTION: "Answer choices",
|
|
||||||
ANSWER_TIMER: "Time limit (seconds)",
|
|
||||||
PDF_UPLOAD: "PDF upload",
|
|
||||||
MATCHING_ANSWER: "Matching answer",
|
|
||||||
LABEL_SELECTION: "Label selection",
|
|
||||||
SEQUENCE_ORDER: "Sequence order",
|
|
||||||
}
|
|
||||||
|
|
||||||
export function humanizeKind(kind: string): string {
|
|
||||||
return kind
|
|
||||||
.replace(/_/g, " ")
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/\b\w/g, (c) => c.toUpperCase())
|
|
||||||
}
|
|
||||||
|
|
||||||
export function defaultLabelForKind(kind: string): string {
|
|
||||||
const k = kind.trim()
|
|
||||||
return KIND_DEFAULT_LABELS[k] ?? humanizeKind(k)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function slotLabel(schema: { label?: string | null; kind: string }): string {
|
|
||||||
const trimmed = schema.label?.trim()
|
|
||||||
if (trimmed) return trimmed
|
|
||||||
return defaultLabelForKind(schema.kind)
|
|
||||||
}
|
|
||||||
|
|
@ -1,524 +0,0 @@
|
||||||
export interface ClozeTextSegment {
|
|
||||||
type: "text"
|
|
||||||
value: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ClozeBlankSegment {
|
|
||||||
type: "blank"
|
|
||||||
id: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ClozeSegment = ClozeTextSegment | ClozeBlankSegment
|
|
||||||
|
|
||||||
export interface WordBankItem {
|
|
||||||
id: string
|
|
||||||
text: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SelectMissingWordsStimulusValue {
|
|
||||||
segments: ClozeSegment[]
|
|
||||||
word_bank: WordBankItem[]
|
|
||||||
allow_reuse: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ClozeBlankAnswer {
|
|
||||||
blank_id: string
|
|
||||||
text: string
|
|
||||||
word_id: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SelectMissingWordsResponseValue {
|
|
||||||
blanks: ClozeBlankAnswer[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SELECT_MISSING_WORDS_MIN_BANK = 2
|
|
||||||
export const SELECT_MISSING_WORDS_MIN_BLANKS = 1
|
|
||||||
|
|
||||||
const DEFAULT_BLANK_COUNT = 2
|
|
||||||
const DEFAULT_WORD_BANK_COUNT = 4
|
|
||||||
|
|
||||||
function reindexBlankSegments(segments: ClozeSegment[]): ClozeSegment[] {
|
|
||||||
let blankIndex = 0
|
|
||||||
return segments.map((segment) => {
|
|
||||||
if (segment.type !== "blank") return segment
|
|
||||||
blankIndex += 1
|
|
||||||
return { type: "blank", id: `b${blankIndex}` }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function reindexWordBank(items: WordBankItem[]): WordBankItem[] {
|
|
||||||
return items.map((item, index) => ({
|
|
||||||
id: `w${index + 1}`,
|
|
||||||
text: item.text,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeTextSegment(raw: unknown, index: number): ClozeTextSegment {
|
|
||||||
if (raw && typeof raw === "object") {
|
|
||||||
const record = raw as Record<string, unknown>
|
|
||||||
if (record.type === "text") {
|
|
||||||
return {
|
|
||||||
type: "text",
|
|
||||||
value: String(record.value ?? ""),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { type: "text", value: index === 0 ? "" : "" }
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeBlankSegment(raw: unknown, index: number): ClozeBlankSegment {
|
|
||||||
if (raw && typeof raw === "object") {
|
|
||||||
const record = raw as Record<string, unknown>
|
|
||||||
if (record.type === "blank") {
|
|
||||||
return {
|
|
||||||
type: "blank",
|
|
||||||
id: String(record.id ?? `b${index + 1}`),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { type: "blank", id: `b${index + 1}` }
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeSegment(raw: unknown, index: number): ClozeSegment {
|
|
||||||
if (raw && typeof raw === "object") {
|
|
||||||
const record = raw as Record<string, unknown>
|
|
||||||
if (record.type === "blank") return normalizeBlankSegment(raw, index)
|
|
||||||
if (record.type === "text") return normalizeTextSegment(raw, index)
|
|
||||||
}
|
|
||||||
return { type: "text", value: "" }
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeWordBankItem(raw: unknown, index: number): WordBankItem {
|
|
||||||
if (raw && typeof raw === "object") {
|
|
||||||
const record = raw as Record<string, unknown>
|
|
||||||
return {
|
|
||||||
id: String(record.id ?? `w${index + 1}`),
|
|
||||||
text: String(record.text ?? ""),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { id: `w${index + 1}`, text: "" }
|
|
||||||
}
|
|
||||||
|
|
||||||
export function defaultSelectMissingWordsStimulusSlotValue(
|
|
||||||
blankCount = DEFAULT_BLANK_COUNT,
|
|
||||||
wordBankCount = DEFAULT_WORD_BANK_COUNT,
|
|
||||||
): SelectMissingWordsStimulusValue {
|
|
||||||
const segments: ClozeSegment[] = [{ type: "text", value: "" }]
|
|
||||||
for (let index = 0; index < blankCount; index += 1) {
|
|
||||||
segments.push({ type: "blank", id: `b${index + 1}` })
|
|
||||||
segments.push({ type: "text", value: "" })
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
segments,
|
|
||||||
word_bank: Array.from({ length: wordBankCount }, (_, index) => ({
|
|
||||||
id: `w${index + 1}`,
|
|
||||||
text: "",
|
|
||||||
})),
|
|
||||||
allow_reuse: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function blankIdsFromStimulus(
|
|
||||||
stimulus: SelectMissingWordsStimulusValue,
|
|
||||||
): string[] {
|
|
||||||
return stimulus.segments
|
|
||||||
.filter((segment): segment is ClozeBlankSegment => segment.type === "blank")
|
|
||||||
.map((segment) => segment.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function defaultSelectMissingWordsResponseFromStimulus(
|
|
||||||
stimulus: SelectMissingWordsStimulusValue,
|
|
||||||
): SelectMissingWordsResponseValue {
|
|
||||||
return {
|
|
||||||
blanks: blankIdsFromStimulus(stimulus).map((blankId) => ({
|
|
||||||
blank_id: blankId,
|
|
||||||
text: "",
|
|
||||||
word_id: "",
|
|
||||||
})),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function serializeSelectMissingWordsStimulusSlotValue(
|
|
||||||
value: SelectMissingWordsStimulusValue,
|
|
||||||
): string {
|
|
||||||
return JSON.stringify(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function serializeSelectMissingWordsResponseSlotValue(
|
|
||||||
value: SelectMissingWordsResponseValue,
|
|
||||||
): string {
|
|
||||||
return JSON.stringify(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function wordBankItemHasValue(text: string): boolean {
|
|
||||||
return text.length > 0
|
|
||||||
}
|
|
||||||
|
|
||||||
function ensureMinWordBank(
|
|
||||||
value: SelectMissingWordsStimulusValue,
|
|
||||||
): SelectMissingWordsStimulusValue {
|
|
||||||
const wordBank = [...value.word_bank]
|
|
||||||
while (wordBank.length < SELECT_MISSING_WORDS_MIN_BANK) {
|
|
||||||
wordBank.push({ id: `w${wordBank.length + 1}`, text: "" })
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...value,
|
|
||||||
segments:
|
|
||||||
value.segments.length > 0
|
|
||||||
? reindexBlankSegments(value.segments)
|
|
||||||
: defaultSelectMissingWordsStimulusSlotValue().segments,
|
|
||||||
word_bank: reindexWordBank(wordBank),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeSelectMissingWordsStimulusValue(
|
|
||||||
raw: unknown,
|
|
||||||
): SelectMissingWordsStimulusValue {
|
|
||||||
if (raw && typeof raw === "object") {
|
|
||||||
const record = raw as Record<string, unknown>
|
|
||||||
const segments = Array.isArray(record.segments)
|
|
||||||
? record.segments.map((segment, index) => normalizeSegment(segment, index))
|
|
||||||
: []
|
|
||||||
const wordBank = Array.isArray(record.word_bank)
|
|
||||||
? record.word_bank.map((item, index) => normalizeWordBankItem(item, index))
|
|
||||||
: []
|
|
||||||
if (segments.length > 0 || wordBank.length > 0) {
|
|
||||||
return ensureMinWordBank({
|
|
||||||
segments,
|
|
||||||
word_bank: wordBank,
|
|
||||||
allow_reuse: Boolean(record.allow_reuse),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof raw === "string" && raw.trim()) {
|
|
||||||
try {
|
|
||||||
return normalizeSelectMissingWordsStimulusValue(JSON.parse(raw) as unknown)
|
|
||||||
} catch {
|
|
||||||
return defaultSelectMissingWordsStimulusSlotValue()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return defaultSelectMissingWordsStimulusSlotValue()
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeBlankAnswer(raw: unknown, index: number): ClozeBlankAnswer {
|
|
||||||
if (raw && typeof raw === "object") {
|
|
||||||
const record = raw as Record<string, unknown>
|
|
||||||
return {
|
|
||||||
blank_id: String(record.blank_id ?? record.blankId ?? `b${index + 1}`),
|
|
||||||
text: String(record.text ?? ""),
|
|
||||||
word_id: String(record.word_id ?? record.wordId ?? ""),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { blank_id: `b${index + 1}`, text: "", word_id: "" }
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeSelectMissingWordsResponseValue(
|
|
||||||
raw: unknown,
|
|
||||||
): SelectMissingWordsResponseValue {
|
|
||||||
if (raw && typeof raw === "object") {
|
|
||||||
const record = raw as Record<string, unknown>
|
|
||||||
if (Array.isArray(record.blanks)) {
|
|
||||||
return {
|
|
||||||
blanks: record.blanks.map((blank, index) =>
|
|
||||||
normalizeBlankAnswer(blank, index),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof raw === "string" && raw.trim()) {
|
|
||||||
try {
|
|
||||||
return normalizeSelectMissingWordsResponseValue(JSON.parse(raw) as unknown)
|
|
||||||
} catch {
|
|
||||||
return { blanks: [] }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { blanks: [] }
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseSelectMissingWordsStimulusSlotValue(
|
|
||||||
raw: string | undefined,
|
|
||||||
): SelectMissingWordsStimulusValue {
|
|
||||||
const trimmed = (raw ?? "").trim()
|
|
||||||
if (!trimmed) return defaultSelectMissingWordsStimulusSlotValue()
|
|
||||||
try {
|
|
||||||
return ensureMinWordBank(
|
|
||||||
normalizeSelectMissingWordsStimulusValue(JSON.parse(trimmed) as unknown),
|
|
||||||
)
|
|
||||||
} catch {
|
|
||||||
return defaultSelectMissingWordsStimulusSlotValue()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseSelectMissingWordsResponseSlotValue(
|
|
||||||
raw: string | undefined,
|
|
||||||
stimulus?: SelectMissingWordsStimulusValue | null,
|
|
||||||
): SelectMissingWordsResponseValue {
|
|
||||||
const trimmed = (raw ?? "").trim()
|
|
||||||
if (!trimmed) {
|
|
||||||
return stimulus
|
|
||||||
? defaultSelectMissingWordsResponseFromStimulus(stimulus)
|
|
||||||
: { blanks: [] }
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const parsed = normalizeSelectMissingWordsResponseValue(
|
|
||||||
JSON.parse(trimmed) as unknown,
|
|
||||||
)
|
|
||||||
if (parsed.blanks.length > 0) return parsed
|
|
||||||
return stimulus
|
|
||||||
? defaultSelectMissingWordsResponseFromStimulus(stimulus)
|
|
||||||
: parsed
|
|
||||||
} catch {
|
|
||||||
return stimulus
|
|
||||||
? defaultSelectMissingWordsResponseFromStimulus(stimulus)
|
|
||||||
: { blanks: [] }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function selectMissingWordsStimulusHasContent(
|
|
||||||
value: SelectMissingWordsStimulusValue,
|
|
||||||
): boolean {
|
|
||||||
return (
|
|
||||||
value.segments.some(
|
|
||||||
(segment) =>
|
|
||||||
segment.type === "blank" ||
|
|
||||||
(segment.type === "text" && segment.value.length > 0),
|
|
||||||
) || value.word_bank.some((item) => wordBankItemHasValue(item.text))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function selectMissingWordsResponseHasContent(
|
|
||||||
value: SelectMissingWordsResponseValue,
|
|
||||||
): boolean {
|
|
||||||
return value.blanks.some(
|
|
||||||
(blank) =>
|
|
||||||
blank.blank_id.trim().length > 0 &&
|
|
||||||
blank.word_id.trim().length > 0 &&
|
|
||||||
blank.text.length > 0,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function finalizeSelectMissingWordsStimulusPayload(
|
|
||||||
value: SelectMissingWordsStimulusValue,
|
|
||||||
): SelectMissingWordsStimulusValue {
|
|
||||||
return {
|
|
||||||
segments: value.segments.map((segment) =>
|
|
||||||
segment.type === "text"
|
|
||||||
? { type: "text", value: segment.value }
|
|
||||||
: { type: "blank", id: segment.id },
|
|
||||||
),
|
|
||||||
word_bank: value.word_bank
|
|
||||||
.filter((item) => wordBankItemHasValue(item.text))
|
|
||||||
.map((item) => ({ id: item.id, text: item.text })),
|
|
||||||
allow_reuse: value.allow_reuse,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function finalizeSelectMissingWordsResponsePayload(
|
|
||||||
value: SelectMissingWordsResponseValue,
|
|
||||||
): SelectMissingWordsResponseValue {
|
|
||||||
return {
|
|
||||||
blanks: value.blanks
|
|
||||||
.filter(
|
|
||||||
(blank) =>
|
|
||||||
blank.blank_id.trim().length > 0 &&
|
|
||||||
blank.word_id.trim().length > 0 &&
|
|
||||||
blank.text.length > 0,
|
|
||||||
)
|
|
||||||
.map((blank) => ({
|
|
||||||
blank_id: blank.blank_id,
|
|
||||||
text: blank.text,
|
|
||||||
word_id: blank.word_id,
|
|
||||||
})),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function addWordBankItem(
|
|
||||||
value: SelectMissingWordsStimulusValue,
|
|
||||||
): SelectMissingWordsStimulusValue {
|
|
||||||
const next = ensureMinWordBank(value)
|
|
||||||
return {
|
|
||||||
...next,
|
|
||||||
word_bank: reindexWordBank([
|
|
||||||
...next.word_bank,
|
|
||||||
{ id: `w${next.word_bank.length + 1}`, text: "" },
|
|
||||||
]),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function removeWordBankItem(
|
|
||||||
value: SelectMissingWordsStimulusValue,
|
|
||||||
index: number,
|
|
||||||
): SelectMissingWordsStimulusValue {
|
|
||||||
if (value.word_bank.length <= SELECT_MISSING_WORDS_MIN_BANK) return value
|
|
||||||
return ensureMinWordBank({
|
|
||||||
...value,
|
|
||||||
word_bank: value.word_bank.filter((_, itemIndex) => itemIndex !== index),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function addTextSegment(
|
|
||||||
value: SelectMissingWordsStimulusValue,
|
|
||||||
): SelectMissingWordsStimulusValue {
|
|
||||||
return {
|
|
||||||
...value,
|
|
||||||
segments: [...value.segments, { type: "text", value: "" }],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function addBlankSegment(
|
|
||||||
value: SelectMissingWordsStimulusValue,
|
|
||||||
): SelectMissingWordsStimulusValue {
|
|
||||||
const blankCount = blankIdsFromStimulus(value).length
|
|
||||||
return ensureMinWordBank({
|
|
||||||
...value,
|
|
||||||
segments: [
|
|
||||||
...value.segments,
|
|
||||||
{ type: "blank", id: `b${blankCount + 1}` },
|
|
||||||
],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function removeSegment(
|
|
||||||
value: SelectMissingWordsStimulusValue,
|
|
||||||
index: number,
|
|
||||||
): SelectMissingWordsStimulusValue {
|
|
||||||
if (value.segments.length <= 1) return value
|
|
||||||
return ensureMinWordBank({
|
|
||||||
...value,
|
|
||||||
segments: value.segments.filter((_, segmentIndex) => segmentIndex !== index),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function updateTextSegment(
|
|
||||||
value: SelectMissingWordsStimulusValue,
|
|
||||||
index: number,
|
|
||||||
text: string,
|
|
||||||
): SelectMissingWordsStimulusValue {
|
|
||||||
const segments = [...value.segments]
|
|
||||||
const segment = segments[index]
|
|
||||||
if (!segment || segment.type !== "text") return value
|
|
||||||
segments[index] = { type: "text", value: text }
|
|
||||||
return { ...value, segments }
|
|
||||||
}
|
|
||||||
|
|
||||||
export function updateWordBankText(
|
|
||||||
value: SelectMissingWordsStimulusValue,
|
|
||||||
index: number,
|
|
||||||
text: string,
|
|
||||||
): SelectMissingWordsStimulusValue {
|
|
||||||
const wordBank = [...value.word_bank]
|
|
||||||
if (!wordBank[index]) return value
|
|
||||||
wordBank[index] = { ...wordBank[index], text }
|
|
||||||
return { ...value, word_bank: wordBank }
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setAllowReuse(
|
|
||||||
value: SelectMissingWordsStimulusValue,
|
|
||||||
allowReuse: boolean,
|
|
||||||
): SelectMissingWordsStimulusValue {
|
|
||||||
return { ...value, allow_reuse: allowReuse }
|
|
||||||
}
|
|
||||||
|
|
||||||
export function syncResponseBlanksWithStimulus(
|
|
||||||
response: SelectMissingWordsResponseValue,
|
|
||||||
stimulus: SelectMissingWordsStimulusValue,
|
|
||||||
): SelectMissingWordsResponseValue {
|
|
||||||
const blankIds = blankIdsFromStimulus(stimulus)
|
|
||||||
const existing = new Map(
|
|
||||||
response.blanks.map((blank) => [blank.blank_id, blank]),
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
blanks: blankIds.map((blankId) => {
|
|
||||||
const current = existing.get(blankId)
|
|
||||||
return (
|
|
||||||
current ?? {
|
|
||||||
blank_id: blankId,
|
|
||||||
text: "",
|
|
||||||
word_id: "",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function validateSelectMissingWordsStimulusSlotValue(
|
|
||||||
value: SelectMissingWordsStimulusValue,
|
|
||||||
): string | null {
|
|
||||||
const blankCount = blankIdsFromStimulus(value).length
|
|
||||||
if (blankCount < SELECT_MISSING_WORDS_MIN_BLANKS) {
|
|
||||||
return `Add at least ${SELECT_MISSING_WORDS_MIN_BLANKS} blank in the passage.`
|
|
||||||
}
|
|
||||||
const filledWords = value.word_bank.filter((item) =>
|
|
||||||
wordBankItemHasValue(item.text),
|
|
||||||
)
|
|
||||||
if (filledWords.length < SELECT_MISSING_WORDS_MIN_BANK) {
|
|
||||||
return `Add at least ${SELECT_MISSING_WORDS_MIN_BANK} words in the word bank.`
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
export function validateSelectMissingWordsResponseSlotValue(
|
|
||||||
value: SelectMissingWordsResponseValue,
|
|
||||||
stimulus?: SelectMissingWordsStimulusValue | null,
|
|
||||||
): string | null {
|
|
||||||
const filled = value.blanks.filter(
|
|
||||||
(blank) =>
|
|
||||||
blank.blank_id.trim() &&
|
|
||||||
blank.word_id.trim() &&
|
|
||||||
blank.text.length > 0,
|
|
||||||
)
|
|
||||||
if (filled.length < SELECT_MISSING_WORDS_MIN_BLANKS) {
|
|
||||||
return "Select a word for each blank."
|
|
||||||
}
|
|
||||||
|
|
||||||
const blankIds = new Set(stimulus ? blankIdsFromStimulus(stimulus) : [])
|
|
||||||
const wordIds = new Set(
|
|
||||||
stimulus?.word_bank
|
|
||||||
.filter((item) => wordBankItemHasValue(item.text))
|
|
||||||
.map((item) => item.id) ?? [],
|
|
||||||
)
|
|
||||||
const wordTextById = new Map(
|
|
||||||
stimulus?.word_bank.map((item) => [item.id, item.text]) ?? [],
|
|
||||||
)
|
|
||||||
|
|
||||||
for (const blank of filled) {
|
|
||||||
if (blankIds.size > 0 && !blankIds.has(blank.blank_id)) {
|
|
||||||
return `Unknown blank id "${blank.blank_id}".`
|
|
||||||
}
|
|
||||||
if (wordIds.size > 0 && !wordIds.has(blank.word_id)) {
|
|
||||||
return `Unknown word id "${blank.word_id}" for blank "${blank.blank_id}".`
|
|
||||||
}
|
|
||||||
const expectedText = wordTextById.get(blank.word_id)
|
|
||||||
if (expectedText !== undefined && blank.text !== expectedText) {
|
|
||||||
return `Answer text for blank "${blank.blank_id}" must match the selected word.`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (stimulus && !stimulus.allow_reuse) {
|
|
||||||
const usedWordIds = filled.map((blank) => blank.word_id)
|
|
||||||
const unique = new Set(usedWordIds)
|
|
||||||
if (unique.size !== usedWordIds.length) {
|
|
||||||
return "Each word can only be used once when reuse is disabled."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
export function findSelectMissingWordsStimulusInFieldValues(
|
|
||||||
fieldValues: Record<string, string>,
|
|
||||||
stimulusSchema: { id: string; kind: string }[],
|
|
||||||
): SelectMissingWordsStimulusValue | null {
|
|
||||||
for (const row of stimulusSchema) {
|
|
||||||
if (row.kind.trim().toUpperCase() !== "SELECT_MISSING_WORDS") continue
|
|
||||||
const parsed = parseSelectMissingWordsStimulusSlotValue(
|
|
||||||
fieldValues[`stimulus:${row.id}`],
|
|
||||||
)
|
|
||||||
if (selectMissingWordsStimulusHasContent(parsed)) return parsed
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
@ -1,61 +0,0 @@
|
||||||
import type {
|
|
||||||
SubscriptionPlan,
|
|
||||||
SubscriptionPlanCategory,
|
|
||||||
SubscriptionPlanDurationUnit,
|
|
||||||
} from "../types/subscription.types"
|
|
||||||
|
|
||||||
export const SUBSCRIPTION_PLAN_CATEGORIES: {
|
|
||||||
value: SubscriptionPlanCategory
|
|
||||||
label: string
|
|
||||||
}[] = [
|
|
||||||
{ value: "LEARN_ENGLISH", label: "Learn English" },
|
|
||||||
{ value: "EXAM_PREP", label: "Exam prep" },
|
|
||||||
{ value: "SKILLS", label: "Skills" },
|
|
||||||
]
|
|
||||||
|
|
||||||
export const SUBSCRIPTION_DURATION_UNITS: {
|
|
||||||
value: SubscriptionPlanDurationUnit
|
|
||||||
label: string
|
|
||||||
}[] = [
|
|
||||||
{ value: "DAY", label: "Day(s)" },
|
|
||||||
{ value: "WEEK", label: "Week(s)" },
|
|
||||||
{ value: "MONTH", label: "Month(s)" },
|
|
||||||
{ value: "YEAR", label: "Year(s)" },
|
|
||||||
]
|
|
||||||
|
|
||||||
export const SUBSCRIPTION_CURRENCIES = ["ETB", "USD"] as const
|
|
||||||
|
|
||||||
export function formatPlanDuration(plan: Pick<SubscriptionPlan, "duration_value" | "duration_unit">): string {
|
|
||||||
const v = plan.duration_value
|
|
||||||
const u = String(plan.duration_unit).toUpperCase()
|
|
||||||
const word =
|
|
||||||
u === "MONTH" ? "month" : u === "YEAR" ? "year" : u === "WEEK" ? "week" : u === "DAY" ? "day" : plan.duration_unit
|
|
||||||
if (u === "MONTH" || u === "YEAR" || u === "WEEK" || u === "DAY") {
|
|
||||||
return `${v} ${v === 1 ? word : `${word}s`}`
|
|
||||||
}
|
|
||||||
return `${v} ${word}`
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatPlanPrice(plan: Pick<SubscriptionPlan, "price" | "currency">): string {
|
|
||||||
const amount = Number(plan.price)
|
|
||||||
const formatted = Number.isFinite(amount)
|
|
||||||
? amount.toLocaleString(undefined, { minimumFractionDigits: 0, maximumFractionDigits: 2 })
|
|
||||||
: String(plan.price)
|
|
||||||
return `${formatted} ${plan.currency}`
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatPlanCategory(category: string): string {
|
|
||||||
const match = SUBSCRIPTION_PLAN_CATEGORIES.find((c) => c.value === category)
|
|
||||||
if (match) return match.label
|
|
||||||
return category.replace(/_/g, " ").toLowerCase().replace(/\b\w/g, (c) => c.toUpperCase())
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatPlanCreatedAt(iso: string): string {
|
|
||||||
const d = new Date(iso)
|
|
||||||
if (Number.isNaN(d.getTime())) return iso
|
|
||||||
return d.toLocaleDateString(undefined, {
|
|
||||||
year: "numeric",
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
/** Standard page-size choices for admin data tables. */
|
|
||||||
export const TABLE_PAGE_SIZE_OPTIONS = [5, 10, 30, 50, 100] as const
|
|
||||||
|
|
||||||
export type TablePageSize = (typeof TABLE_PAGE_SIZE_OPTIONS)[number]
|
|
||||||
|
|
||||||
export const DEFAULT_TABLE_PAGE_SIZE: TablePageSize = 10
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
import type { VerifyInvitationData } from "../types/teamInvitation.types"
|
|
||||||
|
|
||||||
export function formatTeamRoleLabel(role: string | undefined): string {
|
|
||||||
if (!role) return "—"
|
|
||||||
return role.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatInvitationExpiry(raw: string | undefined): string | null {
|
|
||||||
if (!raw) return null
|
|
||||||
const d = new Date(raw)
|
|
||||||
if (Number.isNaN(d.getTime())) return raw
|
|
||||||
return d.toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" })
|
|
||||||
}
|
|
||||||
|
|
||||||
/** User-facing title when verify returns valid: false. */
|
|
||||||
export function getInvalidInvitationTitle(data: VerifyInvitationData | null): string {
|
|
||||||
const status = data?.status?.toLowerCase() ?? ""
|
|
||||||
const message = (data?.message ?? "").toLowerCase()
|
|
||||||
|
|
||||||
if (status === "expired" || message.includes("expir")) {
|
|
||||||
return "This invitation has expired"
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
status === "accepted" ||
|
|
||||||
message.includes("already") ||
|
|
||||||
message.includes("used") ||
|
|
||||||
message.includes("accepted")
|
|
||||||
) {
|
|
||||||
return "This invitation was already used"
|
|
||||||
}
|
|
||||||
if (status === "revoked" || message.includes("revok")) {
|
|
||||||
return "This invitation was revoked"
|
|
||||||
}
|
|
||||||
return "This invitation link is invalid"
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getInvalidInvitationDescription(
|
|
||||||
data: VerifyInvitationData | null,
|
|
||||||
apiMessage?: string,
|
|
||||||
): string {
|
|
||||||
const specific = data?.message?.trim() || apiMessage?.trim()
|
|
||||||
if (specific) return specific
|
|
||||||
return "The link may be expired, invalid, or already used. Ask your administrator to send a new invitation."
|
|
||||||
}
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
import type { Role } from "../types/rbac.types"
|
|
||||||
|
|
||||||
export const TEAM_ROLE_OPTIONS = [
|
|
||||||
{ value: "SUPER_ADMIN", label: "Super Admin" },
|
|
||||||
{ value: "ADMIN", label: "Admin" },
|
|
||||||
{ value: "CONTENT_MANAGER", label: "Content Manager" },
|
|
||||||
{ value: "SUPPORT_AGENT", label: "Support Agent" },
|
|
||||||
{ value: "INSTRUCTOR", label: "Instructor" },
|
|
||||||
{ value: "FINANCE", label: "Finance" },
|
|
||||||
{ value: "HR", label: "HR" },
|
|
||||||
{ value: "ANALYST", label: "Analyst" },
|
|
||||||
] as const
|
|
||||||
|
|
||||||
export const EMPLOYMENT_TYPE_OPTIONS = [
|
|
||||||
{ value: "full_time", label: "Full-time" },
|
|
||||||
{ value: "part_time", label: "Part-time" },
|
|
||||||
{ value: "contractor", label: "Contractor" },
|
|
||||||
{ value: "intern", label: "Intern" },
|
|
||||||
] as const
|
|
||||||
|
|
||||||
/** Map RBAC role display name to API team_role (e.g. CONTENT_MANAGER). */
|
|
||||||
export function rbacRoleNameToTeamRole(roleName: string): string {
|
|
||||||
const normalized = roleName.trim().toUpperCase().replace(/[\s-]+/g, "_")
|
|
||||||
const byValue = TEAM_ROLE_OPTIONS.find((o) => o.value === normalized)
|
|
||||||
if (byValue) return byValue.value
|
|
||||||
const byLabel = TEAM_ROLE_OPTIONS.find(
|
|
||||||
(o) => o.label.toUpperCase().replace(/[\s-]+/g, "_") === normalized,
|
|
||||||
)
|
|
||||||
if (byLabel) return byLabel.value
|
|
||||||
return normalized
|
|
||||||
}
|
|
||||||
|
|
||||||
export function teamRoleFromRbacRole(role: Role): string {
|
|
||||||
return rbacRoleNameToTeamRole(role.name)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatTeamRoleLabel(teamRole: string): string {
|
|
||||||
const found = TEAM_ROLE_OPTIONS.find(
|
|
||||||
(o) => o.value === teamRole || o.value === teamRole.toUpperCase(),
|
|
||||||
)
|
|
||||||
if (found) return found.label
|
|
||||||
return teamRole.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())
|
|
||||||
}
|
|
||||||
|
|
@ -1,64 +0,0 @@
|
||||||
export type ThemeMode = "light" | "dark" | "system"
|
|
||||||
export type ResolvedTheme = "light" | "dark"
|
|
||||||
|
|
||||||
export const THEME_STORAGE_KEY = "yimaru-admin-theme"
|
|
||||||
|
|
||||||
const MEDIA_QUERY = "(prefers-color-scheme: dark)"
|
|
||||||
|
|
||||||
export function getSystemTheme(): ResolvedTheme {
|
|
||||||
if (typeof window === "undefined") return "light"
|
|
||||||
return window.matchMedia(MEDIA_QUERY).matches ? "dark" : "light"
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Resolved appearance: light mode always forces light; dark forces dark; system follows OS. */
|
|
||||||
export function resolveTheme(mode: ThemeMode): ResolvedTheme {
|
|
||||||
if (mode === "light") return "light"
|
|
||||||
if (mode === "dark") return "dark"
|
|
||||||
return getSystemTheme()
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getStoredTheme(): ThemeMode {
|
|
||||||
if (typeof window === "undefined") return "light"
|
|
||||||
const value = localStorage.getItem(THEME_STORAGE_KEY)
|
|
||||||
if (value === "light" || value === "dark" || value === "system") return value
|
|
||||||
return "light"
|
|
||||||
}
|
|
||||||
|
|
||||||
function syncMetaThemeColor(resolved: ResolvedTheme) {
|
|
||||||
if (typeof document === "undefined") return
|
|
||||||
let meta = document.querySelector('meta[name="theme-color"]') as HTMLMetaElement | null
|
|
||||||
if (!meta) {
|
|
||||||
meta = document.createElement("meta")
|
|
||||||
meta.name = "theme-color"
|
|
||||||
document.head.appendChild(meta)
|
|
||||||
}
|
|
||||||
meta.content = resolved === "dark" ? "#12121a" : "#f5f5f5"
|
|
||||||
}
|
|
||||||
|
|
||||||
export function applyTheme(mode: ThemeMode): ResolvedTheme {
|
|
||||||
const resolved = resolveTheme(mode)
|
|
||||||
const root = document.documentElement
|
|
||||||
|
|
||||||
root.classList.remove("dark")
|
|
||||||
if (resolved === "dark") {
|
|
||||||
root.classList.add("dark")
|
|
||||||
}
|
|
||||||
|
|
||||||
root.dataset.theme = resolved
|
|
||||||
root.dataset.themePreference = mode
|
|
||||||
root.style.colorScheme = resolved
|
|
||||||
|
|
||||||
syncMetaThemeColor(resolved)
|
|
||||||
|
|
||||||
return resolved
|
|
||||||
}
|
|
||||||
|
|
||||||
export function watchSystemTheme(onChange: (resolved: ResolvedTheme) => void): () => void {
|
|
||||||
if (typeof window === "undefined") return () => undefined
|
|
||||||
|
|
||||||
const media = window.matchMedia(MEDIA_QUERY)
|
|
||||||
const handler = () => onChange(getSystemTheme())
|
|
||||||
|
|
||||||
media.addEventListener("change", handler)
|
|
||||||
return () => media.removeEventListener("change", handler)
|
|
||||||
}
|
|
||||||
|
|
@ -88,19 +88,6 @@ export function formatPreviewLength(totalSeconds: number): string {
|
||||||
return `${totalSeconds} seconds`;
|
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.
|
* 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).
|
* Vimeo: time range in URL fragment (supported on many vimeo.com player links).
|
||||||
|
|
|
||||||
|
|
@ -3,14 +3,11 @@ import { createRoot } from 'react-dom/client'
|
||||||
import { BrowserRouter } from 'react-router-dom'
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
import App from './App.tsx'
|
import App from './App.tsx'
|
||||||
import { ThemeProvider } from './contexts/ThemeContext.tsx'
|
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<ThemeProvider>
|
<BrowserRouter>
|
||||||
<BrowserRouter>
|
<App />
|
||||||
<App />
|
</BrowserRouter>
|
||||||
</BrowserRouter>
|
|
||||||
</ThemeProvider>
|
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import {
|
import {
|
||||||
// Activity,
|
// Activity,
|
||||||
BadgeCheck,
|
BadgeCheck,
|
||||||
Video,
|
BookOpen,
|
||||||
// Coins,
|
// Coins,
|
||||||
DollarSign,
|
DollarSign,
|
||||||
HelpCircle,
|
HelpCircle,
|
||||||
|
|
@ -10,15 +10,15 @@ import {
|
||||||
TicketCheck,
|
TicketCheck,
|
||||||
// TrendingUp,
|
// TrendingUp,
|
||||||
Users,
|
Users,
|
||||||
UserX,
|
|
||||||
Bell,
|
Bell,
|
||||||
CreditCard,
|
|
||||||
UsersRound,
|
UsersRound,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import spinnerSrc from "../assets/Circular-indeterminate progress indicator.svg"
|
import spinnerSrc from "../assets/Circular-indeterminate progress indicator.svg"
|
||||||
import {
|
import {
|
||||||
Area,
|
Area,
|
||||||
AreaChart,
|
AreaChart,
|
||||||
|
Bar,
|
||||||
|
BarChart,
|
||||||
CartesianGrid,
|
CartesianGrid,
|
||||||
Cell,
|
Cell,
|
||||||
Pie,
|
Pie,
|
||||||
|
|
@ -28,27 +28,15 @@ import {
|
||||||
XAxis,
|
XAxis,
|
||||||
YAxis,
|
YAxis,
|
||||||
} from "recharts"
|
} from "recharts"
|
||||||
import { RevenueTrendCard } from "../components/dashboard/RevenueTrendCard"
|
|
||||||
import { StatCard } from "../components/dashboard/StatCard"
|
import { StatCard } from "../components/dashboard/StatCard"
|
||||||
import alertSrc from "../assets/Alert.svg"
|
import alertSrc from "../assets/Alert.svg"
|
||||||
import { Badge } from "../components/ui/badge"
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card"
|
||||||
import { cn } from "../lib/utils"
|
import { cn } from "../lib/utils"
|
||||||
import { getTeamMemberById } from "../api/team.api"
|
import { getTeamMemberById } from "../api/team.api"
|
||||||
import { getDashboard } from "../api/analytics.api"
|
import { getDashboard } from "../api/analytics.api"
|
||||||
import { getSubscriptionPlans } from "../api/subscription-plans.api"
|
|
||||||
import { getRatings } from "../api/courses.api"
|
import { getRatings } from "../api/courses.api"
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { AnalyticsTimeRangeFilter } from "../components/analytics/AnalyticsTimeRangeFilter"
|
import type { DashboardData } from "../types/analytics.types"
|
||||||
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"
|
import type { Rating } from "../types/course.types"
|
||||||
|
|
||||||
const PIE_COLORS = ["#9E2891", "#FFD23F", "#1DE9B6", "#C26FC0", "#6366F1", "#F97316", "#14B8A6", "#EF4444"]
|
const PIE_COLORS = ["#9E2891", "#FFD23F", "#1DE9B6", "#C26FC0", "#6366F1", "#F97316", "#14B8A6", "#EF4444"]
|
||||||
|
|
@ -58,8 +46,6 @@ function formatDate(dateStr: string) {
|
||||||
return d.toLocaleDateString("en-US", { month: "short", day: "numeric" })
|
return d.toLocaleDateString("en-US", { month: "short", day: "numeric" })
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_FILTERS: DashboardFilters = { mode: "all_time" }
|
|
||||||
|
|
||||||
export function DashboardPage() {
|
export function DashboardPage() {
|
||||||
const [userFirstName, setUserFirstName] = useState<string>("")
|
const [userFirstName, setUserFirstName] = useState<string>("")
|
||||||
const [dashboard, setDashboard] = useState<DashboardData | null>(null)
|
const [dashboard, setDashboard] = useState<DashboardData | null>(null)
|
||||||
|
|
@ -67,9 +53,6 @@ export function DashboardPage() {
|
||||||
const [activeStatTab, setActiveStatTab] = useState<"primary" | "secondary">("primary")
|
const [activeStatTab, setActiveStatTab] = useState<"primary" | "secondary">("primary")
|
||||||
const [appRatings, setAppRatings] = useState<Rating[]>([])
|
const [appRatings, setAppRatings] = useState<Rating[]>([])
|
||||||
const [appRatingsLoading, setAppRatingsLoading] = useState(true)
|
const [appRatingsLoading, setAppRatingsLoading] = useState(true)
|
||||||
const [filters, setFilters] = useState<DashboardFilters>(DEFAULT_FILTERS)
|
|
||||||
const [subscriptionPlans, setSubscriptionPlans] = useState<SubscriptionPlan[]>([])
|
|
||||||
const [subscriptionPlansLoading, setSubscriptionPlansLoading] = useState(true)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchUser = async () => {
|
const fetchUser = async () => {
|
||||||
|
|
@ -87,10 +70,17 @@ export function DashboardPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fetchUser()
|
const fetchDashboard = async () => {
|
||||||
}, [])
|
try {
|
||||||
|
const res = await getDashboard()
|
||||||
|
setDashboard(res.data as unknown as DashboardData)
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchAppRatings = async () => {
|
const fetchAppRatings = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await getRatings({ target_type: "app", target_id: 1, limit: 5 })
|
const res = await getRatings({ target_type: "app", target_id: 1, limit: 5 })
|
||||||
|
|
@ -102,49 +92,23 @@ export function DashboardPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fetchUser()
|
||||||
|
fetchDashboard()
|
||||||
fetchAppRatings()
|
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 =
|
const registrationData =
|
||||||
dashboard?.users.registrations_last_30_days.map((d) => ({
|
dashboard?.users.registrations_last_30_days.map((d) => ({
|
||||||
date: formatDate(d.date),
|
date: formatDate(d.date),
|
||||||
count: d.count,
|
count: d.count,
|
||||||
})) ?? []
|
})) ?? []
|
||||||
|
|
||||||
|
const revenueData =
|
||||||
|
dashboard?.payments.revenue_last_30_days.map((d) => ({
|
||||||
|
date: formatDate(d.date),
|
||||||
|
revenue: d.revenue,
|
||||||
|
})) ?? []
|
||||||
|
|
||||||
const subscriptionStatusData =
|
const subscriptionStatusData =
|
||||||
dashboard?.subscriptions.by_status.map((s, i) => ({
|
dashboard?.subscriptions.by_status.map((s, i) => ({
|
||||||
name: s.label,
|
name: s.label,
|
||||||
|
|
@ -159,17 +123,9 @@ export function DashboardPage() {
|
||||||
color: PIE_COLORS[i % PIE_COLORS.length],
|
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 (
|
return (
|
||||||
<div className="mx-auto w-full max-w-6xl">
|
<div className="mx-auto w-full max-w-6xl">
|
||||||
<div className="mb-2 flex flex-wrap items-center justify-between gap-3">
|
<div className="mb-2 text-sm font-semibold text-grayScale-500">Dashboard</div>
|
||||||
<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">
|
<div className="mb-5 text-2xl font-semibold tracking-tight">
|
||||||
Welcome, {userFirstName || localStorage.getItem("user_first_name")}
|
Welcome, {userFirstName || localStorage.getItem("user_first_name")}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -233,11 +189,11 @@ export function DashboardPage() {
|
||||||
deltaPositive={dashboard.users.new_month > 0}
|
deltaPositive={dashboard.users.new_month > 0}
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
icon={CreditCard}
|
icon={BadgeCheck}
|
||||||
label="Payments"
|
label="Active Subscribers"
|
||||||
value={dashboard.payments.total_payments.toLocaleString()}
|
value={dashboard.subscriptions.active_subscriptions.toLocaleString()}
|
||||||
deltaLabel={`${dashboard.payments.successful_payments} successful`}
|
deltaLabel={`+${dashboard.subscriptions.new_month} this month`}
|
||||||
deltaPositive={dashboard.payments.successful_payments > 0}
|
deltaPositive={dashboard.subscriptions.new_month > 0}
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
icon={DollarSign}
|
icon={DollarSign}
|
||||||
|
|
@ -257,49 +213,21 @@ export function DashboardPage() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Secondary Stats */}
|
{/* Secondary Stats */}
|
||||||
{activeStatTab === "secondary" && subscriptionMetrics && (
|
{activeStatTab === "secondary" && (
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
<StatCard
|
<StatCard
|
||||||
icon={CreditCard}
|
icon={BookOpen}
|
||||||
label="Total Subscriptions"
|
label="Courses"
|
||||||
value={subscriptionMetrics.total.toLocaleString()}
|
value={dashboard.courses.total_courses.toLocaleString()}
|
||||||
deltaLabel={`+${dashboard.subscriptions.new_month} this month`}
|
deltaLabel={`${dashboard.courses.total_sub_courses} sub-modules, ${dashboard.courses.total_videos} videos`}
|
||||||
deltaPositive={dashboard.subscriptions.new_month > 0}
|
deltaPositive
|
||||||
/>
|
|
||||||
<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
|
<StatCard
|
||||||
icon={HelpCircle}
|
icon={HelpCircle}
|
||||||
label="Questions"
|
label="Questions"
|
||||||
value={dashboard.content.total_questions.toLocaleString()}
|
value={dashboard.content.total_questions.toLocaleString()}
|
||||||
deltaLabel={getPrimaryQuestionTypeSummary(dashboard.content.questions_by_type)}
|
deltaLabel={`${dashboard.content.total_question_sets} question sets`}
|
||||||
deltaPositive={dashboard.content.total_questions > 0}
|
deltaPositive
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
icon={Bell}
|
icon={Bell}
|
||||||
|
|
@ -333,7 +261,7 @@ export function DashboardPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-full bg-grayScale-100 px-3 py-1 text-xs font-semibold text-grayScale-500">
|
<div className="rounded-full bg-grayScale-100 px-3 py-1 text-xs font-semibold text-grayScale-500">
|
||||||
{seriesPeriodLabel}
|
Last 30 Days
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
@ -429,69 +357,76 @@ export function DashboardPage() {
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<RevenueTrendCard />
|
{/* Revenue Chart */}
|
||||||
|
<Card className="shadow-none">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle>Revenue Trend</CardTitle>
|
||||||
|
<div className="mt-2 text-2xl font-semibold tracking-tight">
|
||||||
|
ETB {dashboard.payments.total_revenue.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs font-medium text-grayScale-500">Last 30 Days (ETB)</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="h-[220px] p-6 pt-2">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={revenueData} margin={{ left: 8, right: 8, top: 8 }}>
|
||||||
|
<CartesianGrid vertical={false} stroke="#E0E0E0" strokeDasharray="4 4" />
|
||||||
|
<XAxis dataKey="date" tickLine={false} axisLine={false} fontSize={12} />
|
||||||
|
<YAxis tickLine={false} axisLine={false} fontSize={12} width={42} />
|
||||||
|
<Tooltip
|
||||||
|
formatter={(v) => [`${Number(v).toLocaleString()}`, "ETB"]}
|
||||||
|
contentStyle={{
|
||||||
|
borderRadius: 12,
|
||||||
|
border: "1px solid #E0E0E0",
|
||||||
|
boxShadow: "0 10px 30px rgba(0,0,0,0.08)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Bar dataKey="revenue" radius={[10, 10, 0, 0]} fill="#9E2891" />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Subscription plans (from catalog API) */}
|
{/* Users by Role / Region / Knowledge Level */}
|
||||||
<Card className="shadow-none">
|
<div className="grid gap-4 lg:grid-cols-3">
|
||||||
<CardHeader className="pb-2">
|
{[
|
||||||
<div className="flex items-center gap-2">
|
{ title: "Users by Role", data: dashboard.users.by_role },
|
||||||
<CreditCard className="h-5 w-5 text-brand-500" />
|
{ title: "Users by Region", data: dashboard.users.by_region },
|
||||||
<CardTitle>Subscription plans</CardTitle>
|
{ title: "Users by Knowledge Level", data: dashboard.users.by_knowledge_level },
|
||||||
</div>
|
].map(({ title, data }) => (
|
||||||
<p className="text-sm text-grayScale-500">Available billing plans for learners.</p>
|
<Card key={title} className="shadow-none">
|
||||||
</CardHeader>
|
<CardHeader className="pb-2">
|
||||||
<CardContent className="p-6 pt-2">
|
<CardTitle>{title}</CardTitle>
|
||||||
{subscriptionPlansLoading ? (
|
</CardHeader>
|
||||||
<div className="flex items-center justify-center py-10">
|
<CardContent className="p-6 pt-2">
|
||||||
<img src={spinnerSrc} alt="" className="h-8 w-8 animate-spin" />
|
{data.length > 0 ? (
|
||||||
</div>
|
<div className="space-y-3">
|
||||||
) : subscriptionPlans.length === 0 ? (
|
{data.map((item, i) => (
|
||||||
<div className="flex items-center justify-center py-10 text-sm text-grayScale-400">
|
<div key={item.label} className="flex items-center justify-between gap-3 text-sm">
|
||||||
No subscription plans found
|
<div className="flex items-center gap-2">
|
||||||
</div>
|
<span
|
||||||
) : (
|
className="h-2.5 w-2.5 rounded-full"
|
||||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
style={{ backgroundColor: PIE_COLORS[i % PIE_COLORS.length] }}
|
||||||
{subscriptionPlans.map((plan) => (
|
/>
|
||||||
<div
|
<span className="text-grayScale-600">{item.label}</span>
|
||||||
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>
|
</div>
|
||||||
|
<span className="font-semibold text-grayScale-600">{item.count.toLocaleString()}</span>
|
||||||
</div>
|
</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>
|
||||||
))}
|
) : (
|
||||||
</div>
|
<div className="flex items-center justify-center py-6 text-sm text-grayScale-400">
|
||||||
)}
|
No data available
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* App Ratings */}
|
{/* App Ratings */}
|
||||||
<Card className="shadow-none">
|
<Card className="shadow-none">
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,11 @@
|
||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
|
Bell,
|
||||||
Eye,
|
Eye,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
Globe,
|
Globe,
|
||||||
|
KeyRound,
|
||||||
|
Languages,
|
||||||
Lock,
|
Lock,
|
||||||
Moon,
|
Moon,
|
||||||
Palette,
|
Palette,
|
||||||
|
|
@ -11,7 +14,8 @@ import {
|
||||||
Sun,
|
Sun,
|
||||||
User,
|
User,
|
||||||
CreditCard,
|
CreditCard,
|
||||||
Smartphone,
|
AlertTriangle,
|
||||||
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
|
|
@ -22,33 +26,228 @@ import {
|
||||||
import { Input } from "../components/ui/input";
|
import { Input } from "../components/ui/input";
|
||||||
import { Button } from "../components/ui/button";
|
import { Button } from "../components/ui/button";
|
||||||
import { Select } from "../components/ui/select";
|
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 { cn } from "../lib/utils";
|
||||||
import { SpinnerIcon } from "../components/ui/spinner-icon";
|
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 { getMyProfile, updateProfile } from "../api/users.api";
|
||||||
import type { UserProfileData } from "../types/user.types";
|
import type { UserProfileData } from "../types/user.types";
|
||||||
import { toast } from "sonner";
|
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 =
|
type SettingsTab =
|
||||||
| "subscription"
|
| "subscription"
|
||||||
| "app-versions"
|
|
||||||
| "profile"
|
| "profile"
|
||||||
| "security"
|
| "security"
|
||||||
|
| "notifications"
|
||||||
| "appearance";
|
| "appearance";
|
||||||
|
|
||||||
const tabs: { id: SettingsTab; label: string; icon: typeof User }[] = [
|
const tabs: { id: SettingsTab; label: string; icon: typeof User }[] = [
|
||||||
{ id: "subscription", label: "Subscription packages", icon: CreditCard },
|
{ id: "subscription", label: "Subscription", icon: CreditCard },
|
||||||
{ id: "app-versions", label: "App versions", icon: Smartphone },
|
|
||||||
{ id: "profile", label: "Profile", icon: User },
|
{ id: "profile", label: "Profile", icon: User },
|
||||||
{ id: "security", label: "Security", icon: Shield },
|
{ id: "security", label: "Security", icon: Shield },
|
||||||
|
{ id: "notifications", label: "Notifications", icon: Bell },
|
||||||
{ id: "appearance", label: "Appearance", icon: Palette },
|
{ 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 }) {
|
function ProfileTab({ profile }: { profile: UserProfileData }) {
|
||||||
const [firstName, setFirstName] = useState(profile.first_name);
|
const [firstName, setFirstName] = useState(profile.first_name);
|
||||||
const [lastName, setLastName] = useState(profile.last_name);
|
const [lastName, setLastName] = useState(profile.last_name);
|
||||||
|
|
@ -164,46 +363,17 @@ function ProfileTab({ profile }: { profile: UserProfileData }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SecurityTab({ memberId }: { memberId: number }) {
|
function SecurityTab() {
|
||||||
const [currentPassword, setCurrentPassword] = useState("");
|
|
||||||
const [newPassword, setNewPassword] = useState("");
|
|
||||||
const [confirmPassword, setConfirmPassword] = useState("");
|
|
||||||
const [showCurrent, setShowCurrent] = useState(false);
|
const [showCurrent, setShowCurrent] = useState(false);
|
||||||
const [showNew, setShowNew] = useState(false);
|
const [showNew, setShowNew] = useState(false);
|
||||||
const [showConfirm, setShowConfirm] = useState(false);
|
const [showConfirm, setShowConfirm] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
const handleChangePassword = async () => {
|
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);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
await changeTeamMemberPassword(memberId, {
|
await new Promise((r) => setTimeout(r, 600));
|
||||||
current_password: currentPassword,
|
toast.success("Password updated successfully");
|
||||||
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 {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
|
|
@ -227,11 +397,7 @@ function SecurityTab({ memberId }: { memberId: number }) {
|
||||||
<Input
|
<Input
|
||||||
type={showCurrent ? "text" : "password"}
|
type={showCurrent ? "text" : "password"}
|
||||||
placeholder="Enter current password"
|
placeholder="Enter current password"
|
||||||
className="rounded-[6px] pr-10"
|
className="rounded-[6px]"
|
||||||
autoComplete="current-password"
|
|
||||||
value={currentPassword}
|
|
||||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
|
||||||
disabled={saving}
|
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -255,11 +421,7 @@ function SecurityTab({ memberId }: { memberId: number }) {
|
||||||
<Input
|
<Input
|
||||||
type={showNew ? "text" : "password"}
|
type={showNew ? "text" : "password"}
|
||||||
placeholder="Enter new password"
|
placeholder="Enter new password"
|
||||||
className="rounded-[6px] pr-10"
|
className="rounded-[6px]"
|
||||||
autoComplete="new-password"
|
|
||||||
value={newPassword}
|
|
||||||
onChange={(e) => setNewPassword(e.target.value)}
|
|
||||||
disabled={saving}
|
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -282,11 +444,7 @@ function SecurityTab({ memberId }: { memberId: number }) {
|
||||||
<Input
|
<Input
|
||||||
type={showConfirm ? "text" : "password"}
|
type={showConfirm ? "text" : "password"}
|
||||||
placeholder="Confirm new password"
|
placeholder="Confirm new password"
|
||||||
className="rounded-[6px] pr-10"
|
className="rounded-[6px]"
|
||||||
autoComplete="new-password"
|
|
||||||
value={confirmPassword}
|
|
||||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
|
||||||
disabled={saving}
|
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -374,101 +532,52 @@ function NotificationsTab() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function AppearanceTab() {
|
function AppearanceTab() {
|
||||||
const { theme, setTheme, resolvedTheme, systemTheme } = useTheme();
|
const [theme, setTheme] = useState<"light" | "dark" | "system">("light");
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<div className="space-y-4 animate-in fade-in slide-in-from-bottom-2 duration-300">
|
<Card className="border border-grayScale-100 rounded-[6px] overflow-hidden 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" />
|
||||||
<div className="h-1 w-full bg-brand-400" />
|
<CardHeader className="pb-3 border-b border-grayScale-50">
|
||||||
<CardHeader className="border-b border-grayScale-200 pb-3">
|
<CardTitle className="text-sm font-bold text-grayScale-900">
|
||||||
<CardTitle className="text-sm font-bold text-grayScale-600">Theme</CardTitle>
|
Theme
|
||||||
<p className="text-xs text-grayScale-400">
|
</CardTitle>
|
||||||
Active appearance:{" "}
|
</CardHeader>
|
||||||
<span className="font-semibold capitalize text-grayScale-600">{resolvedTheme}</span>
|
<CardContent className="pb-6">
|
||||||
{theme === "system" ? " (from your device setting)" : null}
|
<div className="grid gap-3 sm:grid-cols-3">
|
||||||
{theme === "light" ? " (fixed — not tied to device)" : null}
|
{(
|
||||||
</p>
|
[
|
||||||
</CardHeader>
|
{ id: "light", label: "Light", icon: Sun },
|
||||||
<CardContent className="pb-6 pt-4">
|
{ id: "dark", label: "Dark", icon: Moon },
|
||||||
<div className="grid gap-3 sm:grid-cols-3">
|
{ id: "system", label: "System", icon: Globe },
|
||||||
{options.map(({ id, label, description, icon: Icon, preview }) => {
|
] as const
|
||||||
const selected = theme === id;
|
).map(({ id, label, icon: Icon }) => (
|
||||||
return (
|
<button
|
||||||
<button
|
key={id}
|
||||||
key={id}
|
type="button"
|
||||||
type="button"
|
onClick={() => setTheme(id)}
|
||||||
onClick={() => setTheme(id)}
|
className={cn(
|
||||||
className={cn(
|
"flex flex-col items-center gap-2.5 rounded-[6px] border-2 px-4 py-5 transition-all",
|
||||||
"flex flex-col items-stretch gap-3 rounded-[8px] border-2 p-3 text-left transition-all",
|
theme === id
|
||||||
selected
|
? "border-brand-500 bg-brand-50 text-brand-600 shadow-sm"
|
||||||
? "border-brand-500 bg-brand-500/10 shadow-sm ring-1 ring-brand-500/30"
|
: "border-grayScale-100 bg-white text-grayScale-400 hover:border-grayScale-200 hover:bg-grayScale-50",
|
||||||
: "border-grayScale-200 bg-grayScale-50 hover:border-grayScale-300 hover:bg-grayScale-100",
|
)}
|
||||||
)}
|
>
|
||||||
>
|
<div
|
||||||
<ThemeModePreview
|
className={cn(
|
||||||
variant={preview}
|
"flex h-10 w-10 items-center justify-center rounded-[6px]",
|
||||||
systemResolved={systemTheme}
|
theme === id
|
||||||
/>
|
? "bg-brand-500 text-white"
|
||||||
<div className="flex items-center gap-2">
|
: "bg-grayScale-100 text-grayScale-400",
|
||||||
<div
|
)}
|
||||||
className={cn(
|
>
|
||||||
"flex h-9 w-9 shrink-0 items-center justify-center rounded-[6px]",
|
<Icon className="h-5 w-5" />
|
||||||
selected
|
</div>
|
||||||
? "bg-brand-500 text-white"
|
<span className="text-sm font-medium">{label}</span>
|
||||||
: "bg-grayScale-100 text-grayScale-500",
|
</button>
|
||||||
)}
|
))}
|
||||||
>
|
</div>
|
||||||
<Icon className="h-4 w-4" />
|
</CardContent>
|
||||||
</div>
|
</Card>
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -535,36 +644,10 @@ export function SettingsPage() {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex min-w-0 flex-col gap-8 lg:flex-row lg:items-start">
|
<div className="flex flex-col gap-8">
|
||||||
<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">
|
{/* Content Area */}
|
||||||
{tabs.map((tab) => {
|
<main className="min-h-[400px]">
|
||||||
const Icon = tab.icon;
|
{activeTab === "subscription" && <SubscriptionTab />}
|
||||||
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>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -39,15 +39,7 @@ import { Badge } from "../../components/ui/badge"
|
||||||
import { Button } from "../../components/ui/button"
|
import { Button } from "../../components/ui/button"
|
||||||
import { cn } from "../../lib/utils"
|
import { cn } from "../../lib/utils"
|
||||||
import { getDashboard } from "../../api/analytics.api"
|
import { getDashboard } from "../../api/analytics.api"
|
||||||
import { AnalyticsTimeRangeFilter, getDashboardFilterLabel } from "../../components/analytics/AnalyticsTimeRangeFilter"
|
import type { DashboardData, LabelCount } from "../../types/analytics.types"
|
||||||
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"]
|
const PIE_COLORS = ["#9E2891", "#FFD23F", "#1DE9B6", "#C26FC0", "#6366F1", "#F97316", "#14B8A6", "#EF4444", "#8B5CF6", "#EC4899", "#06B6D4", "#84CC16"]
|
||||||
|
|
||||||
|
|
@ -117,43 +109,31 @@ function BreakdownList({
|
||||||
title,
|
title,
|
||||||
data,
|
data,
|
||||||
total,
|
total,
|
||||||
scrollable,
|
|
||||||
}: {
|
}: {
|
||||||
title: string
|
title: string
|
||||||
data: LabelCount[]
|
data: LabelCount[]
|
||||||
total?: number
|
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 computedTotal = total ?? data.reduce((s, d) => s + d.count, 0)
|
||||||
const sorted = [...data].sort((a, b) => b.count - a.count)
|
|
||||||
return (
|
return (
|
||||||
<Card className="shadow-none">
|
<Card className="shadow-none">
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardTitle className="text-sm">{title}</CardTitle>
|
<CardTitle className="text-sm">{title}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-4 pt-0">
|
<CardContent className="p-4 pt-0">
|
||||||
{sorted.length > 0 ? (
|
{data.length > 0 ? (
|
||||||
<div
|
<div className="space-y-2.5">
|
||||||
className={cn(
|
{data.map((item, i) => {
|
||||||
"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 pct = computedTotal > 0 ? (item.count / computedTotal) * 100 : 0
|
||||||
const displayLabel = formatAnalyticsLabel(item.label)
|
|
||||||
return (
|
return (
|
||||||
<div key={`${item.label}-${i}`}>
|
<div key={item.label}>
|
||||||
<div className="mb-1 flex items-center justify-between gap-2 text-xs">
|
<div className="mb-1 flex items-center justify-between text-xs">
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span
|
<span
|
||||||
className="h-2 w-2 shrink-0 rounded-full"
|
className="h-2 w-2 rounded-full"
|
||||||
style={{ backgroundColor: PIE_COLORS[i % PIE_COLORS.length] }}
|
style={{ backgroundColor: PIE_COLORS[i % PIE_COLORS.length] }}
|
||||||
/>
|
/>
|
||||||
<span className="truncate text-grayScale-600" title={displayLabel}>
|
<span className="text-grayScale-600">{item.label}</span>
|
||||||
{displayLabel}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<span className="font-semibold text-grayScale-700">
|
<span className="font-semibold text-grayScale-700">
|
||||||
{item.count.toLocaleString()}
|
{item.count.toLocaleString()}
|
||||||
|
|
@ -305,21 +285,18 @@ function Section({
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_FILTERS: DashboardFilters = { mode: "all_time" }
|
|
||||||
|
|
||||||
export function AnalyticsPage() {
|
export function AnalyticsPage() {
|
||||||
const [dashboard, setDashboard] = useState<DashboardData | null>(null)
|
const [dashboard, setDashboard] = useState<DashboardData | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState(false)
|
const [error, setError] = useState(false)
|
||||||
const [activeSummaryTab, setActiveSummaryTab] = useState<"key" | "content" | "operations">("key")
|
const [activeSummaryTab, setActiveSummaryTab] = useState<"key" | "content" | "operations">("key")
|
||||||
const [filters, setFilters] = useState<DashboardFilters>(DEFAULT_FILTERS)
|
|
||||||
|
|
||||||
const fetchData = async (nextFilters: DashboardFilters = filters) => {
|
const fetchData = async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(false)
|
setError(false)
|
||||||
try {
|
try {
|
||||||
const res = await getDashboard(nextFilters)
|
const res = await getDashboard()
|
||||||
setDashboard(res.data)
|
setDashboard(res.data as unknown as DashboardData)
|
||||||
} catch {
|
} catch {
|
||||||
setError(true)
|
setError(true)
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -328,11 +305,10 @@ export function AnalyticsPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchData(filters)
|
fetchData()
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
}, [])
|
||||||
}, [filters])
|
|
||||||
|
|
||||||
if (!dashboard && loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto w-full max-w-[1280px] px-2 sm:px-4">
|
<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 text-xs font-semibold uppercase tracking-wide text-grayScale-400">Analytics</div>
|
||||||
|
|
@ -347,14 +323,11 @@ export function AnalyticsPage() {
|
||||||
if (error || !dashboard) {
|
if (error || !dashboard) {
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto w-full max-w-[1280px] px-2 sm:px-4">
|
<div className="mx-auto w-full max-w-[1280px] px-2 sm:px-4">
|
||||||
<div className="mb-6 flex flex-wrap items-center justify-between gap-3">
|
<div className="mb-6 text-xs font-semibold uppercase tracking-wide text-grayScale-400">Analytics</div>
|
||||||
<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">
|
<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" />
|
<img src={alertSrc} alt="" className="h-12 w-12" />
|
||||||
<span className="text-sm text-destructive">Failed to load analytics data.</span>
|
<span className="text-sm text-destructive">Failed to load analytics data.</span>
|
||||||
<Button variant="outline" size="sm" onClick={() => fetchData(filters)}>
|
<Button variant="outline" size="sm" onClick={fetchData}>
|
||||||
<RefreshCw className="mr-2 h-4 w-4" />
|
<RefreshCw className="mr-2 h-4 w-4" />
|
||||||
Retry
|
Retry
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -364,10 +337,6 @@ export function AnalyticsPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const { users, subscriptions, payments, courses, content, notifications, issues, team } = dashboard
|
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) => ({
|
const registrationData = users.registrations_last_30_days.map((d) => ({
|
||||||
date: formatDate(d.date),
|
date: formatDate(d.date),
|
||||||
|
|
@ -418,25 +387,15 @@ export function AnalyticsPage() {
|
||||||
<div className="mb-1 text-xs font-semibold uppercase tracking-wide text-grayScale-400">Analytics</div>
|
<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>
|
<h1 className="text-3xl font-semibold tracking-tight text-grayScale-900">Platform Overview</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="text-xs text-grayScale-400">
|
<span className="text-xs text-grayScale-400">Generated {generatedAt}</span>
|
||||||
{getDashboardFilterLabel(filters)} · Generated {generatedAt}
|
<Button variant="outline" size="sm" onClick={fetchData}>
|
||||||
</span>
|
<RefreshCw className="mr-2 h-3.5 w-3.5" />
|
||||||
<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
|
Refresh
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Summary Tabs */}
|
||||||
<div className="mb-6 rounded-2xl border border-grayScale-100 bg-white px-5 pt-4 shadow-sm">
|
<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">
|
<div className="-mb-px flex gap-6">
|
||||||
|
|
@ -493,10 +452,10 @@ export function AnalyticsPage() {
|
||||||
trend={users.new_month > 0 ? "up" : "neutral"}
|
trend={users.new_month > 0 ? "up" : "neutral"}
|
||||||
/>
|
/>
|
||||||
<KpiCard
|
<KpiCard
|
||||||
icon={CreditCard}
|
icon={BadgeCheck}
|
||||||
label="Total Subscriptions"
|
label="Active Subscriptions"
|
||||||
value={formatNumber(subscriptionMetrics.total)}
|
value={formatNumber(subscriptions.active_subscriptions)}
|
||||||
sub={`${subscriptionMetrics.active} active · ${subscriptionMetrics.inactive} inactive`}
|
sub={`${subscriptions.total_subscriptions} total · +${subscriptions.new_month} this month`}
|
||||||
trend={subscriptions.new_month > 0 ? "up" : "neutral"}
|
trend={subscriptions.new_month > 0 ? "up" : "neutral"}
|
||||||
/>
|
/>
|
||||||
<KpiCard
|
<KpiCard
|
||||||
|
|
@ -524,7 +483,7 @@ export function AnalyticsPage() {
|
||||||
<Section
|
<Section
|
||||||
title="Content & Platform"
|
title="Content & Platform"
|
||||||
icon={BookOpen}
|
icon={BookOpen}
|
||||||
count={courses.total_videos + content.total_questions}
|
count={courses.total_courses + content.total_questions}
|
||||||
defaultOpen
|
defaultOpen
|
||||||
>
|
>
|
||||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
|
@ -532,29 +491,28 @@ export function AnalyticsPage() {
|
||||||
icon={FolderOpen}
|
icon={FolderOpen}
|
||||||
label="Categories"
|
label="Categories"
|
||||||
value={courses.total_categories.toLocaleString()}
|
value={courses.total_categories.toLocaleString()}
|
||||||
sub={`${courses.total_courses} courses · ${courses.total_sub_courses} modules`}
|
sub={`${courses.total_courses} courses`}
|
||||||
trend="neutral"
|
trend="neutral"
|
||||||
/>
|
/>
|
||||||
<KpiCard
|
<KpiCard
|
||||||
icon={BookOpen}
|
icon={BookOpen}
|
||||||
label="LMS Programs"
|
label="Sub-Courses"
|
||||||
value={(lms?.programs ?? 0).toLocaleString()}
|
value={courses.total_sub_courses.toLocaleString()}
|
||||||
sub={`${lms?.courses ?? 0} courses · ${lms?.practices ?? 0} practices`}
|
sub={`across ${courses.total_courses} courses`}
|
||||||
trend="neutral"
|
trend="neutral"
|
||||||
/>
|
/>
|
||||||
<KpiCard
|
<KpiCard
|
||||||
icon={Video}
|
icon={Video}
|
||||||
label="Videos"
|
label="Videos"
|
||||||
value={courses.total_videos.toLocaleString()}
|
value={courses.total_videos.toLocaleString()}
|
||||||
sub={getVideoLessonsSummary(lms?.lessons_with_video, examPrep?.lessons_with_video)}
|
trend="neutral"
|
||||||
trend={courses.total_videos > 0 ? "up" : "neutral"}
|
|
||||||
/>
|
/>
|
||||||
<KpiCard
|
<KpiCard
|
||||||
icon={HelpCircle}
|
icon={HelpCircle}
|
||||||
label="Questions"
|
label="Questions"
|
||||||
value={content.total_questions.toLocaleString()}
|
value={content.total_questions.toLocaleString()}
|
||||||
sub={getPrimaryQuestionTypeSummary(content.questions_by_type)}
|
sub={`${content.total_question_sets} question sets`}
|
||||||
trend={content.total_questions > 0 ? "up" : "neutral"}
|
trend="neutral"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
@ -615,7 +573,7 @@ export function AnalyticsPage() {
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="secondary">{seriesPeriodLabel}</Badge>
|
<Badge variant="secondary">Last 30 Days</Badge>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="h-[280px] p-6 pt-2">
|
<CardContent className="h-[280px] p-6 pt-2">
|
||||||
|
|
@ -643,77 +601,12 @@ export function AnalyticsPage() {
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<div className="mt-4 space-y-6">
|
<div className="mt-4 grid items-start gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
<div>
|
<BreakdownList title="Users by Role" data={users.by_role} total={users.total_users} />
|
||||||
<p className="mb-3 text-xs font-semibold uppercase tracking-wide text-grayScale-400">
|
<BreakdownList title="Users by Status" data={users.by_status} total={users.total_users} />
|
||||||
Profile & demographics
|
<BreakdownList title="Users by Age Group" data={users.by_age_group} total={users.total_users} />
|
||||||
</p>
|
<BreakdownList title="Users by Knowledge Level" data={users.by_knowledge_level} total={users.total_users} />
|
||||||
<div className="grid items-start gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
<BreakdownList title="Users by Region" data={users.by_region} total={users.total_users} />
|
||||||
<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>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
|
|
@ -732,7 +625,7 @@ export function AnalyticsPage() {
|
||||||
+{subscriptions.new_today} today · +{subscriptions.new_week} this week
|
+{subscriptions.new_today} today · +{subscriptions.new_week} this week
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="secondary">{seriesPeriodLabel}</Badge>
|
<Badge variant="secondary">Last 30 Days</Badge>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="h-[240px] p-6 pt-2">
|
<CardContent className="h-[240px] p-6 pt-2">
|
||||||
|
|
@ -771,7 +664,7 @@ export function AnalyticsPage() {
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-grayScale-400">Daily revenue over last 30 days</div>
|
<div className="text-xs text-grayScale-400">Daily revenue over last 30 days</div>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="secondary">{seriesPeriodLabel}</Badge>
|
<Badge variant="secondary">Last 30 Days</Badge>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="h-[240px] p-6 pt-2">
|
<CardContent className="h-[240px] p-6 pt-2">
|
||||||
|
|
@ -835,43 +728,6 @@ export function AnalyticsPage() {
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</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 ─── */}
|
{/* ─── Content Breakdown ─── */}
|
||||||
<Section title="Content Breakdown" icon={HelpCircle} count={content.total_questions} defaultOpen={false}>
|
<Section title="Content Breakdown" icon={HelpCircle} count={content.total_questions} defaultOpen={false}>
|
||||||
<div className="grid items-start gap-4 sm:grid-cols-2">
|
<div className="grid items-start gap-4 sm:grid-cols-2">
|
||||||
|
|
|
||||||
|
|
@ -1,467 +0,0 @@
|
||||||
import { useCallback, useEffect, useState } from "react"
|
|
||||||
import { Link, Navigate, useNavigate, useSearchParams } from "react-router-dom"
|
|
||||||
import {
|
|
||||||
AlertCircle,
|
|
||||||
Briefcase,
|
|
||||||
Building2,
|
|
||||||
Eye,
|
|
||||||
EyeOff,
|
|
||||||
Mail,
|
|
||||||
Phone,
|
|
||||||
Shield,
|
|
||||||
User,
|
|
||||||
} from "lucide-react"
|
|
||||||
import { toast } from "sonner"
|
|
||||||
import {
|
|
||||||
acceptTeamInvitation,
|
|
||||||
parseVerifyInvitation,
|
|
||||||
verifyTeamInvitation,
|
|
||||||
} from "../../api/team.api"
|
|
||||||
import { BrandLogo } from "../../components/brand/BrandLogo"
|
|
||||||
import { Button } from "../../components/ui/button"
|
|
||||||
import { Input } from "../../components/ui/input"
|
|
||||||
import { SpinnerIcon } from "../../components/ui/spinner-icon"
|
|
||||||
import { cn } from "../../lib/utils"
|
|
||||||
import {
|
|
||||||
formatInvitationExpiry,
|
|
||||||
formatTeamRoleLabel,
|
|
||||||
getInvalidInvitationDescription,
|
|
||||||
getInvalidInvitationTitle,
|
|
||||||
} from "../../lib/teamInvitation"
|
|
||||||
import type { VerifyInvitationData } from "../../types/teamInvitation.types"
|
|
||||||
|
|
||||||
export function AcceptInvitePage() {
|
|
||||||
const navigate = useNavigate()
|
|
||||||
const [searchParams] = useSearchParams()
|
|
||||||
const token = searchParams.get("token")?.trim() ?? ""
|
|
||||||
|
|
||||||
const [verifyState, setVerifyState] = useState<
|
|
||||||
"loading" | "invalid" | "ready" | "success"
|
|
||||||
>("loading")
|
|
||||||
const [inviteInfo, setInviteInfo] = useState<VerifyInvitationData | null>(null)
|
|
||||||
const [invalidTitle, setInvalidTitle] = useState("")
|
|
||||||
const [invalidDescription, setInvalidDescription] = useState("")
|
|
||||||
|
|
||||||
const [firstName, setFirstName] = useState("")
|
|
||||||
const [lastName, setLastName] = useState("")
|
|
||||||
const [phoneNumber, setPhoneNumber] = useState("")
|
|
||||||
const [department, setDepartment] = useState("")
|
|
||||||
const [jobTitle, setJobTitle] = useState("")
|
|
||||||
const [password, setPassword] = useState("")
|
|
||||||
const [confirmPassword, setConfirmPassword] = useState("")
|
|
||||||
const [showPassword, setShowPassword] = useState(false)
|
|
||||||
const [submitting, setSubmitting] = useState(false)
|
|
||||||
|
|
||||||
const loadVerification = useCallback(async () => {
|
|
||||||
if (!token) {
|
|
||||||
setInviteInfo(null)
|
|
||||||
setInvalidTitle("This invitation link is invalid")
|
|
||||||
setInvalidDescription("Invitation link is missing a token.")
|
|
||||||
setVerifyState("invalid")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setVerifyState("loading")
|
|
||||||
setInviteInfo(null)
|
|
||||||
try {
|
|
||||||
const res = await verifyTeamInvitation(token)
|
|
||||||
const data = parseVerifyInvitation(res)
|
|
||||||
|
|
||||||
if (!data || data.valid !== true) {
|
|
||||||
setInviteInfo(data)
|
|
||||||
setInvalidTitle(getInvalidInvitationTitle(data))
|
|
||||||
setInvalidDescription(
|
|
||||||
getInvalidInvitationDescription(data, res.data?.message),
|
|
||||||
)
|
|
||||||
setVerifyState("invalid")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setInviteInfo(data)
|
|
||||||
setFirstName(data.first_name?.trim() ?? "")
|
|
||||||
setLastName(data.last_name?.trim() ?? "")
|
|
||||||
setVerifyState("ready")
|
|
||||||
} catch (e: unknown) {
|
|
||||||
setInviteInfo(null)
|
|
||||||
setInvalidTitle("This invitation link is invalid")
|
|
||||||
setInvalidDescription(
|
|
||||||
(e as { response?: { data?: { message?: string } } })?.response?.data
|
|
||||||
?.message ??
|
|
||||||
"The link may be expired, invalid, or already used. Ask your administrator to send a new invitation.",
|
|
||||||
)
|
|
||||||
setVerifyState("invalid")
|
|
||||||
}
|
|
||||||
}, [token])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void loadVerification()
|
|
||||||
}, [loadVerification])
|
|
||||||
|
|
||||||
const handleAccept = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault()
|
|
||||||
if (!token) return
|
|
||||||
|
|
||||||
if (!firstName.trim() || !lastName.trim()) {
|
|
||||||
toast.error("First name and last name are required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (password.length < 8) {
|
|
||||||
toast.error("Password must be at least 8 characters")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (password !== confirmPassword) {
|
|
||||||
toast.error("Passwords do not match")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setSubmitting(true)
|
|
||||||
try {
|
|
||||||
const res = await acceptTeamInvitation({
|
|
||||||
token,
|
|
||||||
password,
|
|
||||||
first_name: firstName.trim(),
|
|
||||||
last_name: lastName.trim(),
|
|
||||||
phone_number: phoneNumber.trim(),
|
|
||||||
department: department.trim(),
|
|
||||||
job_title: jobTitle.trim(),
|
|
||||||
})
|
|
||||||
setVerifyState("success")
|
|
||||||
toast.success(res.data?.message ?? "Account setup complete. You can sign in now.")
|
|
||||||
navigate("/login", { replace: true })
|
|
||||||
} catch (e: unknown) {
|
|
||||||
const msg =
|
|
||||||
(e as { response?: { data?: { message?: string } } })?.response?.data
|
|
||||||
?.message ?? "Failed to complete setup"
|
|
||||||
toast.error(msg)
|
|
||||||
} finally {
|
|
||||||
setSubmitting(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const expiryLabel = formatInvitationExpiry(inviteInfo?.expires_at)
|
|
||||||
const setupTitle = inviteInfo?.needs_profile_setup
|
|
||||||
? "Complete your account setup"
|
|
||||||
: "Set your password"
|
|
||||||
|
|
||||||
if (localStorage.getItem("access_token")) {
|
|
||||||
return <Navigate to="/dashboard" replace />
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative flex min-h-screen overflow-hidden">
|
|
||||||
<div className="relative hidden items-center justify-center bg-gradient-to-br from-brand-600 via-brand-500 to-brand-400 lg:flex lg:w-1/2 xl:w-[55%]">
|
|
||||||
<div className="absolute inset-0 overflow-hidden">
|
|
||||||
<div className="absolute -left-20 -top-20 h-96 w-96 rounded-full bg-white/5" />
|
|
||||||
<div className="absolute -bottom-32 -right-16 h-[500px] w-[500px] rounded-full bg-white/5" />
|
|
||||||
</div>
|
|
||||||
<div className="relative z-10 max-w-md px-12 text-center">
|
|
||||||
<BrandLogo variant="light" className="mx-auto mb-8 h-16" />
|
|
||||||
<p className="text-base leading-relaxed text-white/70">
|
|
||||||
You have been invited to join the Yimaru admin panel. Verify your invitation,
|
|
||||||
then complete setup to activate your account.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex h-screen min-h-0 w-full flex-col overflow-hidden bg-white px-6 py-6 lg:w-1/2 lg:py-8 xl:w-[45%]">
|
|
||||||
<div className="mx-auto flex h-full min-h-0 w-full max-w-[440px] flex-col">
|
|
||||||
<div className="shrink-0">
|
|
||||||
<div className="mb-6 flex justify-center lg:hidden">
|
|
||||||
<BrandLogo />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-4 lg:mb-6">
|
|
||||||
<p className="mb-1.5 text-sm font-medium uppercase tracking-widest text-brand-400">
|
|
||||||
Team invitation
|
|
||||||
</p>
|
|
||||||
<h1 className="mb-2 text-2xl font-bold tracking-tight text-grayScale-600 sm:text-3xl">
|
|
||||||
{verifyState === "success"
|
|
||||||
? "You're all set"
|
|
||||||
: verifyState === "invalid"
|
|
||||||
? invalidTitle
|
|
||||||
: "Accept invitation"}
|
|
||||||
</h1>
|
|
||||||
<p className="text-sm leading-relaxed text-grayScale-400">
|
|
||||||
{verifyState === "success"
|
|
||||||
? "Redirecting you to sign in…"
|
|
||||||
: verifyState === "invalid"
|
|
||||||
? invalidDescription
|
|
||||||
: verifyState === "ready"
|
|
||||||
? setupTitle
|
|
||||||
: "Verifying your invitation link…"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"min-h-0 flex-1",
|
|
||||||
verifyState === "ready" ? "overflow-y-auto overscroll-contain" : "flex flex-col justify-center",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{verifyState === "loading" && (
|
|
||||||
<div className="flex flex-col items-center gap-3 py-16">
|
|
||||||
<SpinnerIcon className="h-8 w-8" />
|
|
||||||
<p className="text-sm text-grayScale-400">Verifying invitation…</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{verifyState === "invalid" && (
|
|
||||||
<div className="space-y-4 rounded-xl border border-red-200 bg-red-50 px-4 py-4">
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<AlertCircle className="mt-0.5 h-5 w-5 shrink-0 text-red-600" />
|
|
||||||
<div className="space-y-2 text-sm text-red-800">
|
|
||||||
<p className="font-semibold">{invalidTitle}</p>
|
|
||||||
<p>{invalidDescription}</p>
|
|
||||||
<p className="text-xs text-red-700/90">
|
|
||||||
Common reasons: expired, invalid, or already used.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{inviteInfo?.email ? (
|
|
||||||
<p className="border-t border-red-200/80 pt-3 text-xs text-red-700/80">
|
|
||||||
Invitation email: <span className="font-medium">{inviteInfo.email}</span>
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="border-red-200 bg-white"
|
|
||||||
onClick={() => void loadVerification()}
|
|
||||||
>
|
|
||||||
Try again
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{verifyState === "ready" && inviteInfo && (
|
|
||||||
<form
|
|
||||||
onSubmit={(e) => void handleAccept(e)}
|
|
||||||
className="space-y-5 pr-1 pb-4"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
htmlFor="invite-email"
|
|
||||||
className="mb-1.5 flex items-center gap-1.5 text-sm font-medium text-grayScale-600"
|
|
||||||
>
|
|
||||||
<Mail className="h-3.5 w-3.5" />
|
|
||||||
Email
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
id="invite-email"
|
|
||||||
type="email"
|
|
||||||
readOnly
|
|
||||||
value={inviteInfo.email ?? ""}
|
|
||||||
className="cursor-not-allowed bg-grayScale-50 text-grayScale-700"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
htmlFor="invite-role"
|
|
||||||
className="mb-1.5 flex items-center gap-1.5 text-sm font-medium text-grayScale-600"
|
|
||||||
>
|
|
||||||
<Shield className="h-3.5 w-3.5" />
|
|
||||||
Role
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
id="invite-role"
|
|
||||||
readOnly
|
|
||||||
value={formatTeamRoleLabel(inviteInfo.team_role)}
|
|
||||||
className="cursor-not-allowed bg-grayScale-50 text-grayScale-700"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{expiryLabel ? (
|
|
||||||
<p className="text-xs text-grayScale-400">
|
|
||||||
Invitation expires {expiryLabel}
|
|
||||||
{inviteInfo.status ? ` · Status: ${inviteInfo.status}` : null}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="border-t border-grayScale-100 pt-4">
|
|
||||||
<p className="mb-3 text-xs font-semibold uppercase tracking-wide text-grayScale-400">
|
|
||||||
Your details
|
|
||||||
</p>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
htmlFor="first-name"
|
|
||||||
className="mb-1.5 flex items-center gap-1.5 text-sm font-medium text-grayScale-600"
|
|
||||||
>
|
|
||||||
<User className="h-3.5 w-3.5" />
|
|
||||||
First name
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
id="first-name"
|
|
||||||
value={firstName}
|
|
||||||
onChange={(e) => setFirstName(e.target.value)}
|
|
||||||
placeholder="John"
|
|
||||||
autoComplete="given-name"
|
|
||||||
disabled={submitting}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
htmlFor="last-name"
|
|
||||||
className="mb-1.5 flex items-center gap-1.5 text-sm font-medium text-grayScale-600"
|
|
||||||
>
|
|
||||||
<User className="h-3.5 w-3.5" />
|
|
||||||
Last name
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
id="last-name"
|
|
||||||
value={lastName}
|
|
||||||
onChange={(e) => setLastName(e.target.value)}
|
|
||||||
placeholder="Doe"
|
|
||||||
autoComplete="family-name"
|
|
||||||
disabled={submitting}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
htmlFor="phone"
|
|
||||||
className="mb-1.5 flex items-center gap-1.5 text-sm font-medium text-grayScale-600"
|
|
||||||
>
|
|
||||||
<Phone className="h-3.5 w-3.5" />
|
|
||||||
Phone number
|
|
||||||
<span className="font-normal text-grayScale-400">(optional)</span>
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
id="phone"
|
|
||||||
type="tel"
|
|
||||||
value={phoneNumber}
|
|
||||||
onChange={(e) => setPhoneNumber(e.target.value)}
|
|
||||||
placeholder="+251..."
|
|
||||||
autoComplete="tel"
|
|
||||||
disabled={submitting}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
htmlFor="department"
|
|
||||||
className="mb-1.5 flex items-center gap-1.5 text-sm font-medium text-grayScale-600"
|
|
||||||
>
|
|
||||||
<Building2 className="h-3.5 w-3.5" />
|
|
||||||
Department
|
|
||||||
<span className="font-normal text-grayScale-400">(optional)</span>
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
id="department"
|
|
||||||
value={department}
|
|
||||||
onChange={(e) => setDepartment(e.target.value)}
|
|
||||||
placeholder="e.g. LMS"
|
|
||||||
disabled={submitting}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
htmlFor="job-title"
|
|
||||||
className="mb-1.5 flex items-center gap-1.5 text-sm font-medium text-grayScale-600"
|
|
||||||
>
|
|
||||||
<Briefcase className="h-3.5 w-3.5" />
|
|
||||||
Job title
|
|
||||||
<span className="font-normal text-grayScale-400">(optional)</span>
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
id="job-title"
|
|
||||||
value={jobTitle}
|
|
||||||
onChange={(e) => setJobTitle(e.target.value)}
|
|
||||||
placeholder="e.g. Content Lead"
|
|
||||||
disabled={submitting}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border-t border-grayScale-100 pt-4">
|
|
||||||
<p className="mb-3 text-xs font-semibold uppercase tracking-wide text-grayScale-400">
|
|
||||||
Account password
|
|
||||||
</p>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
htmlFor="password"
|
|
||||||
className="mb-1.5 block text-sm font-medium text-grayScale-600"
|
|
||||||
>
|
|
||||||
Password
|
|
||||||
</label>
|
|
||||||
<div className="relative">
|
|
||||||
<Input
|
|
||||||
id="password"
|
|
||||||
type={showPassword ? "text" : "password"}
|
|
||||||
autoComplete="new-password"
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
placeholder="At least 8 characters"
|
|
||||||
className="pr-10"
|
|
||||||
disabled={submitting}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-grayScale-400"
|
|
||||||
onClick={() => setShowPassword((v) => !v)}
|
|
||||||
tabIndex={-1}
|
|
||||||
>
|
|
||||||
{showPassword ? (
|
|
||||||
<EyeOff className="h-4 w-4" />
|
|
||||||
) : (
|
|
||||||
<Eye className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
htmlFor="confirmPassword"
|
|
||||||
className="mb-1.5 block text-sm font-medium text-grayScale-600"
|
|
||||||
>
|
|
||||||
Confirm password
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
id="confirmPassword"
|
|
||||||
type={showPassword ? "text" : "password"}
|
|
||||||
autoComplete="new-password"
|
|
||||||
value={confirmPassword}
|
|
||||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
|
||||||
disabled={submitting}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
className="h-11 w-full bg-brand-500 text-white hover:bg-brand-600"
|
|
||||||
disabled={submitting}
|
|
||||||
>
|
|
||||||
{submitting ? "Completing setup…" : "Complete account setup"}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{verifyState === "success" && (
|
|
||||||
<div className="flex flex-col items-center gap-3 py-8 text-center">
|
|
||||||
<SpinnerIcon className="h-6 w-6" />
|
|
||||||
<p className="text-sm text-grayScale-500">Taking you to sign in…</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="shrink-0 border-t border-grayScale-100 pt-4 text-center text-sm text-grayScale-400">
|
|
||||||
Already have an account?{" "}
|
|
||||||
<Link to="/login" className="font-semibold text-brand-500 hover:text-brand-600">
|
|
||||||
Sign in
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useState, useEffect, useRef, useCallback } from "react";
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
import { Link, Navigate, useNavigate, useSearchParams } from "react-router-dom";
|
import { Link, Navigate, useNavigate } from "react-router-dom";
|
||||||
import { Eye, EyeOff } from "lucide-react";
|
import { Eye, EyeOff } from "lucide-react";
|
||||||
|
|
||||||
import { BrandLogo } from "../../components/brand/BrandLogo";
|
import { BrandLogo } from "../../components/brand/BrandLogo";
|
||||||
|
|
@ -65,18 +65,9 @@ function GoogleIcon({ className }: { className?: string }) {
|
||||||
|
|
||||||
export function LoginPage() {
|
export function LoginPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
|
||||||
|
|
||||||
const token = localStorage.getItem("access_token");
|
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 [showPassword, setShowPassword] = useState(false);
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,6 @@ import { toast } from "sonner"
|
||||||
import { addQuestionToSet, createLesson, createQuestion } from "../../api/courses.api"
|
import { addQuestionToSet, createLesson, createQuestion } from "../../api/courses.api"
|
||||||
import { uploadVideoFile } from "../../api/files.api"
|
import { uploadVideoFile } from "../../api/files.api"
|
||||||
import { PracticeQuestionEditorFields } from "../../components/content-management/PracticeQuestionEditorFields"
|
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 { Button } from "../../components/ui/button"
|
||||||
import { Card } from "../../components/ui/card"
|
import { Card } from "../../components/ui/card"
|
||||||
import { Input } from "../../components/ui/input"
|
import { Input } from "../../components/ui/input"
|
||||||
|
|
@ -14,7 +12,7 @@ import { SpinnerIcon } from "../../components/ui/spinner-icon"
|
||||||
import type { QuestionOption } from "../../types/course.types"
|
import type { QuestionOption } from "../../types/course.types"
|
||||||
|
|
||||||
type Step = 1 | 2 | 3 | 4
|
type Step = 1 | 2 | 3 | 4
|
||||||
type QuestionType = "MCQ" | "TRUE_FALSE" | "SHORT" | "AUDIO" | "DYNAMIC"
|
type QuestionType = "MCQ" | "TRUE_FALSE" | "SHORT" | "AUDIO"
|
||||||
type DifficultyLevel = "EASY" | "MEDIUM" | "HARD"
|
type DifficultyLevel = "EASY" | "MEDIUM" | "HARD"
|
||||||
type ResultStatus = "success" | "error"
|
type ResultStatus = "success" | "error"
|
||||||
|
|
||||||
|
|
@ -37,10 +35,6 @@ interface Question {
|
||||||
audioCorrectAnswerText: string
|
audioCorrectAnswerText: string
|
||||||
shortAnswers: string[]
|
shortAnswers: string[]
|
||||||
imageUrl: string
|
imageUrl: string
|
||||||
questionTypeDefinitionId: number | null
|
|
||||||
dynamicStimulusRows: PracticeQuestionDynamicRow[]
|
|
||||||
dynamicResponseRows: PracticeQuestionDynamicRow[]
|
|
||||||
dynamicFieldValues: Record<string, string>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const STEPS = [
|
const STEPS = [
|
||||||
|
|
@ -69,10 +63,6 @@ function createEmptyQuestion(id: string): Question {
|
||||||
audioCorrectAnswerText: "",
|
audioCorrectAnswerText: "",
|
||||||
shortAnswers: [],
|
shortAnswers: [],
|
||||||
imageUrl: "",
|
imageUrl: "",
|
||||||
questionTypeDefinitionId: null,
|
|
||||||
dynamicStimulusRows: [],
|
|
||||||
dynamicResponseRows: [],
|
|
||||||
dynamicFieldValues: {},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -114,7 +104,6 @@ function questionTypeLabel(type: QuestionType): string {
|
||||||
if (type === "TRUE_FALSE") return "True/False"
|
if (type === "TRUE_FALSE") return "True/False"
|
||||||
if (type === "SHORT") return "Short Answer"
|
if (type === "SHORT") return "Short Answer"
|
||||||
if (type === "AUDIO") return "Audio"
|
if (type === "AUDIO") return "Audio"
|
||||||
if (type === "DYNAMIC") return "Dynamic"
|
|
||||||
return "Multiple Choice"
|
return "Multiple Choice"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -235,39 +224,6 @@ export function AddNewLessonPage() {
|
||||||
for (let i = 0; i < questions.length; i++) {
|
for (let i = 0; i < questions.length; i++) {
|
||||||
const q = questions[i]
|
const q = questions[i]
|
||||||
if (!q.questionText.trim()) continue
|
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[] =
|
const options: QuestionOption[] =
|
||||||
q.questionType === "MCQ"
|
q.questionType === "MCQ"
|
||||||
? q.options.map((opt, idx) => ({
|
? q.options.map((opt, idx) => ({
|
||||||
|
|
@ -277,15 +233,6 @@ export function AddNewLessonPage() {
|
||||||
}))
|
}))
|
||||||
: []
|
: []
|
||||||
|
|
||||||
const dynamicPayload =
|
|
||||||
q.questionType === "DYNAMIC" && q.questionTypeDefinitionId != null
|
|
||||||
? buildDynamicQuestionPayload({
|
|
||||||
stimulusRows: q.dynamicStimulusRows,
|
|
||||||
responseRows: q.dynamicResponseRows,
|
|
||||||
fieldValues: q.dynamicFieldValues,
|
|
||||||
})
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
const qRes = await createQuestion({
|
const qRes = await createQuestion({
|
||||||
question_text: q.questionText,
|
question_text: q.questionText,
|
||||||
question_type: q.questionType,
|
question_type: q.questionType,
|
||||||
|
|
@ -293,22 +240,13 @@ export function AddNewLessonPage() {
|
||||||
points: q.points,
|
points: q.points,
|
||||||
tips: q.tips || undefined,
|
tips: q.tips || undefined,
|
||||||
explanation: q.explanation || undefined,
|
explanation: q.explanation || undefined,
|
||||||
status,
|
status: "PUBLISHED",
|
||||||
options: options.length > 0 ? options : undefined,
|
options: options.length > 0 ? options : undefined,
|
||||||
voice_prompt: q.questionType === "DYNAMIC" ? undefined : q.voicePrompt || undefined,
|
voice_prompt: q.voicePrompt || undefined,
|
||||||
sample_answer_voice_prompt:
|
sample_answer_voice_prompt: q.sampleAnswerVoicePrompt || undefined,
|
||||||
q.questionType === "DYNAMIC" ? undefined : q.sampleAnswerVoicePrompt || undefined,
|
audio_correct_answer_text: q.audioCorrectAnswerText || undefined,
|
||||||
audio_correct_answer_text:
|
image_url: q.imageUrl.trim() || undefined,
|
||||||
q.questionType === "DYNAMIC" ? undefined : q.audioCorrectAnswerText || undefined,
|
short_answers: q.shortAnswers.length > 0 ? q.shortAnswers : 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
|
const questionId = qRes.data?.data?.id
|
||||||
if (questionId) {
|
if (questionId) {
|
||||||
|
|
@ -519,10 +457,6 @@ export function AddNewLessonPage() {
|
||||||
audioCorrectAnswerText: question.audioCorrectAnswerText,
|
audioCorrectAnswerText: question.audioCorrectAnswerText,
|
||||||
shortAnswer: question.shortAnswers[0] ?? "",
|
shortAnswer: question.shortAnswers[0] ?? "",
|
||||||
imageUrl: question.imageUrl,
|
imageUrl: question.imageUrl,
|
||||||
questionTypeDefinitionId: question.questionTypeDefinitionId,
|
|
||||||
dynamicStimulusRows: question.dynamicStimulusRows,
|
|
||||||
dynamicResponseRows: question.dynamicResponseRows,
|
|
||||||
dynamicFieldValues: question.dynamicFieldValues,
|
|
||||||
}}
|
}}
|
||||||
onChange={(next) =>
|
onChange={(next) =>
|
||||||
updateQuestion(question.id, {
|
updateQuestion(question.id, {
|
||||||
|
|
@ -538,10 +472,6 @@ export function AddNewLessonPage() {
|
||||||
audioCorrectAnswerText: next.audioCorrectAnswerText,
|
audioCorrectAnswerText: next.audioCorrectAnswerText,
|
||||||
shortAnswers: next.shortAnswer.trim() ? [next.shortAnswer.trim()] : [],
|
shortAnswers: next.shortAnswer.trim() ? [next.shortAnswer.trim()] : [],
|
||||||
imageUrl: next.imageUrl,
|
imageUrl: next.imageUrl,
|
||||||
questionTypeDefinitionId: next.questionTypeDefinitionId,
|
|
||||||
dynamicStimulusRows: next.dynamicStimulusRows,
|
|
||||||
dynamicResponseRows: next.dynamicResponseRows,
|
|
||||||
dynamicFieldValues: next.dynamicFieldValues,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
mediaBusy={saving}
|
mediaBusy={saving}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@ import {
|
||||||
Plus,
|
Plus,
|
||||||
Trash2,
|
Trash2,
|
||||||
GripVertical,
|
GripVertical,
|
||||||
|
Edit,
|
||||||
|
Rocket,
|
||||||
Loader2,
|
Loader2,
|
||||||
Upload,
|
Upload,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
@ -17,24 +19,26 @@ import { Card } from "../../components/ui/card";
|
||||||
import { Button } from "../../components/ui/button";
|
import { Button } from "../../components/ui/button";
|
||||||
import { Input } from "../../components/ui/input";
|
import { Input } from "../../components/ui/input";
|
||||||
import { PracticeQuestionEditorFields } from "../../components/content-management/PracticeQuestionEditorFields";
|
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 {
|
import {
|
||||||
createQuestionSet,
|
createQuestionSet,
|
||||||
createQuestion,
|
createQuestion,
|
||||||
addQuestionToSet,
|
addQuestionToSet,
|
||||||
} from "../../api/courses.api";
|
} from "../../api/courses.api";
|
||||||
import { uploadVideoFile } from "../../api/files.api";
|
import { uploadVideoFile } from "../../api/files.api";
|
||||||
|
import { Select } from "../../components/ui/select";
|
||||||
import type { QuestionOption } from "../../types/course.types";
|
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 Step = 1 | 2 | 3 | 4 | 5;
|
||||||
type ResultStatus = "success" | "error";
|
type ResultStatus = "success" | "error";
|
||||||
type QuestionType = "MCQ" | "TRUE_FALSE" | "SHORT" | "AUDIO" | "DYNAMIC";
|
type QuestionType = "MCQ" | "TRUE_FALSE" | "SHORT" | "AUDIO";
|
||||||
type DifficultyLevel = "EASY" | "MEDIUM" | "HARD";
|
type DifficultyLevel = "EASY" | "MEDIUM" | "HARD";
|
||||||
|
|
||||||
|
interface Persona {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
avatar: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface MCQOption {
|
interface MCQOption {
|
||||||
text: string;
|
text: string;
|
||||||
isCorrect: boolean;
|
isCorrect: boolean;
|
||||||
|
|
@ -54,12 +58,51 @@ interface Question {
|
||||||
audioCorrectAnswerText: string;
|
audioCorrectAnswerText: string;
|
||||||
shortAnswers: string[];
|
shortAnswers: string[];
|
||||||
imageUrl: 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 = [
|
const STEPS = [
|
||||||
{ number: 1, label: "Context" },
|
{ number: 1, label: "Context" },
|
||||||
{ number: 2, label: "Persona" },
|
{ number: 2, label: "Persona" },
|
||||||
|
|
@ -110,6 +153,64 @@ function isDirectVideoFile(url: string): boolean {
|
||||||
return /\.(mp4|webm|ogg|mov|m4v)$/.test(clean);
|
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 {
|
function createEmptyQuestion(id: string): Question {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
|
|
@ -130,10 +231,6 @@ function createEmptyQuestion(id: string): Question {
|
||||||
audioCorrectAnswerText: "",
|
audioCorrectAnswerText: "",
|
||||||
shortAnswers: [],
|
shortAnswers: [],
|
||||||
imageUrl: "",
|
imageUrl: "",
|
||||||
questionTypeDefinitionId: null,
|
|
||||||
dynamicStimulusRows: [],
|
|
||||||
dynamicResponseRows: [],
|
|
||||||
dynamicFieldValues: {},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -175,12 +272,6 @@ export function AddNewPracticePage() {
|
||||||
|
|
||||||
// Step 2: Persona
|
// Step 2: Persona
|
||||||
const [selectedPersona, setSelectedPersona] = useState<string | null>(null);
|
const [selectedPersona, setSelectedPersona] = useState<string | null>(null);
|
||||||
const {
|
|
||||||
personas,
|
|
||||||
loading: personasLoading,
|
|
||||||
error: personasError,
|
|
||||||
reload: reloadPersonas,
|
|
||||||
} = useActivePersonas();
|
|
||||||
|
|
||||||
// Step 3: Questions
|
// Step 3: Questions
|
||||||
const [questions, setQuestions] = useState<Question[]>([
|
const [questions, setQuestions] = useState<Question[]>([
|
||||||
|
|
@ -273,6 +364,11 @@ export function AddNewPracticePage() {
|
||||||
return null;
|
return null;
|
||||||
}, [introVideoUrl]);
|
}, [introVideoUrl]);
|
||||||
|
|
||||||
|
const descriptionPreviewHtml = useMemo(
|
||||||
|
() => formatDescriptionForPreview(practiceDescription),
|
||||||
|
[practiceDescription],
|
||||||
|
);
|
||||||
|
|
||||||
const addQuestion = () => {
|
const addQuestion = () => {
|
||||||
setQuestions([...questions, createEmptyQuestion(String(Date.now()))]);
|
setQuestions([...questions, createEmptyQuestion(String(Date.now()))]);
|
||||||
};
|
};
|
||||||
|
|
@ -293,12 +389,7 @@ export function AddNewPracticePage() {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setSaveError(null);
|
setSaveError(null);
|
||||||
try {
|
try {
|
||||||
if (!selectedPersona) {
|
const persona = PERSONAS.find((p) => p.id === selectedPersona);
|
||||||
toast.error("Select a persona before saving.");
|
|
||||||
setSaving(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const persona = personas.find((p) => p.id === selectedPersona);
|
|
||||||
const setRes = await createQuestionSet({
|
const setRes = await createQuestionSet({
|
||||||
title: practiceTitle || "Untitled Practice",
|
title: practiceTitle || "Untitled Practice",
|
||||||
set_type: "PRACTICE",
|
set_type: "PRACTICE",
|
||||||
|
|
@ -323,38 +414,6 @@ export function AddNewPracticePage() {
|
||||||
const q = questions[i];
|
const q = questions[i];
|
||||||
if (!q.questionText.trim()) continue;
|
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[] =
|
const options: QuestionOption[] =
|
||||||
q.questionType === "MCQ"
|
q.questionType === "MCQ"
|
||||||
? q.options.map((opt, idx) => ({
|
? q.options.map((opt, idx) => ({
|
||||||
|
|
@ -364,43 +423,22 @@ export function AddNewPracticePage() {
|
||||||
}))
|
}))
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
const dynamicPayload =
|
const qRes = await createQuestion({
|
||||||
q.questionType === "DYNAMIC" && q.questionTypeDefinitionId != null
|
question_text: q.questionText,
|
||||||
? buildDynamicQuestionPayload({
|
question_type: q.questionType,
|
||||||
stimulusRows: q.dynamicStimulusRows,
|
difficulty_level: q.difficultyLevel,
|
||||||
responseRows: q.dynamicResponseRows,
|
points: q.points,
|
||||||
fieldValues: q.dynamicFieldValues,
|
tips: q.tips || undefined,
|
||||||
})
|
explanation: q.explanation || undefined,
|
||||||
: undefined;
|
status: "PUBLISHED",
|
||||||
|
options: options.length > 0 ? options : undefined,
|
||||||
const qRes = await createQuestion(
|
voice_prompt: q.voicePrompt || undefined,
|
||||||
q.questionType === "DYNAMIC" && q.questionTypeDefinitionId != null && dynamicPayload
|
sample_answer_voice_prompt: q.sampleAnswerVoicePrompt || undefined,
|
||||||
? {
|
audio_correct_answer_text: q.audioCorrectAnswerText || undefined,
|
||||||
question_type: "DYNAMIC",
|
image_url: q.imageUrl.trim() || undefined,
|
||||||
question_type_definition_id: q.questionTypeDefinitionId,
|
short_answers:
|
||||||
dynamic_payload: dynamicPayload,
|
q.shortAnswers.length > 0 ? q.shortAnswers : undefined,
|
||||||
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;
|
const questionId = qRes.data?.data?.id;
|
||||||
if (questionId) {
|
if (questionId) {
|
||||||
|
|
@ -803,17 +841,66 @@ export function AddNewPracticePage() {
|
||||||
practice.
|
practice.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-5 sm:p-8 lg:p-10">
|
<div className="p-5 sm:p-8 lg:p-10">
|
||||||
<PersonaStep
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 sm:gap-4 lg:grid-cols-4 lg:gap-5">
|
||||||
personas={personas}
|
{PERSONAS.map((persona) => (
|
||||||
loading={personasLoading}
|
<button
|
||||||
error={personasError}
|
key={persona.id}
|
||||||
onRetry={() => void reloadPersonas()}
|
onClick={() => setSelectedPersona(persona.id)}
|
||||||
selectedPersona={selectedPersona}
|
className={`group relative flex flex-col items-center rounded-xl border-2 p-6 transition-all duration-200 ${
|
||||||
setSelectedPersona={setSelectedPersona}
|
selectedPersona === persona.id
|
||||||
nextStep={handleNext}
|
? "border-brand-500 bg-brand-50 shadow-md shadow-brand-100"
|
||||||
prevStep={handleBack}
|
: "border-grayScale-200 bg-white hover:border-brand-300 hover:shadow-sm"
|
||||||
/>
|
}`}
|
||||||
|
>
|
||||||
|
{selectedPersona === persona.id && (
|
||||||
|
<div className="absolute right-2.5 top-2.5 flex h-6 w-6 items-center justify-center rounded-full bg-brand-500 text-white shadow-sm">
|
||||||
|
<Check className="h-3.5 w-3.5" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={`mb-3 h-20 w-20 overflow-hidden rounded-full bg-grayScale-100 ring-2 transition-all duration-200 ${
|
||||||
|
selectedPersona === persona.id
|
||||||
|
? "ring-brand-300 ring-offset-2"
|
||||||
|
: "ring-transparent group-hover:ring-grayScale-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={persona.avatar}
|
||||||
|
alt={persona.name}
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`text-sm font-semibold transition-colors ${
|
||||||
|
selectedPersona === persona.id
|
||||||
|
? "text-brand-600"
|
||||||
|
: "text-grayScale-900"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{persona.name}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col-reverse items-stretch justify-between gap-3 border-t border-grayScale-100 bg-grayScale-50/30 px-5 py-4 sm:flex-row sm:items-center sm:px-8 sm:py-5">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleBack}
|
||||||
|
className="sm:w-auto"
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="w-full bg-brand-500 hover:bg-brand-600 sm:w-auto sm:min-w-[180px]"
|
||||||
|
onClick={handleNext}
|
||||||
|
>
|
||||||
|
{getNextButtonLabel()}
|
||||||
|
<ArrowRight className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
@ -825,7 +912,7 @@ export function AddNewPracticePage() {
|
||||||
Step 3: Questions
|
Step 3: Questions
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mt-1.5 max-w-3xl text-sm leading-relaxed text-grayScale-500">
|
<p className="mt-1.5 max-w-3xl text-sm leading-relaxed text-grayScale-500">
|
||||||
Add MCQ, True/False, Short Answer, Audio, or Dynamic (schema-driven) items. Use the full
|
Add MCQ, True/False, Short Answer, or Audio items. Use the full
|
||||||
width for stems and options.
|
width for stems and options.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -865,10 +952,6 @@ export function AddNewPracticePage() {
|
||||||
audioCorrectAnswerText: question.audioCorrectAnswerText,
|
audioCorrectAnswerText: question.audioCorrectAnswerText,
|
||||||
shortAnswer: question.shortAnswers[0] ?? "",
|
shortAnswer: question.shortAnswers[0] ?? "",
|
||||||
imageUrl: question.imageUrl,
|
imageUrl: question.imageUrl,
|
||||||
questionTypeDefinitionId: question.questionTypeDefinitionId,
|
|
||||||
dynamicStimulusRows: question.dynamicStimulusRows,
|
|
||||||
dynamicResponseRows: question.dynamicResponseRows,
|
|
||||||
dynamicFieldValues: question.dynamicFieldValues,
|
|
||||||
}}
|
}}
|
||||||
onChange={(next) => {
|
onChange={(next) => {
|
||||||
updateQuestion(question.id, {
|
updateQuestion(question.id, {
|
||||||
|
|
@ -887,10 +970,6 @@ export function AddNewPracticePage() {
|
||||||
? [next.shortAnswer.trim()]
|
? [next.shortAnswer.trim()]
|
||||||
: [],
|
: [],
|
||||||
imageUrl: next.imageUrl,
|
imageUrl: next.imageUrl,
|
||||||
questionTypeDefinitionId: next.questionTypeDefinitionId,
|
|
||||||
dynamicStimulusRows: next.dynamicStimulusRows,
|
|
||||||
dynamicResponseRows: next.dynamicResponseRows,
|
|
||||||
dynamicFieldValues: next.dynamicFieldValues,
|
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
mediaBusy={saving}
|
mediaBusy={saving}
|
||||||
|
|
@ -930,26 +1009,259 @@ export function AddNewPracticePage() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{currentStep === 4 && (
|
{currentStep === 4 && (
|
||||||
<AddNewPracticeReviewStep
|
<div className="w-full space-y-6">
|
||||||
practiceTitle={practiceTitle}
|
<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">
|
||||||
practiceDescription={practiceDescription}
|
<h2 className="text-lg font-semibold tracking-tight text-grayScale-900 sm:text-xl">
|
||||||
selectedProgram={selectedProgram}
|
Step 4: Review & publish
|
||||||
selectedCourse={selectedCourse}
|
</h2>
|
||||||
moduleLabel={
|
<p className="mt-1.5 max-w-3xl text-sm leading-relaxed text-grayScale-500">
|
||||||
subModuleId ? `Module ${subModuleId}` : "Current module"
|
Confirm context, persona, and questions before saving or
|
||||||
}
|
publishing.
|
||||||
selectedPersona={selectedPersona}
|
</p>
|
||||||
personas={personas}
|
</div>
|
||||||
introVideoPreview={introVideoPreview}
|
|
||||||
questions={questions}
|
<div className="grid gap-6 lg:grid-cols-2 lg:items-start lg:gap-8">
|
||||||
saving={saving}
|
{/* Basic Information Card */}
|
||||||
saveError={saveError}
|
<Card className="overflow-hidden border-grayScale-200/80 p-0 shadow-sm">
|
||||||
onEditContext={() => setCurrentStep(1)}
|
<div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-4">
|
||||||
onEditQuestions={() => setCurrentStep(3)}
|
<h3 className="font-semibold text-grayScale-900">
|
||||||
onBack={handleBack}
|
Basic Information
|
||||||
onSaveDraft={handleSaveAsDraft}
|
</h3>
|
||||||
onPublish={handlePublish}
|
<button
|
||||||
/>
|
onClick={() => setCurrentStep(1)}
|
||||||
|
className="flex items-center gap-1.5 rounded-md px-2.5 py-1 text-sm font-medium text-brand-500 transition-colors hover:bg-brand-50 hover:text-brand-600"
|
||||||
|
>
|
||||||
|
<Edit className="h-3.5 w-3.5" />
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-grayScale-100">
|
||||||
|
<div className="flex justify-between px-6 py-3.5 odd:bg-grayScale-50/50">
|
||||||
|
<span className="text-sm text-grayScale-500">Title</span>
|
||||||
|
<span className="text-sm font-medium text-grayScale-900">
|
||||||
|
{practiceTitle || "Untitled Practice"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="bg-grayScale-50/50 px-6 py-4">
|
||||||
|
<span className="text-sm text-grayScale-500">
|
||||||
|
Description
|
||||||
|
</span>
|
||||||
|
{descriptionPreviewHtml ? (
|
||||||
|
<div
|
||||||
|
className="mt-2 rounded-lg border border-grayScale-200 bg-white px-4 py-3 text-sm leading-relaxed text-grayScale-800 [&_h1]:text-xl [&_h1]:font-semibold [&_h2]:text-lg [&_h2]:font-semibold [&_ol]:list-decimal [&_ol]:pl-6 [&_p]:mb-2 [&_strong]:font-semibold [&_ul]:list-disc [&_ul]:pl-6"
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: descriptionPreviewHtml,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<p className="mt-2 text-sm text-grayScale-400">—</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between px-6 py-3.5">
|
||||||
|
<span className="text-sm text-grayScale-500">
|
||||||
|
Intro video URL
|
||||||
|
</span>
|
||||||
|
<span className="max-w-[min(28rem,55%)] break-all text-right text-sm text-grayScale-700">
|
||||||
|
{introVideoUrl.trim() || "—"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{introVideoPreview ? (
|
||||||
|
<div className="bg-grayScale-50/50 px-6 py-4">
|
||||||
|
<span className="text-sm text-grayScale-500">
|
||||||
|
Intro video preview
|
||||||
|
</span>
|
||||||
|
<div className="mt-2 rounded-lg border border-grayScale-200 bg-white p-3">
|
||||||
|
{introVideoPreview.kind === "vimeo" ? (
|
||||||
|
<div className="overflow-hidden rounded-lg border border-grayScale-200 bg-black">
|
||||||
|
<iframe
|
||||||
|
src={introVideoPreview.url}
|
||||||
|
title="Intro video preview"
|
||||||
|
className="aspect-video w-full"
|
||||||
|
allow="autoplay; fullscreen; picture-in-picture"
|
||||||
|
allowFullScreen
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<video
|
||||||
|
controls
|
||||||
|
src={introVideoPreview.url}
|
||||||
|
className="aspect-video w-full rounded-lg border border-grayScale-200 bg-black"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="flex justify-between bg-grayScale-50/50 px-6 py-3.5">
|
||||||
|
<span className="text-sm text-grayScale-500">
|
||||||
|
Passing Score
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-medium text-grayScale-900">
|
||||||
|
{passingScore}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between px-6 py-3.5">
|
||||||
|
<span className="text-sm text-grayScale-500">
|
||||||
|
Time Limit
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-medium text-grayScale-900">
|
||||||
|
{timeLimitMinutes} minutes
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between bg-grayScale-50/50 px-6 py-3.5">
|
||||||
|
<span className="text-sm text-grayScale-500">
|
||||||
|
Shuffle Questions
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-medium text-grayScale-900">
|
||||||
|
{shuffleQuestions ? "Yes" : "No"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between px-6 py-3.5">
|
||||||
|
<span className="text-sm text-grayScale-500">Persona</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{selectedPersona && (
|
||||||
|
<div className="h-6 w-6 overflow-hidden rounded-full bg-grayScale-100 ring-2 ring-brand-100">
|
||||||
|
<img
|
||||||
|
src={
|
||||||
|
PERSONAS.find((p) => p.id === selectedPersona)
|
||||||
|
?.avatar
|
||||||
|
}
|
||||||
|
alt="Persona"
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<span className="text-sm font-medium text-brand-600">
|
||||||
|
{PERSONAS.find((p) => p.id === selectedPersona)?.name ||
|
||||||
|
"None selected"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Questions Review */}
|
||||||
|
<Card className="overflow-hidden border-grayScale-200/80 p-0 shadow-sm lg:min-h-0">
|
||||||
|
<div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-4">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<h3 className="font-semibold text-grayScale-900">
|
||||||
|
Questions
|
||||||
|
</h3>
|
||||||
|
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-brand-100 text-xs font-semibold text-brand-600">
|
||||||
|
{questions.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentStep(3)}
|
||||||
|
className="flex items-center gap-1.5 rounded-md px-2.5 py-1 text-sm font-medium text-brand-500 transition-colors hover:bg-brand-50 hover:text-brand-600"
|
||||||
|
>
|
||||||
|
<Edit className="h-3.5 w-3.5" />
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-[min(70vh,52rem)] space-y-3 overflow-y-auto px-4 py-4 sm:px-6">
|
||||||
|
{questions.map((question, index) => (
|
||||||
|
<div
|
||||||
|
key={question.id}
|
||||||
|
className="rounded-xl border border-grayScale-200 bg-grayScale-50/20 p-4 transition-colors hover:border-grayScale-300 sm:p-4"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-brand-100 text-xs font-bold text-brand-600">
|
||||||
|
{index + 1}
|
||||||
|
</span>
|
||||||
|
<div className="flex-1 space-y-2.5">
|
||||||
|
<p className="text-sm font-medium leading-relaxed text-grayScale-900">
|
||||||
|
{question.questionText}
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="rounded-md bg-blue-50 px-2 py-0.5 text-xs font-medium text-blue-600">
|
||||||
|
{question.questionType === "MCQ"
|
||||||
|
? "Multiple Choice"
|
||||||
|
: question.questionType === "TRUE_FALSE"
|
||||||
|
? "True/False"
|
||||||
|
: question.questionType === "AUDIO"
|
||||||
|
? "Audio"
|
||||||
|
: "Short Answer"}
|
||||||
|
</span>
|
||||||
|
<span className="rounded-md bg-purple-50 px-2 py-0.5 text-xs font-medium text-purple-600">
|
||||||
|
{question.difficultyLevel}
|
||||||
|
</span>
|
||||||
|
<span className="rounded-md bg-grayScale-100 px-2 py-0.5 text-xs font-medium text-grayScale-600">
|
||||||
|
{question.points} pt
|
||||||
|
{question.points !== 1 ? "s" : ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{question.questionType === "MCQ" &&
|
||||||
|
question.options.length > 0 && (
|
||||||
|
<div className="mt-2 space-y-1">
|
||||||
|
{question.options.map((opt, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={`flex items-center gap-2 rounded-md px-2.5 py-1.5 text-sm ${
|
||||||
|
opt.isCorrect
|
||||||
|
? "bg-green-50 font-medium text-green-700"
|
||||||
|
: "text-grayScale-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{opt.isCorrect && (
|
||||||
|
<Check className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
{opt.text || `Option ${i + 1}`}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{question.tips && (
|
||||||
|
<p className="rounded-md bg-amber-50 px-2.5 py-1.5 text-xs text-amber-600">
|
||||||
|
💡 Tip: {question.tips}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{question.explanation && (
|
||||||
|
<p className="rounded-md bg-grayScale-50 px-2.5 py-1.5 text-xs text-grayScale-500">
|
||||||
|
Explanation: {question.explanation}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{saveError && (
|
||||||
|
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-3">
|
||||||
|
<p className="text-sm font-medium text-red-600">{saveError}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col-reverse items-stretch justify-between gap-3 rounded-2xl border border-grayScale-200/80 bg-grayScale-50/30 px-4 py-4 sm:flex-row sm:items-center sm:px-6 sm:py-5">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleBack}
|
||||||
|
className="sm:w-auto"
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<div className="flex w-full flex-col gap-3 sm:w-auto sm:flex-row sm:justify-end">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleSaveAsDraft}
|
||||||
|
disabled={saving}
|
||||||
|
className="sm:min-w-[140px]"
|
||||||
|
>
|
||||||
|
{saving ? "Saving..." : "Save as Draft"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="bg-brand-500 hover:bg-brand-600 sm:min-w-[160px]"
|
||||||
|
onClick={handlePublish}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
<Rocket className="mr-2 h-4 w-4" />
|
||||||
|
{saving ? "Publishing..." : "Publish Now"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Step 5: Result */}
|
{/* Step 5: Result */}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useState } from "react";
|
||||||
import {
|
import {
|
||||||
Link,
|
Link,
|
||||||
useNavigate,
|
useNavigate,
|
||||||
|
|
@ -6,370 +6,70 @@ import {
|
||||||
useSearchParams,
|
useSearchParams,
|
||||||
} from "react-router-dom";
|
} from "react-router-dom";
|
||||||
import { ArrowLeft } from "lucide-react";
|
import { ArrowLeft } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
|
||||||
import { Button } from "../../components/ui/button";
|
import { Button } from "../../components/ui/button";
|
||||||
import { Stepper } from "../../components/ui/stepper";
|
import { Stepper } from "../../components/ui/stepper";
|
||||||
import successIcon from "../../assets/success.svg";
|
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 { ContextStep } from "./components/practice-steps/ContextStep";
|
||||||
import { ScenarioStep } from "./components/practice-steps/ScenarioStep";
|
import { ScenarioStep } from "./components/practice-steps/ScenarioStep";
|
||||||
import { PersonaStep } from "./components/practice-steps/PersonaStep";
|
import { PersonaStep } from "./components/practice-steps/PersonaStep";
|
||||||
import { QuestionsStep } from "./components/practice-steps/QuestionsStep";
|
import { QuestionsStep } from "./components/practice-steps/QuestionsStep";
|
||||||
import { ReviewStep } from "./components/practice-steps/ReviewStep";
|
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() {
|
export function AddPracticeFlow() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const {
|
const { level } = useParams<{ level: string }>();
|
||||||
level,
|
|
||||||
programType,
|
|
||||||
courseId: routeCourseId,
|
|
||||||
unitId: routeUnitId,
|
|
||||||
moduleId: routeModuleId,
|
|
||||||
} = useParams<{
|
|
||||||
level?: string;
|
|
||||||
programType?: string;
|
|
||||||
courseId?: string;
|
|
||||||
unitId?: string;
|
|
||||||
moduleId?: string;
|
|
||||||
}>();
|
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const backToParam = searchParams.get("backTo");
|
const backTo = searchParams.get("backTo");
|
||||||
const lessonId = searchParams.get("lessonId");
|
const courseId = searchParams.get("courseId");
|
||||||
const lessonTitleRaw = searchParams.get("lessonTitle");
|
const moduleId = searchParams.get("moduleId");
|
||||||
|
|
||||||
const isExamPrep = Boolean(programType?.trim());
|
const isModuleContext = backTo === "module";
|
||||||
|
const isCourseContext = backTo === "modules";
|
||||||
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 =
|
const backLabel =
|
||||||
effectiveBackTo === "module"
|
backTo === "module"
|
||||||
? "Back to Module"
|
? "Back to Module"
|
||||||
: effectiveBackTo === "modules"
|
: backTo === "modules"
|
||||||
? "Back to Modules"
|
? "Back to Modules"
|
||||||
: effectiveBackTo === "courses"
|
: "Back to Courses";
|
||||||
? "Back to Course"
|
const backPath =
|
||||||
: isExamPrep
|
backTo === "module" && courseId && moduleId
|
||||||
? "Back to Program"
|
? `/new-content/learn-english/${level}/courses/${courseId}/modules/${moduleId}`
|
||||||
: "Back to Courses";
|
: backTo === "modules" && courseId
|
||||||
|
? `/new-content/learn-english/${level}/courses/${courseId}`
|
||||||
|
: `/new-content/learn-english/${level}/courses`;
|
||||||
|
|
||||||
const backPath = useMemo(() => {
|
const flowSteps = isModuleContext
|
||||||
if (isExamPrep) {
|
? ["Context", "Persona", "Questions", "Review"]
|
||||||
if (
|
: ["Context", "Scenario", "Persona", "Questions", "Review"];
|
||||||
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 [currentStep, setCurrentStep] = useState(1);
|
||||||
|
const [selectedPersona, setSelectedPersona] = useState<string | null>(
|
||||||
|
"dawit",
|
||||||
|
);
|
||||||
const [isPublished, setIsPublished] = useState(false);
|
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({
|
const [formData, setFormData] = useState({
|
||||||
|
program: "Intermediate",
|
||||||
|
course: "A2",
|
||||||
title: "",
|
title: "",
|
||||||
description: "",
|
description: "",
|
||||||
storyImageUrl: "",
|
selectedVideo: "",
|
||||||
shuffleQuestions: false,
|
tips: "Focus on using the present perfect continuous tense to describe an action that started in the past and continues now.",
|
||||||
tips: "",
|
|
||||||
questions: [
|
questions: [
|
||||||
{
|
{
|
||||||
id: "q1",
|
id: "q1",
|
||||||
questionTypeDefinitionId: null as number | null,
|
text: "How long have you been studying English?",
|
||||||
text: "",
|
type: "Speaking",
|
||||||
dynamicFieldValues: {} as Record<string, string>,
|
voicePrompt: "prompt_q1_en.mp3",
|
||||||
mcqOptions: [
|
sampleAnswer: "prompt_q1_en.mp3",
|
||||||
{ 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 = () =>
|
const nextStep = () =>
|
||||||
setCurrentStep((prev) => Math.min(prev + 1, STEP_LABELS.length));
|
setCurrentStep((prev) => Math.min(prev + 1, flowSteps.length));
|
||||||
const prevStep = () => setCurrentStep((prev) => Math.max(prev - 1, 1));
|
const prevStep = () => setCurrentStep((prev) => Math.max(prev - 1, 1));
|
||||||
|
|
||||||
if (isPublished) {
|
if (isPublished) {
|
||||||
|
|
@ -387,47 +87,23 @@ export function AddPracticeFlow() {
|
||||||
Practice Published Successfully!
|
Practice Published Successfully!
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-grayScale-600 text-md mb-14 max-w-lg font-medium leading-relaxed">
|
<p className="text-grayScale-600 text-md mb-14 max-w-lg font-medium leading-relaxed">
|
||||||
{lessonId
|
Your speaking practice is now active and available inside the module.
|
||||||
? "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>
|
</p>
|
||||||
<div className="flex flex-col gap-4 w-full max-w-[400px]">
|
<div className="flex flex-col gap-4 w-full max-w-[400px]">
|
||||||
<Button
|
<Button
|
||||||
onClick={() => navigate(backPath)}
|
onClick={() => navigate(backPath)}
|
||||||
className="h-14 rounded-[6px] bg-[#9E2891] font-bold shadow-xl shadow-brand-500/20 text-[16px] text-white "
|
className="h-14 rounded-[6px] bg-[#9E2891] font-bold shadow-xl shadow-brand-500/20 text-[16px] text-white "
|
||||||
>
|
>
|
||||||
{backLabel}
|
Go back to Module
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIsPublished(false);
|
setIsPublished(false);
|
||||||
setCurrentStep(1);
|
setCurrentStep(1);
|
||||||
setSelectedPersona(null);
|
|
||||||
setFormData({
|
setFormData({
|
||||||
|
...formData,
|
||||||
title: "",
|
title: "",
|
||||||
description: "",
|
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"
|
variant="outline"
|
||||||
|
|
@ -440,8 +116,9 @@ export function AddPracticeFlow() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper to map currentStep to the actual component for the module flow
|
||||||
const renderStep = () => {
|
const renderStep = () => {
|
||||||
if (isModuleContext) {
|
if (!isModuleContext) {
|
||||||
switch (currentStep) {
|
switch (currentStep) {
|
||||||
case 1:
|
case 1:
|
||||||
return (
|
return (
|
||||||
|
|
@ -449,19 +126,70 @@ export function AddPracticeFlow() {
|
||||||
formData={formData}
|
formData={formData}
|
||||||
setFormData={setFormData}
|
setFormData={setFormData}
|
||||||
nextStep={nextStep}
|
nextStep={nextStep}
|
||||||
onCancel={() => navigate(backPath)}
|
navigate={navigate}
|
||||||
isLessonPractice={isLearnEnglishLessonPractice}
|
level={level!}
|
||||||
lessonTitle={lessonTitleDisplay}
|
isModuleContext={isModuleContext}
|
||||||
parentSummary={parentSummary}
|
isCourseContext={isCourseContext}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 2:
|
||||||
|
return (
|
||||||
|
<ScenarioStep
|
||||||
|
formData={formData}
|
||||||
|
setFormData={setFormData}
|
||||||
|
nextStep={nextStep}
|
||||||
|
prevStep={prevStep}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 3:
|
||||||
|
return (
|
||||||
|
<PersonaStep
|
||||||
|
selectedPersona={selectedPersona}
|
||||||
|
setSelectedPersona={setSelectedPersona}
|
||||||
|
nextStep={nextStep}
|
||||||
|
prevStep={prevStep}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 4:
|
||||||
|
return (
|
||||||
|
<QuestionsStep
|
||||||
|
formData={formData}
|
||||||
|
setFormData={setFormData}
|
||||||
|
nextStep={nextStep}
|
||||||
|
prevStep={prevStep}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 5:
|
||||||
|
return (
|
||||||
|
<ReviewStep
|
||||||
|
formData={formData}
|
||||||
|
selectedPersona={selectedPersona}
|
||||||
|
prevStep={prevStep}
|
||||||
|
setIsPublished={setIsPublished}
|
||||||
|
isModuleContext={isModuleContext}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Module Context Flow (Skips Scenario)
|
||||||
|
switch (currentStep) {
|
||||||
|
case 1:
|
||||||
|
return (
|
||||||
|
<ContextStep
|
||||||
|
formData={formData}
|
||||||
|
setFormData={setFormData}
|
||||||
|
nextStep={nextStep}
|
||||||
|
navigate={navigate}
|
||||||
|
level={level!}
|
||||||
|
isModuleContext={isModuleContext}
|
||||||
|
isCourseContext={isCourseContext}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case 2:
|
case 2:
|
||||||
return (
|
return (
|
||||||
<PersonaStep
|
<PersonaStep
|
||||||
personas={personas}
|
|
||||||
loading={personasLoading}
|
|
||||||
error={personasError}
|
|
||||||
onRetry={() => void reloadPersonas()}
|
|
||||||
selectedPersona={selectedPersona}
|
selectedPersona={selectedPersona}
|
||||||
setSelectedPersona={setSelectedPersona}
|
setSelectedPersona={setSelectedPersona}
|
||||||
nextStep={nextStep}
|
nextStep={nextStep}
|
||||||
|
|
@ -475,9 +203,6 @@ export function AddPracticeFlow() {
|
||||||
setFormData={setFormData}
|
setFormData={setFormData}
|
||||||
nextStep={nextStep}
|
nextStep={nextStep}
|
||||||
prevStep={prevStep}
|
prevStep={prevStep}
|
||||||
typeDefinitions={typeDefinitions}
|
|
||||||
definitionsLoading={definitionsLoading}
|
|
||||||
definitionsError={definitionsError}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case 4:
|
case 4:
|
||||||
|
|
@ -485,92 +210,20 @@ export function AddPracticeFlow() {
|
||||||
<ReviewStep
|
<ReviewStep
|
||||||
formData={formData}
|
formData={formData}
|
||||||
selectedPersona={selectedPersona}
|
selectedPersona={selectedPersona}
|
||||||
personas={personas}
|
|
||||||
isLessonPractice={isLearnEnglishLessonPractice}
|
|
||||||
lessonTitle={lessonTitleDisplay}
|
|
||||||
programLabel={programLabel}
|
|
||||||
courseLabel={courseId ? `Course ${courseId}` : null}
|
|
||||||
moduleLabel={moduleId ? `Module ${moduleId}` : null}
|
|
||||||
prevStep={prevStep}
|
prevStep={prevStep}
|
||||||
onEditContext={() => setCurrentStep(1)}
|
setIsPublished={setIsPublished}
|
||||||
onEditQuestions={() => setCurrentStep(3)}
|
isModuleContext={isModuleContext}
|
||||||
parentSummary={parentSummary}
|
|
||||||
typeDefinitions={typeDefinitions}
|
|
||||||
canPublish={parentContext !== null}
|
|
||||||
submitting={submitting}
|
|
||||||
onSaveDraft={() => void submitPractice("DRAFT")}
|
|
||||||
onPublish={() => void submitPractice("PUBLISHED")}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
default:
|
default:
|
||||||
return null;
|
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 (
|
return (
|
||||||
<div className="space-y-8 pb-32 px-6 pt-6 min-h-screen ">
|
<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="mx-auto max-w-7xl w-full">
|
||||||
<div className="flex items-center justify-between mb-8">
|
<div className="flex items-center justify-between mb-8">
|
||||||
<Link
|
<Link
|
||||||
|
|
@ -596,34 +249,16 @@ export function AddPracticeFlow() {
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-grayScale-400 text-base">
|
<p className="text-grayScale-400 text-base">
|
||||||
Create a practice with story details, a persona, and questions from your question type library.
|
Create a new immersive practice session for students.
|
||||||
</p>
|
</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>
|
||||||
|
|
||||||
<div className="mx-auto w-[70%] mb-12">
|
<div className="mx-auto w-[70%] mb-12">
|
||||||
<Stepper steps={[...STEP_LABELS]} currentStep={currentStep} />
|
<Stepper steps={flowSteps} currentStep={currentStep} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={`mx-auto ${currentStep === 3 || currentStep === 4 ? "max-w-6xl" : "max-w-4xl"}`}
|
className={`mx-auto ${(!isModuleContext && currentStep === 3) || (isModuleContext && currentStep === 2) || currentStep === 5 ? "max-w-6xl" : "max-w-4xl"}`}
|
||||||
>
|
>
|
||||||
{renderStep()}
|
{renderStep()}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -8,21 +8,11 @@ import { Input } from "../../components/ui/input"
|
||||||
import { Textarea } from "../../components/ui/textarea"
|
import { Textarea } from "../../components/ui/textarea"
|
||||||
import { Select } from "../../components/ui/select"
|
import { Select } from "../../components/ui/select"
|
||||||
import { createQuestion, getQuestionById, updateQuestion } from "../../api/courses.api"
|
import { createQuestion, getQuestionById, updateQuestion } from "../../api/courses.api"
|
||||||
import {
|
|
||||||
getQuestionTypeDefinitions,
|
|
||||||
questionTypeDefinitionListLabel,
|
|
||||||
} from "../../api/questionTypeDefinitions.api"
|
|
||||||
import type { QuestionTypeDefinition } from "../../types/questionTypeDefinition.types"
|
|
||||||
|
|
||||||
type QuestionType = "MCQ" | "TRUE_FALSE" | "SHORT_ANSWER" | "AUDIO" | "DYNAMIC"
|
type QuestionType = "MCQ" | "TRUE_FALSE" | "SHORT_ANSWER" | "AUDIO"
|
||||||
type Difficulty = "EASY" | "MEDIUM" | "HARD"
|
type Difficulty = "EASY" | "MEDIUM" | "HARD"
|
||||||
type QuestionStatus = "DRAFT" | "PUBLISHED" | "INACTIVE"
|
type QuestionStatus = "DRAFT" | "PUBLISHED" | "INACTIVE"
|
||||||
|
|
||||||
const defaultDynamicPayloadJson = `{
|
|
||||||
"stimulus": [],
|
|
||||||
"response": []
|
|
||||||
}`
|
|
||||||
|
|
||||||
interface Question {
|
interface Question {
|
||||||
id?: number
|
id?: number
|
||||||
question: string
|
question: string
|
||||||
|
|
@ -37,9 +27,6 @@ interface Question {
|
||||||
voicePrompt: string
|
voicePrompt: string
|
||||||
sampleAnswerVoicePrompt: string
|
sampleAnswerVoicePrompt: string
|
||||||
audioCorrectAnswerText: string
|
audioCorrectAnswerText: string
|
||||||
/** Definition id as string for select value */
|
|
||||||
questionTypeDefinitionId: string
|
|
||||||
dynamicPayloadJson: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialForm: Question = {
|
const initialForm: Question = {
|
||||||
|
|
@ -55,8 +42,6 @@ const initialForm: Question = {
|
||||||
voicePrompt: "",
|
voicePrompt: "",
|
||||||
sampleAnswerVoicePrompt: "",
|
sampleAnswerVoicePrompt: "",
|
||||||
audioCorrectAnswerText: "",
|
audioCorrectAnswerText: "",
|
||||||
questionTypeDefinitionId: "",
|
|
||||||
dynamicPayloadJson: defaultDynamicPayloadJson,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AddQuestionPage() {
|
export function AddQuestionPage() {
|
||||||
|
|
@ -67,7 +52,6 @@ export function AddQuestionPage() {
|
||||||
const [formData, setFormData] = useState<Question>(initialForm)
|
const [formData, setFormData] = useState<Question>(initialForm)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
const [typeDefinitions, setTypeDefinitions] = useState<QuestionTypeDefinition[]>([])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadQuestion = async () => {
|
const loadQuestion = async () => {
|
||||||
|
|
@ -80,8 +64,7 @@ export function AddQuestionPage() {
|
||||||
q.question_type === "MCQ" ||
|
q.question_type === "MCQ" ||
|
||||||
q.question_type === "TRUE_FALSE" ||
|
q.question_type === "TRUE_FALSE" ||
|
||||||
q.question_type === "SHORT_ANSWER" ||
|
q.question_type === "SHORT_ANSWER" ||
|
||||||
q.question_type === "AUDIO" ||
|
q.question_type === "AUDIO"
|
||||||
q.question_type === "DYNAMIC"
|
|
||||||
? q.question_type
|
? q.question_type
|
||||||
: "MCQ"
|
: "MCQ"
|
||||||
const shortAnswer = Array.isArray(q.short_answers) && q.short_answers.length > 0
|
const shortAnswer = Array.isArray(q.short_answers) && q.short_answers.length > 0
|
||||||
|
|
@ -117,14 +100,6 @@ export function AddQuestionPage() {
|
||||||
voicePrompt: q.voice_prompt || "",
|
voicePrompt: q.voice_prompt || "",
|
||||||
sampleAnswerVoicePrompt: q.sample_answer_voice_prompt || "",
|
sampleAnswerVoicePrompt: q.sample_answer_voice_prompt || "",
|
||||||
audioCorrectAnswerText: q.audio_correct_answer_text || "",
|
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) {
|
} catch (error) {
|
||||||
console.error("Failed to load question:", error)
|
console.error("Failed to load question:", error)
|
||||||
|
|
@ -136,22 +111,6 @@ export function AddQuestionPage() {
|
||||||
loadQuestion()
|
loadQuestion()
|
||||||
}, [isEditing, id])
|
}, [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) => {
|
const handleTypeChange = (type: QuestionType) => {
|
||||||
setFormData((prev) => {
|
setFormData((prev) => {
|
||||||
if (type === "TRUE_FALSE") {
|
if (type === "TRUE_FALSE") {
|
||||||
|
|
@ -161,15 +120,6 @@ export function AddQuestionPage() {
|
||||||
options: ["True", "False"],
|
options: ["True", "False"],
|
||||||
correctAnswer: prev.correctAnswer === "True" || prev.correctAnswer === "False" ? prev.correctAnswer : "",
|
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") {
|
} else if (type === "SHORT_ANSWER" || type === "AUDIO") {
|
||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
|
|
@ -250,27 +200,6 @@ export function AddQuestionPage() {
|
||||||
})
|
})
|
||||||
return
|
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)
|
setSubmitting(true)
|
||||||
|
|
@ -292,18 +221,6 @@ export function AddQuestionPage() {
|
||||||
{ acceptable_answer: formData.correctAnswer.trim(), match_type: "CASE_INSENSITIVE" as const },
|
{ acceptable_answer: formData.correctAnswer.trim(), match_type: "CASE_INSENSITIVE" as const },
|
||||||
]
|
]
|
||||||
: undefined
|
: 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 = {
|
const payload = {
|
||||||
question_text: formData.question,
|
question_text: formData.question,
|
||||||
question_type: formData.type,
|
question_type: formData.type,
|
||||||
|
|
@ -319,12 +236,6 @@ export function AddQuestionPage() {
|
||||||
formData.type === "AUDIO" ? formData.sampleAnswerVoicePrompt : formData.sampleAnswerVoicePrompt || undefined,
|
formData.type === "AUDIO" ? formData.sampleAnswerVoicePrompt : formData.sampleAnswerVoicePrompt || undefined,
|
||||||
audio_correct_answer_text:
|
audio_correct_answer_text:
|
||||||
formData.type === "AUDIO" ? formData.audioCorrectAnswerText : undefined,
|
formData.type === "AUDIO" ? formData.audioCorrectAnswerText : undefined,
|
||||||
...(formData.type === "DYNAMIC" && dynamicPayload
|
|
||||||
? {
|
|
||||||
question_type_definition_id: Number(formData.questionTypeDefinitionId),
|
|
||||||
dynamic_payload: dynamicPayload,
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
}
|
}
|
||||||
if (isEditing && id) {
|
if (isEditing && id) {
|
||||||
await updateQuestion(Number(id), payload)
|
await updateQuestion(Number(id), payload)
|
||||||
|
|
@ -346,105 +257,68 @@ export function AddQuestionPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 pb-6">
|
<div className="space-y-8">
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
|
{/* Page Header */}
|
||||||
|
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-4">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => navigate("/content/questions")}
|
onClick={() => navigate("/content/questions")}
|
||||||
className="h-9 w-9 shrink-0 rounded-lg bg-grayScale-50 hover:bg-brand-500/10 hover:text-brand-500"
|
className="rounded-lg bg-grayScale-50 hover:bg-brand-500/10 hover:text-brand-500 transition-colors"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<div className="min-w-0">
|
<div>
|
||||||
<h1 className="text-lg font-bold tracking-tight text-grayScale-800 sm:text-xl">
|
<h1 className="text-2xl font-bold tracking-tight text-grayScale-600">
|
||||||
{isEditing ? "Edit Question" : "Add New Question"}
|
{isEditing ? "Edit Question" : "Add New Question"}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-0.5 text-xs text-grayScale-500 sm:text-sm">
|
<p className="mt-1 text-sm text-grayScale-400">
|
||||||
{isEditing ? "Update fields below" : "Create a bank question"}
|
{isEditing ? "Update the question details below" : "Fill in the details to create a new question"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mx-auto max-w-2xl">
|
<div className="max-w-3xl mx-auto">
|
||||||
{loading && (
|
{loading && (
|
||||||
<Card className="mb-2 border border-grayScale-200">
|
<Card className="mb-4 border border-grayScale-200">
|
||||||
<CardContent className="py-2.5 text-xs text-grayScale-500">Loading…</CardContent>
|
<CardContent className="py-4 text-sm text-grayScale-500">Loading question details...</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
<Card className="rounded-lg border border-grayScale-100 shadow-sm">
|
<Card className="shadow-sm border border-grayScale-100 rounded-xl">
|
||||||
<CardHeader className="space-y-0 px-4 py-3 sm:px-5">
|
<CardHeader className="pb-2">
|
||||||
<CardTitle className="text-base font-semibold text-grayScale-700">Question details</CardTitle>
|
<CardTitle className="text-lg font-semibold text-grayScale-600">Question Details</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3 px-4 pb-4 pt-0 sm:px-5 sm:pb-5">
|
<CardContent className="space-y-7">
|
||||||
|
{/* Question Type */}
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-xs font-medium text-grayScale-600">
|
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
|
||||||
Question Type
|
Question Type
|
||||||
</label>
|
</label>
|
||||||
<Select
|
<Select
|
||||||
value={formData.type}
|
value={formData.type}
|
||||||
onChange={(e) => handleTypeChange(e.target.value as QuestionType)}
|
onChange={(e) => handleTypeChange(e.target.value as QuestionType)}
|
||||||
className="h-9 text-sm"
|
|
||||||
>
|
>
|
||||||
<option value="MCQ">Multiple Choice</option>
|
<option value="MCQ">Multiple Choice</option>
|
||||||
<option value="TRUE_FALSE">True/False</option>
|
<option value="TRUE_FALSE">True/False</option>
|
||||||
<option value="SHORT_ANSWER">Short Answer</option>
|
<option value="SHORT_ANSWER">Short Answer</option>
|
||||||
<option value="AUDIO">Audio</option>
|
<option value="AUDIO">Audio</option>
|
||||||
<option value="DYNAMIC">Dynamic (schema-driven)</option>
|
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</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" />
|
<hr className="border-grayScale-100" />
|
||||||
|
|
||||||
|
{/* Question Text */}
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="question" className="mb-1 block text-xs font-medium text-grayScale-600">
|
<label htmlFor="question" className="mb-1.5 block text-sm font-medium text-grayScale-500">
|
||||||
{formData.type === "DYNAMIC" ? "Title / stem" : "Question"}
|
Question
|
||||||
</label>
|
</label>
|
||||||
<Textarea
|
<Textarea
|
||||||
id="question"
|
id="question"
|
||||||
placeholder="Enter your question here..."
|
placeholder="Enter your question here..."
|
||||||
value={formData.question}
|
value={formData.question}
|
||||||
onChange={(e) => setFormData((prev) => ({ ...prev, question: e.target.value }))}
|
onChange={(e) => setFormData((prev) => ({ ...prev, question: e.target.value }))}
|
||||||
rows={2}
|
rows={3}
|
||||||
className="min-h-[72px] text-sm"
|
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -452,11 +326,13 @@ export function AddQuestionPage() {
|
||||||
{/* Options for Multiple Choice */}
|
{/* Options for Multiple Choice */}
|
||||||
{(formData.type === "MCQ" || formData.type === "TRUE_FALSE") && (
|
{(formData.type === "MCQ" || formData.type === "TRUE_FALSE") && (
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-xs font-medium text-grayScale-600">Options</label>
|
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
|
||||||
<div className="space-y-1.5">
|
Options
|
||||||
|
</label>
|
||||||
|
<div className="space-y-3">
|
||||||
{formData.options.map((option, index) => (
|
{formData.options.map((option, index) => (
|
||||||
<div key={index} className="group flex items-center gap-1.5">
|
<div key={index} className="flex items-center gap-2 group">
|
||||||
<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">
|
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-grayScale-50 text-grayScale-400 text-xs font-medium flex items-center justify-center">
|
||||||
{index + 1}
|
{index + 1}
|
||||||
</span>
|
</span>
|
||||||
<Input
|
<Input
|
||||||
|
|
@ -464,7 +340,6 @@ export function AddQuestionPage() {
|
||||||
onChange={(e) => handleOptionChange(index, e.target.value)}
|
onChange={(e) => handleOptionChange(index, e.target.value)}
|
||||||
placeholder={`Option ${index + 1}`}
|
placeholder={`Option ${index + 1}`}
|
||||||
disabled={formData.type === "TRUE_FALSE"}
|
disabled={formData.type === "TRUE_FALSE"}
|
||||||
className="h-9 text-sm"
|
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
{formData.type === "MCQ" && formData.options.length > 2 && (
|
{formData.type === "MCQ" && formData.options.length > 2 && (
|
||||||
|
|
@ -473,22 +348,17 @@ export function AddQuestionPage() {
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => removeOption(index)}
|
onClick={() => removeOption(index)}
|
||||||
className="h-8 w-8 shrink-0 opacity-0 transition-all group-hover:opacity-100 hover:bg-red-50 hover:text-red-500"
|
className="opacity-0 group-hover:opacity-100 hover:bg-red-50 hover:text-red-500 transition-all"
|
||||||
>
|
>
|
||||||
<X className="h-3.5 w-3.5" />
|
<X className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{formData.type === "MCQ" && (
|
{formData.type === "MCQ" && (
|
||||||
<Button
|
<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">
|
||||||
type="button"
|
<Plus className="h-4 w-4" />
|
||||||
variant="outline"
|
Add Option
|
||||||
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>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -498,10 +368,9 @@ export function AddQuestionPage() {
|
||||||
<hr className="border-grayScale-100" />
|
<hr className="border-grayScale-100" />
|
||||||
|
|
||||||
{/* Correct Answer */}
|
{/* Correct Answer */}
|
||||||
{formData.type !== "DYNAMIC" && (
|
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-xs font-medium text-grayScale-600">
|
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
|
||||||
{formData.type === "AUDIO" ? "Audio correct answer" : "Correct answer"}
|
{formData.type === "AUDIO" ? "Audio Correct Answer Text" : "Correct Answer"}
|
||||||
</label>
|
</label>
|
||||||
{formData.type === "MCQ" || formData.type === "TRUE_FALSE" ? (
|
{formData.type === "MCQ" || formData.type === "TRUE_FALSE" ? (
|
||||||
<Select
|
<Select
|
||||||
|
|
@ -509,7 +378,6 @@ export function AddQuestionPage() {
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setFormData((prev) => ({ ...prev, correctAnswer: e.target.value }))
|
setFormData((prev) => ({ ...prev, correctAnswer: e.target.value }))
|
||||||
}
|
}
|
||||||
className="h-9 text-sm"
|
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
<option value="">Select correct answer</option>
|
<option value="">Select correct answer</option>
|
||||||
|
|
@ -521,7 +389,7 @@ export function AddQuestionPage() {
|
||||||
</Select>
|
</Select>
|
||||||
) : (
|
) : (
|
||||||
<Textarea
|
<Textarea
|
||||||
placeholder={formData.type === "AUDIO" ? "Expected spoken answer…" : "Correct answer…"}
|
placeholder={formData.type === "AUDIO" ? "Enter audio correct answer text..." : "Enter the correct answer..."}
|
||||||
value={formData.type === "AUDIO" ? formData.audioCorrectAnswerText : formData.correctAnswer}
|
value={formData.type === "AUDIO" ? formData.audioCorrectAnswerText : formData.correctAnswer}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setFormData((prev) =>
|
setFormData((prev) =>
|
||||||
|
|
@ -531,26 +399,24 @@ export function AddQuestionPage() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
rows={2}
|
rows={2}
|
||||||
className="min-h-[60px] text-sm"
|
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
<hr className="border-grayScale-100" />
|
<hr className="border-grayScale-100" />
|
||||||
|
|
||||||
{/* Points and Difficulty side by side */}
|
{/* Points and Difficulty side by side */}
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||||
|
{/* Points */}
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="points" className="mb-1 block text-xs font-medium text-grayScale-600">
|
<label htmlFor="points" className="mb-1.5 block text-sm font-medium text-grayScale-500">
|
||||||
Points
|
Points
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
id="points"
|
id="points"
|
||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
className="h-9 text-sm"
|
|
||||||
value={formData.points}
|
value={formData.points}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setFormData((prev) => ({ ...prev, points: parseInt(e.target.value) || 1 }))
|
setFormData((prev) => ({ ...prev, points: parseInt(e.target.value) || 1 }))
|
||||||
|
|
@ -559,12 +425,14 @@ export function AddQuestionPage() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Difficulty */}
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-xs font-medium text-grayScale-600">Difficulty</label>
|
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
|
||||||
|
Difficulty (Optional)
|
||||||
|
</label>
|
||||||
<Select
|
<Select
|
||||||
value={formData.difficulty}
|
value={formData.difficulty}
|
||||||
onChange={(e) => setFormData((prev) => ({ ...prev, difficulty: e.target.value as Difficulty }))}
|
onChange={(e) => setFormData((prev) => ({ ...prev, difficulty: e.target.value as Difficulty }))}
|
||||||
className="h-9 text-sm"
|
|
||||||
>
|
>
|
||||||
<option value="EASY">Easy</option>
|
<option value="EASY">Easy</option>
|
||||||
<option value="MEDIUM">Medium</option>
|
<option value="MEDIUM">Medium</option>
|
||||||
|
|
@ -575,11 +443,12 @@ export function AddQuestionPage() {
|
||||||
|
|
||||||
{/* Status */}
|
{/* Status */}
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-xs font-medium text-grayScale-600">Status</label>
|
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
|
||||||
|
Status
|
||||||
|
</label>
|
||||||
<Select
|
<Select
|
||||||
value={formData.status}
|
value={formData.status}
|
||||||
onChange={(e) => setFormData((prev) => ({ ...prev, status: e.target.value as QuestionStatus }))}
|
onChange={(e) => setFormData((prev) => ({ ...prev, status: e.target.value as QuestionStatus }))}
|
||||||
className="h-9 text-sm"
|
|
||||||
>
|
>
|
||||||
<option value="DRAFT">Draft</option>
|
<option value="DRAFT">Draft</option>
|
||||||
<option value="PUBLISHED">Published</option>
|
<option value="PUBLISHED">Published</option>
|
||||||
|
|
@ -588,71 +457,58 @@ export function AddQuestionPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(formData.type === "AUDIO" || formData.type === "SHORT_ANSWER") && (
|
{(formData.type === "AUDIO" || formData.type === "SHORT_ANSWER") && (
|
||||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
<>
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-xs font-medium text-grayScale-600">
|
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
|
||||||
Voice prompt{formData.type === "AUDIO" ? "" : " (opt.)"}
|
Voice Prompt{formData.type === "AUDIO" ? "" : " (Optional)"}
|
||||||
</label>
|
</label>
|
||||||
<Textarea
|
<Textarea
|
||||||
value={formData.voicePrompt}
|
value={formData.voicePrompt}
|
||||||
onChange={(e) => setFormData((prev) => ({ ...prev, voicePrompt: e.target.value }))}
|
onChange={(e) => setFormData((prev) => ({ ...prev, voicePrompt: e.target.value }))}
|
||||||
rows={2}
|
rows={2}
|
||||||
placeholder="URL or key…"
|
placeholder="Please say your answer..."
|
||||||
className="min-h-[60px] text-sm"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-xs font-medium text-grayScale-600">
|
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
|
||||||
Sample answer (voice){formData.type === "AUDIO" ? "" : " (opt.)"}
|
Sample Answer Voice Prompt{formData.type === "AUDIO" ? "" : " (Optional)"}
|
||||||
</label>
|
</label>
|
||||||
<Textarea
|
<Textarea
|
||||||
value={formData.sampleAnswerVoicePrompt}
|
value={formData.sampleAnswerVoicePrompt}
|
||||||
onChange={(e) => setFormData((prev) => ({ ...prev, sampleAnswerVoicePrompt: e.target.value }))}
|
onChange={(e) => setFormData((prev) => ({ ...prev, sampleAnswerVoicePrompt: e.target.value }))}
|
||||||
rows={2}
|
rows={2}
|
||||||
placeholder="URL or key…"
|
placeholder="Sample spoken answer..."
|
||||||
className="min-h-[60px] text-sm"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
<div>
|
||||||
<div>
|
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">Tips (Optional)</label>
|
||||||
<label className="mb-1 block text-xs font-medium text-grayScale-600">Tips (opt.)</label>
|
<Input
|
||||||
<Input
|
value={formData.tips}
|
||||||
value={formData.tips}
|
onChange={(e) => setFormData((prev) => ({ ...prev, tips: e.target.value }))}
|
||||||
onChange={(e) => setFormData((prev) => ({ ...prev, tips: e.target.value }))}
|
placeholder="Helpful tip for learners"
|
||||||
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>
|
||||||
|
|
||||||
<div className="flex flex-col-reverse gap-2 border-t border-grayScale-100 pt-3 sm:flex-row sm:justify-end">
|
<div>
|
||||||
<Button
|
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">Explanation (Optional)</label>
|
||||||
type="button"
|
<Textarea
|
||||||
variant="outline"
|
value={formData.explanation}
|
||||||
onClick={() => navigate("/content/questions")}
|
onChange={(e) => setFormData((prev) => ({ ...prev, explanation: e.target.value }))}
|
||||||
className="h-9 w-full text-sm sm:w-auto"
|
rows={2}
|
||||||
>
|
placeholder="Explain why the answer is correct"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex flex-col-reverse sm:flex-row justify-end gap-3 pt-6 border-t border-grayScale-100">
|
||||||
|
<Button type="button" variant="outline" onClick={() => navigate("/content/questions")} className="w-full sm:w-auto hover:bg-grayScale-50">
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<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">
|
||||||
type="submit"
|
{isEditing ? "Update Question" : "Create Question"}
|
||||||
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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ import { ArrowLeft } from "lucide-react";
|
||||||
import { Button } from "../../components/ui/button";
|
import { Button } from "../../components/ui/button";
|
||||||
import { Stepper } from "../../components/ui/stepper";
|
import { Stepper } from "../../components/ui/stepper";
|
||||||
import { createModuleLesson } from "../../api/courses.api";
|
import { createModuleLesson } from "../../api/courses.api";
|
||||||
import type { PracticePublishStatus } from "../../types/course.types";
|
|
||||||
|
|
||||||
import { VideoDetailStep } from "./components/video-steps/VideoDetailStep";
|
import { VideoDetailStep } from "./components/video-steps/VideoDetailStep";
|
||||||
import { ReviewPublishStep } from "./components/video-steps/ReviewPublishStep";
|
import { ReviewPublishStep } from "./components/video-steps/ReviewPublishStep";
|
||||||
|
|
@ -18,7 +17,7 @@ const STEPS = [
|
||||||
|
|
||||||
export type AddLessonFormData = {
|
export type AddLessonFormData = {
|
||||||
title: string;
|
title: string;
|
||||||
sortOrder: string;
|
order: string;
|
||||||
description: string;
|
description: string;
|
||||||
videoUrl: string;
|
videoUrl: string;
|
||||||
thumbnailUrl: string;
|
thumbnailUrl: string;
|
||||||
|
|
@ -26,7 +25,7 @@ export type AddLessonFormData = {
|
||||||
|
|
||||||
const emptyForm = (): AddLessonFormData => ({
|
const emptyForm = (): AddLessonFormData => ({
|
||||||
title: "",
|
title: "",
|
||||||
sortOrder: "0",
|
order: "1",
|
||||||
description: "",
|
description: "",
|
||||||
videoUrl: "",
|
videoUrl: "",
|
||||||
thumbnailUrl: "",
|
thumbnailUrl: "",
|
||||||
|
|
@ -52,8 +51,6 @@ export function AddVideoFlow() {
|
||||||
}>();
|
}>();
|
||||||
const [currentStep, setCurrentStep] = useState(1);
|
const [currentStep, setCurrentStep] = useState(1);
|
||||||
const [isPublished, setIsPublished] = useState(false);
|
const [isPublished, setIsPublished] = useState(false);
|
||||||
const [lastCreatedPublishStatus, setLastCreatedPublishStatus] =
|
|
||||||
useState<PracticePublishStatus>("PUBLISHED");
|
|
||||||
const [formData, setFormData] = useState<AddLessonFormData>(emptyForm);
|
const [formData, setFormData] = useState<AddLessonFormData>(emptyForm);
|
||||||
const [publishing, setPublishing] = useState(false);
|
const [publishing, setPublishing] = useState(false);
|
||||||
const [formResetKey, setFormResetKey] = useState(0);
|
const [formResetKey, setFormResetKey] = useState(0);
|
||||||
|
|
@ -63,7 +60,7 @@ export function AddVideoFlow() {
|
||||||
|
|
||||||
const backPath = `/new-content/learn-english/${level}/courses/${courseId}/modules/${moduleId}`;
|
const backPath = `/new-content/learn-english/${level}/courses/${courseId}/modules/${moduleId}`;
|
||||||
|
|
||||||
const handleCreateLesson = async (publishStatus: PracticePublishStatus) => {
|
const handlePublish = async () => {
|
||||||
const mid = Number(moduleId);
|
const mid = Number(moduleId);
|
||||||
if (!Number.isFinite(mid) || mid < 1) {
|
if (!Number.isFinite(mid) || mid < 1) {
|
||||||
toast.error("Invalid module");
|
toast.error("Invalid module");
|
||||||
|
|
@ -89,16 +86,6 @@ export function AddVideoFlow() {
|
||||||
toast.error("Description is required");
|
toast.error("Description is required");
|
||||||
return;
|
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);
|
setPublishing(true);
|
||||||
try {
|
try {
|
||||||
await createModuleLesson(mid, {
|
await createModuleLesson(mid, {
|
||||||
|
|
@ -106,15 +93,8 @@ export function AddVideoFlow() {
|
||||||
video_url: videoUrl,
|
video_url: videoUrl,
|
||||||
thumbnail,
|
thumbnail,
|
||||||
description,
|
description,
|
||||||
sort_order,
|
|
||||||
publish_status: publishStatus,
|
|
||||||
});
|
});
|
||||||
setLastCreatedPublishStatus(publishStatus);
|
toast.success("Lesson created");
|
||||||
toast.success(
|
|
||||||
publishStatus === "DRAFT"
|
|
||||||
? "Lesson saved as draft"
|
|
||||||
: "Lesson published",
|
|
||||||
);
|
|
||||||
setIsPublished(true);
|
setIsPublished(true);
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
|
|
@ -143,14 +123,10 @@ export function AddVideoFlow() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1 className="text-[26px] font-bold text-grayScale-900 mb-4">
|
<h1 className="text-[26px] font-bold text-grayScale-900 mb-4">
|
||||||
{lastCreatedPublishStatus === "DRAFT"
|
Lesson created successfully
|
||||||
? "Lesson saved as draft"
|
|
||||||
: "Lesson published successfully"}
|
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-grayScale-600 text-base mb-14 max-w-lg font-medium leading-relaxed">
|
<p className="text-grayScale-600 text-base mb-14 max-w-lg font-medium leading-relaxed">
|
||||||
{lastCreatedPublishStatus === "DRAFT"
|
Your lesson is now available in this module.
|
||||||
? "You can finish editing and publish it later from the module."
|
|
||||||
: "Your lesson is now available in this module."}
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="flex flex-col gap-4 w-full max-w-[400px]">
|
<div className="flex flex-col gap-4 w-full max-w-[400px]">
|
||||||
|
|
@ -164,7 +140,6 @@ export function AddVideoFlow() {
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setFormData(emptyForm());
|
setFormData(emptyForm());
|
||||||
setFormResetKey((k) => k + 1);
|
setFormResetKey((k) => k + 1);
|
||||||
setLastCreatedPublishStatus("PUBLISHED");
|
|
||||||
setIsPublished(false);
|
setIsPublished(false);
|
||||||
setCurrentStep(1);
|
setCurrentStep(1);
|
||||||
}}
|
}}
|
||||||
|
|
@ -230,7 +205,7 @@ export function AddVideoFlow() {
|
||||||
<ReviewPublishStep
|
<ReviewPublishStep
|
||||||
formData={formData}
|
formData={formData}
|
||||||
prevStep={prevStep}
|
prevStep={prevStep}
|
||||||
onCreateLesson={(status) => void handleCreateLesson(status)}
|
onPublish={() => void handlePublish()}
|
||||||
publishing={publishing}
|
publishing={publishing}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,6 @@ import {
|
||||||
import { Textarea } from "../../components/ui/textarea"
|
import { Textarea } from "../../components/ui/textarea"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { cn } from "../../lib/utils"
|
import { cn } from "../../lib/utils"
|
||||||
import { TABLE_PAGE_SIZE_OPTIONS } from "../../lib/tablePagination"
|
|
||||||
import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg"
|
import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg"
|
||||||
|
|
||||||
type CourseWithCategory = Course & { category_name: string }
|
type CourseWithCategory = Course & { category_name: string }
|
||||||
|
|
@ -423,7 +422,7 @@ export function AllCoursesPage() {
|
||||||
}}
|
}}
|
||||||
className="h-8 appearance-none rounded-md border bg-white pl-2 pr-7 text-sm font-medium text-grayScale-600 focus:outline-none"
|
className="h-8 appearance-none rounded-md border bg-white pl-2 pr-7 text-sm font-medium text-grayScale-600 focus:outline-none"
|
||||||
>
|
>
|
||||||
{TABLE_PAGE_SIZE_OPTIONS.map((size) => (
|
{[10, 20, 50].map((size) => (
|
||||||
<option key={size} value={size}>
|
<option key={size} value={size}>
|
||||||
{size}
|
{size}
|
||||||
</option>
|
</option>
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
import { Outlet } from "react-router-dom";
|
import { Outlet } from "react-router-dom";
|
||||||
|
import { ContentHierarchyList } from "./components/ContentHierarchyList";
|
||||||
|
|
||||||
export function ContentManagementLayout() {
|
export function ContentManagementLayout() {
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto w-full max-w-7xl px-4 py-6 sm:px-6 lg:px-8">
|
<div className="mx-auto w-full max-w-7xl px-4 py-6 sm:px-6 lg:px-8">
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<div className="mb-8 flex items-center gap-3">
|
<div className="flex items-center gap-3 mb-8">
|
||||||
<div className="h-9 w-1 rounded-full bg-gradient-to-b from-brand-500 to-brand-600" />
|
<div className="h-9 w-1 rounded-full bg-gradient-to-b from-brand-500 to-brand-600" />
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight text-grayScale-900 sm:text-[1.65rem]">
|
<h1 className="text-2xl font-bold tracking-tight text-grayScale-900 sm:text-[1.65rem]">
|
||||||
|
|
@ -15,6 +16,8 @@ export function ContentManagementLayout() {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ContentHierarchyList />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Outlet />
|
<Outlet />
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Plus,
|
Plus,
|
||||||
|
|
@ -21,33 +21,23 @@ import {
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "../../components/ui/dialog";
|
} from "../../components/ui/dialog";
|
||||||
import { Input } from "../../components/ui/input";
|
import { Input } from "../../components/ui/input";
|
||||||
|
import { Textarea } from "../../components/ui/textarea";
|
||||||
import { cn } from "../../lib/utils";
|
import { cn } from "../../lib/utils";
|
||||||
import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg";
|
import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg";
|
||||||
import alertSrc from "../../assets/Alert.svg";
|
import alertSrc from "../../assets/Alert.svg";
|
||||||
import {
|
import {
|
||||||
deleteTopLevelCourseModule,
|
deleteTopLevelCourseModule,
|
||||||
getPracticesByParentCourse,
|
|
||||||
getProgramCourses,
|
getProgramCourses,
|
||||||
getTopLevelCourseModules,
|
getTopLevelCourseModules,
|
||||||
publishParentLinkedPractice,
|
|
||||||
updateParentLinkedPractice,
|
|
||||||
updateTopLevelCourseModule,
|
updateTopLevelCourseModule,
|
||||||
} from "../../api/courses.api";
|
} from "../../api/courses.api";
|
||||||
import { refreshFileUrl, resolveFileUrl } from "../../api/files.api";
|
import { refreshFileUrl, resolveFileUrl } from "../../api/files.api";
|
||||||
import type {
|
import type {
|
||||||
ParentContextPractice,
|
|
||||||
ProgramCourseListItem,
|
ProgramCourseListItem,
|
||||||
TopLevelCourseModuleItem,
|
TopLevelCourseModuleItem,
|
||||||
} from "../../types/course.types";
|
} from "../../types/course.types";
|
||||||
import {
|
|
||||||
isPracticeDraft,
|
|
||||||
isPracticePublished,
|
|
||||||
unwrapPracticesList,
|
|
||||||
} from "../../lib/parentContextPractice";
|
|
||||||
import { AddModuleModal } from "./components/AddModuleModal";
|
import { AddModuleModal } from "./components/AddModuleModal";
|
||||||
import { ModuleIconUploadField } from "./components/ModuleIconUploadField";
|
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;
|
const MODULE_CARD_GRADIENT = "from-[#8E44AD] to-[#C39BD3]" as const;
|
||||||
|
|
||||||
|
|
@ -155,7 +145,7 @@ export function CourseDetailPage() {
|
||||||
const [editingModule, setEditingModule] =
|
const [editingModule, setEditingModule] =
|
||||||
useState<TopLevelCourseModuleItem | null>(null);
|
useState<TopLevelCourseModuleItem | null>(null);
|
||||||
const [editModuleName, setEditModuleName] = useState("");
|
const [editModuleName, setEditModuleName] = useState("");
|
||||||
const [editModuleSortOrder, setEditModuleSortOrder] = useState("");
|
const [editModuleDescription, setEditModuleDescription] = useState("");
|
||||||
const [editModuleIcon, setEditModuleIcon] = useState("");
|
const [editModuleIcon, setEditModuleIcon] = useState("");
|
||||||
const [editModuleIconUploadBusy, setEditModuleIconUploadBusy] =
|
const [editModuleIconUploadBusy, setEditModuleIconUploadBusy] =
|
||||||
useState(false);
|
useState(false);
|
||||||
|
|
@ -165,21 +155,10 @@ export function CourseDetailPage() {
|
||||||
useState<TopLevelCourseModuleItem | null>(null);
|
useState<TopLevelCourseModuleItem | null>(null);
|
||||||
const [deletingModuleInFlight, setDeletingModuleInFlight] = useState(false);
|
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) => {
|
const openEditModule = (module: TopLevelCourseModuleItem) => {
|
||||||
setEditingModule(module);
|
setEditingModule(module);
|
||||||
setEditModuleName(module.name ?? "");
|
setEditModuleName(module.name ?? "");
|
||||||
setEditModuleSortOrder(String(module.sort_order ?? 0));
|
setEditModuleDescription(module.description ?? "");
|
||||||
setEditModuleIcon(module.icon?.trim() ?? "");
|
setEditModuleIcon(module.icon?.trim() ?? "");
|
||||||
setEditModuleIconUploadBusy(false);
|
setEditModuleIconUploadBusy(false);
|
||||||
};
|
};
|
||||||
|
|
@ -281,91 +260,6 @@ export function CourseDetailPage() {
|
||||||
void loadPage();
|
void loadPage();
|
||||||
}, [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 () => {
|
const handleSaveModuleEdit = async () => {
|
||||||
if (!editingModule) return;
|
if (!editingModule) return;
|
||||||
const name = editModuleName.trim();
|
const name = editModuleName.trim();
|
||||||
|
|
@ -373,23 +267,12 @@ export function CourseDetailPage() {
|
||||||
toast.error("Module name is required");
|
toast.error("Module name is required");
|
||||||
return;
|
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);
|
setSavingModuleEdit(true);
|
||||||
try {
|
try {
|
||||||
await updateTopLevelCourseModule(editingModule.id, {
|
await updateTopLevelCourseModule(editingModule.id, {
|
||||||
name,
|
name,
|
||||||
description: editingModule.description?.trim() ?? "",
|
description: editModuleDescription.trim(),
|
||||||
icon: editModuleIcon.trim(),
|
icon: editModuleIcon.trim(),
|
||||||
sort_order,
|
|
||||||
});
|
});
|
||||||
toast.success("Module updated");
|
toast.success("Module updated");
|
||||||
setEditModuleIconUploadBusy(false);
|
setEditModuleIconUploadBusy(false);
|
||||||
|
|
@ -497,32 +380,20 @@ export function CourseDetailPage() {
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="border-b border-grayScale-200">
|
<div className="relative">
|
||||||
<div className="flex gap-10">
|
<div
|
||||||
<button
|
className="absolute inset-0 flex items-center"
|
||||||
type="button"
|
aria-hidden="true"
|
||||||
onClick={() => setActiveTab("modules")}
|
>
|
||||||
className={cn(
|
<div className="w-full border-t border-grayScale-200" />
|
||||||
"pb-4 text-[16px] font-medium transition-all relative",
|
</div>
|
||||||
activeTab === "modules"
|
<div className="relative flex justify-center">
|
||||||
? "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"
|
<div
|
||||||
: "text-grayScale-400 hover:text-grayScale-600",
|
className="h-[0.5px] w-full rounded-full opacity-20"
|
||||||
)}
|
style={{
|
||||||
>
|
background: "gray",
|
||||||
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -541,15 +412,18 @@ export function CourseDetailPage() {
|
||||||
if (!open) closeEditModule();
|
if (!open) closeEditModule();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DialogContent className="flex max-h-[min(90vh,calc(100dvh-2rem))] max-w-lg flex-col gap-0 overflow-hidden p-0">
|
<DialogContent className="max-w-lg">
|
||||||
<DialogHeader className="shrink-0 space-y-1.5 border-b border-grayScale-100 px-6 pb-4 pt-6 pr-12">
|
<DialogHeader>
|
||||||
<DialogTitle>Edit module</DialogTitle>
|
<DialogTitle>Edit module</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Update name, sort order, and icon (upload or URL).
|
Update name, description, and icon (upload or URL). Saved with{" "}
|
||||||
|
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px]">
|
||||||
|
PUT /modules/:id
|
||||||
|
</code>
|
||||||
|
.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-6 py-4">
|
<div className="grid gap-4 py-2">
|
||||||
<div className="grid gap-4">
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-grayScale-700">
|
<label className="text-sm font-medium text-grayScale-700">
|
||||||
Name
|
Name
|
||||||
|
|
@ -563,27 +437,17 @@ export function CourseDetailPage() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label
|
<label className="text-sm font-medium text-grayScale-700">
|
||||||
htmlFor="edit-module-sort-order"
|
Description
|
||||||
className="text-sm font-medium text-grayScale-700"
|
|
||||||
>
|
|
||||||
Sort Order
|
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Textarea
|
||||||
id="edit-module-sort-order"
|
value={editModuleDescription}
|
||||||
type="number"
|
onChange={(e) => setEditModuleDescription(e.target.value)}
|
||||||
min={0}
|
rows={4}
|
||||||
step={1}
|
className="min-h-[100px] resize-y rounded-xl"
|
||||||
inputMode="numeric"
|
placeholder="Optional short description."
|
||||||
value={editModuleSortOrder}
|
disabled={savingModuleEdit}
|
||||||
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>
|
</div>
|
||||||
<ModuleIconUploadField
|
<ModuleIconUploadField
|
||||||
value={editModuleIcon}
|
value={editModuleIcon}
|
||||||
|
|
@ -592,8 +456,7 @@ export function CourseDetailPage() {
|
||||||
onUploadBusyChange={setEditModuleIconUploadBusy}
|
onUploadBusyChange={setEditModuleIconUploadBusy}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<DialogFooter className="gap-2 sm:gap-0">
|
||||||
<DialogFooter className="shrink-0 gap-2 border-t border-grayScale-100 bg-white px-6 py-4 sm:gap-0">
|
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|
@ -614,184 +477,97 @@ export function CourseDetailPage() {
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
{activeTab === "modules" ? (
|
{modules.length === 0 ? (
|
||||||
modules.length === 0 ? (
|
<div className="rounded-xl border border-dashed border-grayScale-200 bg-grayScale-50/50 px-6 py-14 text-center">
|
||||||
<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">
|
||||||
<p className="text-sm font-medium text-grayScale-600">
|
No modules in this course yet
|
||||||
No modules in this course yet
|
</p>
|
||||||
</p>
|
<p className="mt-1 text-sm text-grayScale-400">
|
||||||
<p className="mt-1 text-sm text-grayScale-400">
|
Add modules when your workflow is connected, or create them via
|
||||||
Add a module to organize lessons and practices for this course.
|
the API.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
className="grid justify-start gap-10"
|
|
||||||
style={{
|
|
||||||
gridTemplateColumns: "repeat(auto-fill, minmax(330px, 330px))",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{modules.map((module, index) => {
|
|
||||||
const iconSrc = module.icon?.trim() ?? "";
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
key={module.id}
|
|
||||||
className="group relative flex h-full min-h-0 w-full flex-col overflow-hidden rounded-[16px] border border-grayScale-50 bg-white shadow-sm transition-all duration-300 hover:shadow-lg"
|
|
||||||
>
|
|
||||||
<div className="absolute right-2 top-2 z-10 flex translate-y-1 gap-1 opacity-0 pointer-events-none transition-all duration-300 ease-out group-hover:translate-y-0 group-hover:opacity-100 group-hover:pointer-events-auto">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="secondary"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8 rounded-md bg-white/95 text-grayScale-600 shadow-sm transition-colors hover:bg-white"
|
|
||||||
aria-label={`Edit ${module.name}`}
|
|
||||||
onClick={() => openEditModule(module)}
|
|
||||||
>
|
|
||||||
<Pencil className="h-3.5 w-3.5" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="secondary"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8 rounded-md bg-white/95 text-red-600 shadow-sm transition-colors hover:bg-red-50"
|
|
||||||
aria-label={`Delete ${module.name}`}
|
|
||||||
onClick={() => setDeletingModule(module)}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<ModuleCardTopMedia iconSrc={iconSrc} />
|
|
||||||
|
|
||||||
<div className="flex min-h-0 flex-1 flex-col gap-6 p-2 pb-4 pt-4">
|
|
||||||
<div className="flex min-h-0 flex-1 gap-4">
|
|
||||||
<ModuleIconCircle iconSrc={iconSrc} index={index} />
|
|
||||||
|
|
||||||
<div className="min-w-0 flex-1 space-y-1">
|
|
||||||
<h3 className="text-lg font-bold tracking-tight text-[#0F172A]">
|
|
||||||
{module.name}
|
|
||||||
</h3>
|
|
||||||
<p className="text-[12px] font-medium leading-snug text-grayScale-400 line-clamp-3">
|
|
||||||
{module.description?.trim()
|
|
||||||
? module.description
|
|
||||||
: "—"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-auto flex shrink-0 items-center gap-3">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="h-10 flex-1 rounded-[6px] border-[#9E2891] text-sm text-[#9E2891] transition-all"
|
|
||||||
onClick={() =>
|
|
||||||
navigate(
|
|
||||||
`/new-content/learn-english/${programIdParam}/courses/${courseIdParam}/modules/${module.id}`,
|
|
||||||
{
|
|
||||||
state: {
|
|
||||||
moduleName: module.name,
|
|
||||||
moduleDescription:
|
|
||||||
module.description?.trim() ?? "",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
View Detail
|
|
||||||
</Button>
|
|
||||||
<PublishPracticeButton
|
|
||||||
parentKind="MODULE"
|
|
||||||
parentId={module.id}
|
|
||||||
className="h-10 flex-1 rounded-[6px] bg-brand-500 text-sm text-white shadow-md shadow-brand-500/10 hover:bg-brand-600 disabled:opacity-60"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-8">
|
<div
|
||||||
<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">
|
className="grid justify-start gap-10"
|
||||||
<div className="mr-2 flex items-center gap-2 text-[12px] font-bold uppercase tracking-widest text-grayScale-300">
|
style={{
|
||||||
STATUS:
|
gridTemplateColumns: "repeat(auto-fill, minmax(330px, 330px))",
|
||||||
</div>
|
}}
|
||||||
<div className="flex items-center gap-3">
|
>
|
||||||
{["All", "Published", "Draft", "Archived"].map((label) => (
|
{modules.map((module, index) => {
|
||||||
<button
|
const iconSrc = module.icon?.trim() ?? "";
|
||||||
key={label}
|
return (
|
||||||
type="button"
|
<Card
|
||||||
onClick={() => setPracticeFilter(label)}
|
key={module.id}
|
||||||
className={cn(
|
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"
|
||||||
"h-9 rounded-full px-5 text-[13px] font-bold transition-all",
|
>
|
||||||
practiceFilter === label
|
<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">
|
||||||
? "bg-brand-500 text-white shadow-md shadow-brand-500/20"
|
<Button
|
||||||
: "bg-[#F1F5F9] text-grayScale-500 hover:bg-grayScale-100",
|
type="button"
|
||||||
)}
|
variant="secondary"
|
||||||
>
|
size="icon"
|
||||||
{label}
|
className="h-8 w-8 rounded-md bg-white/95 text-grayScale-600 shadow-sm transition-colors hover:bg-white"
|
||||||
</button>
|
aria-label={`Edit ${module.name}`}
|
||||||
))}
|
onClick={() => openEditModule(module)}
|
||||||
</div>
|
>
|
||||||
</div>
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
{practicesLoading ? (
|
<Button
|
||||||
<div className="flex flex-col items-center justify-center py-24 text-[15px] font-medium text-grayScale-500">
|
type="button"
|
||||||
Loading practices…
|
variant="secondary"
|
||||||
</div>
|
size="icon"
|
||||||
) : practicesLoadError ? (
|
className="h-8 w-8 rounded-md bg-white/95 text-red-600 shadow-sm transition-colors hover:bg-red-50"
|
||||||
<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">
|
aria-label={`Delete ${module.name}`}
|
||||||
{practicesLoadError}
|
onClick={() => setDeletingModule(module)}
|
||||||
</div>
|
>
|
||||||
) : filteredPractices.length > 0 ? (
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4">
|
</Button>
|
||||||
{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>
|
</div>
|
||||||
</div>
|
<ModuleCardTopMedia iconSrc={iconSrc} />
|
||||||
<h2 className="mb-3 text-2xl font-extrabold text-grayScale-900">
|
|
||||||
{practices.length === 0
|
<div className="flex min-h-0 flex-1 flex-col gap-6 p-2 pb-4 pt-4">
|
||||||
? "No practices for this course yet"
|
<div className="flex min-h-0 flex-1 gap-4">
|
||||||
: "No practices match this filter"}
|
<ModuleIconCircle iconSrc={iconSrc} index={index} />
|
||||||
</h2>
|
|
||||||
<p className="mb-10 max-w-sm text-center text-[15px] font-medium leading-relaxed text-grayScale-400">
|
<div className="min-w-0 flex-1 space-y-1">
|
||||||
{practices.length === 0
|
<h3 className="text-lg font-bold tracking-tight text-[#0F172A]">
|
||||||
? "Add a course-level practice to give learners exercises attached to this course."
|
{module.name}
|
||||||
: "Try another status filter or add a new practice."}
|
</h3>
|
||||||
</p>
|
<p className="text-[12px] font-medium leading-snug text-grayScale-400 line-clamp-3">
|
||||||
{practices.length === 0 ? (
|
{module.description?.trim()
|
||||||
<Button
|
? module.description
|
||||||
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"
|
</p>
|
||||||
onClick={() =>
|
</div>
|
||||||
navigate(
|
</div>
|
||||||
`/new-content/learn-english/${programIdParam}/courses/add-practice?backTo=modules&courseId=${courseIdParam}`,
|
|
||||||
)
|
<div className="mt-auto flex shrink-0 items-center gap-3">
|
||||||
}
|
<Button
|
||||||
>
|
variant="outline"
|
||||||
<Calendar className="h-5 w-5" />
|
className="h-10 flex-1 rounded-[6px] border-[#9E2891] text-sm text-[#9E2891] transition-all"
|
||||||
Add Practice
|
onClick={() =>
|
||||||
</Button>
|
navigate(
|
||||||
) : null}
|
`/new-content/learn-english/${programIdParam}/courses/${courseIdParam}/modules/${module.id}`,
|
||||||
</div>
|
{
|
||||||
)}
|
state: {
|
||||||
|
moduleName: module.name,
|
||||||
|
moduleDescription:
|
||||||
|
module.description?.trim() ?? "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
View Detail
|
||||||
|
</Button>
|
||||||
|
<Button className="h-10 flex-1 rounded-[6px] bg-brand-500 text-sm text-white shadow-md shadow-brand-500/10">
|
||||||
|
Publish Practice
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { Link, useParams, useNavigate } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Plus,
|
Plus,
|
||||||
|
FileText,
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
PlayCircle,
|
PlayCircle,
|
||||||
ClipboardCheck,
|
ClipboardCheck,
|
||||||
|
|
@ -14,6 +15,7 @@ import {
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "../../components/ui/button";
|
import { Button } from "../../components/ui/button";
|
||||||
import { Card } from "../../components/ui/card";
|
import { Card } from "../../components/ui/card";
|
||||||
|
import { Textarea } from "../../components/ui/textarea";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
|
@ -43,7 +45,7 @@ export function CourseManagementPage() {
|
||||||
const catalogCourseId = Number(courseId);
|
const catalogCourseId = Number(courseId);
|
||||||
const [addUnitOpen, setAddUnitOpen] = useState(false);
|
const [addUnitOpen, setAddUnitOpen] = useState(false);
|
||||||
const [createName, setCreateName] = useState("");
|
const [createName, setCreateName] = useState("");
|
||||||
const [createSortOrder, setCreateSortOrder] = useState("");
|
const [createDescription, setCreateDescription] = useState("");
|
||||||
const [createThumbnail, setCreateThumbnail] = useState("");
|
const [createThumbnail, setCreateThumbnail] = useState("");
|
||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
const [uploadingThumbnail, setUploadingThumbnail] = useState(false);
|
const [uploadingThumbnail, setUploadingThumbnail] = useState(false);
|
||||||
|
|
@ -64,6 +66,7 @@ export function CourseManagementPage() {
|
||||||
const [unitsLoading, setUnitsLoading] = useState(false);
|
const [unitsLoading, setUnitsLoading] = useState(false);
|
||||||
const [editingUnitId, setEditingUnitId] = useState<number | null>(null);
|
const [editingUnitId, setEditingUnitId] = useState<number | null>(null);
|
||||||
const [editName, setEditName] = useState("");
|
const [editName, setEditName] = useState("");
|
||||||
|
const [editDescription, setEditDescription] = useState("");
|
||||||
const [editThumbnail, setEditThumbnail] = useState("");
|
const [editThumbnail, setEditThumbnail] = useState("");
|
||||||
const [editSortOrder, setEditSortOrder] = useState("1");
|
const [editSortOrder, setEditSortOrder] = useState("1");
|
||||||
const [savingEdit, setSavingEdit] = useState(false);
|
const [savingEdit, setSavingEdit] = useState(false);
|
||||||
|
|
@ -149,7 +152,7 @@ export function CourseManagementPage() {
|
||||||
|
|
||||||
const clearCreateUnitForm = () => {
|
const clearCreateUnitForm = () => {
|
||||||
setCreateName("");
|
setCreateName("");
|
||||||
setCreateSortOrder("");
|
setCreateDescription("");
|
||||||
setCreateThumbnail("");
|
setCreateThumbnail("");
|
||||||
if (createThumbnailFileInputRef.current) {
|
if (createThumbnailFileInputRef.current) {
|
||||||
createThumbnailFileInputRef.current.value = "";
|
createThumbnailFileInputRef.current.value = "";
|
||||||
|
|
@ -199,24 +202,13 @@ export function CourseManagementPage() {
|
||||||
toast.error("Unit name is required");
|
toast.error("Unit name is required");
|
||||||
return;
|
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);
|
setCreating(true);
|
||||||
try {
|
try {
|
||||||
const minioThumbnail = await resolveThumbnailToMinioUrl(createThumbnail);
|
const minioThumbnail = await resolveThumbnailToMinioUrl(createThumbnail);
|
||||||
const response = await createExamPrepCatalogUnit(catalogCourseId, {
|
const response = await createExamPrepCatalogUnit(catalogCourseId, {
|
||||||
name,
|
name,
|
||||||
description: null,
|
description: createDescription.trim() || null,
|
||||||
thumbnail: minioThumbnail || null,
|
thumbnail: minioThumbnail || null,
|
||||||
sort_order,
|
|
||||||
});
|
});
|
||||||
void response;
|
void response;
|
||||||
await loadUnits();
|
await loadUnits();
|
||||||
|
|
@ -279,16 +271,18 @@ export function CourseManagementPage() {
|
||||||
const openEditUnit = (unit: (typeof units)[number]) => {
|
const openEditUnit = (unit: (typeof units)[number]) => {
|
||||||
setEditingUnitId(unit.id);
|
setEditingUnitId(unit.id);
|
||||||
setEditName(unit.name ?? "");
|
setEditName(unit.name ?? "");
|
||||||
|
setEditDescription(unit.description ?? "");
|
||||||
setEditThumbnail(unit.thumbnail ?? "");
|
setEditThumbnail(unit.thumbnail ?? "");
|
||||||
setEditSortOrder(String(unit.sortOrder ?? 0));
|
setEditSortOrder(String(unit.sortOrder ?? 1));
|
||||||
};
|
};
|
||||||
|
|
||||||
const closeEditUnit = () => {
|
const closeEditUnit = () => {
|
||||||
if (savingEdit || uploadingEditThumbnail) return;
|
if (savingEdit || uploadingEditThumbnail) return;
|
||||||
setEditingUnitId(null);
|
setEditingUnitId(null);
|
||||||
setEditName("");
|
setEditName("");
|
||||||
|
setEditDescription("");
|
||||||
setEditThumbnail("");
|
setEditThumbnail("");
|
||||||
setEditSortOrder("");
|
setEditSortOrder("1");
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEditUnitThumbnailFile = async (
|
const handleEditUnitThumbnailFile = async (
|
||||||
|
|
@ -326,30 +320,20 @@ export function CourseManagementPage() {
|
||||||
toast.error("Unit name is required");
|
toast.error("Unit name is required");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const sortOrderRaw = editSortOrder.trim();
|
const sortOrderNum = Number(editSortOrder);
|
||||||
if (!sortOrderRaw) {
|
if (!Number.isFinite(sortOrderNum) || sortOrderNum < 0) {
|
||||||
toast.error("Sort order is required");
|
toast.error("Sort order must be a valid number");
|
||||||
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setSavingEdit(true);
|
setSavingEdit(true);
|
||||||
try {
|
try {
|
||||||
const existing = units.find((u) => u.id === editingUnitId);
|
|
||||||
const preservedDescription =
|
|
||||||
existing?.description && existing.description !== "—"
|
|
||||||
? existing.description
|
|
||||||
: null;
|
|
||||||
const minioThumbnail = await resolveThumbnailToMinioUrl(editThumbnail);
|
const minioThumbnail = await resolveThumbnailToMinioUrl(editThumbnail);
|
||||||
await updateExamPrepCatalogUnit(editingUnitId, {
|
await updateExamPrepCatalogUnit(editingUnitId, {
|
||||||
name,
|
name,
|
||||||
description: preservedDescription,
|
description: editDescription.trim() || null,
|
||||||
thumbnail: minioThumbnail || null,
|
thumbnail: minioThumbnail || null,
|
||||||
sort_order,
|
sort_order: sortOrderNum,
|
||||||
});
|
});
|
||||||
await loadUnits();
|
await loadUnits();
|
||||||
toast.success("Unit updated");
|
toast.success("Unit updated");
|
||||||
|
|
@ -441,29 +425,18 @@ export function CourseManagementPage() {
|
||||||
disabled={creating || uploadingThumbnail}
|
disabled={creating || uploadingThumbnail}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<label
|
<label className="text-[15px] text-grayScale-800">
|
||||||
htmlFor="create-unit-sort-order"
|
Description
|
||||||
className="text-[15px] text-grayScale-800"
|
|
||||||
>
|
|
||||||
Sort Order
|
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Textarea
|
||||||
id="create-unit-sort-order"
|
value={createDescription}
|
||||||
type="number"
|
onChange={(e) => setCreateDescription(e.target.value)}
|
||||||
min={0}
|
placeholder="Short unit description"
|
||||||
step={1}
|
rows={4}
|
||||||
inputMode="numeric"
|
className="min-h-[96px] rounded-[8px] border-grayScale-400"
|
||||||
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}
|
disabled={creating || uploadingThumbnail}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-grayScale-500">
|
|
||||||
Lower numbers appear first when units are listed.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
|
@ -555,6 +528,17 @@ export function CourseManagementPage() {
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -706,27 +690,25 @@ export function CourseManagementPage() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<label
|
<label className="text-[15px] text-grayScale-800">Description</label>
|
||||||
htmlFor="edit-unit-sort-order"
|
<Textarea
|
||||||
className="text-[15px] text-grayScale-800"
|
value={editDescription}
|
||||||
>
|
onChange={(e) => setEditDescription(e.target.value)}
|
||||||
Sort Order
|
rows={4}
|
||||||
</label>
|
className="min-h-[96px] rounded-[8px] border-grayScale-400"
|
||||||
<Input
|
disabled={savingEdit || uploadingEditThumbnail}
|
||||||
id="edit-unit-sort-order"
|
/>
|
||||||
type="number"
|
</div>
|
||||||
min={0}
|
<div className="space-y-3">
|
||||||
step={1}
|
<label className="text-[15px] text-grayScale-800">Sort Order</label>
|
||||||
inputMode="numeric"
|
<Input
|
||||||
value={editSortOrder}
|
type="number"
|
||||||
onChange={(e) => setEditSortOrder(e.target.value)}
|
min={0}
|
||||||
placeholder="e.g. 0"
|
value={editSortOrder}
|
||||||
className="h-12 border-grayScale-400 rounded-[8px] px-4 placeholder:text-grayScale-400 text-[15px] focus:ring-brand-500/20"
|
onChange={(e) => setEditSortOrder(e.target.value)}
|
||||||
|
className="h-12 border-grayScale-400 rounded-[8px] px-4"
|
||||||
disabled={savingEdit || uploadingEditThumbnail}
|
disabled={savingEdit || uploadingEditThumbnail}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-grayScale-500">
|
|
||||||
Lower numbers appear first when units are listed.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<label className="text-[15px] text-grayScale-800">Thumbnail</label>
|
<label className="text-[15px] text-grayScale-800">Thumbnail</label>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { ArrowLeft, Plus, FileText, Video } from "lucide-react";
|
import { ArrowLeft, Plus, FileText, Pencil, Trash2 } from "lucide-react";
|
||||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||||
import { Button } from "../../components/ui/button";
|
import { Button } from "../../components/ui/button";
|
||||||
import { cn } from "../../lib/utils";
|
import { cn } from "../../lib/utils";
|
||||||
|
|
@ -23,18 +23,8 @@ import {
|
||||||
updateExamPrepModuleLesson,
|
updateExamPrepModuleLesson,
|
||||||
deleteExamPrepModuleLesson,
|
deleteExamPrepModuleLesson,
|
||||||
getExamPrepModuleLessons,
|
getExamPrepModuleLessons,
|
||||||
publishExamPrepModuleLesson,
|
|
||||||
} from "../../api/courses.api";
|
} from "../../api/courses.api";
|
||||||
import { uploadImageFile, uploadVideoFile } from "../../api/files.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 = [
|
const MOCK_PRACTICES = [
|
||||||
{
|
{
|
||||||
|
|
@ -70,17 +60,12 @@ export function CourseModuleDetailPage() {
|
||||||
id: number;
|
id: number;
|
||||||
title: string;
|
title: string;
|
||||||
videoUrl: string;
|
videoUrl: string;
|
||||||
description: string | null;
|
description: string;
|
||||||
thumbnail: string;
|
thumbnail: string;
|
||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
publishStatus: PracticePublishStatus | string | null;
|
gradient: string;
|
||||||
durationSeconds: number | null;
|
|
||||||
}>
|
}>
|
||||||
>([]);
|
>([]);
|
||||||
const [lessonsLoadError, setLessonsLoadError] = useState<string | null>(null);
|
|
||||||
const [publishStatusLessonId, setPublishStatusLessonId] = useState<
|
|
||||||
number | null
|
|
||||||
>(null);
|
|
||||||
const [createLessonOpen, setCreateLessonOpen] = useState(false);
|
const [createLessonOpen, setCreateLessonOpen] = useState(false);
|
||||||
const [createTitle, setCreateTitle] = useState("");
|
const [createTitle, setCreateTitle] = useState("");
|
||||||
const [createVideoUrl, setCreateVideoUrl] = useState("");
|
const [createVideoUrl, setCreateVideoUrl] = useState("");
|
||||||
|
|
@ -136,7 +121,6 @@ export function CourseModuleDetailPage() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setLessonsLoading(true);
|
setLessonsLoading(true);
|
||||||
setLessonsLoadError(null);
|
|
||||||
try {
|
try {
|
||||||
const response = await getExamPrepModuleLessons(parsedModuleId, {
|
const response = await getExamPrepModuleLessons(parsedModuleId, {
|
||||||
limit: 20,
|
limit: 20,
|
||||||
|
|
@ -145,27 +129,24 @@ export function CourseModuleDetailPage() {
|
||||||
const rows = response.data?.data?.lessons;
|
const rows = response.data?.data?.lessons;
|
||||||
const list = Array.isArray(rows) ? rows : [];
|
const list = Array.isArray(rows) ? rows : [];
|
||||||
setLessons(
|
setLessons(
|
||||||
list.map((row) => {
|
list.map((row, index) => ({
|
||||||
const raw = row.duration_seconds ?? row.duration ?? null;
|
id: Number(row.id),
|
||||||
const n =
|
title: row.title?.trim() || `Lesson ${row.id}`,
|
||||||
raw == null ? NaN : typeof raw === "number" ? raw : Number(raw);
|
videoUrl: row.video_url?.trim() || "",
|
||||||
const durationSeconds =
|
description: row.description?.trim() || "—",
|
||||||
Number.isFinite(n) && n > 0 ? n : null;
|
thumbnail: row.thumbnail?.trim() || "",
|
||||||
return {
|
sortOrder: Number(row.sort_order ?? 0),
|
||||||
id: Number(row.id),
|
gradient:
|
||||||
title: row.title?.trim() || `Lesson ${row.id}`,
|
index % 3 === 1
|
||||||
videoUrl: row.video_url?.trim() || "",
|
? "linear-gradient(135deg, rgba(79, 70, 229, 0.35) 0%, rgba(79, 70, 229, 0.6) 100%)"
|
||||||
description: row.description?.trim() || null,
|
: index % 3 === 2
|
||||||
thumbnail: row.thumbnail?.trim() || "",
|
? "linear-gradient(135deg, rgba(124, 58, 237, 0.35) 0%, rgba(124, 58, 237, 0.6) 100%)"
|
||||||
sortOrder: Number(row.sort_order ?? 0),
|
: "linear-gradient(135deg, rgba(158, 40, 145, 0.35) 0%, rgba(158, 40, 145, 0.6) 100%)",
|
||||||
publishStatus: row.publish_status ?? null,
|
})),
|
||||||
durationSeconds,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
setLessonsLoadError("Failed to load lessons. Please try again.");
|
toast.error("Failed to load lessons");
|
||||||
setLessons([]);
|
setLessons([]);
|
||||||
} finally {
|
} finally {
|
||||||
setLessonsLoading(false);
|
setLessonsLoading(false);
|
||||||
|
|
@ -271,7 +252,7 @@ export function CourseModuleDetailPage() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCreateLesson = async (publishStatus: PracticePublishStatus) => {
|
const handleCreateLesson = async () => {
|
||||||
if (!Number.isFinite(parsedModuleId) || parsedModuleId < 1) {
|
if (!Number.isFinite(parsedModuleId) || parsedModuleId < 1) {
|
||||||
toast.error("Invalid module");
|
toast.error("Invalid module");
|
||||||
return;
|
return;
|
||||||
|
|
@ -295,14 +276,9 @@ export function CourseModuleDetailPage() {
|
||||||
video_url: videoUrl,
|
video_url: videoUrl,
|
||||||
thumbnail: minioThumbnail || null,
|
thumbnail: minioThumbnail || null,
|
||||||
description: createDescription.trim() || null,
|
description: createDescription.trim() || null,
|
||||||
publish_status: publishStatus,
|
|
||||||
});
|
});
|
||||||
await loadLessons();
|
await loadLessons();
|
||||||
toast.success(
|
toast.success("Lesson created");
|
||||||
publishStatus === "DRAFT"
|
|
||||||
? "Lesson saved as draft"
|
|
||||||
: "Lesson created",
|
|
||||||
);
|
|
||||||
clearCreateLessonForm();
|
clearCreateLessonForm();
|
||||||
setCreateLessonOpen(false);
|
setCreateLessonOpen(false);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
|
|
@ -472,45 +448,6 @@ export function CourseModuleDetailPage() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleToggleLessonPublishStatus = async (
|
|
||||||
lessonId: number,
|
|
||||||
nextStatus: PracticePublishStatus,
|
|
||||||
) => {
|
|
||||||
setPublishStatusLessonId(lessonId);
|
|
||||||
try {
|
|
||||||
await publishExamPrepModuleLesson(lessonId, {
|
|
||||||
publish_status: nextStatus,
|
|
||||||
});
|
|
||||||
setLessons((prev) =>
|
|
||||||
prev.map((l) =>
|
|
||||||
l.id === lessonId ? { ...l, publishStatus: nextStatus } : l,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
toast.success(
|
|
||||||
nextStatus === "PUBLISHED"
|
|
||||||
? "Lesson published"
|
|
||||||
: "Lesson saved as draft",
|
|
||||||
);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
console.error(error);
|
|
||||||
const message =
|
|
||||||
(error as { response?: { data?: { message?: string } } })?.response?.data
|
|
||||||
?.message ??
|
|
||||||
(nextStatus === "PUBLISHED"
|
|
||||||
? "Failed to publish lesson"
|
|
||||||
: "Failed to save lesson as draft");
|
|
||||||
toast.error(message);
|
|
||||||
} finally {
|
|
||||||
setPublishStatusLessonId(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const lessonAttachPracticePath = (lesson: (typeof lessons)[number]) =>
|
|
||||||
`/new-content/courses/${programType}/${courseId}/${unitId}/${moduleId}/add-practice?lessonId=${lesson.id}&lessonTitle=${encodeURIComponent(lesson.title)}`;
|
|
||||||
|
|
||||||
const lessonPracticesPath = (lesson: (typeof lessons)[number]) =>
|
|
||||||
`/new-content/courses/${programType}/${courseId}/${unitId}/${moduleId}/lessons/${lesson.id}/practices?lessonTitle=${encodeURIComponent(lesson.title)}`;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8 animate-in fade-in duration-500 pb-10">
|
<div className="space-y-8 animate-in fade-in duration-500 pb-10">
|
||||||
{/* Navigation */}
|
{/* Navigation */}
|
||||||
|
|
@ -539,12 +476,12 @@ export function CourseModuleDetailPage() {
|
||||||
className="h-10 px-6 rounded-[6px] border-brand-500 text-brand-500 font-bold hover:bg-brand-50 transition-all flex items-center gap-2 shadow-sm"
|
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={() =>
|
onClick={() =>
|
||||||
navigate(
|
navigate(
|
||||||
`/new-content/courses/${programType}/${courseId}/${unitId}/${moduleId}/add-practice`,
|
`/new-content/courses/${programType}/${courseId}/unit/${unitId}/module/${moduleId}/attach-practice`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<FileText className="h-5 w-5" />
|
<FileText className="h-5 w-5" />
|
||||||
Add Practice
|
Attach Practice
|
||||||
</Button>
|
</Button>
|
||||||
<Dialog
|
<Dialog
|
||||||
open={createLessonOpen}
|
open={createLessonOpen}
|
||||||
|
|
@ -704,7 +641,7 @@ export function CourseModuleDetailPage() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="shrink-0 px-8 py-6 bg-grayScale-50/30 border-t border-grayScale-50 flex flex-wrap justify-end gap-3">
|
<div className="shrink-0 px-8 py-6 bg-grayScale-50/30 border-t border-grayScale-50 flex justify-end gap-3">
|
||||||
<DialogClose asChild>
|
<DialogClose asChild>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -716,22 +653,13 @@ export function CourseModuleDetailPage() {
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
</DialogClose>
|
</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
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
className="h-11 px-8 rounded-[8px] bg-brand-500 text-white font-bold hover:bg-brand-600"
|
className="h-11 px-8 rounded-[8px] bg-brand-500 text-white font-bold hover:bg-brand-600"
|
||||||
disabled={creatingLesson || uploadingThumbnail || uploadingVideo}
|
disabled={creatingLesson || uploadingThumbnail || uploadingVideo}
|
||||||
onClick={() => void handleCreateLesson("PUBLISHED")}
|
onClick={() => void handleCreateLesson()}
|
||||||
>
|
>
|
||||||
{creatingLesson ? "Creating..." : "Publish lesson"}
|
{creatingLesson ? "Creating..." : "Create Lesson"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -741,101 +669,61 @@ export function CourseModuleDetailPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<div className="border-b border-grayScale-200">
|
<div className="flex gap-10 border-b border-grayScale-100">
|
||||||
<div className="flex gap-10">
|
<button
|
||||||
<button
|
onClick={() => setActiveTab("video")}
|
||||||
onClick={() => setActiveTab("video")}
|
className={cn(
|
||||||
className={cn(
|
"pb-4 text-[16px] font-bold transition-all relative px-2",
|
||||||
"pb-4 text-[16px] font-medium transition-all relative",
|
activeTab === "video"
|
||||||
activeTab === "video"
|
? "text-brand-500 after:absolute after:bottom-0 after:left-0 after:right-0 after:h-[2px] after:bg-brand-500"
|
||||||
? "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",
|
||||||
: "text-grayScale-400 hover:text-grayScale-600",
|
)}
|
||||||
)}
|
>
|
||||||
>
|
Lesson
|
||||||
Lesson
|
</button>
|
||||||
</button>
|
<button
|
||||||
<button
|
onClick={() => setActiveTab("practice")}
|
||||||
onClick={() => setActiveTab("practice")}
|
className={cn(
|
||||||
className={cn(
|
"pb-4 text-[16px] font-bold transition-all relative px-2",
|
||||||
"pb-4 text-[16px] font-medium transition-all relative",
|
activeTab === "practice"
|
||||||
activeTab === "practice"
|
? "text-brand-500 after:absolute after:bottom-0 after:left-0 after:right-0 after:h-[2px] after:bg-brand-500"
|
||||||
? "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",
|
||||||
: "text-grayScale-400 hover:text-grayScale-600",
|
)}
|
||||||
)}
|
>
|
||||||
>
|
Practice
|
||||||
Practice
|
</button>
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Grid of Content */}
|
||||||
<div className="mt-8">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 pt-4">
|
||||||
{activeTab === "video" ? (
|
{activeTab === "video" ? (
|
||||||
lessonsLoading ? (
|
lessonsLoading ? (
|
||||||
<div className="flex flex-col items-center justify-center py-24 text-grayScale-500 text-[15px] font-medium">
|
<p className="text-sm text-grayScale-500">Loading lessons...</p>
|
||||||
Loading lessons…
|
) : lessons.length === 0 ? (
|
||||||
</div>
|
<div className="col-span-full rounded-xl border border-dashed border-grayScale-200 bg-grayScale-50/50 px-6 py-14 text-center">
|
||||||
) : lessonsLoadError ? (
|
<p className="text-sm font-medium text-grayScale-600">
|
||||||
<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">
|
No lessons for this module yet
|
||||||
{lessonsLoadError}
|
</p>
|
||||||
</div>
|
<p className="mt-1 text-sm text-grayScale-400">
|
||||||
) : lessons.length > 0 ? (
|
Create your first lesson to start building this module.
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
</p>
|
||||||
{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>
|
</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">
|
lessons.map((lesson) => (
|
||||||
<div className="h-20 w-20 rounded-full bg-[#FAF5FF] flex items-center justify-center mb-6">
|
<VideoCard
|
||||||
<div className="h-14 w-14 rounded-full bg-[#F5EBFF] flex items-center justify-center">
|
key={lesson.id}
|
||||||
<Video className="h-7 w-7 text-brand-500 fill-brand-500/10" />
|
title={lesson.title}
|
||||||
</div>
|
thumbnailUrl={lesson.thumbnail}
|
||||||
</div>
|
videoUrl={lesson.videoUrl}
|
||||||
<h2 className="text-2xl font-extrabold text-grayScale-900 mb-3">
|
thumbnailGradient={lesson.gradient}
|
||||||
No lessons in this module yet
|
hoverModuleActions
|
||||||
</h2>
|
onEdit={() => openEditLesson(lesson)}
|
||||||
<p className="text-grayScale-400 font-medium text-[15px] text-center max-w-sm mb-10 leading-relaxed">
|
onDelete={() => setDeletingLessonId(lesson.id)}
|
||||||
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>
|
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<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} />)
|
||||||
{MOCK_PRACTICES.map((item) => (
|
|
||||||
<PracticeCard key={item.id} {...item} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,6 @@ import {
|
||||||
} from "../../api/courses.api"
|
} from "../../api/courses.api"
|
||||||
import type { CategorySubCategoryListItem, CourseCategory } from "../../types/course.types"
|
import type { CategorySubCategoryListItem, CourseCategory } from "../../types/course.types"
|
||||||
import { cn } from "../../lib/utils"
|
import { cn } from "../../lib/utils"
|
||||||
import { TABLE_PAGE_SIZE_OPTIONS } from "../../lib/tablePagination"
|
|
||||||
|
|
||||||
export function CoursesPage() {
|
export function CoursesPage() {
|
||||||
const { categoryId } = useParams<{ categoryId: string }>()
|
const { categoryId } = useParams<{ categoryId: string }>()
|
||||||
|
|
@ -514,7 +513,7 @@ export function CoursesPage() {
|
||||||
}}
|
}}
|
||||||
className="h-8 appearance-none rounded-md border bg-white pl-2 pr-7 text-sm font-medium text-grayScale-600 focus:outline-none"
|
className="h-8 appearance-none rounded-md border bg-white pl-2 pr-7 text-sm font-medium text-grayScale-600 focus:outline-none"
|
||||||
>
|
>
|
||||||
{TABLE_PAGE_SIZE_OPTIONS.map((size) => (
|
{[10, 20, 50].map((size) => (
|
||||||
<option key={size} value={size}>
|
<option key={size} value={size}>
|
||||||
{size}
|
{size}
|
||||||
</option>
|
</option>
|
||||||
|
|
|
||||||
|
|
@ -1,281 +1,31 @@
|
||||||
import { useEffect, useMemo, useState } from "react"
|
import { useState } from "react";
|
||||||
import { Link, useNavigate, useParams } from "react-router-dom"
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
import { ArrowLeft } from "lucide-react"
|
import { ArrowLeft } from "lucide-react";
|
||||||
import { toast } from "sonner"
|
import { Button } from "../../components/ui/button";
|
||||||
import { Button } from "../../components/ui/button"
|
import { Stepper } from "../../components/ui/stepper";
|
||||||
import { Card } from "../../components/ui/card"
|
import { QuestionTypeBasicInfoStep } from "./components/question-type-steps/QuestionTypeBasicInfoStep";
|
||||||
import { Stepper } from "../../components/ui/stepper"
|
import { QuestionTypeConfigStep } from "./components/question-type-steps/QuestionTypeConfigStep";
|
||||||
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() {
|
export function CreateQuestionTypeFlow() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate();
|
||||||
const { definitionId: definitionIdParam } = useParams<{ definitionId?: string }>()
|
const [currentStep, setCurrentStep] = useState(1);
|
||||||
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 [currentStep, setCurrentStep] = useState(1)
|
const steps = [
|
||||||
const [draft, setDraft] = useState<QuestionTypeDefinitionCreatePayload>(initialDraft)
|
"Basic Info",
|
||||||
const [stepErrors, setStepErrors] = useState<FieldErrorMap>({})
|
"Input & Answer Configuration",
|
||||||
const [definitionReady, setDefinitionReady] = useState(!isEdit)
|
"Versions",
|
||||||
const [isSystemDefinition, setIsSystemDefinition] = useState(false)
|
"Review & Publish",
|
||||||
|
];
|
||||||
|
|
||||||
const [componentCatalog, setComponentCatalog] = useState<QuestionComponentCatalog>({
|
const handleNext = () =>
|
||||||
stimulus_component_kinds: [],
|
setCurrentStep((prev) => Math.min(prev + 1, steps.length));
|
||||||
response_component_kinds: [],
|
const handleBack = () => setCurrentStep((prev) => Math.max(prev - 1, 1));
|
||||||
})
|
|
||||||
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 (
|
return (
|
||||||
<div className="min-h-screen pb-20 overflow-x-hidden">
|
<div className="min-h-screen pb-20 overflow-x-hidden">
|
||||||
<div className=" border-b border-grayScale-100 sticky top-0 z-50 bg-white/95 backdrop-blur">
|
{/* Header */}
|
||||||
<div className="max-w-[1440px] mx-auto py-6 px-4 sm:px-6">
|
<div className=" border-b border-grayScale-100 sticky top-0 z-50">
|
||||||
|
<div className="max-w-[1440px] mx-auto py-6">
|
||||||
<div className="flex items-center justify-between mb-8">
|
<div className="flex items-center justify-between mb-8">
|
||||||
<Link
|
<Link
|
||||||
to="/new-content/question-types"
|
to="/new-content/question-types"
|
||||||
|
|
@ -286,18 +36,16 @@ export function CreateQuestionTypeFlow() {
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col lg:flex-row lg:items-start lg:justify-between gap-6">
|
<div className="flex items-start justify-between">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<h1 className="text-[28px] font-bold text-grayScale-900 tracking-tight">
|
<h1 className="text-[28px] font-bold text-grayScale-900 tracking-tight">
|
||||||
{isEdit ? "Edit question type definition" : "Create question type definition"}
|
Create Question Type
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-grayScale-500 text-[14px] font-medium max-w-2xl">
|
<p className="text-grayScale-500 text-[14px] font-medium">
|
||||||
{isEdit
|
Create a new immersive practice session for students.
|
||||||
? `Update reusable question type definition #${editDefinitionId}.`
|
|
||||||
: "Build a reusable question type template for dynamic practice and assessment questions."}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4 shrink-0">
|
<div className="flex items-center gap-4">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="h-10 px-8 rounded-[6px] border-grayScale-200 text-grayScale-900 font-medium hover:bg-grayScale-50"
|
className="h-10 px-8 rounded-[6px] border-grayScale-200 text-grayScale-900 font-medium hover:bg-grayScale-50"
|
||||||
|
|
@ -305,10 +53,7 @@ export function CreateQuestionTypeFlow() {
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<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">
|
||||||
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
|
Save as Draft
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -320,41 +65,23 @@ export function CreateQuestionTypeFlow() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="max-w-[1440px] mx-auto px-4 sm:px-10 mt-12 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
{/* Main Content */}
|
||||||
{currentStep === 1 && (
|
<div className="max-w-[1440px] mx-auto px-10 mt-12 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||||
<QuestionTypeBasicInfoStep
|
{currentStep === 1 && <QuestionTypeBasicInfoStep onNext={handleNext} />}
|
||||||
draft={draft}
|
|
||||||
setDraft={setDraft}
|
|
||||||
errors={stepErrors}
|
|
||||||
keyReadOnly={isEdit}
|
|
||||||
onNext={handleNextFromStep1}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{currentStep === 2 && (
|
{currentStep === 2 && (
|
||||||
<QuestionTypeConfigStep
|
<QuestionTypeConfigStep onNext={handleNext} onBack={handleBack} />
|
||||||
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 && (
|
{currentStep > 2 && (
|
||||||
<QuestionTypeValidatePreviewStep draft={draft} onNext={() => setCurrentStep(4)} onBack={handleBack} />
|
<div className="bg-white rounded-2xl p-12 text-center border border-grayScale-100 shadow-sm">
|
||||||
)}
|
<p className="text-grayScale-400 font-medium">
|
||||||
{currentStep === 4 && (
|
Step {currentStep} implementation in progress...
|
||||||
<QuestionTypeReviewPublishStep
|
</p>
|
||||||
draft={draft}
|
<Button onClick={handleBack} variant="outline" className="mt-4">
|
||||||
onBack={handleBack}
|
Go Back
|
||||||
editDefinitionId={editDefinitionId}
|
</Button>
|
||||||
isSystem={isSystemDefinition}
|
</div>
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, D
|
||||||
import { Input } from "../../components/ui/input"
|
import { Input } from "../../components/ui/input"
|
||||||
import { Select } from "../../components/ui/select"
|
import { Select } from "../../components/ui/select"
|
||||||
import { SpinnerIcon } from "../../components/ui/spinner-icon"
|
import { SpinnerIcon } from "../../components/ui/spinner-icon"
|
||||||
|
import { Textarea } from "../../components/ui/textarea"
|
||||||
import {
|
import {
|
||||||
createModule,
|
createModule,
|
||||||
deleteModule,
|
deleteModule,
|
||||||
|
|
@ -240,6 +241,7 @@ export function HumanLanguageHierarchyPage() {
|
||||||
const [createModuleTitle, setCreateModuleTitle] = useState("")
|
const [createModuleTitle, setCreateModuleTitle] = useState("")
|
||||||
const [createModuleUseDefaultNaming, setCreateModuleUseDefaultNaming] = useState(false)
|
const [createModuleUseDefaultNaming, setCreateModuleUseDefaultNaming] = useState(false)
|
||||||
const [createModuleDefaultTitle, setCreateModuleDefaultTitle] = useState("")
|
const [createModuleDefaultTitle, setCreateModuleDefaultTitle] = useState("")
|
||||||
|
const [createModuleDescription, setCreateModuleDescription] = useState("")
|
||||||
const [createModuleIconSource, setCreateModuleIconSource] = useState<"url" | "file">("url")
|
const [createModuleIconSource, setCreateModuleIconSource] = useState<"url" | "file">("url")
|
||||||
const [createModuleIconUrl, setCreateModuleIconUrl] = useState("")
|
const [createModuleIconUrl, setCreateModuleIconUrl] = useState("")
|
||||||
const [createModuleIconFile, setCreateModuleIconFile] = useState<File | null>(null)
|
const [createModuleIconFile, setCreateModuleIconFile] = useState<File | null>(null)
|
||||||
|
|
@ -251,6 +253,7 @@ export function HumanLanguageHierarchyPage() {
|
||||||
const [editModuleSaving, setEditModuleSaving] = useState(false)
|
const [editModuleSaving, setEditModuleSaving] = useState(false)
|
||||||
const [editModuleTarget, setEditModuleTarget] = useState<EditModuleTarget | null>(null)
|
const [editModuleTarget, setEditModuleTarget] = useState<EditModuleTarget | null>(null)
|
||||||
const [editModuleTitle, setEditModuleTitle] = useState("")
|
const [editModuleTitle, setEditModuleTitle] = useState("")
|
||||||
|
const [editModuleDescription, setEditModuleDescription] = useState("")
|
||||||
const [editModuleDisplayOrder, setEditModuleDisplayOrder] = useState(0)
|
const [editModuleDisplayOrder, setEditModuleDisplayOrder] = useState(0)
|
||||||
const [editModuleIconSource, setEditModuleIconSource] = useState<"url" | "file">("url")
|
const [editModuleIconSource, setEditModuleIconSource] = useState<"url" | "file">("url")
|
||||||
const [editModuleIconUrl, setEditModuleIconUrl] = useState("")
|
const [editModuleIconUrl, setEditModuleIconUrl] = useState("")
|
||||||
|
|
@ -464,6 +467,7 @@ export function HumanLanguageHierarchyPage() {
|
||||||
setCreateModuleUseDefaultNaming(false)
|
setCreateModuleUseDefaultNaming(false)
|
||||||
setCreateModuleDefaultTitle(getNextDefaultModuleName(level))
|
setCreateModuleDefaultTitle(getNextDefaultModuleName(level))
|
||||||
setCreateModuleTitle("")
|
setCreateModuleTitle("")
|
||||||
|
setCreateModuleDescription("")
|
||||||
setCreateModuleIconSource("url")
|
setCreateModuleIconSource("url")
|
||||||
setCreateModuleIconUrl("")
|
setCreateModuleIconUrl("")
|
||||||
setCreateModuleIconFile(null)
|
setCreateModuleIconFile(null)
|
||||||
|
|
@ -499,6 +503,7 @@ export function HumanLanguageHierarchyPage() {
|
||||||
await createModule({
|
await createModule({
|
||||||
level_id: createModuleLevelId,
|
level_id: createModuleLevelId,
|
||||||
title,
|
title,
|
||||||
|
description: createModuleDescription.trim() || undefined,
|
||||||
icon_url: uploadedIconUrl,
|
icon_url: uploadedIconUrl,
|
||||||
display_order: createModuleDisplayOrder,
|
display_order: createModuleDisplayOrder,
|
||||||
is_active: true,
|
is_active: true,
|
||||||
|
|
@ -548,6 +553,7 @@ export function HumanLanguageHierarchyPage() {
|
||||||
levelKey,
|
levelKey,
|
||||||
})
|
})
|
||||||
setEditModuleTitle(module.title)
|
setEditModuleTitle(module.title)
|
||||||
|
setEditModuleDescription("")
|
||||||
setEditModuleDisplayOrder(moduleDisplayOrder)
|
setEditModuleDisplayOrder(moduleDisplayOrder)
|
||||||
setEditModuleIconSource("url")
|
setEditModuleIconSource("url")
|
||||||
setEditModuleIconUrl(existingIconUrl)
|
setEditModuleIconUrl(existingIconUrl)
|
||||||
|
|
@ -588,6 +594,7 @@ export function HumanLanguageHierarchyPage() {
|
||||||
|
|
||||||
await updateModule(editModuleTarget.moduleId, {
|
await updateModule(editModuleTarget.moduleId, {
|
||||||
title,
|
title,
|
||||||
|
description: editModuleDescription.trim() || undefined,
|
||||||
icon_url: uploadedIconUrl,
|
icon_url: uploadedIconUrl,
|
||||||
display_order: editModuleDisplayOrder,
|
display_order: editModuleDisplayOrder,
|
||||||
is_active: true,
|
is_active: true,
|
||||||
|
|
@ -787,7 +794,7 @@ export function HumanLanguageHierarchyPage() {
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-grayScale-800">Select a sub-category to start managing hierarchy</p>
|
<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">
|
<p className="mt-1 text-sm text-grayScale-500">
|
||||||
Choose a sub-category from the list to view and manage its course structure.
|
Powered by `GET /course-management/human-language/hierarchy` and `GET /course-management/courses/:courseId/hierarchy`.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
@ -1019,7 +1026,7 @@ export function HumanLanguageHierarchyPage() {
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Create module</DialogTitle>
|
<DialogTitle>Create module</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Add a module to this level.
|
Add a module to this level. This will call `POST /course-management/modules`.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
|
|
@ -1061,6 +1068,17 @@ export function HumanLanguageHierarchyPage() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
<div>
|
||||||
<label className="mb-1.5 block text-sm font-medium text-grayScale-600">Icon URL (optional)</label>
|
<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">
|
<div className="mb-2 grid grid-cols-2 gap-2">
|
||||||
|
|
@ -1140,7 +1158,7 @@ export function HumanLanguageHierarchyPage() {
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Update module</DialogTitle>
|
<DialogTitle>Update module</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Update this module's name, order, and settings.
|
Update this module using `PUT /course-management/modules/:moduleId`.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
|
|
@ -1155,6 +1173,17 @@ export function HumanLanguageHierarchyPage() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
<div>
|
||||||
<label className="mb-1.5 block text-sm font-medium text-grayScale-600">Display order</label>
|
<label className="mb-1.5 block text-sm font-medium text-grayScale-600">Display order</label>
|
||||||
<Input
|
<Input
|
||||||
|
|
|
||||||
|
|
@ -1029,7 +1029,7 @@ export function HumanLanguageSubModulePage() {
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Lesson detail</DialogTitle>
|
<DialogTitle>Lesson detail</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
View and edit lesson details.
|
Loaded from `GET /course-management/sub-module-lessons/:lessonId`.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,26 +24,9 @@ import {
|
||||||
updateLearningProgram,
|
updateLearningProgram,
|
||||||
deleteLearningProgram,
|
deleteLearningProgram,
|
||||||
} from "../../api/courses.api";
|
} from "../../api/courses.api";
|
||||||
import { refreshFileUrl, uploadImageFile } from "../../api/files.api";
|
import { uploadImageFile } from "../../api/files.api";
|
||||||
import type { LearningProgramListItem } from "../../types/course.types";
|
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() {
|
export function LearnEnglishPage() {
|
||||||
const [programs, setPrograms] = useState<LearningProgramListItem[]>([]);
|
const [programs, setPrograms] = useState<LearningProgramListItem[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
@ -53,7 +36,6 @@ export function LearnEnglishPage() {
|
||||||
useState<LearningProgramListItem | null>(null);
|
useState<LearningProgramListItem | null>(null);
|
||||||
const [editName, setEditName] = useState("");
|
const [editName, setEditName] = useState("");
|
||||||
const [editDescription, setEditDescription] = useState("");
|
const [editDescription, setEditDescription] = useState("");
|
||||||
const [editSortOrder, setEditSortOrder] = useState("");
|
|
||||||
const [editThumbnail, setEditThumbnail] = useState("");
|
const [editThumbnail, setEditThumbnail] = useState("");
|
||||||
const [savingEdit, setSavingEdit] = useState(false);
|
const [savingEdit, setSavingEdit] = useState(false);
|
||||||
const [uploadingEditThumbnail, setUploadingEditThumbnail] = useState(false);
|
const [uploadingEditThumbnail, setUploadingEditThumbnail] = useState(false);
|
||||||
|
|
@ -62,7 +44,6 @@ export function LearnEnglishPage() {
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [createName, setCreateName] = useState("");
|
const [createName, setCreateName] = useState("");
|
||||||
const [createDescription, setCreateDescription] = useState("");
|
const [createDescription, setCreateDescription] = useState("");
|
||||||
const [createSortOrder, setCreateSortOrder] = useState("");
|
|
||||||
const [createThumbnail, setCreateThumbnail] = useState("");
|
const [createThumbnail, setCreateThumbnail] = useState("");
|
||||||
const [createSaving, setCreateSaving] = useState(false);
|
const [createSaving, setCreateSaving] = useState(false);
|
||||||
const [createUploadingThumbnail, setCreateUploadingThumbnail] = useState(false);
|
const [createUploadingThumbnail, setCreateUploadingThumbnail] = useState(false);
|
||||||
|
|
@ -76,7 +57,6 @@ export function LearnEnglishPage() {
|
||||||
setEditingProgram(program);
|
setEditingProgram(program);
|
||||||
setEditName(program.name ?? "");
|
setEditName(program.name ?? "");
|
||||||
setEditDescription(program.description?.trim() ?? "");
|
setEditDescription(program.description?.trim() ?? "");
|
||||||
setEditSortOrder(String(program.sort_order ?? 0));
|
|
||||||
setEditThumbnail(program.thumbnail?.trim() ?? "");
|
setEditThumbnail(program.thumbnail?.trim() ?? "");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -84,7 +64,6 @@ export function LearnEnglishPage() {
|
||||||
setEditingProgram(null);
|
setEditingProgram(null);
|
||||||
setEditName("");
|
setEditName("");
|
||||||
setEditDescription("");
|
setEditDescription("");
|
||||||
setEditSortOrder("");
|
|
||||||
setEditThumbnail("");
|
setEditThumbnail("");
|
||||||
setUploadingEditThumbnail(false);
|
setUploadingEditThumbnail(false);
|
||||||
if (editThumbnailFileInputRef.current) editThumbnailFileInputRef.current.value = "";
|
if (editThumbnailFileInputRef.current) editThumbnailFileInputRef.current.value = "";
|
||||||
|
|
@ -128,7 +107,6 @@ export function LearnEnglishPage() {
|
||||||
const clearCreateFormFields = () => {
|
const clearCreateFormFields = () => {
|
||||||
setCreateName("");
|
setCreateName("");
|
||||||
setCreateDescription("");
|
setCreateDescription("");
|
||||||
setCreateSortOrder("");
|
|
||||||
setCreateThumbnail("");
|
setCreateThumbnail("");
|
||||||
if (createThumbnailFileInputRef.current) {
|
if (createThumbnailFileInputRef.current) {
|
||||||
createThumbnailFileInputRef.current.value = "";
|
createThumbnailFileInputRef.current.value = "";
|
||||||
|
|
@ -182,23 +160,12 @@ export function LearnEnglishPage() {
|
||||||
toast.error("Program name is required");
|
toast.error("Program name is required");
|
||||||
return;
|
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);
|
setCreateSaving(true);
|
||||||
try {
|
try {
|
||||||
await createLearningProgram({
|
await createLearningProgram({
|
||||||
name,
|
name,
|
||||||
description: createDescription.trim(),
|
description: createDescription.trim(),
|
||||||
thumbnail: createThumbnail.trim(),
|
thumbnail: createThumbnail.trim(),
|
||||||
sort_order,
|
|
||||||
});
|
});
|
||||||
toast.success("Program created");
|
toast.success("Program created");
|
||||||
clearCreateFormFields();
|
clearCreateFormFields();
|
||||||
|
|
@ -222,23 +189,12 @@ export function LearnEnglishPage() {
|
||||||
toast.error("Program name is required");
|
toast.error("Program name is required");
|
||||||
return;
|
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);
|
setSavingEdit(true);
|
||||||
try {
|
try {
|
||||||
await updateLearningProgram(editingProgram.id, {
|
await updateLearningProgram(editingProgram.id, {
|
||||||
name,
|
name,
|
||||||
description: editDescription.trim(),
|
description: editDescription.trim(),
|
||||||
thumbnail: editThumbnail.trim(),
|
thumbnail: editThumbnail.trim(),
|
||||||
sort_order,
|
|
||||||
});
|
});
|
||||||
toast.success("Program updated");
|
toast.success("Program updated");
|
||||||
closeEdit();
|
closeEdit();
|
||||||
|
|
@ -284,35 +240,6 @@ export function LearnEnglishPage() {
|
||||||
(a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0),
|
(a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0),
|
||||||
);
|
);
|
||||||
setPrograms(sorted);
|
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) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
setError("Failed to load programs");
|
setError("Failed to load programs");
|
||||||
|
|
@ -356,8 +283,15 @@ export function LearnEnglishPage() {
|
||||||
Add New Program
|
Add New Program
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription className="text-sm text-grayScale-400">
|
<DialogDescription className="text-sm text-grayScale-400">
|
||||||
Create a new learning program. Add a thumbnail as an image URL or by uploading a
|
Create a learning program via{" "}
|
||||||
file.
|
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px] text-grayScale-600">
|
||||||
|
POST /programs
|
||||||
|
</code>
|
||||||
|
. Thumbnail can be a URL or a file uploaded through{" "}
|
||||||
|
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px] text-grayScale-600">
|
||||||
|
POST /files/upload
|
||||||
|
</code>
|
||||||
|
.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
{/* Gradient Divider */}
|
{/* Gradient Divider */}
|
||||||
|
|
@ -414,27 +348,6 @@ export function LearnEnglishPage() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<div className="space-y-2">
|
||||||
<label className="text-[15px] text-grayScale-700">
|
<label className="text-[15px] text-grayScale-700">
|
||||||
Thumbnail
|
Thumbnail
|
||||||
|
|
@ -636,17 +549,16 @@ export function LearnEnglishPage() {
|
||||||
if (!open) closeEdit();
|
if (!open) closeEdit();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DialogContent className="flex max-h-[min(90vh,calc(100dvh-2rem))] max-w-lg flex-col gap-0 overflow-hidden p-0">
|
<DialogContent className="max-w-lg">
|
||||||
<DialogHeader className="shrink-0 space-y-1.5 border-b border-grayScale-100 px-6 pb-4 pt-6 pr-12">
|
<DialogHeader>
|
||||||
<DialogTitle>Edit program</DialogTitle>
|
<DialogTitle>Edit program</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Update name, description, sort order, and thumbnail. Upload an image
|
Update name, description, and thumbnail. Upload an image from your
|
||||||
from your computer (via file storage) or paste a URL. Changes are
|
computer (via file storage) or paste a URL. Changes are saved to the
|
||||||
saved to the server.
|
server.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-6 py-4">
|
<div className="grid gap-4 py-2">
|
||||||
<div className="grid gap-4">
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-grayScale-700">
|
<label className="text-sm font-medium text-grayScale-700">
|
||||||
Name
|
Name
|
||||||
|
|
@ -672,26 +584,6 @@ export function LearnEnglishPage() {
|
||||||
disabled={savingEdit || uploadingEditThumbnail}
|
disabled={savingEdit || uploadingEditThumbnail}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-grayScale-700">
|
<label className="text-sm font-medium text-grayScale-700">
|
||||||
Thumbnail
|
Thumbnail
|
||||||
|
|
@ -732,12 +624,15 @@ export function LearnEnglishPage() {
|
||||||
disabled={savingEdit || uploadingEditThumbnail}
|
disabled={savingEdit || uploadingEditThumbnail}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-grayScale-500">
|
<p className="text-xs text-grayScale-500">
|
||||||
Uploaded images are stored and used as the program thumbnail.
|
Local images are sent to{" "}
|
||||||
|
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px]">
|
||||||
|
POST /files/upload
|
||||||
|
</code>
|
||||||
|
; the returned URL is stored as the program thumbnail.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<DialogFooter className="gap-2 sm:gap-0">
|
||||||
<DialogFooter className="shrink-0 gap-2 border-t border-grayScale-100 bg-white px-6 py-4 sm:gap-0">
|
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|
|
||||||
|
|
@ -1,380 +0,0 @@
|
||||||
import { useCallback, useEffect, useState } from "react";
|
|
||||||
import {
|
|
||||||
AlertCircle,
|
|
||||||
ArrowLeft,
|
|
||||||
BookOpen,
|
|
||||||
Calendar,
|
|
||||||
Clock,
|
|
||||||
Hash,
|
|
||||||
Loader2,
|
|
||||||
RefreshCw,
|
|
||||||
Sparkles,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { getPracticesByParentLesson } from "../../api/courses.api";
|
|
||||||
import { Badge } from "../../components/ui/badge";
|
|
||||||
import { Button } from "../../components/ui/button";
|
|
||||||
import { Card, CardContent } from "../../components/ui/card";
|
|
||||||
import type {
|
|
||||||
GetPracticesByParentContextResponse,
|
|
||||||
ParentContextPractice,
|
|
||||||
} from "../../types/course.types";
|
|
||||||
import { resolveThumbnailForPreview } from "../../lib/videoPreview";
|
|
||||||
import { cn } from "../../lib/utils";
|
|
||||||
|
|
||||||
function unwrapPracticesEnvelope(
|
|
||||||
res: { data?: GetPracticesByParentContextResponse & { Data?: GetPracticesByParentContextResponse["data"] } },
|
|
||||||
): GetPracticesByParentContextResponse["data"] | null {
|
|
||||||
const b = res.data;
|
|
||||||
if (!b) return null;
|
|
||||||
return b.data ?? b.Data ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatPracticeDate(iso: string): string {
|
|
||||||
const d = new Date(iso);
|
|
||||||
return Number.isNaN(d.getTime())
|
|
||||||
? iso
|
|
||||||
: d.toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" });
|
|
||||||
}
|
|
||||||
|
|
||||||
function PracticeCard({
|
|
||||||
practice,
|
|
||||||
index,
|
|
||||||
total,
|
|
||||||
}: {
|
|
||||||
practice: ParentContextPractice;
|
|
||||||
index: number;
|
|
||||||
total: number;
|
|
||||||
}) {
|
|
||||||
const [imgFailed, setImgFailed] = useState(false);
|
|
||||||
const thumb = resolveThumbnailForPreview(practice.story_image);
|
|
||||||
const showThumb = Boolean(thumb) && !imgFailed;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
className={cn(
|
|
||||||
"overflow-hidden border-grayScale-100/90 bg-white shadow-sm transition-all duration-300",
|
|
||||||
"hover:border-brand-200/60 hover:shadow-md hover:shadow-brand-500/5",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<CardContent className="p-0">
|
|
||||||
<div className="flex flex-col lg:flex-row lg:items-stretch">
|
|
||||||
<div className="relative shrink-0 lg:w-[280px]">
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"relative aspect-[16/10] w-full overflow-hidden bg-gradient-to-br from-grayScale-100 to-grayScale-50 lg:aspect-auto lg:h-full lg:min-h-[220px]",
|
|
||||||
!showThumb && "grid min-h-[180px] place-items-center lg:min-h-[220px]",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{showThumb ? (
|
|
||||||
<>
|
|
||||||
<img
|
|
||||||
src={thumb!}
|
|
||||||
alt=""
|
|
||||||
className="h-full w-full object-cover"
|
|
||||||
onError={() => setImgFailed(true)}
|
|
||||||
/>
|
|
||||||
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/25 via-transparent to-black/10" />
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col items-center gap-2 text-grayScale-400">
|
|
||||||
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-white/80 shadow-inner ring-1 ring-grayScale-200/80">
|
|
||||||
<BookOpen className="h-7 w-7" />
|
|
||||||
</div>
|
|
||||||
<span className="text-[11px] font-semibold uppercase tracking-wider">
|
|
||||||
No cover image
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex min-w-0 flex-1 flex-col p-6 sm:p-7">
|
|
||||||
<div className="mb-3 flex flex-wrap items-center gap-2">
|
|
||||||
<span className="text-[11px] font-bold uppercase tracking-[0.14em] text-brand-500">
|
|
||||||
Practice {index + 1} of {total}
|
|
||||||
</span>
|
|
||||||
<Badge variant="secondary" className="font-mono text-[10px] font-semibold">
|
|
||||||
ID {practice.id}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h2 className="text-xl font-semibold leading-snug tracking-tight text-grayScale-900 sm:text-[1.35rem]">
|
|
||||||
{practice.title}
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
{practice.story_description?.trim() ? (
|
|
||||||
<div className="mt-4 rounded-xl border border-grayScale-100 bg-grayScale-50/80 px-4 py-3.5">
|
|
||||||
<p className="text-[10px] font-bold uppercase tracking-wider text-grayScale-400">
|
|
||||||
Story & instructions
|
|
||||||
</p>
|
|
||||||
<p className="mt-2 whitespace-pre-line text-[14px] leading-relaxed text-grayScale-700">
|
|
||||||
{practice.story_description}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{practice.quick_tips?.trim() ? (
|
|
||||||
<div className="mt-4 border-l-[3px] border-amber-400 bg-gradient-to-r from-amber-50/90 to-amber-50/30 py-3 pl-4 pr-3">
|
|
||||||
<p className="text-[10px] font-bold uppercase tracking-wider text-amber-900/75">
|
|
||||||
Quick tips
|
|
||||||
</p>
|
|
||||||
<p className="mt-1.5 whitespace-pre-line text-[13px] leading-relaxed text-grayScale-800">
|
|
||||||
{practice.quick_tips}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="mt-6 flex flex-wrap gap-2 border-t border-grayScale-100 pt-5">
|
|
||||||
<Badge variant="secondary" className="gap-1.5 pl-2 pr-2.5 py-1 font-medium normal-case">
|
|
||||||
<Hash className="h-3 w-3 opacity-70" aria-hidden />
|
|
||||||
Question set {practice.question_set_id}
|
|
||||||
</Badge>
|
|
||||||
<Badge variant="secondary" className="gap-1.5 pl-2 pr-2.5 py-1 font-medium normal-case">
|
|
||||||
<Clock className="h-3 w-3 opacity-70" aria-hidden />
|
|
||||||
{formatPracticeDate(practice.created_at)}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function LessonPracticesPage() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { level, programType, courseId, unitId, moduleId, lessonId } = useParams<{
|
|
||||||
level?: string;
|
|
||||||
programType?: string;
|
|
||||||
courseId?: string;
|
|
||||||
unitId?: string;
|
|
||||||
moduleId?: string;
|
|
||||||
lessonId?: string;
|
|
||||||
}>();
|
|
||||||
const [searchParams] = useSearchParams();
|
|
||||||
const lessonTitle = searchParams.get("lessonTitle")?.trim() || "";
|
|
||||||
|
|
||||||
const isExamPrep = Boolean(programType?.trim());
|
|
||||||
const backHref = isExamPrep
|
|
||||||
? `/new-content/courses/${programType}/${courseId}/${unitId}/${moduleId}`
|
|
||||||
: `/new-content/learn-english/${level}/courses/${courseId}/modules/${moduleId}`;
|
|
||||||
|
|
||||||
const [practices, setPractices] = useState<ParentContextPractice[]>([]);
|
|
||||||
const [totalCount, setTotalCount] = useState(0);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [loadError, setLoadError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const lid = lessonId ? Number(lessonId) : NaN;
|
|
||||||
const validLesson = Number.isFinite(lid) && lid > 0;
|
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
|
||||||
if (!validLesson) {
|
|
||||||
setLoading(false);
|
|
||||||
setLoadError("Invalid lesson.");
|
|
||||||
setPractices([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setLoading(true);
|
|
||||||
setLoadError(null);
|
|
||||||
try {
|
|
||||||
const res = await getPracticesByParentLesson(lid, { limit: 100, offset: 0 });
|
|
||||||
const envelope = unwrapPracticesEnvelope(res);
|
|
||||||
const list = Array.isArray(envelope?.practices) ? envelope.practices : [];
|
|
||||||
setPractices(list);
|
|
||||||
setTotalCount(
|
|
||||||
typeof envelope?.total_count === "number"
|
|
||||||
? envelope.total_count
|
|
||||||
: list.length,
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
setPractices([]);
|
|
||||||
setTotalCount(0);
|
|
||||||
setLoadError("Could not load practices for this lesson.");
|
|
||||||
toast.error("Failed to load practices");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [lid, validLesson]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void load();
|
|
||||||
}, [load]);
|
|
||||||
|
|
||||||
const displayTitle =
|
|
||||||
lessonTitle || (validLesson ? `Lesson #${lid}` : "Lesson practices");
|
|
||||||
|
|
||||||
const addPracticeHref = isExamPrep
|
|
||||||
? `/new-content/courses/${programType}/${courseId}/${unitId}/${moduleId}/add-practice?lessonId=${lid}&lessonTitle=${encodeURIComponent(lessonTitle || displayTitle)}`
|
|
||||||
: `/new-content/learn-english/${level}/courses/add-practice?backTo=module&courseId=${courseId}&moduleId=${moduleId}&lessonId=${lid}&lessonTitle=${encodeURIComponent(lessonTitle || displayTitle)}`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-gradient-to-b from-[#F4F6FB] via-white to-[#F8FAFC]">
|
|
||||||
<div className="pointer-events-none fixed inset-0 -z-10 overflow-hidden">
|
|
||||||
<div className="absolute -right-24 -top-24 h-72 w-72 rounded-full bg-brand-500/[0.06] blur-3xl" />
|
|
||||||
<div className="absolute -bottom-32 -left-20 h-80 w-80 rounded-full bg-violet-500/[0.05] blur-3xl" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mx-auto max-w-4xl px-4 pb-24 pt-8 sm:px-6 lg:px-8">
|
|
||||||
<div className="animate-in fade-in slide-in-from-bottom-2 duration-500">
|
|
||||||
<Link
|
|
||||||
to={backHref}
|
|
||||||
className="group mb-6 inline-flex items-center gap-2 rounded-full border border-transparent px-1 py-1 text-[14px] font-medium text-grayScale-600 transition-colors hover:border-grayScale-200 hover:bg-white/80 hover:text-brand-600"
|
|
||||||
>
|
|
||||||
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-white shadow-sm ring-1 ring-grayScale-100 transition-transform group-hover:-translate-x-0.5">
|
|
||||||
<ArrowLeft className="h-4 w-4" />
|
|
||||||
</span>
|
|
||||||
Back to module
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Card className="mb-10 border-grayScale-100/80 bg-white/90 shadow-md shadow-grayScale-200/40 backdrop-blur-sm">
|
|
||||||
<CardContent className="p-6 sm:p-8">
|
|
||||||
<div className="flex flex-col gap-6 lg:flex-row lg:items-start lg:justify-between">
|
|
||||||
<div className="flex min-w-0 gap-4">
|
|
||||||
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl bg-gradient-to-br from-brand-500 to-brand-600 text-white shadow-lg shadow-brand-500/25">
|
|
||||||
<BookOpen className="h-7 w-7" strokeWidth={1.75} />
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="text-[11px] font-bold uppercase tracking-[0.2em] text-brand-500/90">
|
|
||||||
Lesson practices
|
|
||||||
</p>
|
|
||||||
<h1 className="mt-1.5 text-2xl font-semibold tracking-tight text-grayScale-900 sm:text-3xl">
|
|
||||||
{displayTitle}
|
|
||||||
</h1>
|
|
||||||
<p className="mt-2 max-w-xl text-[15px] leading-relaxed text-grayScale-500">
|
|
||||||
Review speaking practices linked to this lesson. Thumbnails
|
|
||||||
and copy come from your published practice content.
|
|
||||||
</p>
|
|
||||||
{!loading && !loadError ? (
|
|
||||||
<div className="mt-4 flex flex-wrap items-center gap-2">
|
|
||||||
<Badge variant="default" className="px-3 py-1 text-xs font-semibold">
|
|
||||||
{practices.length}{" "}
|
|
||||||
{practices.length === 1 ? "practice" : "practices"}
|
|
||||||
</Badge>
|
|
||||||
{totalCount > practices.length ? (
|
|
||||||
<span className="text-[12px] text-grayScale-500">
|
|
||||||
Showing {practices.length} of {totalCount}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex shrink-0 flex-col gap-2 sm:flex-row lg:flex-col">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
className="h-11 rounded-xl bg-brand-500 px-6 font-semibold shadow-md shadow-brand-500/20 hover:bg-brand-600"
|
|
||||||
onClick={() => void navigate(addPracticeHref)}
|
|
||||||
>
|
|
||||||
<Calendar className="mr-2 h-4 w-4" />
|
|
||||||
Add practice
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
className="h-11 rounded-xl border-grayScale-200 font-semibold text-grayScale-700 hover:bg-grayScale-50"
|
|
||||||
disabled={loading}
|
|
||||||
onClick={() => void load()}
|
|
||||||
>
|
|
||||||
<RefreshCw
|
|
||||||
className={cn("mr-2 h-4 w-4", loading && "animate-spin")}
|
|
||||||
/>
|
|
||||||
Refresh
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{loading ? (
|
|
||||||
<Card className="border-grayScale-100 bg-white/95 py-20 shadow-sm">
|
|
||||||
<CardContent className="flex flex-col items-center justify-center gap-4 pt-6">
|
|
||||||
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-brand-50 ring-1 ring-brand-100">
|
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-brand-500" />
|
|
||||||
</div>
|
|
||||||
<div className="text-center">
|
|
||||||
<p className="text-[16px] font-semibold text-grayScale-800">
|
|
||||||
Loading practices
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-[14px] text-grayScale-500">
|
|
||||||
Fetching content for this lesson…
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
) : loadError ? (
|
|
||||||
<Card className="border-red-100 bg-gradient-to-br from-red-50/90 to-white shadow-sm">
|
|
||||||
<CardContent className="flex flex-col items-center gap-5 py-14 text-center sm:py-16">
|
|
||||||
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-red-100 text-red-600">
|
|
||||||
<AlertCircle className="h-8 w-8" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-lg font-semibold text-grayScale-900">
|
|
||||||
Something went wrong
|
|
||||||
</p>
|
|
||||||
<p className="mx-auto mt-2 max-w-md text-[14px] leading-relaxed text-grayScale-600">
|
|
||||||
{loadError}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
className="rounded-xl border-grayScale-300 font-semibold"
|
|
||||||
onClick={() => void load()}
|
|
||||||
>
|
|
||||||
Try again
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
) : practices.length === 0 ? (
|
|
||||||
<Card className="border-dashed border-grayScale-200 bg-white/90 shadow-sm">
|
|
||||||
<CardContent className="flex flex-col items-center px-6 py-16 text-center sm:py-20">
|
|
||||||
<div className="mb-6 flex h-20 w-20 items-center justify-center rounded-3xl bg-gradient-to-br from-violet-50 to-brand-50 ring-1 ring-brand-100/60">
|
|
||||||
<Sparkles className="h-9 w-9 text-brand-500" strokeWidth={1.5} />
|
|
||||||
</div>
|
|
||||||
<p className="text-xl font-semibold text-grayScale-900">
|
|
||||||
No practices yet
|
|
||||||
</p>
|
|
||||||
<p className="mx-auto mt-3 max-w-md text-[15px] leading-relaxed text-grayScale-500">
|
|
||||||
This lesson does not have any linked practices. Create one to
|
|
||||||
give learners a structured speaking activity after the video.
|
|
||||||
</p>
|
|
||||||
<div className="mt-8 flex flex-col gap-3 sm:flex-row">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
className="h-11 rounded-xl bg-brand-500 px-8 font-semibold shadow-md shadow-brand-500/15 hover:bg-brand-600"
|
|
||||||
onClick={() => void navigate(addPracticeHref)}
|
|
||||||
>
|
|
||||||
<Calendar className="mr-2 h-4 w-4" />
|
|
||||||
Create practice
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
className="h-11 rounded-xl border-grayScale-200 px-8 font-semibold"
|
|
||||||
asChild
|
|
||||||
>
|
|
||||||
<Link to={backHref}>Return to module</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-5">
|
|
||||||
{practices.map((p, i) => (
|
|
||||||
<PracticeCard
|
|
||||||
key={p.id}
|
|
||||||
practice={p}
|
|
||||||
index={i}
|
|
||||||
total={practices.length}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,27 +1,23 @@
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { ArrowLeft, Video, Calendar, Trash2, X } from "lucide-react";
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Video,
|
||||||
|
Calendar,
|
||||||
|
Mic,
|
||||||
|
Layers,
|
||||||
|
Edit2,
|
||||||
|
Trash2,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
import { Link, useLocation, useNavigate, useParams } from "react-router-dom";
|
import { Link, useLocation, useNavigate, useParams } from "react-router-dom";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
deleteTopLevelModuleLesson,
|
deleteTopLevelModuleLesson,
|
||||||
getModuleLessons,
|
getModuleLessons,
|
||||||
getPracticesByParentModule,
|
|
||||||
getTopLevelCourseModules,
|
getTopLevelCourseModules,
|
||||||
publishParentLinkedPractice,
|
|
||||||
publishTopLevelModuleLesson,
|
|
||||||
updateParentLinkedPractice,
|
|
||||||
updateTopLevelModuleLesson,
|
updateTopLevelModuleLesson,
|
||||||
} from "../../api/courses.api";
|
} from "../../api/courses.api";
|
||||||
import type {
|
import type { TopLevelModuleLessonItem } from "../../types/course.types";
|
||||||
ParentContextPractice,
|
|
||||||
PracticePublishStatus,
|
|
||||||
TopLevelModuleLessonItem,
|
|
||||||
} from "../../types/course.types";
|
|
||||||
import {
|
|
||||||
isPracticeDraft,
|
|
||||||
isPracticePublished,
|
|
||||||
unwrapPracticesList,
|
|
||||||
} from "../../lib/parentContextPractice";
|
|
||||||
import { Button } from "../../components/ui/button";
|
import { Button } from "../../components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
|
@ -36,7 +32,6 @@ import { Textarea } from "../../components/ui/textarea";
|
||||||
import { resolveThumbnailForPreview } from "../../lib/videoPreview";
|
import { resolveThumbnailForPreview } from "../../lib/videoPreview";
|
||||||
import { cn } from "../../lib/utils";
|
import { cn } from "../../lib/utils";
|
||||||
import { LessonMediaUploadField } from "./components/LessonMediaUploadField";
|
import { LessonMediaUploadField } from "./components/LessonMediaUploadField";
|
||||||
import { ModulePracticeCard } from "./components/ModulePracticeCard";
|
|
||||||
import { VideoCard } from "./components/VideoCard";
|
import { VideoCard } from "./components/VideoCard";
|
||||||
|
|
||||||
const LESSON_THUMB_GRADIENTS = [
|
const LESSON_THUMB_GRADIENTS = [
|
||||||
|
|
@ -46,6 +41,37 @@ const LESSON_THUMB_GRADIENTS = [
|
||||||
"from-[#FCE7F3] to-[#F9A8D4]",
|
"from-[#FCE7F3] to-[#F9A8D4]",
|
||||||
] as const;
|
] 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 = {
|
type ModuleDetailState = {
|
||||||
moduleName?: string;
|
moduleName?: string;
|
||||||
moduleDescription?: string;
|
moduleDescription?: string;
|
||||||
|
|
@ -61,14 +87,13 @@ export function ModuleDetailPage() {
|
||||||
moduleId: string;
|
moduleId: string;
|
||||||
}>();
|
}>();
|
||||||
const [activeTab, setActiveTab] = useState<"video" | "practice">("video");
|
const [activeTab, setActiveTab] = useState<"video" | "practice">("video");
|
||||||
const [activeFilter, setActiveFilter] = useState("All");
|
const [activeFilter, setActiveFilter] = useState("Draft");
|
||||||
const [lessons, setLessons] = useState<TopLevelModuleLessonItem[]>([]);
|
const [lessons, setLessons] = useState<TopLevelModuleLessonItem[]>([]);
|
||||||
const [lessonsLoading, setLessonsLoading] = useState(true);
|
const [lessonsLoading, setLessonsLoading] = useState(true);
|
||||||
const [lessonsLoadError, setLessonsLoadError] = useState<string | null>(null);
|
const [lessonsLoadError, setLessonsLoadError] = useState<string | null>(null);
|
||||||
const [editingLesson, setEditingLesson] =
|
const [editingLesson, setEditingLesson] =
|
||||||
useState<TopLevelModuleLessonItem | null>(null);
|
useState<TopLevelModuleLessonItem | null>(null);
|
||||||
const [editLessonTitle, setEditLessonTitle] = useState("");
|
const [editLessonTitle, setEditLessonTitle] = useState("");
|
||||||
const [editLessonSortOrder, setEditLessonSortOrder] = useState("");
|
|
||||||
const [editLessonVideoUrl, setEditLessonVideoUrl] = useState("");
|
const [editLessonVideoUrl, setEditLessonVideoUrl] = useState("");
|
||||||
const [editLessonThumbnail, setEditLessonThumbnail] = useState("");
|
const [editLessonThumbnail, setEditLessonThumbnail] = useState("");
|
||||||
const [editLessonDescription, setEditLessonDescription] = useState("");
|
const [editLessonDescription, setEditLessonDescription] = useState("");
|
||||||
|
|
@ -79,17 +104,7 @@ export function ModuleDetailPage() {
|
||||||
const [deletingLesson, setDeletingLesson] =
|
const [deletingLesson, setDeletingLesson] =
|
||||||
useState<TopLevelModuleLessonItem | null>(null);
|
useState<TopLevelModuleLessonItem | null>(null);
|
||||||
const [deletingLessonInFlight, setDeletingLessonInFlight] = useState(false);
|
const [deletingLessonInFlight, setDeletingLessonInFlight] = useState(false);
|
||||||
const [publishStatusLessonId, setPublishStatusLessonId] = useState<
|
const [practices] = useState(MOCK_PRACTICES);
|
||||||
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 [loadedModuleName, setLoadedModuleName] = useState<string | null>(null);
|
||||||
const [loadedModuleDescription, setLoadedModuleDescription] = useState<
|
const [loadedModuleDescription, setLoadedModuleDescription] = useState<
|
||||||
string | null
|
string | null
|
||||||
|
|
@ -218,96 +233,9 @@ export function ModuleDetailPage() {
|
||||||
void loadModuleLessons({ showPageLoading: true });
|
void loadModuleLessons({ showPageLoading: true });
|
||||||
}, [loadModuleLessons]);
|
}, [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) => {
|
const openEditLesson = (lesson: TopLevelModuleLessonItem) => {
|
||||||
setEditingLesson(lesson);
|
setEditingLesson(lesson);
|
||||||
setEditLessonTitle(lesson.title ?? "");
|
setEditLessonTitle(lesson.title ?? "");
|
||||||
setEditLessonSortOrder(String(lesson.sort_order ?? 0));
|
|
||||||
setEditLessonVideoUrl(lesson.video_url ?? "");
|
setEditLessonVideoUrl(lesson.video_url ?? "");
|
||||||
setEditLessonThumbnail(lesson.thumbnail ?? "");
|
setEditLessonThumbnail(lesson.thumbnail ?? "");
|
||||||
setEditLessonDescription(lesson.description ?? "");
|
setEditLessonDescription(lesson.description ?? "");
|
||||||
|
|
@ -325,16 +253,6 @@ export function ModuleDetailPage() {
|
||||||
toast.error("Title is required");
|
toast.error("Title is required");
|
||||||
return;
|
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);
|
setSavingLessonEdit(true);
|
||||||
try {
|
try {
|
||||||
await updateTopLevelModuleLesson(editingLesson.id, {
|
await updateTopLevelModuleLesson(editingLesson.id, {
|
||||||
|
|
@ -342,7 +260,6 @@ export function ModuleDetailPage() {
|
||||||
video_url: editLessonVideoUrl.trim(),
|
video_url: editLessonVideoUrl.trim(),
|
||||||
thumbnail: editLessonThumbnail.trim(),
|
thumbnail: editLessonThumbnail.trim(),
|
||||||
description: editLessonDescription.trim(),
|
description: editLessonDescription.trim(),
|
||||||
sort_order,
|
|
||||||
});
|
});
|
||||||
toast.success("Lesson updated");
|
toast.success("Lesson updated");
|
||||||
setEditingLesson(null);
|
setEditingLesson(null);
|
||||||
|
|
@ -358,39 +275,6 @@ export function ModuleDetailPage() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleToggleLessonPublishStatus = async (
|
|
||||||
lessonId: number,
|
|
||||||
nextStatus: PracticePublishStatus,
|
|
||||||
) => {
|
|
||||||
setPublishStatusLessonId(lessonId);
|
|
||||||
try {
|
|
||||||
await publishTopLevelModuleLesson(lessonId, {
|
|
||||||
publish_status: nextStatus,
|
|
||||||
});
|
|
||||||
setLessons((prev) =>
|
|
||||||
prev.map((l) =>
|
|
||||||
l.id === lessonId ? { ...l, publish_status: nextStatus } : l,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
toast.success(
|
|
||||||
nextStatus === "PUBLISHED"
|
|
||||||
? "Lesson published"
|
|
||||||
: "Lesson saved as draft",
|
|
||||||
);
|
|
||||||
} catch (e: unknown) {
|
|
||||||
console.error(e);
|
|
||||||
const msg =
|
|
||||||
(e as { response?: { data?: { message?: string } } })?.response?.data
|
|
||||||
?.message ??
|
|
||||||
(nextStatus === "PUBLISHED"
|
|
||||||
? "Failed to publish lesson"
|
|
||||||
: "Failed to save lesson as draft");
|
|
||||||
toast.error(msg);
|
|
||||||
} finally {
|
|
||||||
setPublishStatusLessonId(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleConfirmDeleteLesson = async () => {
|
const handleConfirmDeleteLesson = async () => {
|
||||||
if (!deletingLesson) return;
|
if (!deletingLesson) return;
|
||||||
setDeletingLessonInFlight(true);
|
setDeletingLessonInFlight(true);
|
||||||
|
|
@ -509,34 +393,11 @@ export function ModuleDetailPage() {
|
||||||
id={lesson.id}
|
id={lesson.id}
|
||||||
title={lesson.title}
|
title={lesson.title}
|
||||||
videoUrl={lesson.video_url}
|
videoUrl={lesson.video_url}
|
||||||
publishStatus={lesson.publish_status}
|
|
||||||
hoverModuleActions
|
hoverModuleActions
|
||||||
thumbnailUrl={resolveThumbnailForPreview(lesson.thumbnail)}
|
thumbnailUrl={resolveThumbnailForPreview(lesson.thumbnail)}
|
||||||
thumbnailGradient={LESSON_THUMB_GRADIENTS[i % LESSON_THUMB_GRADIENTS.length]}
|
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)}
|
onEdit={() => openEditLesson(lesson)}
|
||||||
onDelete={() => setDeletingLesson(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>
|
</div>
|
||||||
|
|
@ -593,66 +454,12 @@ export function ModuleDetailPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{practicesLoading ? (
|
{/* Practice Cards Grid */}
|
||||||
<div className="flex flex-col items-center justify-center py-24 text-grayScale-500 text-[15px] font-medium">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
Loading practices…
|
{practices.map((practice) => (
|
||||||
</div>
|
<PracticeCard key={practice.id} {...practice} />
|
||||||
) : 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">
|
</div>
|
||||||
{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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -668,7 +475,15 @@ export function ModuleDetailPage() {
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Edit lesson</DialogTitle>
|
<DialogTitle>Edit lesson</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Update lesson details. Uploaded video and thumbnail files are stored automatically.
|
Update details. Video and thumbnail files use{" "}
|
||||||
|
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px]">
|
||||||
|
POST /files/upload
|
||||||
|
</code>
|
||||||
|
; the form is saved with{" "}
|
||||||
|
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px]">
|
||||||
|
PUT /lessons/:id
|
||||||
|
</code>
|
||||||
|
.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="grid gap-4 py-2">
|
<div className="grid gap-4 py-2">
|
||||||
|
|
@ -686,28 +501,6 @@ export function ModuleDetailPage() {
|
||||||
disabled={savingLessonEdit}
|
disabled={savingLessonEdit}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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
|
<LessonMediaUploadField
|
||||||
kind="video"
|
kind="video"
|
||||||
value={editLessonVideoUrl}
|
value={editLessonVideoUrl}
|
||||||
|
|
@ -816,3 +609,68 @@ export function ModuleDetailPage() {
|
||||||
</div>
|
</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,31 +7,14 @@ export function NewContentPage() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{/* Header section */}
|
{/* Header section */}
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div>
|
||||||
<div>
|
<h1 className="text-2xl font-bold tracking-tight text-grayScale-700">
|
||||||
<h1 className="text-2xl font-bold tracking-tight text-grayScale-700">
|
Content Management
|
||||||
Content Management
|
</h1>
|
||||||
</h1>
|
<p className="mt-1 text-sm text-grayScale-500">
|
||||||
<p className="mt-1 text-sm text-grayScale-500">
|
Upload, organize, and manage learning content across programs and
|
||||||
Upload, organize, and manage learning content across programs and
|
courses
|
||||||
courses
|
</p>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
{/* Gradient Divider */}
|
{/* Gradient Divider */}
|
||||||
|
|
|
||||||
|
|
@ -1093,6 +1093,9 @@ export function PracticeDetailsPage() {
|
||||||
placeholder="Optional"
|
placeholder="Optional"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-xs text-grayScale-500">
|
||||||
|
Uses <span className="font-mono">PUT /practices/{id}</span> with the fields above.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter className="gap-2 sm:gap-0">
|
<DialogFooter className="gap-2 sm:gap-0">
|
||||||
<Button type="button" variant="outline" onClick={() => setEditOpen(false)} disabled={savePracticeLoading}>
|
<Button type="button" variant="outline" onClick={() => setEditOpen(false)} disabled={savePracticeLoading}>
|
||||||
|
|
@ -1112,8 +1115,9 @@ export function PracticeDetailsPage() {
|
||||||
<DialogTitle>Delete this practice?</DialogTitle>
|
<DialogTitle>Delete this practice?</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<p className="text-sm text-grayScale-600">
|
<p className="text-sm text-grayScale-600">
|
||||||
This permanently removes the practice for this {parentTabCopy[parentTab].label.toLowerCase()}. The linked
|
This will call <span className="font-mono">DELETE /practices/{id}</span> and remove the practice
|
||||||
question set may remain unless you remove it separately.
|
for this {parentTabCopy[parentTab].label.toLowerCase()}. The question set is not deleted unless your API
|
||||||
|
cascades.
|
||||||
</p>
|
</p>
|
||||||
<DialogFooter className="gap-2 sm:gap-0">
|
<DialogFooter className="gap-2 sm:gap-0">
|
||||||
<Button
|
<Button
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,6 @@ import { Input } from "../../components/ui/input"
|
||||||
import { Select } from "../../components/ui/select"
|
import { Select } from "../../components/ui/select"
|
||||||
import { Textarea } from "../../components/ui/textarea"
|
import { Textarea } from "../../components/ui/textarea"
|
||||||
import type { PracticeQuestion, QuestionSetQuestion, QuestionDetail } from "../../types/course.types"
|
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 QuestionType = "MCQ" | "TRUE_FALSE" | "SHORT" | "AUDIO"
|
||||||
type DifficultyLevel = "EASY" | "MEDIUM" | "HARD"
|
type DifficultyLevel = "EASY" | "MEDIUM" | "HARD"
|
||||||
|
|
@ -85,7 +84,7 @@ export function PracticeQuestionsPage() {
|
||||||
const [loadingDetailIds, setLoadingDetailIds] = useState<Record<number, boolean>>({})
|
const [loadingDetailIds, setLoadingDetailIds] = useState<Record<number, boolean>>({})
|
||||||
const [groupBy, setGroupBy] = useState<GroupByOption>("none")
|
const [groupBy, setGroupBy] = useState<GroupByOption>("none")
|
||||||
const [pointsSort, setPointsSort] = useState<PointsSortOption>("desc")
|
const [pointsSort, setPointsSort] = useState<PointsSortOption>("desc")
|
||||||
const [pageSize, setPageSize] = useState(DEFAULT_TABLE_PAGE_SIZE)
|
const [pageSize] = useState(10)
|
||||||
const [currentPage, setCurrentPage] = useState(1)
|
const [currentPage, setCurrentPage] = useState(1)
|
||||||
const [totalQuestions, setTotalQuestions] = useState(0)
|
const [totalQuestions, setTotalQuestions] = useState(0)
|
||||||
|
|
||||||
|
|
@ -737,56 +736,29 @@ export function PracticeQuestionsPage() {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{totalQuestions > 0 && (
|
{totalQuestions > pageSize && (
|
||||||
<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 items-center justify-between rounded-xl border border-grayScale-200 bg-white px-4 py-3">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<p className="text-sm text-grayScale-500">
|
||||||
<span>
|
Page {currentPage} of {Math.max(1, Math.ceil(totalQuestions / pageSize))}
|
||||||
Page {currentPage} of {Math.max(1, Math.ceil(totalQuestions / pageSize))} ({totalQuestions}{" "}
|
</p>
|
||||||
total)
|
<div className="flex items-center gap-2">
|
||||||
</span>
|
<Button
|
||||||
<span className="hidden h-4 w-px bg-grayScale-200 sm:inline" />
|
variant="outline"
|
||||||
<span className="flex items-center gap-2">
|
size="sm"
|
||||||
Rows per page
|
disabled={currentPage <= 1}
|
||||||
<div className="relative">
|
onClick={() => void fetchQuestions(currentPage - 1)}
|
||||||
<select
|
>
|
||||||
value={pageSize}
|
Previous
|
||||||
onChange={(e) => {
|
</Button>
|
||||||
setPageSize(Number(e.target.value))
|
<Button
|
||||||
setCurrentPage(1)
|
variant="outline"
|
||||||
void fetchQuestions(1)
|
size="sm"
|
||||||
}}
|
disabled={currentPage >= Math.ceil(totalQuestions / pageSize)}
|
||||||
className="h-8 appearance-none rounded-md border bg-white pl-2 pr-7 text-sm font-medium text-grayScale-600 focus:outline-none"
|
onClick={() => void fetchQuestions(currentPage + 1)}
|
||||||
>
|
>
|
||||||
{TABLE_PAGE_SIZE_OPTIONS.map((size) => (
|
Next
|
||||||
<option key={size} value={size}>
|
</Button>
|
||||||
{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>
|
</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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { ArrowLeft, Plus, Pencil, Trash2, X } from "lucide-react";
|
import { ArrowLeft, Plus, FileText, Pencil, Trash2, X } from "lucide-react";
|
||||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Card, CardContent } from "../../components/ui/card";
|
import { Card, CardContent } from "../../components/ui/card";
|
||||||
|
|
@ -14,6 +14,7 @@ import {
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from "../../components/ui/dialog";
|
} from "../../components/ui/dialog";
|
||||||
import { Input } from "../../components/ui/input";
|
import { Input } from "../../components/ui/input";
|
||||||
|
import { Textarea } from "../../components/ui/textarea";
|
||||||
import uploadIcon from "../../assets/icons/upload.png";
|
import uploadIcon from "../../assets/icons/upload.png";
|
||||||
import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg";
|
import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg";
|
||||||
import alertSrc from "../../assets/Alert.svg";
|
import alertSrc from "../../assets/Alert.svg";
|
||||||
|
|
@ -29,7 +30,6 @@ import type {
|
||||||
LearningProgramListItem,
|
LearningProgramListItem,
|
||||||
ProgramCourseListItem,
|
ProgramCourseListItem,
|
||||||
} from "../../types/course.types";
|
} from "../../types/course.types";
|
||||||
import { PublishPracticeButton } from "./components/PublishPracticeButton";
|
|
||||||
|
|
||||||
export function ProgramCoursesPage() {
|
export function ProgramCoursesPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
@ -51,7 +51,7 @@ export function ProgramCoursesPage() {
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
const [editName, setEditName] = useState("");
|
const [editName, setEditName] = useState("");
|
||||||
const [editSortOrder, setEditSortOrder] = useState("");
|
const [editDescription, setEditDescription] = useState("");
|
||||||
const [editThumbnail, setEditThumbnail] = useState("");
|
const [editThumbnail, setEditThumbnail] = useState("");
|
||||||
const [savingEdit, setSavingEdit] = useState(false);
|
const [savingEdit, setSavingEdit] = useState(false);
|
||||||
const [uploadingEditThumbnail, setUploadingEditThumbnail] = useState(false);
|
const [uploadingEditThumbnail, setUploadingEditThumbnail] = useState(false);
|
||||||
|
|
@ -59,7 +59,7 @@ export function ProgramCoursesPage() {
|
||||||
|
|
||||||
const [createCourseOpen, setCreateCourseOpen] = useState(false);
|
const [createCourseOpen, setCreateCourseOpen] = useState(false);
|
||||||
const [createName, setCreateName] = useState("");
|
const [createName, setCreateName] = useState("");
|
||||||
const [createSortOrder, setCreateSortOrder] = useState("");
|
const [createDescription, setCreateDescription] = useState("");
|
||||||
const [createThumbnail, setCreateThumbnail] = useState("");
|
const [createThumbnail, setCreateThumbnail] = useState("");
|
||||||
const [createSaving, setCreateSaving] = useState(false);
|
const [createSaving, setCreateSaving] = useState(false);
|
||||||
const [createUploadingThumbnail, setCreateUploadingThumbnail] = useState(false);
|
const [createUploadingThumbnail, setCreateUploadingThumbnail] = useState(false);
|
||||||
|
|
@ -133,16 +133,16 @@ export function ProgramCoursesPage() {
|
||||||
const openEditCourse = (course: ProgramCourseListItem) => {
|
const openEditCourse = (course: ProgramCourseListItem) => {
|
||||||
setEditingCourse(course);
|
setEditingCourse(course);
|
||||||
setEditName(course.name ?? "");
|
setEditName(course.name ?? "");
|
||||||
|
setEditDescription(course.description?.trim() ?? "");
|
||||||
setEditThumbnail(
|
setEditThumbnail(
|
||||||
course.thumbnail?.trim() || course.thumbnail_url?.trim() || "",
|
course.thumbnail?.trim() || course.thumbnail_url?.trim() || "",
|
||||||
);
|
);
|
||||||
setEditSortOrder(String(course.sort_order ?? 0));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const closeEditCourse = () => {
|
const closeEditCourse = () => {
|
||||||
setEditingCourse(null);
|
setEditingCourse(null);
|
||||||
setEditName("");
|
setEditName("");
|
||||||
setEditSortOrder("");
|
setEditDescription("");
|
||||||
setEditThumbnail("");
|
setEditThumbnail("");
|
||||||
setUploadingEditThumbnail(false);
|
setUploadingEditThumbnail(false);
|
||||||
if (editThumbnailFileInputRef.current) {
|
if (editThumbnailFileInputRef.current) {
|
||||||
|
|
@ -192,23 +192,12 @@ export function ProgramCoursesPage() {
|
||||||
toast.error("Course name is required");
|
toast.error("Course name is required");
|
||||||
return;
|
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);
|
setSavingEdit(true);
|
||||||
try {
|
try {
|
||||||
await updateTopLevelCourse(editingCourse.id, {
|
await updateTopLevelCourse(editingCourse.id, {
|
||||||
name,
|
name,
|
||||||
description: editingCourse.description?.trim() ?? "",
|
description: editDescription.trim(),
|
||||||
thumbnail: editThumbnail.trim(),
|
thumbnail: editThumbnail.trim(),
|
||||||
sort_order,
|
|
||||||
});
|
});
|
||||||
toast.success("Course updated");
|
toast.success("Course updated");
|
||||||
closeEditCourse();
|
closeEditCourse();
|
||||||
|
|
@ -226,7 +215,7 @@ export function ProgramCoursesPage() {
|
||||||
|
|
||||||
const clearCreateCourseForm = () => {
|
const clearCreateCourseForm = () => {
|
||||||
setCreateName("");
|
setCreateName("");
|
||||||
setCreateSortOrder("");
|
setCreateDescription("");
|
||||||
setCreateThumbnail("");
|
setCreateThumbnail("");
|
||||||
setCreateUploadingThumbnail(false);
|
setCreateUploadingThumbnail(false);
|
||||||
if (createThumbnailFileInputRef.current) {
|
if (createThumbnailFileInputRef.current) {
|
||||||
|
|
@ -282,23 +271,12 @@ export function ProgramCoursesPage() {
|
||||||
toast.error("Course name is required");
|
toast.error("Course name is required");
|
||||||
return;
|
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);
|
setCreateSaving(true);
|
||||||
try {
|
try {
|
||||||
await createProgramCourse(programId, {
|
await createProgramCourse(programId, {
|
||||||
name,
|
name,
|
||||||
description: "",
|
description: createDescription.trim(),
|
||||||
thumbnail: createThumbnail.trim(),
|
thumbnail: createThumbnail.trim(),
|
||||||
sort_order,
|
|
||||||
});
|
});
|
||||||
toast.success("Course created");
|
toast.success("Course created");
|
||||||
clearCreateCourseForm();
|
clearCreateCourseForm();
|
||||||
|
|
@ -359,6 +337,18 @@ export function ProgramCoursesPage() {
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
{programIdValid ? (
|
{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
|
<Dialog
|
||||||
open={createCourseOpen}
|
open={createCourseOpen}
|
||||||
onOpenChange={handleCreateCourseDialogOpenChange}
|
onOpenChange={handleCreateCourseDialogOpenChange}
|
||||||
|
|
@ -379,8 +369,15 @@ export function ProgramCoursesPage() {
|
||||||
Add New Course
|
Add New Course
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription className="text-sm text-grayScale-400">
|
<DialogDescription className="text-sm text-grayScale-400">
|
||||||
Add a new course to this program. Use an image URL or upload a file for the
|
Create a course via{" "}
|
||||||
thumbnail.
|
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px] text-grayScale-600">
|
||||||
|
POST /programs/:program_id/courses
|
||||||
|
</code>
|
||||||
|
. Thumbnail can be a URL or a file from{" "}
|
||||||
|
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px] text-grayScale-600">
|
||||||
|
POST /files/upload
|
||||||
|
</code>
|
||||||
|
.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
|
|
@ -425,27 +422,17 @@ export function ProgramCoursesPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label
|
<label className="text-[15px] font-medium text-grayScale-700">
|
||||||
htmlFor="create-course-sort-order"
|
Description
|
||||||
className="text-[15px] font-medium text-grayScale-700"
|
|
||||||
>
|
|
||||||
Sort Order
|
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Textarea
|
||||||
id="create-course-sort-order"
|
value={createDescription}
|
||||||
type="number"
|
onChange={(e) => setCreateDescription(e.target.value)}
|
||||||
min={0}
|
placeholder="Short summary of the course"
|
||||||
step={1}
|
rows={3}
|
||||||
inputMode="numeric"
|
className="min-h-[88px] resize-y rounded-xl"
|
||||||
value={createSortOrder}
|
|
||||||
onChange={(e) => setCreateSortOrder(e.target.value)}
|
|
||||||
placeholder="e.g. 5"
|
|
||||||
className="h-12 rounded-xl"
|
|
||||||
disabled={createSaving || createUploadingThumbnail}
|
disabled={createSaving || createUploadingThumbnail}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-grayScale-500">
|
|
||||||
Lower numbers appear first when courses are listed.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|
@ -677,11 +664,9 @@ export function ProgramCoursesPage() {
|
||||||
>
|
>
|
||||||
View Detail
|
View Detail
|
||||||
</Button>
|
</Button>
|
||||||
<PublishPracticeButton
|
<Button className="h-10 flex-1 rounded-[6px] bg-brand-500 text-[13px] font-semibold ">
|
||||||
parentKind="COURSE"
|
Publish Practice
|
||||||
parentId={course.id}
|
</Button>
|
||||||
className="h-10 flex-1 rounded-[6px] bg-brand-500 text-[13px] font-semibold hover:bg-brand-600 disabled:opacity-60"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
@ -697,15 +682,18 @@ export function ProgramCoursesPage() {
|
||||||
if (!open) closeEditCourse();
|
if (!open) closeEditCourse();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DialogContent className="flex max-h-[min(90vh,calc(100dvh-2rem))] max-w-lg flex-col gap-0 overflow-hidden p-0">
|
<DialogContent className="max-w-lg">
|
||||||
<DialogHeader className="shrink-0 space-y-1.5 border-b border-grayScale-100 px-6 pb-4 pt-6 pr-12">
|
<DialogHeader>
|
||||||
<DialogTitle>Edit course</DialogTitle>
|
<DialogTitle>Edit course</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Update name, sort order, and thumbnail.
|
Update name, description, and thumbnail. Saved with{" "}
|
||||||
|
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px]">
|
||||||
|
PUT /courses/:id
|
||||||
|
</code>
|
||||||
|
.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-6 py-4">
|
<div className="grid gap-4 py-2">
|
||||||
<div className="grid gap-4">
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-grayScale-700">
|
<label className="text-sm font-medium text-grayScale-700">
|
||||||
Name
|
Name
|
||||||
|
|
@ -719,27 +707,17 @@ export function ProgramCoursesPage() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label
|
<label className="text-sm font-medium text-grayScale-700">
|
||||||
htmlFor="edit-course-sort-order"
|
Description
|
||||||
className="text-sm font-medium text-grayScale-700"
|
|
||||||
>
|
|
||||||
Sort Order
|
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Textarea
|
||||||
id="edit-course-sort-order"
|
value={editDescription}
|
||||||
type="number"
|
onChange={(e) => setEditDescription(e.target.value)}
|
||||||
min={0}
|
rows={4}
|
||||||
step={1}
|
className="min-h-[100px] resize-y rounded-xl"
|
||||||
inputMode="numeric"
|
placeholder="Short summary"
|
||||||
value={editSortOrder}
|
|
||||||
onChange={(e) => setEditSortOrder(e.target.value)}
|
|
||||||
className="rounded-xl"
|
|
||||||
placeholder="e.g. 5"
|
|
||||||
disabled={savingEdit || uploadingEditThumbnail}
|
disabled={savingEdit || uploadingEditThumbnail}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-grayScale-500">
|
|
||||||
Lower numbers appear first when courses are listed.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-grayScale-700">
|
<label className="text-sm font-medium text-grayScale-700">
|
||||||
|
|
@ -782,8 +760,7 @@ export function ProgramCoursesPage() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<DialogFooter className="gap-2 sm:gap-0">
|
||||||
<DialogFooter className="shrink-0 gap-2 border-t border-grayScale-100 bg-white px-6 py-4 sm:gap-0">
|
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import {
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "../../components/ui/button";
|
import { Button } from "../../components/ui/button";
|
||||||
import { Card } from "../../components/ui/card";
|
import { Card } from "../../components/ui/card";
|
||||||
|
import { Textarea } from "../../components/ui/textarea";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
|
@ -38,6 +39,7 @@ export function ProgramDetailPage() {
|
||||||
const { programType } = useParams<{ programType: string }>();
|
const { programType } = useParams<{ programType: string }>();
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [createName, setCreateName] = useState("");
|
const [createName, setCreateName] = useState("");
|
||||||
|
const [createDescription, setCreateDescription] = useState("");
|
||||||
const [createThumbnail, setCreateThumbnail] = useState("");
|
const [createThumbnail, setCreateThumbnail] = useState("");
|
||||||
const [createThumbnailFromUpload, setCreateThumbnailFromUpload] = useState(false);
|
const [createThumbnailFromUpload, setCreateThumbnailFromUpload] = useState(false);
|
||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
|
|
@ -58,6 +60,7 @@ export function ProgramDetailPage() {
|
||||||
const [catalogLoading, setCatalogLoading] = useState(false);
|
const [catalogLoading, setCatalogLoading] = useState(false);
|
||||||
const [editingCourseId, setEditingCourseId] = useState<number | null>(null);
|
const [editingCourseId, setEditingCourseId] = useState<number | null>(null);
|
||||||
const [editName, setEditName] = useState("");
|
const [editName, setEditName] = useState("");
|
||||||
|
const [editDescription, setEditDescription] = useState("");
|
||||||
const [editThumbnail, setEditThumbnail] = useState("");
|
const [editThumbnail, setEditThumbnail] = useState("");
|
||||||
const [editSortOrder, setEditSortOrder] = useState("1");
|
const [editSortOrder, setEditSortOrder] = useState("1");
|
||||||
const [savingEdit, setSavingEdit] = useState(false);
|
const [savingEdit, setSavingEdit] = useState(false);
|
||||||
|
|
@ -213,7 +216,7 @@ export function ProgramDetailPage() {
|
||||||
|
|
||||||
const response = await createExamPrepCatalogCourse({
|
const response = await createExamPrepCatalogCourse({
|
||||||
name,
|
name,
|
||||||
description: null,
|
description: createDescription.trim() || null,
|
||||||
thumbnail: thumbnailToSend,
|
thumbnail: thumbnailToSend,
|
||||||
});
|
});
|
||||||
const row = response.data?.data;
|
const row = response.data?.data;
|
||||||
|
|
@ -224,7 +227,7 @@ export function ProgramDetailPage() {
|
||||||
{
|
{
|
||||||
id: row.id,
|
id: row.id,
|
||||||
name: row.name ?? name,
|
name: row.name ?? name,
|
||||||
description: row.description?.trim() || "—",
|
description: row.description?.trim() || createDescription.trim() || "—",
|
||||||
thumbnail: row.thumbnail?.trim() || null,
|
thumbnail: row.thumbnail?.trim() || null,
|
||||||
sortOrder: Number(row.sort_order ?? 0),
|
sortOrder: Number(row.sort_order ?? 0),
|
||||||
unitsCount: Number(row.units_count ?? 0),
|
unitsCount: Number(row.units_count ?? 0),
|
||||||
|
|
@ -236,6 +239,7 @@ export function ProgramDetailPage() {
|
||||||
await loadCatalogCourses();
|
await loadCatalogCourses();
|
||||||
toast.success("Course created");
|
toast.success("Course created");
|
||||||
setCreateName("");
|
setCreateName("");
|
||||||
|
setCreateDescription("");
|
||||||
setCreateThumbnail("");
|
setCreateThumbnail("");
|
||||||
setCreateThumbnailFromUpload(false);
|
setCreateThumbnailFromUpload(false);
|
||||||
setCreateOpen(false);
|
setCreateOpen(false);
|
||||||
|
|
@ -255,6 +259,7 @@ export function ProgramDetailPage() {
|
||||||
if (!Number.isFinite(idNum)) return;
|
if (!Number.isFinite(idNum)) return;
|
||||||
setEditingCourseId(idNum);
|
setEditingCourseId(idNum);
|
||||||
setEditName(String(course.name ?? ""));
|
setEditName(String(course.name ?? ""));
|
||||||
|
setEditDescription(String(course.description ?? ""));
|
||||||
setEditThumbnail(String(course.thumbnail ?? ""));
|
setEditThumbnail(String(course.thumbnail ?? ""));
|
||||||
setEditSortOrder(String(course.sort_order ?? 1));
|
setEditSortOrder(String(course.sort_order ?? 1));
|
||||||
};
|
};
|
||||||
|
|
@ -263,6 +268,7 @@ export function ProgramDetailPage() {
|
||||||
if (savingEdit || uploadingEditThumbnail) return;
|
if (savingEdit || uploadingEditThumbnail) return;
|
||||||
setEditingCourseId(null);
|
setEditingCourseId(null);
|
||||||
setEditName("");
|
setEditName("");
|
||||||
|
setEditDescription("");
|
||||||
setEditThumbnail("");
|
setEditThumbnail("");
|
||||||
setEditSortOrder("1");
|
setEditSortOrder("1");
|
||||||
};
|
};
|
||||||
|
|
@ -311,14 +317,9 @@ export function ProgramDetailPage() {
|
||||||
setSavingEdit(true);
|
setSavingEdit(true);
|
||||||
try {
|
try {
|
||||||
const minioThumbnail = await resolveThumbnailToMinioUrl(editThumbnail);
|
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, {
|
const response = await updateExamPrepCatalogCourse(editingCourseId, {
|
||||||
name,
|
name,
|
||||||
description: preservedDescription,
|
description: editDescription.trim() || null,
|
||||||
thumbnail: minioThumbnail || null,
|
thumbnail: minioThumbnail || null,
|
||||||
sort_order: sortOrderNum,
|
sort_order: sortOrderNum,
|
||||||
});
|
});
|
||||||
|
|
@ -329,7 +330,7 @@ export function ProgramDetailPage() {
|
||||||
? {
|
? {
|
||||||
...course,
|
...course,
|
||||||
name: row?.name ?? name,
|
name: row?.name ?? name,
|
||||||
description: row?.description?.trim() || preservedDescription || "—",
|
description: row?.description?.trim() || editDescription.trim() || "—",
|
||||||
thumbnail: row?.thumbnail?.trim() || null,
|
thumbnail: row?.thumbnail?.trim() || null,
|
||||||
sortOrder: Number(row?.sort_order ?? sortOrderNum),
|
sortOrder: Number(row?.sort_order ?? sortOrderNum),
|
||||||
unitsCount: Number(row?.units_count ?? course.unitsCount ?? 0),
|
unitsCount: Number(row?.units_count ?? course.unitsCount ?? 0),
|
||||||
|
|
@ -466,6 +467,20 @@ export function ProgramDetailPage() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<div className="space-y-3">
|
||||||
<label className="text-[15px] text-grayScale-800">
|
<label className="text-[15px] text-grayScale-800">
|
||||||
Thumbnail
|
Thumbnail
|
||||||
|
|
@ -557,11 +572,11 @@ export function ProgramDetailPage() {
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="h-10 px-6 rounded-[6px] border-brand-500 text-brand-500 font-bold flex items-center gap-2"
|
className="h-10 px-6 rounded-[6px] border-brand-500 text-brand-500 font-bold flex items-center gap-2"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
navigate(`/new-content/courses/${programType}/add-practice`)
|
navigate(`/new-content/courses/${programType}/attach-practice`)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<FileText className="h-5 w-5" />
|
<FileText className="h-5 w-5" />
|
||||||
Add Practice
|
Attach Practice
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -720,6 +735,17 @@ export function ProgramDetailPage() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<div className="space-y-3">
|
||||||
<label className="text-[15px] text-grayScale-800">Sort Order</label>
|
<label className="text-[15px] text-grayScale-800">Sort Order</label>
|
||||||
<Input
|
<Input
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,26 @@
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { GraduationCap, Brain } from "lucide-react";
|
import { GraduationCap, Brain } from "lucide-react";
|
||||||
|
import { Button } from "../../components/ui/button";
|
||||||
|
|
||||||
export function ProgramTypeSelectionPage() {
|
export function ProgramTypeSelectionPage() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
<div className="space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||||
{/* Header section */}
|
{/* Header section */}
|
||||||
<div className="space-y-1.5 pt-2">
|
<div className="flex items-start justify-between">
|
||||||
<h1 className="text-[28px] font-bold tracking-tight text-grayScale-900">
|
<div className="space-y-1.5 pt-2">
|
||||||
Courses
|
<h1 className="text-[28px] font-bold tracking-tight text-grayScale-900">
|
||||||
</h1>
|
Courses
|
||||||
<p className="max-w-2xl text-[15px] font-medium text-grayScale-500">
|
</h1>
|
||||||
Organize courses under skill-based learning or English proficiency
|
<p className="max-w-2xl text-[15px] font-medium text-grayScale-500">
|
||||||
exams. Select a program type to manage curriculum and modules.
|
Organize courses under skill-based learning or English proficiency
|
||||||
</p>
|
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>
|
</div>
|
||||||
|
|
||||||
{/* Gradient Divider */}
|
{/* Gradient Divider */}
|
||||||
|
|
|
||||||
|
|
@ -1,519 +1,127 @@
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
import { useState } from "react";
|
||||||
import { Link, useNavigate, useSearchParams } from "react-router-dom"
|
import { Link } from "react-router-dom";
|
||||||
import {
|
import { ArrowLeft, Plus, Search } from "lucide-react";
|
||||||
ArrowLeft,
|
import { Button } from "../../components/ui/button";
|
||||||
ChevronDown,
|
import { Input } from "../../components/ui/input";
|
||||||
ChevronLeft,
|
import { Select } from "../../components/ui/select";
|
||||||
ChevronRight,
|
import { Card } from "../../components/ui/card";
|
||||||
Layers,
|
import { cn } from "../../lib/utils";
|
||||||
Plus,
|
import { QuestionTypeCard } from "./components/QuestionTypeCard";
|
||||||
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() {
|
export function QuestionTypeLibraryPage() {
|
||||||
const navigate = useNavigate()
|
const [activeTab, setActiveTab] = useState("All");
|
||||||
const [searchParams] = useSearchParams()
|
|
||||||
const createdId = searchParams.get("created")
|
|
||||||
const updatedId = searchParams.get("updated")
|
|
||||||
|
|
||||||
const [loading, setLoading] = useState(true)
|
const questionTypes = [
|
||||||
const [definitions, setDefinitions] = useState<QuestionTypeDefinition[]>([])
|
{
|
||||||
const [totalCount, setTotalCount] = useState<number | undefined>(undefined)
|
title: "Describe a Photo",
|
||||||
const [query, setQuery] = useState("")
|
exam: "DUOLINGO" as const,
|
||||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>("All")
|
skill: "Speaking" as const,
|
||||||
const [scopeFilter, setScopeFilter] = useState<ScopeFilter>("all")
|
variations: 12,
|
||||||
const [pageSize, setPageSize] = useState(DEFAULT_TABLE_PAGE_SIZE)
|
status: "Published" as const,
|
||||||
const [offset, setOffset] = useState(0)
|
},
|
||||||
const [definitionPendingDelete, setDefinitionPendingDelete] = useState<QuestionTypeDefinition | null>(null)
|
{
|
||||||
const [deleteSubmitting, setDeleteSubmitting] = useState(false)
|
title: "Write About the Topic",
|
||||||
|
exam: "DUOLINGO" as const,
|
||||||
const hasActiveFilters =
|
skill: "Writing" as const,
|
||||||
query.trim().length > 0 || statusFilter !== "All" || scopeFilter !== "all"
|
variations: 12,
|
||||||
|
status: "Published" as const,
|
||||||
const load = useCallback(async () => {
|
},
|
||||||
setLoading(true)
|
{
|
||||||
try {
|
title: "Fill in the Blanks",
|
||||||
const isSystemScope = scopeFilter === "system"
|
exam: "IELTS" as const,
|
||||||
const { definitions: rows, total_count } = await getQuestionTypeDefinitions({
|
skill: "Writing" as const,
|
||||||
include_system: scopeFilter !== "custom",
|
variations: 12,
|
||||||
...(statusFilter !== "All" ? { status: statusFilter } : {}),
|
status: "Published" as const,
|
||||||
limit: isSystemScope ? SYSTEM_SCOPE_FETCH_LIMIT : pageSize,
|
},
|
||||||
offset: isSystemScope ? 0 : offset,
|
{
|
||||||
})
|
title: "Describe a Photo",
|
||||||
const visibleRows = isSystemScope ? rows.filter((d) => d.is_system) : rows
|
exam: "DUOLINGO" as const,
|
||||||
setDefinitions(visibleRows)
|
skill: "Speaking" as const,
|
||||||
if (isSystemScope) {
|
variations: 12,
|
||||||
setTotalCount(visibleRows.length)
|
status: "Published" as const,
|
||||||
} 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 (
|
return (
|
||||||
<div className="space-y-8 animate-in fade-in duration-500 pb-20">
|
<div className="space-y-8 animate-in fade-in duration-500 pb-20">
|
||||||
|
{/* Navigation & Header */}
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<Link
|
<Link
|
||||||
to="/new-content"
|
to="/new-content/courses"
|
||||||
className="flex items-center gap-2 text-[15px] font-bold text-grayScale-600 transition-colors hover:text-brand-500 group w-fit"
|
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" />
|
<ArrowLeft className="h-5 w-5 transition-transform group-hover:-translate-x-1" />
|
||||||
Back to Content Management
|
Back to Courses
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
<div className="flex items-start justify-between">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<h1 className="text-[32px] font-medium text-grayScale-900 tracking-tight">Question type definitions</h1>
|
<h1 className="text-[32px] font-medium text-grayScale-900 tracking-tight">
|
||||||
<p className="text-grayScale-500 text-[16px] font-medium max-w-2xl">
|
Question Type Library
|
||||||
Reusable templates that define how practice and assessment questions are structured and answered.
|
</h1>
|
||||||
|
<p className="text-grayScale-500 text-[16px] font-medium">
|
||||||
|
Create and manage reusable question structures for practices and
|
||||||
|
assessments.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Link to="/new-content/question-types/create">
|
<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">
|
<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" />
|
<Plus className="h-5 w-5" />
|
||||||
Create definition
|
Create Question Type
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card className="overflow-hidden rounded-2xl border border-grayScale-200 bg-white shadow-none">
|
{/* Control Bar */}
|
||||||
<CardHeader className="border-b border-grayScale-100 px-6 py-5">
|
<Card className="p-6 border-grayScale-200 rounded-2xl bg-white space-y-6">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex items-center gap-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="relative flex-1">
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-brand-50 text-brand-600">
|
<Search className="absolute left-4 top-1/2 -translate-y-1/2 h-5 w-5 text-grayScale-600" />
|
||||||
<Layers className="h-5 w-5" aria-hidden />
|
<Input
|
||||||
</div>
|
className="h-10 pl-12 rounded-[6px] border-grayScale-200 placeholder:text-grayScale-600 bg-[#F8FAFC] transition-all text-sm"
|
||||||
<div>
|
placeholder="Search by practice name, ID, or keywords..."
|
||||||
<CardTitle className="text-base font-bold text-grayScale-900">Definition library</CardTitle>
|
/>
|
||||||
<p className="text-xs text-grayScale-500 mt-0.5">
|
|
||||||
Browse and filter templates from the question type catalog
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="rounded-[8px] border-grayScale-200"
|
|
||||||
disabled={loading}
|
|
||||||
onClick={() => void load()}
|
|
||||||
>
|
|
||||||
<RefreshCw className={cn("mr-2 h-4 w-4", loading && "animate-spin")} />
|
|
||||||
Refresh
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
<Select className="h-10 w-[180px] rounded-[6px] border-grayScale-200 placeholder:text-grayScale-600 text-grayScale-700 bg-[#F8FAFC] transition-all text-sm">
|
||||||
|
<option>All Exams</option>
|
||||||
|
<option>IELTS</option>
|
||||||
|
<option>Duolingo</option>
|
||||||
|
</Select>
|
||||||
|
<Select className="h-10 w-[180px] rounded-[6px] border-grayScale-200 placeholder:text-grayScale-600 text-grayScale-700 bg-[#F8FAFC] transition-all text-sm">
|
||||||
|
<option>All Skills</option>
|
||||||
|
<option>Speaking</option>
|
||||||
|
<option>Writing</option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<CardContent className="p-0">
|
<div className="flex items-center gap-3">
|
||||||
<div className="border-b border-grayScale-100 bg-grayScale-50/60 px-6 py-5 space-y-4">
|
<span className="text-[12px] font-medium text-grayScale-400 uppercase tracking-widest mr-2">
|
||||||
<div className="relative">
|
STATUS:
|
||||||
<Search className="absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-400" />
|
</span>
|
||||||
<Input
|
{["All", "Published", "Drafts", "Archived"].map((tab) => (
|
||||||
className="h-11 pl-11 pr-10 rounded-[10px] border-grayScale-200 bg-white placeholder:text-grayScale-400 text-sm shadow-sm"
|
<button
|
||||||
placeholder="Search by display name, key, or id on this page…"
|
key={tab}
|
||||||
value={query}
|
onClick={() => setActiveTab(tab)}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
className={cn(
|
||||||
/>
|
"h-10 px-4 rounded-full text-[13px] font-medium transition-all",
|
||||||
{query ? (
|
activeTab === tab
|
||||||
<button
|
? "bg-[#9E2891] text-white shadow-md shadow-brand-500/20"
|
||||||
type="button"
|
: "bg-grayScale-100 text-grayScale-400 hover:bg-grayScale-100",
|
||||||
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>
|
>
|
||||||
) : (
|
{tab}
|
||||||
<div className="grid grid-cols-1 gap-5 px-6 py-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
</button>
|
||||||
{filtered.map((d) => (
|
))}
|
||||||
<QuestionTypeCard
|
</div>
|
||||||
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>
|
</Card>
|
||||||
|
|
||||||
<Dialog open={definitionPendingDelete !== null} onOpenChange={handleDeleteDialogOpenChange}>
|
{/* Grid of Cards */}
|
||||||
<DialogContent className="max-w-md rounded-2xl border-grayScale-200 sm:max-w-md">
|
<div className="grid grid-cols-4 gap-6">
|
||||||
<DialogHeader>
|
{questionTypes.map((qt, index) => (
|
||||||
<DialogTitle className="flex items-center gap-2 text-lg font-bold text-grayScale-900">
|
<QuestionTypeCard key={index} {...qt} />
|
||||||
<Trash2 className="h-5 w-5 text-red-600 shrink-0" aria-hidden />
|
))}
|
||||||
Delete question type definition?
|
</div>
|
||||||
</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>
|
</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,7 +19,6 @@ import { Badge } from "../../components/ui/badge"
|
||||||
import { deleteQuestion, getQuestionById, getQuestions, updateQuestion } from "../../api/courses.api"
|
import { deleteQuestion, getQuestionById, getQuestions, updateQuestion } from "../../api/courses.api"
|
||||||
import type { QuestionDetail } from "../../types/course.types"
|
import type { QuestionDetail } from "../../types/course.types"
|
||||||
import { cn } from "../../lib/utils"
|
import { cn } from "../../lib/utils"
|
||||||
import { TABLE_PAGE_SIZE_OPTIONS } from "../../lib/tablePagination"
|
|
||||||
|
|
||||||
type QuestionTypeFilter = "all" | "MCQ" | "TRUE_FALSE" | "SHORT_ANSWER" | "AUDIO"
|
type QuestionTypeFilter = "all" | "MCQ" | "TRUE_FALSE" | "SHORT_ANSWER" | "AUDIO"
|
||||||
type DifficultyFilter = "all" | "EASY" | "MEDIUM" | "HARD"
|
type DifficultyFilter = "all" | "EASY" | "MEDIUM" | "HARD"
|
||||||
|
|
@ -559,7 +558,7 @@ export function QuestionsPage() {
|
||||||
}}
|
}}
|
||||||
className="h-8 appearance-none rounded-md border bg-white pl-2 pr-7 text-sm font-medium text-grayScale-600 focus:outline-none"
|
className="h-8 appearance-none rounded-md border bg-white pl-2 pr-7 text-sm font-medium text-grayScale-600 focus:outline-none"
|
||||||
>
|
>
|
||||||
{TABLE_PAGE_SIZE_OPTIONS.map((size) => (
|
{[10, 20, 50].map((size) => (
|
||||||
<option key={size} value={size}>
|
<option key={size} value={size}>
|
||||||
{size}
|
{size}
|
||||||
</option>
|
</option>
|
||||||
|
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
import { Link } from "react-router-dom";
|
|
||||||
import { ArrowLeft } from "lucide-react";
|
|
||||||
import { ContentHierarchyList } from "./components/ContentHierarchyList";
|
|
||||||
|
|
||||||
export function ReorderContentPage() {
|
|
||||||
return (
|
|
||||||
<div className="space-y-8 animate-in fade-in duration-500 pb-20">
|
|
||||||
<div className="space-y-6">
|
|
||||||
<Link
|
|
||||||
to="/new-content"
|
|
||||||
className="flex items-center gap-2 text-[15px] font-bold text-grayScale-600 transition-colors hover:text-brand-500 group w-fit"
|
|
||||||
>
|
|
||||||
<ArrowLeft className="h-5 w-5 transition-transform group-hover:-translate-x-1" />
|
|
||||||
Back to Content Management
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<div className="space-y-1">
|
|
||||||
<h1 className="text-2xl font-bold tracking-tight text-grayScale-700">
|
|
||||||
Reorder Content
|
|
||||||
</h1>
|
|
||||||
<p className="max-w-2xl text-sm text-grayScale-500">
|
|
||||||
Drag and drop programs, courses, modules, and lessons to change
|
|
||||||
their display order.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ContentHierarchyList />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -30,7 +30,6 @@ import {
|
||||||
} from "../../components/ui/dropdown-menu"
|
} from "../../components/ui/dropdown-menu"
|
||||||
import type { Course, CourseCategory, QuestionDetail, QuestionSet, SubCourse } from "../../types/course.types"
|
import type { Course, CourseCategory, QuestionDetail, QuestionSet, SubCourse } from "../../types/course.types"
|
||||||
import { toast } from "sonner"
|
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 MAX_AUDIO_SIZE_BYTES = 50 * 1024 * 1024
|
||||||
const ALLOWED_AUDIO_EXTENSIONS = new Set(["mp3", "wav", "ogg", "m4a", "aac", "webm", "flac"])
|
const ALLOWED_AUDIO_EXTENSIONS = new Set(["mp3", "wav", "ogg", "m4a", "aac", "webm", "flac"])
|
||||||
|
|
@ -150,7 +149,7 @@ export function SpeakingPage() {
|
||||||
const [audioQuestions, setAudioQuestions] = useState<AudioListQuestion[]>([])
|
const [audioQuestions, setAudioQuestions] = useState<AudioListQuestion[]>([])
|
||||||
const [audioTotalCount, setAudioTotalCount] = useState(0)
|
const [audioTotalCount, setAudioTotalCount] = useState(0)
|
||||||
const [audioPage, setAudioPage] = useState(1)
|
const [audioPage, setAudioPage] = useState(1)
|
||||||
const [audioPageSize, setAudioPageSize] = useState(DEFAULT_TABLE_PAGE_SIZE)
|
const [audioPageSize] = useState(12)
|
||||||
const [practiceOptions, setPracticeOptions] = useState<PracticeFilterOption[]>([])
|
const [practiceOptions, setPracticeOptions] = useState<PracticeFilterOption[]>([])
|
||||||
const [selectedPracticeId, setSelectedPracticeId] = useState<string>("")
|
const [selectedPracticeId, setSelectedPracticeId] = useState<string>("")
|
||||||
const [practiceFilterOpen, setPracticeFilterOpen] = useState(false)
|
const [practiceFilterOpen, setPracticeFilterOpen] = useState(false)
|
||||||
|
|
@ -1511,58 +1510,31 @@ export function SpeakingPage() {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{audioTotalCount > 0 ? (
|
{audioTotalCount > audioPageSize ? (
|
||||||
<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="mt-4 flex items-center justify-between rounded-xl border border-grayScale-200 bg-white px-3 py-2">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<p className="text-xs text-grayScale-500 sm:text-sm">
|
||||||
<span>
|
Page {audioPage} of {Math.max(1, Math.ceil(audioTotalCount / audioPageSize))}
|
||||||
Page {audioPage} of {Math.max(1, Math.ceil(audioTotalCount / audioPageSize))} (
|
</p>
|
||||||
{audioTotalCount} total)
|
<div className="flex items-center gap-2">
|
||||||
</span>
|
<Button
|
||||||
<span className="hidden h-4 w-px bg-grayScale-200 sm:inline" />
|
type="button"
|
||||||
<span className="flex items-center gap-2">
|
variant="outline"
|
||||||
Rows per page
|
size="sm"
|
||||||
<div className="relative">
|
disabled={audioPage <= 1 || loading}
|
||||||
<select
|
onClick={() => fetchAudioQuestions(audioPage - 1)}
|
||||||
value={audioPageSize}
|
>
|
||||||
disabled={loading}
|
Previous
|
||||||
onChange={(e) => {
|
</Button>
|
||||||
setAudioPageSize(Number(e.target.value))
|
<Button
|
||||||
void fetchAudioQuestions(1)
|
type="button"
|
||||||
}}
|
variant="outline"
|
||||||
className="h-8 appearance-none rounded-md border bg-white pl-2 pr-7 text-sm font-medium text-grayScale-600 focus:outline-none"
|
size="sm"
|
||||||
>
|
disabled={audioPage >= Math.ceil(audioTotalCount / audioPageSize) || loading}
|
||||||
{TABLE_PAGE_SIZE_OPTIONS.map((size) => (
|
onClick={() => fetchAudioQuestions(audioPage + 1)}
|
||||||
<option key={size} value={size}>
|
>
|
||||||
{size}
|
Next
|
||||||
</option>
|
</Button>
|
||||||
))}
|
|
||||||
</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>
|
</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>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import {
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "../../components/ui/button";
|
import { Button } from "../../components/ui/button";
|
||||||
import { Card } from "../../components/ui/card";
|
import { Card } from "../../components/ui/card";
|
||||||
|
import { Textarea } from "../../components/ui/textarea";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
|
@ -54,6 +55,7 @@ export function UnitManagementPage() {
|
||||||
const parsedUnitId = Number(unitId);
|
const parsedUnitId = Number(unitId);
|
||||||
const [addModuleOpen, setAddModuleOpen] = useState(false);
|
const [addModuleOpen, setAddModuleOpen] = useState(false);
|
||||||
const [createName, setCreateName] = useState("");
|
const [createName, setCreateName] = useState("");
|
||||||
|
const [createDescription, setCreateDescription] = useState("");
|
||||||
const [createThumbnail, setCreateThumbnail] = useState("");
|
const [createThumbnail, setCreateThumbnail] = useState("");
|
||||||
const [createIcon, setCreateIcon] = useState("");
|
const [createIcon, setCreateIcon] = useState("");
|
||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
|
|
@ -77,6 +79,7 @@ export function UnitManagementPage() {
|
||||||
>([]);
|
>([]);
|
||||||
const [editingModuleId, setEditingModuleId] = useState<number | null>(null);
|
const [editingModuleId, setEditingModuleId] = useState<number | null>(null);
|
||||||
const [editName, setEditName] = useState("");
|
const [editName, setEditName] = useState("");
|
||||||
|
const [editDescription, setEditDescription] = useState("");
|
||||||
const [editThumbnail, setEditThumbnail] = useState("");
|
const [editThumbnail, setEditThumbnail] = useState("");
|
||||||
const [editIcon, setEditIcon] = useState("");
|
const [editIcon, setEditIcon] = useState("");
|
||||||
const [editSortOrder, setEditSortOrder] = useState("1");
|
const [editSortOrder, setEditSortOrder] = useState("1");
|
||||||
|
|
@ -156,6 +159,7 @@ export function UnitManagementPage() {
|
||||||
|
|
||||||
const clearCreateModuleForm = () => {
|
const clearCreateModuleForm = () => {
|
||||||
setCreateName("");
|
setCreateName("");
|
||||||
|
setCreateDescription("");
|
||||||
setCreateThumbnail("");
|
setCreateThumbnail("");
|
||||||
setCreateIcon("");
|
setCreateIcon("");
|
||||||
if (createThumbnailFileInputRef.current) {
|
if (createThumbnailFileInputRef.current) {
|
||||||
|
|
@ -260,7 +264,7 @@ export function UnitManagementPage() {
|
||||||
const minioIcon = await resolveToMinioUrl(createIcon);
|
const minioIcon = await resolveToMinioUrl(createIcon);
|
||||||
await createExamPrepUnitModule(parsedUnitId, {
|
await createExamPrepUnitModule(parsedUnitId, {
|
||||||
name,
|
name,
|
||||||
description: null,
|
description: createDescription.trim() || null,
|
||||||
thumbnail: minioThumbnail || null,
|
thumbnail: minioThumbnail || null,
|
||||||
icon: minioIcon || null,
|
icon: minioIcon || null,
|
||||||
});
|
});
|
||||||
|
|
@ -282,6 +286,7 @@ export function UnitManagementPage() {
|
||||||
const openEditModule = (module: (typeof modules)[number]) => {
|
const openEditModule = (module: (typeof modules)[number]) => {
|
||||||
setEditingModuleId(module.id);
|
setEditingModuleId(module.id);
|
||||||
setEditName(module.name ?? "");
|
setEditName(module.name ?? "");
|
||||||
|
setEditDescription(module.description ?? "");
|
||||||
setEditThumbnail(module.thumbnail ?? "");
|
setEditThumbnail(module.thumbnail ?? "");
|
||||||
setEditIcon(module.icon ?? "");
|
setEditIcon(module.icon ?? "");
|
||||||
setEditSortOrder(String(module.sortOrder ?? 1));
|
setEditSortOrder(String(module.sortOrder ?? 1));
|
||||||
|
|
@ -291,6 +296,7 @@ export function UnitManagementPage() {
|
||||||
if (savingEdit || uploadingEditThumbnail || uploadingEditIcon) return;
|
if (savingEdit || uploadingEditThumbnail || uploadingEditIcon) return;
|
||||||
setEditingModuleId(null);
|
setEditingModuleId(null);
|
||||||
setEditName("");
|
setEditName("");
|
||||||
|
setEditDescription("");
|
||||||
setEditThumbnail("");
|
setEditThumbnail("");
|
||||||
setEditIcon("");
|
setEditIcon("");
|
||||||
setEditSortOrder("1");
|
setEditSortOrder("1");
|
||||||
|
|
@ -385,16 +391,11 @@ export function UnitManagementPage() {
|
||||||
|
|
||||||
setSavingEdit(true);
|
setSavingEdit(true);
|
||||||
try {
|
try {
|
||||||
const existing = modules.find((m) => m.id === editingModuleId);
|
|
||||||
const preservedDescription =
|
|
||||||
existing?.description && existing.description !== "—"
|
|
||||||
? existing.description
|
|
||||||
: null;
|
|
||||||
const minioThumbnail = await resolveToMinioUrl(editThumbnail);
|
const minioThumbnail = await resolveToMinioUrl(editThumbnail);
|
||||||
const minioIcon = await resolveToMinioUrl(editIcon);
|
const minioIcon = await resolveToMinioUrl(editIcon);
|
||||||
await updateExamPrepUnitModule(editingModuleId, {
|
await updateExamPrepUnitModule(editingModuleId, {
|
||||||
name,
|
name,
|
||||||
description: preservedDescription,
|
description: editDescription.trim() || null,
|
||||||
thumbnail: minioThumbnail || null,
|
thumbnail: minioThumbnail || null,
|
||||||
icon: minioIcon || null,
|
icon: minioIcon || null,
|
||||||
sort_order: sortOrderNum,
|
sort_order: sortOrderNum,
|
||||||
|
|
@ -488,6 +489,20 @@ export function UnitManagementPage() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<div className="space-y-3">
|
||||||
<label className="text-[15px] text-grayScale-800">Thumbnail</label>
|
<label className="text-[15px] text-grayScale-800">Thumbnail</label>
|
||||||
<input
|
<input
|
||||||
|
|
@ -797,6 +812,16 @@ export function UnitManagementPage() {
|
||||||
disabled={savingEdit || uploadingEditThumbnail || uploadingEditIcon}
|
disabled={savingEdit || uploadingEditThumbnail || uploadingEditIcon}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<div className="space-y-3">
|
||||||
<label className="text-[15px] text-grayScale-800">Sort Order</label>
|
<label className="text-[15px] text-grayScale-800">Sort Order</label>
|
||||||
<Input
|
<Input
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import {
|
||||||
DialogClose,
|
DialogClose,
|
||||||
} from "../../../components/ui/dialog";
|
} from "../../../components/ui/dialog";
|
||||||
import { Input } from "../../../components/ui/input";
|
import { Input } from "../../../components/ui/input";
|
||||||
|
import { Textarea } from "../../../components/ui/textarea";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { createTopLevelCourseModule } from "../../../api/courses.api";
|
import { createTopLevelCourseModule } from "../../../api/courses.api";
|
||||||
import { ModuleIconUploadField } from "./ModuleIconUploadField";
|
import { ModuleIconUploadField } from "./ModuleIconUploadField";
|
||||||
|
|
@ -27,7 +28,7 @@ export function AddModuleModal({
|
||||||
onCreated,
|
onCreated,
|
||||||
}: AddModuleModalProps) {
|
}: AddModuleModalProps) {
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [sortOrder, setSortOrder] = useState("");
|
const [description, setDescription] = useState("");
|
||||||
const [icon, setIcon] = useState("");
|
const [icon, setIcon] = useState("");
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [iconUploadBusy, setIconUploadBusy] = useState(false);
|
const [iconUploadBusy, setIconUploadBusy] = useState(false);
|
||||||
|
|
@ -35,7 +36,7 @@ export function AddModuleModal({
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
setName("");
|
setName("");
|
||||||
setSortOrder("");
|
setDescription("");
|
||||||
setIcon("");
|
setIcon("");
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
setIconUploadBusy(false);
|
setIconUploadBusy(false);
|
||||||
|
|
@ -44,7 +45,7 @@ export function AddModuleModal({
|
||||||
|
|
||||||
const resetAndClose = () => {
|
const resetAndClose = () => {
|
||||||
setName("");
|
setName("");
|
||||||
setSortOrder("");
|
setDescription("");
|
||||||
setIcon("");
|
setIcon("");
|
||||||
setIconUploadBusy(false);
|
setIconUploadBusy(false);
|
||||||
onClose();
|
onClose();
|
||||||
|
|
@ -68,23 +69,12 @@ export function AddModuleModal({
|
||||||
toast.error("Invalid course");
|
toast.error("Invalid course");
|
||||||
return;
|
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);
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
await createTopLevelCourseModule(courseId, {
|
await createTopLevelCourseModule(courseId, {
|
||||||
name: trimmedName,
|
name: trimmedName,
|
||||||
description: "",
|
description: description.trim(),
|
||||||
icon: icon.trim(),
|
icon: icon.trim(),
|
||||||
sort_order,
|
|
||||||
});
|
});
|
||||||
toast.success("Module created");
|
toast.success("Module created");
|
||||||
if (onCreated) {
|
if (onCreated) {
|
||||||
|
|
@ -111,7 +101,11 @@ export function AddModuleModal({
|
||||||
Add New Module
|
Add New Module
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription className="text-sm text-grayScale-400">
|
<DialogDescription className="text-sm text-grayScale-400">
|
||||||
Add a new module to this course.
|
Create a module with{" "}
|
||||||
|
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px]">
|
||||||
|
POST /courses/:courseId/modules
|
||||||
|
</code>
|
||||||
|
.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
|
|
@ -150,27 +144,17 @@ export function AddModuleModal({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label
|
<label className="text-[15px] font-medium text-grayScale-700">
|
||||||
htmlFor="create-module-sort-order"
|
Description
|
||||||
className="text-[15px] font-medium text-grayScale-700"
|
|
||||||
>
|
|
||||||
Sort Order
|
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Textarea
|
||||||
id="create-module-sort-order"
|
value={description}
|
||||||
type="number"
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
min={0}
|
placeholder="Learn to introduce yourself and talk about your life."
|
||||||
step={1}
|
className="min-h-[88px] resize-y rounded-xl"
|
||||||
inputMode="numeric"
|
disabled={submitting}
|
||||||
value={sortOrder}
|
rows={3}
|
||||||
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>
|
</div>
|
||||||
|
|
||||||
<ModuleIconUploadField
|
<ModuleIconUploadField
|
||||||
|
|
|
||||||
|
|
@ -1,104 +0,0 @@
|
||||||
import { useMemo } from "react";
|
|
||||||
import {
|
|
||||||
PracticeSequentialReview,
|
|
||||||
type PracticeReviewQuestion,
|
|
||||||
} from "./practice-steps/PracticeSequentialReview";
|
|
||||||
import type { PersonaCardModel } from "../../../lib/personaDisplay";
|
|
||||||
|
|
||||||
type IntroVideoPreview =
|
|
||||||
| { kind: "vimeo"; url: string }
|
|
||||||
| { kind: "video"; url: string }
|
|
||||||
| null;
|
|
||||||
|
|
||||||
function plainTextFromHtml(raw: string): string {
|
|
||||||
if (!raw.trim()) return "";
|
|
||||||
if (!/<\/?[a-z][\s\S]*>/i.test(raw)) return raw.trim();
|
|
||||||
try {
|
|
||||||
const doc = new DOMParser().parseFromString(raw, "text/html");
|
|
||||||
return doc.body.textContent?.replace(/\s+/g, " ").trim() ?? "";
|
|
||||||
} catch {
|
|
||||||
return raw.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type AddNewPracticeReviewStepProps = {
|
|
||||||
practiceTitle: string;
|
|
||||||
practiceDescription: string;
|
|
||||||
selectedProgram: string;
|
|
||||||
selectedCourse: string;
|
|
||||||
moduleLabel: string;
|
|
||||||
selectedPersona: string | null;
|
|
||||||
personas: PersonaCardModel[];
|
|
||||||
introVideoPreview: IntroVideoPreview;
|
|
||||||
questions: PracticeReviewQuestion[];
|
|
||||||
saving: boolean;
|
|
||||||
saveError: string | null;
|
|
||||||
onEditContext: () => void;
|
|
||||||
onEditQuestions: () => void;
|
|
||||||
onBack: () => void;
|
|
||||||
onSaveDraft: () => void;
|
|
||||||
onPublish: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function AddNewPracticeReviewStep({
|
|
||||||
practiceTitle,
|
|
||||||
practiceDescription,
|
|
||||||
selectedProgram,
|
|
||||||
selectedCourse,
|
|
||||||
moduleLabel,
|
|
||||||
selectedPersona,
|
|
||||||
personas,
|
|
||||||
introVideoPreview,
|
|
||||||
questions,
|
|
||||||
saving,
|
|
||||||
saveError,
|
|
||||||
onEditContext,
|
|
||||||
onEditQuestions,
|
|
||||||
onBack,
|
|
||||||
onSaveDraft,
|
|
||||||
onPublish,
|
|
||||||
}: AddNewPracticeReviewStepProps) {
|
|
||||||
const persona = personas.find((p) => p.id === selectedPersona);
|
|
||||||
|
|
||||||
const guidanceText = useMemo(() => {
|
|
||||||
const fromDescription = plainTextFromHtml(practiceDescription);
|
|
||||||
if (fromDescription) return fromDescription;
|
|
||||||
const tips = questions
|
|
||||||
.map((q) => q.tips?.trim() ?? "")
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(" ");
|
|
||||||
return tips || "—";
|
|
||||||
}, [practiceDescription, questions]);
|
|
||||||
|
|
||||||
const thumbnailKind =
|
|
||||||
introVideoPreview?.kind === "video"
|
|
||||||
? "video"
|
|
||||||
: introVideoPreview?.kind === "vimeo"
|
|
||||||
? "vimeo"
|
|
||||||
: "gradient";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PracticeSequentialReview
|
|
||||||
practiceTitle={practiceTitle}
|
|
||||||
thumbnailUrl={
|
|
||||||
introVideoPreview?.kind === "video" ? introVideoPreview.url : null
|
|
||||||
}
|
|
||||||
thumbnailKind={thumbnailKind}
|
|
||||||
persona={persona ?? null}
|
|
||||||
metadata={[
|
|
||||||
{ label: "Program", value: selectedProgram },
|
|
||||||
{ label: "Course", value: selectedCourse },
|
|
||||||
{ label: "Module", value: moduleLabel },
|
|
||||||
]}
|
|
||||||
guidanceText={guidanceText}
|
|
||||||
questions={questions}
|
|
||||||
saving={saving}
|
|
||||||
saveError={saveError}
|
|
||||||
onEditContext={onEditContext}
|
|
||||||
onEditQuestions={onEditQuestions}
|
|
||||||
onBack={onBack}
|
|
||||||
onSaveDraft={onSaveDraft}
|
|
||||||
onPublish={onPublish}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user