diff --git a/e2e/login.spec.ts b/e2e/login.spec.ts
new file mode 100644
index 0000000..3be2f7c
--- /dev/null
+++ b/e2e/login.spec.ts
@@ -0,0 +1,27 @@
+import { expect, test } from "@playwright/test";
+
+/**
+ * Smoke test: the app loads, serves the login page, and the main form
+ * controls are present and reachable. We deliberately avoid hitting the
+ * real backend — this is a lightweight sanity check that catches broken
+ * routing / bundle / asset problems before they reach production.
+ */
+test("login page renders the sign-in form", async ({ page }) => {
+ await page.goto("/login");
+ await expect(
+ page.getByRole("heading", { name: /sign in|login|welcome/i }),
+ ).toBeVisible({ timeout: 10_000 });
+
+ const email = page.getByLabel(/email/i);
+ const password = page.getByLabel(/password/i);
+ await expect(email).toBeVisible();
+ await expect(password).toBeVisible();
+
+ await expect(page.getByRole("button", { name: /sign in|log in/i })).toBeVisible();
+});
+
+test("root redirects to login for anonymous users", async ({ page }) => {
+ const response = await page.goto("/");
+ expect(response?.ok()).toBeTruthy();
+ await page.waitForURL(/\/login/i, { timeout: 10_000 });
+});
diff --git a/package-lock.json b/package-lock.json
index f5e02ef..497f4fa 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -42,6 +42,8 @@
"cmdk": "^1.1.1",
"date-fns": "^3.6.0",
"embla-carousel-react": "^8.6.0",
+ "i18next": "^26.0.6",
+ "i18next-browser-languagedetector": "^8.2.1",
"input-otp": "^1.4.2",
"lucide-react": "^0.462.0",
"next-themes": "^0.3.0",
@@ -49,6 +51,7 @@
"react-day-picker": "^8.10.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.61.1",
+ "react-i18next": "^17.0.4",
"react-resizable-panels": "^2.1.9",
"react-router-dom": "^6.30.1",
"recharts": "^2.15.4",
@@ -60,6 +63,7 @@
},
"devDependencies": {
"@eslint/js": "^9.32.0",
+ "@playwright/test": "^1.59.1",
"@tailwindcss/typography": "^0.5.16",
"@testing-library/jest-dom": "^6.6.0",
"@testing-library/react": "^16.0.0",
@@ -905,6 +909,22 @@
"node": ">= 8"
}
},
+ "node_modules/@playwright/test": {
+ "version": "1.59.1",
+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz",
+ "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright": "1.59.1"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/@radix-ui/number": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
@@ -5480,6 +5500,15 @@
"node": ">=12"
}
},
+ "node_modules/html-parse-stringify": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
+ "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
+ "license": "MIT",
+ "dependencies": {
+ "void-elements": "3.1.0"
+ }
+ },
"node_modules/http-proxy-agent": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz",
@@ -5509,6 +5538,46 @@
"node": ">= 6"
}
},
+ "node_modules/i18next": {
+ "version": "26.0.6",
+ "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.0.6.tgz",
+ "integrity": "sha512-A4U6eCXodIbrhf8EarRurB9/4ebyaurH4+fu4gig9bqxmpSt+fCAFm/GpRQDcN1Xzu/LdFCx4nYHsnM1edIIbg==",
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://www.locize.com/i18next"
+ },
+ {
+ "type": "individual",
+ "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
+ },
+ {
+ "type": "individual",
+ "url": "https://www.locize.com"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.29.2"
+ },
+ "peerDependencies": {
+ "typescript": "^5 || ^6"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/i18next-browser-languagedetector": {
+ "version": "8.2.1",
+ "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.1.tgz",
+ "integrity": "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.23.2"
+ }
+ },
"node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
@@ -6215,6 +6284,53 @@
"node": ">= 6"
}
},
+ "node_modules/playwright": {
+ "version": "1.59.1",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
+ "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.59.1"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.2"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.59.1",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
+ "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/playwright/node_modules/fsevents": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
"node_modules/postcss": {
"version": "8.5.9",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz",
@@ -6547,6 +6663,33 @@
"react": "^16.8.0 || ^17 || ^18 || ^19"
}
},
+ "node_modules/react-i18next": {
+ "version": "17.0.4",
+ "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.4.tgz",
+ "integrity": "sha512-hQipmK4EF0y6RO6tt6WuqnmWpWYEXmQUUzecmMBuNsIgYd3smXcG4GtYPWhvgxn0pqMOItKlEO8H24HCs5hc3g==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.29.2",
+ "html-parse-stringify": "^3.0.1",
+ "use-sync-external-store": "^1.6.0"
+ },
+ "peerDependencies": {
+ "i18next": ">= 26.0.1",
+ "react": ">= 16.8.0",
+ "typescript": "^5 || ^6"
+ },
+ "peerDependenciesMeta": {
+ "react-dom": {
+ "optional": true
+ },
+ "react-native": {
+ "optional": true
+ },
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
"node_modules/react-is": {
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
@@ -7369,7 +7512,7 @@
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
- "dev": true,
+ "devOptional": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
@@ -8164,6 +8307,15 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
+ "node_modules/void-elements": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
+ "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/w3c-xmlserializer": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz",
diff --git a/package.json b/package.json
index 04b2365..230daf3 100644
--- a/package.json
+++ b/package.json
@@ -10,7 +10,9 @@
"lint": "eslint .",
"preview": "vite preview",
"test": "vitest run",
- "test:watch": "vitest"
+ "test:watch": "vitest",
+ "test:e2e": "playwright test",
+ "test:e2e:install": "playwright install --with-deps chromium"
},
"dependencies": {
"@hookform/resolvers": "^3.10.0",
@@ -47,6 +49,8 @@
"cmdk": "^1.1.1",
"date-fns": "^3.6.0",
"embla-carousel-react": "^8.6.0",
+ "i18next": "^26.0.6",
+ "i18next-browser-languagedetector": "^8.2.1",
"input-otp": "^1.4.2",
"lucide-react": "^0.462.0",
"next-themes": "^0.3.0",
@@ -54,6 +58,7 @@
"react-day-picker": "^8.10.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.61.1",
+ "react-i18next": "^17.0.4",
"react-resizable-panels": "^2.1.9",
"react-router-dom": "^6.30.1",
"recharts": "^2.15.4",
@@ -65,9 +70,10 @@
},
"devDependencies": {
"@eslint/js": "^9.32.0",
+ "@playwright/test": "^1.59.1",
+ "@tailwindcss/typography": "^0.5.16",
"@testing-library/jest-dom": "^6.6.0",
"@testing-library/react": "^16.0.0",
- "@tailwindcss/typography": "^0.5.16",
"@types/node": "^22.16.5",
"@types/react": "^18.3.23",
"@types/react-dom": "^18.3.7",
diff --git a/playwright.config.ts b/playwright.config.ts
new file mode 100644
index 0000000..66b3b3a
--- /dev/null
+++ b/playwright.config.ts
@@ -0,0 +1,42 @@
+import { defineConfig, devices } from "@playwright/test";
+
+/**
+ * Playwright configuration for EnCoach frontend smoke tests.
+ *
+ * By default we run against a Vite preview server on localhost:4173. In CI
+ * this is started by the `playwright` step in .github/workflows/ci.yml after
+ * `npm run build`.
+ *
+ * Override the base URL with `PLAYWRIGHT_BASE_URL=https://... npx playwright
+ * test` to run against a deployed environment.
+ */
+const baseURL = process.env.PLAYWRIGHT_BASE_URL || "http://localhost:4173";
+
+export default defineConfig({
+ testDir: "./e2e",
+ timeout: 30_000,
+ expect: { timeout: 5_000 },
+ fullyParallel: true,
+ forbidOnly: !!process.env.CI,
+ retries: process.env.CI ? 2 : 0,
+ reporter: process.env.CI ? [["github"], ["list"]] : [["list"]],
+ use: {
+ baseURL,
+ trace: "on-first-retry",
+ screenshot: "only-on-failure",
+ },
+ webServer: process.env.PLAYWRIGHT_SKIP_WEBSERVER
+ ? undefined
+ : {
+ command: "npm run preview -- --host 127.0.0.1 --port 4173",
+ url: baseURL,
+ reuseExistingServer: !process.env.CI,
+ timeout: 60_000,
+ },
+ projects: [
+ {
+ name: "chromium",
+ use: { ...devices["Desktop Chrome"] },
+ },
+ ],
+});
diff --git a/public/logo.svg b/public/logo.svg
new file mode 100644
index 0000000..d832f1d
--- /dev/null
+++ b/public/logo.svg
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/App.tsx b/src/App.tsx
index 70e3c3d..148daa8 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,3 +1,5 @@
+import { lazy, Suspense } from "react";
+import { ThemeProvider } from "next-themes";
import { Toaster } from "@/components/ui/toaster";
import { Toaster as Sonner } from "@/components/ui/sonner";
import { TooltipProvider } from "@/components/ui/tooltip";
@@ -8,146 +10,166 @@ import ProtectedRoute from "@/components/ProtectedRoute";
import StudentLayout from "@/components/StudentLayout";
import TeacherLayout from "@/components/TeacherLayout";
import AdminLmsLayout from "@/components/AdminLmsLayout";
-import Login from "@/pages/Login";
-import Register from "@/pages/Register";
-import EmailVerification from "@/pages/EmailVerification";
-import OnboardingWizard from "@/pages/OnboardingWizard";
-import ForgotPassword from "@/pages/ForgotPassword";
-import ResetPassword from "@/pages/ResetPassword";
-import ScoreVerification from "@/pages/ScoreVerification";
-// Original platform pages
-import AdminDashboard from "@/pages/AdminDashboard";
-import UsersPage from "@/pages/UsersPage";
-import EntitiesPage from "@/pages/EntitiesPage";
-import AssignmentsPage from "@/pages/AssignmentsPage";
-import ExamsListPage from "@/pages/ExamsListPage";
-import ExamStructuresPage from "@/pages/ExamStructuresPage";
-import RubricsPage from "@/pages/RubricsPage";
-import GenerationPage from "@/pages/GenerationPage";
-import ApprovalWorkflowsPage from "@/pages/ApprovalWorkflowsPage";
-import ClassroomsPage from "@/pages/ClassroomsPage";
-import StudentPerformancePage from "@/pages/StudentPerformancePage";
-import StatsCorporatePage from "@/pages/StatsCorporatePage";
-import RecordPage from "@/pages/RecordPage";
-import VocabularyPage from "@/pages/VocabularyPage";
-import GrammarPage from "@/pages/GrammarPage";
-import PaymentRecordPage from "@/pages/PaymentRecordPage";
-import TicketsPage from "@/pages/TicketsPage";
-import SettingsPage from "@/pages/SettingsPage";
-import ProfilePage from "@/pages/ProfilePage";
-import ExamPage from "@/pages/ExamPage";
-// Student pages
-import StudentDashboard from "@/pages/student/StudentDashboard";
-import StudentCourses from "@/pages/student/StudentCourses";
-import StudentCourseDetail from "@/pages/student/StudentCourseDetail";
-import StudentAssignments from "@/pages/student/StudentAssignments";
-import StudentGrades from "@/pages/student/StudentGrades";
-import StudentAttendance from "@/pages/student/StudentAttendance";
-import StudentTimetable from "@/pages/student/StudentTimetable";
-import StudentProfile from "@/pages/student/StudentProfile";
-// Adaptive learning pages
-import SubjectSelection from "@/pages/student/SubjectSelection";
-import DiagnosticTest from "@/pages/student/DiagnosticTest";
-import ProficiencyProfile from "@/pages/student/ProficiencyProfile";
-import LearningPlanPage from "@/pages/student/LearningPlan";
-import TopicLearning from "@/pages/student/TopicLearning";
-// Teacher pages
-import TeacherDashboard from "@/pages/teacher/TeacherDashboard";
-import TeacherCourses from "@/pages/teacher/TeacherCourses";
-import CourseBuilder from "@/pages/teacher/CourseBuilder";
-import TeacherAssignments from "@/pages/teacher/TeacherAssignments";
-import TeacherAssignmentDetail from "@/pages/teacher/TeacherAssignmentDetail";
-import TeacherAttendance from "@/pages/teacher/TeacherAttendance";
-import TeacherStudents from "@/pages/teacher/TeacherStudents";
-import TeacherTimetable from "@/pages/teacher/TeacherTimetable";
-import TeacherProfile from "@/pages/teacher/TeacherProfile";
-import TeacherLibrary from "@/pages/teacher/TeacherLibrary";
-import AdaptiveSettings from "@/pages/teacher/AdaptiveSettings";
-// Admin LMS pages
-import AdminLmsDashboard from "@/pages/admin/AdminLmsDashboard";
-import AdminCourses from "@/pages/admin/AdminCourses";
-import AdminStudents from "@/pages/admin/AdminStudents";
-import AdminTeachers from "@/pages/admin/AdminTeachers";
-import AdminBatches from "@/pages/admin/AdminBatches";
-import AdminBatchDetail from "@/pages/admin/AdminBatchDetail";
-import AdminTimetable from "@/pages/admin/AdminTimetable";
-import AdminReports from "@/pages/admin/AdminReports";
-import AdminSettings from "@/pages/admin/AdminSettings";
-import AdminProfileLms from "@/pages/admin/AdminProfile";
-import TaxonomyManager from "@/pages/admin/TaxonomyManager";
-import ResourceManager from "@/pages/admin/ResourceManager";
-import AcademicYearManager from "@/pages/admin/AcademicYearManager";
-import DepartmentManager from "@/pages/admin/DepartmentManager";
-import AdmissionList from "@/pages/admin/AdmissionList";
-import AdmissionDetail from "@/pages/admin/AdmissionDetail";
-import AdmissionRegisterPage from "@/pages/admin/AdmissionRegisterPage";
-import InstitutionalExamSessions from "@/pages/admin/InstitutionalExamSessions";
-import MarksheetManager from "@/pages/admin/MarksheetManager";
-import AdminStudentLeave from "@/pages/admin/AdminStudentLeave";
-import AdminFees from "@/pages/admin/AdminFees";
-import AdminLessons from "@/pages/admin/AdminLessons";
-import AdminGradebook from "@/pages/admin/AdminGradebook";
-import AdminStudentProgress from "@/pages/admin/AdminStudentProgress";
-import AdminLibrary from "@/pages/admin/AdminLibrary";
-import AdminActivities from "@/pages/admin/AdminActivities";
-import AdminFacilities from "@/pages/admin/AdminFacilities";
-import AdmissionApplication from "@/pages/AdmissionApplication";
-import SubjectRegistrationPage from "@/pages/student/SubjectRegistrationPage";
-// Courseware pages
-import CourseChapters from "@/pages/teacher/CourseChapters";
-import ChapterDetail from "@/pages/teacher/ChapterDetail";
-import AiWorkbench from "@/pages/teacher/AiWorkbench";
-import TeacherDiscussionBoard from "@/pages/teacher/TeacherDiscussionBoard";
-import TeacherAnnouncements from "@/pages/teacher/TeacherAnnouncements";
-import StudentChapterView from "@/pages/student/StudentChapterView";
-import StudentDiscussionBoard from "@/pages/student/StudentDiscussionBoard";
-import StudentAnnouncements from "@/pages/student/StudentAnnouncements";
-import StudentMessages from "@/pages/student/StudentMessages";
-import StudentJourney from "@/pages/student/StudentJourney";
-import AiEnglishCourse from "@/pages/student/AiEnglishCourse";
-import AiIeltsCourse from "@/pages/student/AiIeltsCourse";
-import ExamSession from "@/pages/student/ExamSession";
-import ExamStatus from "@/pages/student/ExamStatus";
-import ExamResults from "@/pages/student/ExamResults";
-import GapAnalysis from "@/pages/student/GapAnalysis";
-import CourseDelivery from "@/pages/student/CourseDelivery";
-import GradingQueue from "@/pages/admin/GradingQueue";
-import CourseConfig from "@/pages/admin/CourseConfig";
-import ModuleBuilder from "@/pages/admin/ModuleBuilder";
-import CourseProgress from "@/pages/teacher/CourseProgress";
-import PlacementBriefing from "@/pages/student/PlacementBriefing";
-import PlacementTest from "@/pages/student/PlacementTest";
-import PlacementResults from "@/pages/student/PlacementResults";
-import PlacementAccess from "@/pages/student/PlacementAccess";
-import FaqManager from "@/pages/admin/FaqManager";
-import NotificationRules from "@/pages/admin/NotificationRules";
-import ApprovalWorkflowConfig from "@/pages/admin/ApprovalWorkflowConfig";
-import RolesPermissions from "@/pages/admin/RolesPermissions";
-import AuthorityMatrix from "@/pages/admin/AuthorityMatrix";
-import UserRoles from "@/pages/admin/UserRoles";
-import BulkStudentUpload from "@/pages/admin/BulkStudentUpload";
-import CredentialDashboard from "@/pages/admin/CredentialDashboard";
-import AiEnglishQuality from "@/pages/admin/AiEnglishQuality";
-import AiEnglishTaxonomy from "@/pages/admin/AiEnglishTaxonomy";
-import AiIeltsValidation from "@/pages/admin/AiIeltsValidation";
-import AdaptiveDashboard from "@/pages/admin/AdaptiveDashboard";
-import AdaptiveStudentDetail from "@/pages/admin/AdaptiveStudentDetail";
-import LevelMappingConfig from "@/pages/admin/LevelMappingConfig";
-import WhiteLabelBranding from "@/pages/admin/WhiteLabelBranding";
-import ScoreApprovalQueue from "@/pages/admin/ScoreApprovalQueue";
-import ExamTemplateSelection from "@/pages/admin/ExamTemplateSelection";
-import IeltsExamCreate from "@/pages/admin/IeltsExamCreate";
-import IeltsSkillConfig from "@/pages/admin/IeltsSkillConfig";
-import IeltsContentPool from "@/pages/admin/IeltsContentPool";
-import IeltsExamValidation from "@/pages/admin/IeltsExamValidation";
-import CustomExamCreate from "@/pages/admin/CustomExamCreate";
-import OfficialExamAccess from "@/pages/OfficialExamAccess";
-import FaqPage from "@/pages/FaqPage";
-import NotFound from "@/pages/NotFound";
import { queryClient } from "@/lib/query-client";
import { Button } from "@/components/ui/button";
import { ErrorBoundary } from "@/components/ErrorBoundary";
+// -----------------------------------------------------------------------------
+// Lazy-loaded route pages
+// -----------------------------------------------------------------------------
+// Keeping the initial bundle small matters on slow networks / modest hardware
+// (many teachers/students open the app from school Wi-Fi). Each page below is
+// split into its own chunk and only fetched when the user hits the matching
+// route. The shell (layouts, providers, router) is still eagerly imported so
+// the first paint stays snappy.
+
+// Auth
+const Login = lazy(() => import("@/pages/Login"));
+const Register = lazy(() => import("@/pages/Register"));
+const EmailVerification = lazy(() => import("@/pages/EmailVerification"));
+const OnboardingWizard = lazy(() => import("@/pages/OnboardingWizard"));
+const ForgotPassword = lazy(() => import("@/pages/ForgotPassword"));
+const ResetPassword = lazy(() => import("@/pages/ResetPassword"));
+const ScoreVerification = lazy(() => import("@/pages/ScoreVerification"));
+
+// Original platform pages
+const AdminDashboard = lazy(() => import("@/pages/AdminDashboard"));
+const UsersPage = lazy(() => import("@/pages/UsersPage"));
+const EntitiesPage = lazy(() => import("@/pages/EntitiesPage"));
+const AssignmentsPage = lazy(() => import("@/pages/AssignmentsPage"));
+const ExamsListPage = lazy(() => import("@/pages/ExamsListPage"));
+const ExamStructuresPage = lazy(() => import("@/pages/ExamStructuresPage"));
+const RubricsPage = lazy(() => import("@/pages/RubricsPage"));
+const GenerationPage = lazy(() => import("@/pages/GenerationPage"));
+const ApprovalWorkflowsPage = lazy(() => import("@/pages/ApprovalWorkflowsPage"));
+const ClassroomsPage = lazy(() => import("@/pages/ClassroomsPage"));
+const StudentPerformancePage = lazy(() => import("@/pages/StudentPerformancePage"));
+const StatsCorporatePage = lazy(() => import("@/pages/StatsCorporatePage"));
+const RecordPage = lazy(() => import("@/pages/RecordPage"));
+const VocabularyPage = lazy(() => import("@/pages/VocabularyPage"));
+const GrammarPage = lazy(() => import("@/pages/GrammarPage"));
+const PaymentRecordPage = lazy(() => import("@/pages/PaymentRecordPage"));
+const TicketsPage = lazy(() => import("@/pages/TicketsPage"));
+const SettingsPage = lazy(() => import("@/pages/SettingsPage"));
+
+// Student pages
+const StudentDashboard = lazy(() => import("@/pages/student/StudentDashboard"));
+const StudentCourses = lazy(() => import("@/pages/student/StudentCourses"));
+const StudentCourseDetail = lazy(() => import("@/pages/student/StudentCourseDetail"));
+const StudentAssignments = lazy(() => import("@/pages/student/StudentAssignments"));
+const StudentGrades = lazy(() => import("@/pages/student/StudentGrades"));
+const StudentAttendance = lazy(() => import("@/pages/student/StudentAttendance"));
+const StudentTimetable = lazy(() => import("@/pages/student/StudentTimetable"));
+const StudentProfile = lazy(() => import("@/pages/student/StudentProfile"));
+
+// Adaptive learning pages
+const SubjectSelection = lazy(() => import("@/pages/student/SubjectSelection"));
+const DiagnosticTest = lazy(() => import("@/pages/student/DiagnosticTest"));
+const ProficiencyProfile = lazy(() => import("@/pages/student/ProficiencyProfile"));
+const LearningPlanPage = lazy(() => import("@/pages/student/LearningPlan"));
+const TopicLearning = lazy(() => import("@/pages/student/TopicLearning"));
+
+// Teacher pages
+const TeacherDashboard = lazy(() => import("@/pages/teacher/TeacherDashboard"));
+const TeacherCourses = lazy(() => import("@/pages/teacher/TeacherCourses"));
+const CourseBuilder = lazy(() => import("@/pages/teacher/CourseBuilder"));
+const TeacherAssignments = lazy(() => import("@/pages/teacher/TeacherAssignments"));
+const TeacherAssignmentDetail = lazy(() => import("@/pages/teacher/TeacherAssignmentDetail"));
+const TeacherAttendance = lazy(() => import("@/pages/teacher/TeacherAttendance"));
+const TeacherStudents = lazy(() => import("@/pages/teacher/TeacherStudents"));
+const TeacherTimetable = lazy(() => import("@/pages/teacher/TeacherTimetable"));
+const TeacherProfile = lazy(() => import("@/pages/teacher/TeacherProfile"));
+const TeacherLibrary = lazy(() => import("@/pages/teacher/TeacherLibrary"));
+const AdaptiveSettings = lazy(() => import("@/pages/teacher/AdaptiveSettings"));
+
+// Admin LMS pages
+const AdminLmsDashboard = lazy(() => import("@/pages/admin/AdminLmsDashboard"));
+const AdminCourses = lazy(() => import("@/pages/admin/AdminCourses"));
+const AdminStudents = lazy(() => import("@/pages/admin/AdminStudents"));
+const AdminTeachers = lazy(() => import("@/pages/admin/AdminTeachers"));
+const AdminBatches = lazy(() => import("@/pages/admin/AdminBatches"));
+const AdminBatchDetail = lazy(() => import("@/pages/admin/AdminBatchDetail"));
+const AdminTimetable = lazy(() => import("@/pages/admin/AdminTimetable"));
+const AdminReports = lazy(() => import("@/pages/admin/AdminReports"));
+const AdminSettings = lazy(() => import("@/pages/admin/AdminSettings"));
+const AdminProfileLms = lazy(() => import("@/pages/admin/AdminProfile"));
+const TaxonomyManager = lazy(() => import("@/pages/admin/TaxonomyManager"));
+const ResourceManager = lazy(() => import("@/pages/admin/ResourceManager"));
+const AcademicYearManager = lazy(() => import("@/pages/admin/AcademicYearManager"));
+const DepartmentManager = lazy(() => import("@/pages/admin/DepartmentManager"));
+const AdmissionList = lazy(() => import("@/pages/admin/AdmissionList"));
+const AdmissionDetail = lazy(() => import("@/pages/admin/AdmissionDetail"));
+const AdmissionRegisterPage = lazy(() => import("@/pages/admin/AdmissionRegisterPage"));
+const InstitutionalExamSessions = lazy(() => import("@/pages/admin/InstitutionalExamSessions"));
+const MarksheetManager = lazy(() => import("@/pages/admin/MarksheetManager"));
+const AdminStudentLeave = lazy(() => import("@/pages/admin/AdminStudentLeave"));
+const AdminFees = lazy(() => import("@/pages/admin/AdminFees"));
+const AdminLessons = lazy(() => import("@/pages/admin/AdminLessons"));
+const AdminGradebook = lazy(() => import("@/pages/admin/AdminGradebook"));
+const AdminStudentProgress = lazy(() => import("@/pages/admin/AdminStudentProgress"));
+const AdminLibrary = lazy(() => import("@/pages/admin/AdminLibrary"));
+const AdminActivities = lazy(() => import("@/pages/admin/AdminActivities"));
+const AdminFacilities = lazy(() => import("@/pages/admin/AdminFacilities"));
+const AdmissionApplication = lazy(() => import("@/pages/AdmissionApplication"));
+const SubjectRegistrationPage = lazy(() => import("@/pages/student/SubjectRegistrationPage"));
+
+// Courseware pages
+const CourseChapters = lazy(() => import("@/pages/teacher/CourseChapters"));
+const ChapterDetail = lazy(() => import("@/pages/teacher/ChapterDetail"));
+const AiWorkbench = lazy(() => import("@/pages/teacher/AiWorkbench"));
+const TeacherDiscussionBoard = lazy(() => import("@/pages/teacher/TeacherDiscussionBoard"));
+const TeacherAnnouncements = lazy(() => import("@/pages/teacher/TeacherAnnouncements"));
+const StudentChapterView = lazy(() => import("@/pages/student/StudentChapterView"));
+const StudentDiscussionBoard = lazy(() => import("@/pages/student/StudentDiscussionBoard"));
+const StudentAnnouncements = lazy(() => import("@/pages/student/StudentAnnouncements"));
+const StudentMessages = lazy(() => import("@/pages/student/StudentMessages"));
+const StudentJourney = lazy(() => import("@/pages/student/StudentJourney"));
+const AiEnglishCourse = lazy(() => import("@/pages/student/AiEnglishCourse"));
+const AiIeltsCourse = lazy(() => import("@/pages/student/AiIeltsCourse"));
+const ExamSession = lazy(() => import("@/pages/student/ExamSession"));
+const ExamStatus = lazy(() => import("@/pages/student/ExamStatus"));
+const ExamResults = lazy(() => import("@/pages/student/ExamResults"));
+const GapAnalysis = lazy(() => import("@/pages/student/GapAnalysis"));
+const CourseDelivery = lazy(() => import("@/pages/student/CourseDelivery"));
+const GradingQueue = lazy(() => import("@/pages/admin/GradingQueue"));
+const CourseConfig = lazy(() => import("@/pages/admin/CourseConfig"));
+const ModuleBuilder = lazy(() => import("@/pages/admin/ModuleBuilder"));
+const CourseProgress = lazy(() => import("@/pages/teacher/CourseProgress"));
+const PlacementBriefing = lazy(() => import("@/pages/student/PlacementBriefing"));
+const PlacementTest = lazy(() => import("@/pages/student/PlacementTest"));
+const PlacementResults = lazy(() => import("@/pages/student/PlacementResults"));
+const PlacementAccess = lazy(() => import("@/pages/student/PlacementAccess"));
+const FaqManager = lazy(() => import("@/pages/admin/FaqManager"));
+const NotificationRules = lazy(() => import("@/pages/admin/NotificationRules"));
+const ApprovalWorkflowConfig = lazy(() => import("@/pages/admin/ApprovalWorkflowConfig"));
+const RolesPermissions = lazy(() => import("@/pages/admin/RolesPermissions"));
+const AuthorityMatrix = lazy(() => import("@/pages/admin/AuthorityMatrix"));
+const UserRoles = lazy(() => import("@/pages/admin/UserRoles"));
+const BulkStudentUpload = lazy(() => import("@/pages/admin/BulkStudentUpload"));
+const CredentialDashboard = lazy(() => import("@/pages/admin/CredentialDashboard"));
+const AiEnglishQuality = lazy(() => import("@/pages/admin/AiEnglishQuality"));
+const AiEnglishTaxonomy = lazy(() => import("@/pages/admin/AiEnglishTaxonomy"));
+const AiIeltsValidation = lazy(() => import("@/pages/admin/AiIeltsValidation"));
+const AdaptiveDashboard = lazy(() => import("@/pages/admin/AdaptiveDashboard"));
+const AdaptiveStudentDetail = lazy(() => import("@/pages/admin/AdaptiveStudentDetail"));
+const LevelMappingConfig = lazy(() => import("@/pages/admin/LevelMappingConfig"));
+const WhiteLabelBranding = lazy(() => import("@/pages/admin/WhiteLabelBranding"));
+const ScoreApprovalQueue = lazy(() => import("@/pages/admin/ScoreApprovalQueue"));
+const ExamTemplateSelection = lazy(() => import("@/pages/admin/ExamTemplateSelection"));
+const IeltsExamCreate = lazy(() => import("@/pages/admin/IeltsExamCreate"));
+const IeltsSkillConfig = lazy(() => import("@/pages/admin/IeltsSkillConfig"));
+const IeltsContentPool = lazy(() => import("@/pages/admin/IeltsContentPool"));
+const IeltsExamValidation = lazy(() => import("@/pages/admin/IeltsExamValidation"));
+const CustomExamCreate = lazy(() => import("@/pages/admin/CustomExamCreate"));
+const ExamReviewQueue = lazy(() => import("@/pages/admin/ExamReviewQueue"));
+const ExamReviewDetail = lazy(() => import("@/pages/admin/ExamReviewDetail"));
+const AIPromptEditor = lazy(() => import("@/pages/admin/AIPromptEditor"));
+const AIFeedbackTriage = lazy(() => import("@/pages/admin/AIFeedbackTriage"));
+const PrivacyCenter = lazy(() => import("@/pages/PrivacyCenter"));
+const OfficialExamAccess = lazy(() => import("@/pages/OfficialExamAccess"));
+const FaqPage = lazy(() => import("@/pages/FaqPage"));
+const NotFound = lazy(() => import("@/pages/NotFound"));
+
function StudentSubscriptionPlaceholder() {
const navigate = useNavigate();
return (
@@ -158,14 +180,30 @@ function StudentSubscriptionPlaceholder() {
);
}
+function RouteFallback() {
+ return (
+
+ );
+}
+
const App = () => (
+
+ }>
{/* Auth routes */}
} />
@@ -200,6 +238,7 @@ const App = () => (
} />
} />
} />
+ } />
} />
} />
} />
@@ -241,6 +280,7 @@ const App = () => (
} />
} />
} />
+ } />
} />
} />
@@ -263,6 +303,7 @@ const App = () => (
} />
} />
} />
+ } />
{/* Original academic pages */}
} />
} />
@@ -285,13 +326,16 @@ const App = () => (
} />
} />
} />
- } />
} />
} />
} />
} />
} />
} />
+ } />
+ } />
+ } />
+ } />
} />
} />
{/* Institutional LMS pages */}
@@ -340,10 +384,12 @@ const App = () => (
} />
} />
+
+
);
diff --git a/src/components/AIFeedbackButtons.tsx b/src/components/AIFeedbackButtons.tsx
new file mode 100644
index 0000000..b96e851
--- /dev/null
+++ b/src/components/AIFeedbackButtons.tsx
@@ -0,0 +1,164 @@
+import { useEffect, useState } from "react";
+import { ThumbsDown, ThumbsUp } from "lucide-react";
+import { toast } from "sonner";
+
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Textarea } from "@/components/ui/textarea";
+import {
+ useAIFeedbackSummary,
+ useSubmitAIFeedback,
+} from "@/hooks/queries/useAIFeedback";
+import { cn } from "@/lib/utils";
+import type {
+ AIFeedbackRating,
+ AIFeedbackSubjectType,
+} from "@/types/ai-feedback";
+
+export interface AIFeedbackButtonsProps {
+ subjectType: AIFeedbackSubjectType;
+ subjectId: number;
+ promptKey?: string;
+ promptVersion?: number;
+ aiLogId?: number;
+ entityId?: number;
+ courseId?: number;
+ size?: "sm" | "md";
+ className?: string;
+}
+
+/** Thumbs up/down widget for any AI-generated artefact.
+ *
+ * Always renders the same UI regardless of whether the user has rated the
+ * artefact before — a previous rating will show as highlighted. Clicking the
+ * same button again is a no-op; clicking the opposite button updates the
+ * server-side row in place. A thumbs-down always prompts the user for a short
+ * comment (required by the backend).
+ */
+export function AIFeedbackButtons({
+ subjectType,
+ subjectId,
+ promptKey,
+ promptVersion,
+ aiLogId,
+ entityId,
+ courseId,
+ size = "sm",
+ className,
+}: AIFeedbackButtonsProps) {
+ const summary = useAIFeedbackSummary(subjectType, subjectId);
+ const submit = useSubmitAIFeedback();
+
+ const [downOpen, setDownOpen] = useState(false);
+ const [comment, setComment] = useState("");
+
+ useEffect(() => {
+ if (downOpen) {
+ setComment(summary.data?.my_comment ?? "");
+ }
+ }, [downOpen, summary.data?.my_comment]);
+
+ const myRating: AIFeedbackRating | null = summary.data?.my_rating ?? null;
+
+ const submitUp = async () => {
+ if (myRating === "up") return;
+ try {
+ await submit.mutateAsync({
+ subject_type: subjectType,
+ subject_id: subjectId,
+ rating: "up",
+ prompt_key: promptKey,
+ prompt_version: promptVersion,
+ ai_log_id: aiLogId,
+ entity_id: entityId,
+ course_id: courseId,
+ });
+ } catch (err) {
+ toast.error(err instanceof Error ? err.message : "Failed to submit");
+ }
+ };
+
+ const submitDown = async () => {
+ if (!comment.trim()) {
+ toast.error("Please tell us what was wrong.");
+ return;
+ }
+ try {
+ await submit.mutateAsync({
+ subject_type: subjectType,
+ subject_id: subjectId,
+ rating: "down",
+ comment: comment.trim(),
+ prompt_key: promptKey,
+ prompt_version: promptVersion,
+ ai_log_id: aiLogId,
+ entity_id: entityId,
+ course_id: courseId,
+ });
+ setDownOpen(false);
+ } catch (err) {
+ toast.error(err instanceof Error ? err.message : "Failed to submit");
+ }
+ };
+
+ const btnSize = size === "sm" ? "sm" : "default";
+ const iconCls = size === "sm" ? "h-3.5 w-3.5" : "h-4 w-4";
+
+ return (
+
+
+
+ {summary.data?.up ?? 0}
+
+ setDownOpen(true)}
+ disabled={submit.isPending}
+ aria-label="Thumbs down"
+ >
+
+ {summary.data?.down ?? 0}
+
+
+
+
+
+ What went wrong?
+
+
+
+
+ );
+}
+
+export default AIFeedbackButtons;
diff --git a/src/components/LanguageToggle.tsx b/src/components/LanguageToggle.tsx
new file mode 100644
index 0000000..ae6d1f5
--- /dev/null
+++ b/src/components/LanguageToggle.tsx
@@ -0,0 +1,43 @@
+import { Languages } from "lucide-react";
+import { useTranslation } from "react-i18next";
+
+import { Button } from "@/components/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuCheckboxItem,
+ DropdownMenuContent,
+ DropdownMenuLabel,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { SUPPORTED_LANGS, type SupportedLang } from "@/i18n";
+
+export function LanguageToggle() {
+ const { i18n } = useTranslation();
+ const current = (i18n.language?.split("-")[0] || "en") as SupportedLang;
+
+ return (
+
+
+
+
+
+
+
+ Language
+ {SUPPORTED_LANGS.map((lang) => (
+ {
+ if (checked) void i18n.changeLanguage(lang.code);
+ }}
+ >
+ {lang.label}
+
+ ))}
+
+
+ );
+}
+
+export default LanguageToggle;
diff --git a/src/components/ThemeToggle.tsx b/src/components/ThemeToggle.tsx
new file mode 100644
index 0000000..3569fed
--- /dev/null
+++ b/src/components/ThemeToggle.tsx
@@ -0,0 +1,68 @@
+import { Moon, Sun, Monitor } from "lucide-react";
+import { useTheme } from "next-themes";
+import { useEffect, useState } from "react";
+
+import { Button } from "@/components/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+
+/**
+ * Light/dark/system theme toggle.
+ *
+ * We intentionally render a neutral placeholder until `next-themes` has
+ * hydrated — otherwise the first client render disagrees with the server-less
+ * pre-render and the icon briefly flashes the wrong state.
+ */
+export function ThemeToggle() {
+ const { theme, setTheme, resolvedTheme } = useTheme();
+ const [mounted, setMounted] = useState(false);
+
+ useEffect(() => {
+ setMounted(true);
+ }, []);
+
+ const isDark = mounted && (resolvedTheme ?? theme) === "dark";
+
+ return (
+
+
+
+ {mounted ? (
+ isDark ? (
+
+ ) : (
+
+ )
+ ) : (
+
+ )}
+
+
+
+ setTheme("light")}>
+ Light
+ {theme === "light" && ✓ }
+
+ setTheme("dark")}>
+ Dark
+ {theme === "dark" && ✓ }
+
+ setTheme("system")}>
+ System
+ {theme === "system" && ✓ }
+
+
+
+ );
+}
+
+export default ThemeToggle;
diff --git a/src/components/ui/alert-dialog.tsx b/src/components/ui/alert-dialog.tsx
index 6dfbfb4..409537a 100644
--- a/src/components/ui/alert-dialog.tsx
+++ b/src/components/ui/alert-dialog.tsx
@@ -25,22 +25,72 @@ const AlertDialogOverlay = React.forwardRef<
));
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
+function hasAlertDialogDescription(children: React.ReactNode): boolean {
+ let found = false;
+ React.Children.forEach(children, (child) => {
+ if (found || !React.isValidElement(child)) return;
+ const type = child.type as { displayName?: string } | undefined;
+ if (
+ type === AlertDialogPrimitive.Description ||
+ (type && type.displayName === AlertDialogPrimitive.Description.displayName)
+ ) {
+ found = true;
+ return;
+ }
+ const nested = (child.props as { children?: React.ReactNode } | undefined)
+ ?.children;
+ if (nested !== undefined && hasAlertDialogDescription(nested)) {
+ found = true;
+ }
+ });
+ return found;
+}
+
+type AlertDialogContentProps = React.ComponentPropsWithoutRef<
+ typeof AlertDialogPrimitive.Content
+> & {
+ /**
+ * Optional accessible description rendered visually-hidden. When omitted and
+ * no `AlertDialogDescription` is found in the subtree, a generic fallback is
+ * emitted so screen readers still have something to announce.
+ */
+ description?: React.ReactNode;
+};
+
const AlertDialogContent = React.forwardRef<
React.ElementRef,
- React.ComponentPropsWithoutRef
->(({ className, ...props }, ref) => (
-
-
-
-
-));
+ AlertDialogContentProps
+>(({ className, children, description, ...props }, ref) => {
+ const needsFallback =
+ !props["aria-describedby"] &&
+ description === undefined &&
+ !hasAlertDialogDescription(children);
+ return (
+
+
+
+ {description !== undefined && (
+
+ {description}
+
+ )}
+ {needsFallback && (
+
+ Alert dialog content
+
+ )}
+ {children}
+
+
+ );
+});
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes) => (
diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx
index 825c947..186bc96 100644
--- a/src/components/ui/dialog.tsx
+++ b/src/components/ui/dialog.tsx
@@ -27,29 +27,86 @@ const DialogOverlay = React.forwardRef<
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
+/**
+ * Walks `children` looking for any element whose component is
+ * `DialogPrimitive.Description` (or our re-exported `DialogDescription`).
+ * Radix emits an a11y warning when a `DialogContent` has neither a
+ * `Description` nor an explicit `aria-describedby`, so we use this to decide
+ * whether to inject a visually-hidden fallback description.
+ */
+function hasDialogDescription(children: React.ReactNode): boolean {
+ let found = false;
+ React.Children.forEach(children, (child) => {
+ if (found || !React.isValidElement(child)) return;
+ const type = child.type as { displayName?: string } | undefined;
+ if (
+ type === DialogPrimitive.Description ||
+ (type && type.displayName === DialogPrimitive.Description.displayName)
+ ) {
+ found = true;
+ return;
+ }
+ const nested = (child.props as { children?: React.ReactNode } | undefined)
+ ?.children;
+ if (nested !== undefined && hasDialogDescription(nested)) {
+ found = true;
+ }
+ });
+ return found;
+}
+
+type DialogContentProps = React.ComponentPropsWithoutRef<
+ typeof DialogPrimitive.Content
+> & {
+ /**
+ * Optional accessible description. When supplied, a visually-hidden
+ * `DialogDescription` is rendered so screen readers announce it without
+ * affecting layout. Prefer this for simple dialogs; for richer descriptions
+ * continue to place a `` inside ``.
+ */
+ description?: React.ReactNode;
+};
+
const DialogContent = React.forwardRef<
React.ElementRef,
- React.ComponentPropsWithoutRef
->(({ className, children, ...props }, ref) => (
-
-
-
- {children}
-
-
- Close
-
-
-
-));
+ DialogContentProps
+>(({ className, children, description, ...props }, ref) => {
+ const hasExplicitDescribedBy = !!props["aria-describedby"];
+ const needsFallback =
+ !hasExplicitDescribedBy &&
+ description === undefined &&
+ !hasDialogDescription(children);
+
+ return (
+
+
+
+ {description !== undefined && (
+
+ {description}
+
+ )}
+ {needsFallback && (
+
+ Dialog content
+
+ )}
+ {children}
+
+
+ Close
+
+
+
+ );
+});
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({ className, ...props }: React.HTMLAttributes) => (
diff --git a/src/components/ui/sheet.tsx b/src/components/ui/sheet.tsx
index c414714..b8e6ef6 100644
--- a/src/components/ui/sheet.tsx
+++ b/src/components/ui/sheet.tsx
@@ -49,21 +49,61 @@ const sheetVariants = cva(
interface SheetContentProps
extends React.ComponentPropsWithoutRef,
- VariantProps {}
+ VariantProps {
+ /**
+ * Optional accessible description rendered visually-hidden. Falls back to a
+ * generic string when no `SheetDescription` is found in the subtree, which
+ * keeps Radix happy without forcing every call-site to add one.
+ */
+ description?: React.ReactNode;
+}
+
+function hasSheetDescription(children: React.ReactNode): boolean {
+ let found = false;
+ React.Children.forEach(children, (child) => {
+ if (found || !React.isValidElement(child)) return;
+ const type = child.type as { displayName?: string } | undefined;
+ if (
+ type === SheetPrimitive.Description ||
+ (type && type.displayName === SheetPrimitive.Description.displayName)
+ ) {
+ found = true;
+ return;
+ }
+ const nested = (child.props as { children?: React.ReactNode } | undefined)
+ ?.children;
+ if (nested !== undefined && hasSheetDescription(nested)) {
+ found = true;
+ }
+ });
+ return found;
+}
const SheetContent = React.forwardRef, SheetContentProps>(
- ({ side = "right", className, children, ...props }, ref) => (
-
-
-
- {children}
-
-
- Close
-
-
-
- ),
+ ({ side = "right", className, children, description, ...props }, ref) => {
+ const needsFallback =
+ !props["aria-describedby"] &&
+ description === undefined &&
+ !hasSheetDescription(children);
+ return (
+
+
+
+ {description !== undefined && (
+ {description}
+ )}
+ {needsFallback && (
+ Sheet content
+ )}
+ {children}
+
+
+ Close
+
+
+
+ );
+ },
);
SheetContent.displayName = SheetPrimitive.Content.displayName;
diff --git a/src/hooks/queries/keys.ts b/src/hooks/queries/keys.ts
index 59681b8..6dcf269 100644
--- a/src/hooks/queries/keys.ts
+++ b/src/hooks/queries/keys.ts
@@ -4,244 +4,246 @@ export const queryKeys = {
},
users: {
all: ["users"] as const,
- list: (params: Record) => ["users", "list", params] as const,
+ list: (params: object) => ["users", "list", params] as const,
detail: (id: number) => ["users", id] as const,
},
entities: {
all: ["entities"] as const,
- list: (params?: Record) => ["entities", "list", params] as const,
+ list: (params?: object) => ["entities", "list", params] as const,
detail: (id: number) => ["entities", id] as const,
roles: (entityId: number) => ["entities", entityId, "roles"] as const,
permissions: (entityId: number) => ["entities", entityId, "permissions"] as const,
},
exams: {
all: ["exams"] as const,
- list: (module: string, params?: Record) => ["exams", module, params] as const,
+ list: (module: string, params?: object) => ["exams", module, params] as const,
detail: (module: string, id: number) => ["exams", module, id] as const,
- rubrics: (params?: Record) => ["exams", "rubrics", params] as const,
- rubricGroups: (params?: Record) => ["exams", "rubric-groups", params] as const,
- structures: (params?: Record) => ["exams", "structures", params] as const,
+ rubrics: (params?: object) => ["exams", "rubrics", params] as const,
+ rubricGroups: (params?: object) => ["exams", "rubric-groups", params] as const,
+ structures: (params?: object) => ["exams", "structures", params] as const,
avatars: ["exams", "avatars"] as const,
+ reviewQueue: (params?: object) => ["exams", "review", "queue", params] as const,
+ reviewDetail: (id: number) => ["exams", "review", "detail", id] as const,
},
assignments: {
all: ["assignments"] as const,
- list: (params?: Record) => ["assignments", "list", params] as const,
+ list: (params?: object) => ["assignments", "list", params] as const,
detail: (id: number) => ["assignments", id] as const,
},
classrooms: {
all: ["classrooms"] as const,
- list: (params?: Record) => ["classrooms", "list", params] as const,
+ list: (params?: object) => ["classrooms", "list", params] as const,
detail: (id: number) => ["classrooms", id] as const,
},
stats: {
- sessions: (params?: Record) => ["stats", "sessions", params] as const,
- stats: (params?: Record) => ["stats", "stats", params] as const,
- statistical: (params?: Record) => ["stats", "statistical", params] as const,
- performance: (params?: Record) => ["stats", "performance", params] as const,
+ sessions: (params?: object) => ["stats", "sessions", params] as const,
+ stats: (params?: object) => ["stats", "stats", params] as const,
+ statistical: (params?: object) => ["stats", "statistical", params] as const,
+ performance: (params?: object) => ["stats", "performance", params] as const,
},
training: {
all: ["training"] as const,
- tips: (params?: Record) => ["training", "tips", params] as const,
+ tips: (params?: object) => ["training", "tips", params] as const,
walkthroughs: (module?: string) => ["training", "walkthroughs", module] as const,
},
subscriptions: {
packages: ["subscriptions", "packages"] as const,
- payments: (params?: Record) => ["subscriptions", "payments", params] as const,
- discounts: (params?: Record) => ["subscriptions", "discounts", params] as const,
+ payments: (params?: object) => ["subscriptions", "payments", params] as const,
+ discounts: (params?: object) => ["subscriptions", "discounts", params] as const,
},
tickets: {
all: ["tickets"] as const,
- list: (params?: Record) => ["tickets", "list", params] as const,
+ list: (params?: object) => ["tickets", "list", params] as const,
assigned: ["tickets", "assigned"] as const,
},
approvals: {
all: ["approvals"] as const,
- list: (params?: Record) => ["approvals", "list", params] as const,
+ list: (params?: object) => ["approvals", "list", params] as const,
},
taxonomy: {
subjects: ["taxonomy", "subjects"] as const,
subject: (id: number) => ["taxonomy", "subjects", id] as const,
tree: (subjectId: number) => ["taxonomy", "tree", subjectId] as const,
- domains: (params?: Record) => ["taxonomy", "domains", params] as const,
- topics: (params?: Record) => ["taxonomy", "topics", params] as const,
+ domains: (params?: object) => ["taxonomy", "domains", params] as const,
+ topics: (params?: object) => ["taxonomy", "topics", params] as const,
},
adaptive: {
- proficiency: (params?: Record) => ["adaptive", "proficiency", params] as const,
+ proficiency: (params?: object) => ["adaptive", "proficiency", params] as const,
summary: ["adaptive", "proficiency", "summary"] as const,
- plan: (params?: Record) => ["adaptive", "plan", params] as const,
+ plan: (params?: object) => ["adaptive", "plan", params] as const,
topicContent: (topicId: number) => ["adaptive", "topic", topicId, "content"] as const,
diagnostic: (sessionId: number) => ["adaptive", "diagnostic", sessionId] as const,
},
resources: {
all: ["resources"] as const,
- list: (params?: Record) => ["resources", "list", params] as const,
+ list: (params?: object) => ["resources", "list", params] as const,
},
lms: {
- courses: (params?: Record) => ["lms", "courses", params] as const,
+ courses: (params?: object) => ["lms", "courses", params] as const,
course: (id: number) => ["lms", "courses", id] as const,
myCourses: ["lms", "my-courses"] as const,
- students: (params?: Record) => ["lms", "students", params] as const,
- teachers: (params?: Record) => ["lms", "teachers", params] as const,
- batches: (params?: Record) => ["lms", "batches", params] as const,
+ students: (params?: object) => ["lms", "students", params] as const,
+ teachers: (params?: object) => ["lms", "teachers", params] as const,
+ batches: (params?: object) => ["lms", "batches", params] as const,
batch: (id: number) => ["lms", "batches", id] as const,
- timetable: (params?: Record) => ["lms", "timetable", params] as const,
- attendance: (params?: Record) => ["lms", "attendance", params] as const,
- grades: (params?: Record) => ["lms", "grades", params] as const,
+ timetable: (params?: object) => ["lms", "timetable", params] as const,
+ attendance: (params?: object) => ["lms", "attendance", params] as const,
+ grades: (params?: object) => ["lms", "grades", params] as const,
},
analytics: {
- student: (params?: Record) => ["analytics", "student", params] as const,
- class: (params?: Record) => ["analytics", "class", params] as const,
- subject: (params?: Record) => ["analytics", "subject", params] as const,
- contentGaps: (params?: Record) => ["analytics", "content-gaps", params] as const,
+ student: (params?: object) => ["analytics", "student", params] as const,
+ class: (params?: object) => ["analytics", "class", params] as const,
+ subject: (params?: object) => ["analytics", "subject", params] as const,
+ contentGaps: (params?: object) => ["analytics", "content-gaps", params] as const,
alerts: ["analytics", "alerts"] as const,
},
academicYears: {
all: ["academic-years"] as const,
- list: (params?: Record) => ["academic-years", "list", params] as const,
+ list: (params?: object) => ["academic-years", "list", params] as const,
detail: (id: number) => ["academic-years", id] as const,
},
academicTerms: {
all: ["academic-terms"] as const,
- list: (params?: Record) => ["academic-terms", "list", params] as const,
+ list: (params?: object) => ["academic-terms", "list", params] as const,
detail: (id: number) => ["academic-terms", id] as const,
},
departments: {
all: ["departments"] as const,
- list: (params?: Record) => ["departments", "list", params] as const,
+ list: (params?: object) => ["departments", "list", params] as const,
detail: (id: number) => ["departments", id] as const,
},
admissionRegisters: {
all: ["admission-registers"] as const,
- list: (params?: Record) => ["admission-registers", "list", params] as const,
+ list: (params?: object) => ["admission-registers", "list", params] as const,
detail: (id: number) => ["admission-registers", id] as const,
},
admissions: {
all: ["admissions"] as const,
- list: (params?: Record) => ["admissions", "list", params] as const,
+ list: (params?: object) => ["admissions", "list", params] as const,
detail: (id: number) => ["admissions", id] as const,
},
instExamSessions: {
all: ["inst-exam-sessions"] as const,
- list: (params?: Record) => ["inst-exam-sessions", "list", params] as const,
+ list: (params?: object) => ["inst-exam-sessions", "list", params] as const,
detail: (id: number) => ["inst-exam-sessions", id] as const,
},
examTypes: {
all: ["exam-types"] as const,
- list: (params?: Record) => ["exam-types", "list", params] as const,
+ list: (params?: object) => ["exam-types", "list", params] as const,
},
gradeConfigs: {
all: ["grade-configs"] as const,
- list: (params?: Record) => ["grade-configs", "list", params] as const,
+ list: (params?: object) => ["grade-configs", "list", params] as const,
},
resultTemplates: {
all: ["result-templates"] as const,
- list: (params?: Record) => ["result-templates", "list", params] as const,
+ list: (params?: object) => ["result-templates", "list", params] as const,
},
marksheets: {
all: ["marksheets"] as const,
- list: (params?: Record) => ["marksheets", "list", params] as const,
+ list: (params?: object) => ["marksheets", "list", params] as const,
detail: (id: number) => ["marksheets", id] as const,
},
courseAssignments: {
all: ["course-assignments"] as const,
- list: (params?: Record) => ["course-assignments", "list", params] as const,
+ list: (params?: object) => ["course-assignments", "list", params] as const,
detail: (id: number) => ["course-assignments", id] as const,
},
assignmentTypes: {
all: ["assignment-types"] as const,
- list: (params?: Record) => ["assignment-types", "list", params] as const,
+ list: (params?: object) => ["assignment-types", "list", params] as const,
},
subjectRegistrations: {
all: ["subject-registrations"] as const,
- list: (params?: Record) => ["subject-registrations", "list", params] as const,
- available: (params?: Record) => ["subject-registrations", "available", params] as const,
+ list: (params?: object) => ["subject-registrations", "list", params] as const,
+ available: (params?: object) => ["subject-registrations", "available", params] as const,
},
courseCompletion: (courseId: number) => ["course-completion", courseId] as const,
chapters: (courseId: number) => ["chapters", "list", courseId] as const,
chapter: (id: number) => ["chapters", "detail", id] as const,
chapterMaterials: (chapterId: number) => ["chapter-materials", chapterId] as const,
chapterProgress: (chapterId: number) => ["chapter-progress", chapterId] as const,
- discussionBoards: (params?: Record) => ["discussion-boards", params] as const,
- posts: (boardId: number, params?: Record) => ["posts", boardId, params] as const,
- announcements: (params?: Record) => ["announcements", params] as const,
- messages: (params?: Record) => ["messages", params] as const,
- sentMessages: (params?: Record) => ["messages", "sent", params] as const,
+ discussionBoards: (params?: object) => ["discussion-boards", params] as const,
+ posts: (boardId: number, params?: object) => ["posts", boardId, params] as const,
+ announcements: (params?: object) => ["announcements", params] as const,
+ messages: (params?: object) => ["messages", params] as const,
+ sentMessages: (params?: object) => ["messages", "sent", params] as const,
unreadMessages: ["messages", "unread-count"] as const,
- notifications: (params?: Record) => ["notifications", params] as const,
+ notifications: (params?: object) => ["notifications", params] as const,
unreadNotifications: ["notifications", "unread-count"] as const,
notificationRules: ["notification-rules"] as const,
notificationPreferences: ["notification-preferences"] as const,
faqCategories: (audience?: string) => ["faq", "categories", audience] as const,
- faqItems: (params?: Record) => ["faq", "items", params] as const,
+ faqItems: (params?: object) => ["faq", "items", params] as const,
studentLeaves: {
all: ["student-leaves"] as const,
- list: (params?: Record) => ["student-leaves", "list", params] as const,
+ list: (params?: object) => ["student-leaves", "list", params] as const,
},
studentLeaveTypes: {
all: ["student-leave-types"] as const,
- list: (params?: Record) => ["student-leave-types", "list", params] as const,
+ list: (params?: object) => ["student-leave-types", "list", params] as const,
},
feesPlans: {
all: ["fees-plans"] as const,
- list: (params?: Record) => ["fees-plans", "list", params] as const,
+ list: (params?: object) => ["fees-plans", "list", params] as const,
detail: (id: number) => ["fees-plans", id] as const,
},
studentFees: {
all: ["student-fees"] as const,
- list: (params?: Record) => ["student-fees", "list", params] as const,
+ list: (params?: object) => ["student-fees", "list", params] as const,
},
feesTerms: {
all: ["fees-terms"] as const,
- list: (params?: Record) => ["fees-terms", "list", params] as const,
+ list: (params?: object) => ["fees-terms", "list", params] as const,
},
lessons: {
all: ["lessons"] as const,
- list: (params?: Record) => ["lessons", "list", params] as const,
+ list: (params?: object) => ["lessons", "list", params] as const,
},
gradebooks: {
all: ["gradebooks"] as const,
- list: (params?: Record) => ["gradebooks", "list", params] as const,
+ list: (params?: object) => ["gradebooks", "list", params] as const,
},
gradebookLines: {
all: ["gradebook-lines"] as const,
- list: (params?: Record) => ["gradebook-lines", "list", params] as const,
+ list: (params?: object) => ["gradebook-lines", "list", params] as const,
},
gradingAssignments: {
all: ["grading-assignments"] as const,
- list: (params?: Record) => ["grading-assignments", "list", params] as const,
+ list: (params?: object) => ["grading-assignments", "list", params] as const,
},
studentProgress: {
all: ["student-progress"] as const,
- list: (params?: Record) => ["student-progress", "list", params] as const,
+ list: (params?: object) => ["student-progress", "list", params] as const,
detail: (id: number) => ["student-progress", "detail", id] as const,
},
libraryMedia: {
all: ["library-media"] as const,
- list: (params?: Record) => ["library-media", "list", params] as const,
+ list: (params?: object) => ["library-media", "list", params] as const,
},
libraryMovements: {
all: ["library-movements"] as const,
- list: (params?: Record) => ["library-movements", "list", params] as const,
+ list: (params?: object) => ["library-movements", "list", params] as const,
},
libraryCards: {
all: ["library-cards"] as const,
- list: (params?: Record) => ["library-cards", "list", params] as const,
+ list: (params?: object) => ["library-cards", "list", params] as const,
},
activities: {
all: ["activities"] as const,
- list: (params?: Record) => ["activities", "list", params] as const,
+ list: (params?: object) => ["activities", "list", params] as const,
},
activityTypes: {
all: ["activity-types"] as const,
- list: (params?: Record) => ["activity-types", "list", params] as const,
+ list: (params?: object) => ["activity-types", "list", params] as const,
},
facilities: {
all: ["facilities"] as const,
- list: (params?: Record) => ["facilities", "list", params] as const,
+ list: (params?: object) => ["facilities", "list", params] as const,
},
assets: {
all: ["assets"] as const,
- list: (params?: Record) => ["assets", "list", params] as const,
+ list: (params?: object) => ["assets", "list", params] as const,
},
signup: {
goals: ["signup", "goals"] as const,
@@ -258,7 +260,7 @@ export const queryKeys = {
},
ieltsExam: {
skills: (examId: number) => ["ielts-exam", examId, "skills"] as const,
- contentPool: (examId: number, filters?: Record) => ["ielts-exam", examId, "content-pool", filters] as const,
+ contentPool: (examId: number, filters?: object) => ["ielts-exam", examId, "content-pool", filters] as const,
validation: (examId: number) => ["ielts-exam", examId, "validation"] as const,
},
examSession: {
@@ -266,7 +268,7 @@ export const queryKeys = {
status: (examId: number) => ["exam-session", examId, "status"] as const,
},
grading: {
- queue: (params?: Record) => ["grading", "queue", params] as const,
+ queue: (params?: object) => ["grading", "queue", params] as const,
response: (attemptId: number, skill: string) => ["grading", attemptId, "response", skill] as const,
rubric: (attemptId: number, skill: string) => ["grading", attemptId, "rubric", skill] as const,
},
@@ -282,11 +284,11 @@ export const queryKeys = {
englishTaxonomy: ["ai-course", "english", "taxonomy"] as const,
},
entityOnboarding: {
- credentials: (params?: Record) => ["entity-onboarding", "credentials", params] as const,
+ credentials: (params?: object) => ["entity-onboarding", "credentials", params] as const,
},
adaptiveEngine: {
dashboard: ["adaptive-engine", "dashboard"] as const,
- students: (params?: Record) => ["adaptive-engine", "students", params] as const,
+ students: (params?: object) => ["adaptive-engine", "students", params] as const,
signals: (studentId: number) => ["adaptive-engine", "signals", studentId] as const,
ability: (studentId: number) => ["adaptive-engine", "ability", studentId] as const,
settings: ["adaptive-engine", "settings"] as const,
@@ -299,7 +301,7 @@ export const queryKeys = {
current: ["branding", "current"] as const,
},
scoreRelease: {
- pending: (params?: Record) => ["score-release", "pending", params] as const,
+ pending: (params?: object) => ["score-release", "pending", params] as const,
},
verification: {
verify: (hash: string) => ["verification", hash] as const,
diff --git a/src/hooks/queries/useAIFeedback.ts b/src/hooks/queries/useAIFeedback.ts
new file mode 100644
index 0000000..bab2362
--- /dev/null
+++ b/src/hooks/queries/useAIFeedback.ts
@@ -0,0 +1,70 @@
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+
+import { aiFeedbackService } from "@/services/ai-feedback.service";
+import type {
+ AIFeedbackStatus,
+ AIFeedbackSubjectType,
+ AIFeedbackSubmitInput,
+} from "@/types/ai-feedback";
+
+const KEY_ROOT = ["ai", "feedback"] as const;
+
+export function useAIFeedbackSummary(
+ subjectType: AIFeedbackSubjectType | undefined,
+ subjectId: number | undefined,
+) {
+ return useQuery({
+ enabled: !!subjectType && !!subjectId,
+ queryKey: [...KEY_ROOT, "summary", subjectType, subjectId] as const,
+ queryFn: () =>
+ aiFeedbackService.summary(
+ subjectType as AIFeedbackSubjectType,
+ subjectId as number,
+ ),
+ });
+}
+
+export function useSubmitAIFeedback() {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: (input: AIFeedbackSubmitInput) => aiFeedbackService.submit(input),
+ onSuccess: (_res, vars) => {
+ qc.invalidateQueries({
+ queryKey: [...KEY_ROOT, "summary", vars.subject_type, vars.subject_id],
+ });
+ qc.invalidateQueries({ queryKey: [...KEY_ROOT, "list"] });
+ },
+ });
+}
+
+export function useAIFeedbackList(
+ params: {
+ status?: AIFeedbackStatus;
+ rating?: "up" | "down";
+ subject_type?: AIFeedbackSubjectType;
+ prompt_key?: string;
+ page?: number;
+ size?: number;
+ } = {},
+) {
+ return useQuery({
+ queryKey: [...KEY_ROOT, "list", params] as const,
+ queryFn: () => aiFeedbackService.list(params),
+ });
+}
+
+export function useResolveAIFeedback() {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: ({
+ id,
+ status,
+ notes,
+ }: {
+ id: number;
+ status: Exclude;
+ notes?: string;
+ }) => aiFeedbackService.resolve(id, status, notes),
+ onSuccess: () => qc.invalidateQueries({ queryKey: KEY_ROOT }),
+ });
+}
diff --git a/src/hooks/queries/useAIPrompts.ts b/src/hooks/queries/useAIPrompts.ts
new file mode 100644
index 0000000..afd2e67
--- /dev/null
+++ b/src/hooks/queries/useAIPrompts.ts
@@ -0,0 +1,52 @@
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+
+import { aiPromptService } from "@/services/ai-prompt.service";
+import type { AIPromptCreateInput } from "@/types/ai-prompt";
+
+const KEY_ROOT = ["ai", "prompts"] as const;
+
+export function useAIPromptKeys(params: { search?: string; page?: number; size?: number } = {}) {
+ return useQuery({
+ queryKey: [...KEY_ROOT, "keys", params] as const,
+ queryFn: () => aiPromptService.listKeys(params),
+ });
+}
+
+export function useAIPromptVersions(key: string | undefined) {
+ return useQuery({
+ enabled: !!key,
+ queryKey: [...KEY_ROOT, "versions", key] as const,
+ queryFn: () => aiPromptService.listVersions(key as string),
+ });
+}
+
+export function useAIPrompt(id: number | undefined) {
+ return useQuery({
+ enabled: !!id,
+ queryKey: [...KEY_ROOT, "detail", id] as const,
+ queryFn: () => aiPromptService.get(id as number),
+ });
+}
+
+export function useCreateAIPrompt() {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: (input: AIPromptCreateInput) => aiPromptService.create(input),
+ onSuccess: () => qc.invalidateQueries({ queryKey: KEY_ROOT }),
+ });
+}
+
+export function useActivateAIPrompt() {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: (id: number) => aiPromptService.activate(id),
+ onSuccess: () => qc.invalidateQueries({ queryKey: KEY_ROOT }),
+ });
+}
+
+export function useRenderAIPrompt() {
+ return useMutation({
+ mutationFn: ({ id, variables }: { id: number; variables: Record }) =>
+ aiPromptService.render(id, variables),
+ });
+}
diff --git a/src/hooks/queries/useExamReview.ts b/src/hooks/queries/useExamReview.ts
new file mode 100644
index 0000000..d38a2e7
--- /dev/null
+++ b/src/hooks/queries/useExamReview.ts
@@ -0,0 +1,47 @@
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+
+import {
+ examReviewService,
+ type ExamReviewQueueParams,
+} from "@/services/exam-review.service";
+
+import { queryKeys } from "./keys";
+
+export function useExamReviewQueue(params: ExamReviewQueueParams = {}) {
+ return useQuery({
+ queryKey: queryKeys.exams.reviewQueue(params),
+ queryFn: () => examReviewService.queue(params),
+ });
+}
+
+export function useExamReviewDetail(examId: number | undefined) {
+ return useQuery({
+ enabled: !!examId,
+ queryKey: queryKeys.exams.reviewDetail(examId ?? -1),
+ queryFn: () => examReviewService.detail(examId as number),
+ });
+}
+
+export function useApproveExamReview() {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: ({ examId, notes }: { examId: number; notes?: string }) =>
+ examReviewService.approve(examId, notes),
+ onSuccess: (_data, { examId }) => {
+ qc.invalidateQueries({ queryKey: ["exams", "review"] });
+ qc.invalidateQueries({ queryKey: queryKeys.exams.reviewDetail(examId) });
+ },
+ });
+}
+
+export function useRejectExamReview() {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: ({ examId, notes }: { examId: number; notes: string }) =>
+ examReviewService.reject(examId, notes),
+ onSuccess: (_data, { examId }) => {
+ qc.invalidateQueries({ queryKey: ["exams", "review"] });
+ qc.invalidateQueries({ queryKey: queryKeys.exams.reviewDetail(examId) });
+ },
+ });
+}
diff --git a/src/hooks/queries/useExamTemplates.ts b/src/hooks/queries/useExamTemplates.ts
index 3544a64..d5beb79 100644
--- a/src/hooks/queries/useExamTemplates.ts
+++ b/src/hooks/queries/useExamTemplates.ts
@@ -47,7 +47,7 @@ export function useIeltsAutoAssemble() {
});
}
-export function useIeltsValidation(examId: number) {
+export function useIeltsExamValidation(examId: number) {
return useQuery({
queryKey: queryKeys.ieltsExam.validation(examId),
queryFn: () => ieltsExamService.validate(examId),
diff --git a/src/i18n/index.ts b/src/i18n/index.ts
new file mode 100644
index 0000000..7a74a00
--- /dev/null
+++ b/src/i18n/index.ts
@@ -0,0 +1,67 @@
+/**
+ * EnCoach i18n bootstrap.
+ *
+ * We keep the translation strings in TypeScript modules (one per locale) so
+ * they go through type-checking and tree-shaking, and so non-translators
+ * can't ship a broken JSON file that blocks the build.
+ *
+ * Language detection order:
+ * 1. localStorage key ``encoach-lang`` (explicit user pick)
+ * 2. ``navigator.language``
+ * 3. fallback ``en``
+ *
+ * RTL handling: whenever the active language switches to one of RTL_LANGS,
+ * we flip ``document.documentElement.dir`` so Tailwind's RTL utilities +
+ * Radix primitives pick up the change automatically.
+ */
+
+import i18n from "i18next";
+import LanguageDetector from "i18next-browser-languagedetector";
+import { initReactI18next } from "react-i18next";
+
+import ar from "./locales/ar";
+import en from "./locales/en";
+
+const RTL_LANGS = new Set(["ar", "he", "fa", "ur"]);
+
+export const SUPPORTED_LANGS = [
+ { code: "en", label: "English" },
+ { code: "ar", label: "العربية" },
+] as const;
+
+export type SupportedLang = (typeof SUPPORTED_LANGS)[number]["code"];
+
+i18n
+ .use(LanguageDetector)
+ .use(initReactI18next)
+ .init({
+ resources: {
+ en: { translation: en },
+ ar: { translation: ar },
+ },
+ fallbackLng: "en",
+ supportedLngs: SUPPORTED_LANGS.map((l) => l.code),
+ interpolation: { escapeValue: false },
+ detection: {
+ order: ["localStorage", "navigator", "htmlTag"],
+ caches: ["localStorage"],
+ lookupLocalStorage: "encoach-lang",
+ },
+ returnEmptyString: false,
+ });
+
+export function applyDirectionForLang(lang: string) {
+ const base = lang.split("-")[0];
+ const dir = RTL_LANGS.has(base) ? "rtl" : "ltr";
+ if (typeof document !== "undefined") {
+ document.documentElement.dir = dir;
+ document.documentElement.lang = base;
+ }
+}
+
+if (typeof window !== "undefined") {
+ applyDirectionForLang(i18n.language || "en");
+ i18n.on("languageChanged", applyDirectionForLang);
+}
+
+export default i18n;
diff --git a/src/i18n/locales/ar.ts b/src/i18n/locales/ar.ts
new file mode 100644
index 0000000..5f5bfb9
--- /dev/null
+++ b/src/i18n/locales/ar.ts
@@ -0,0 +1,85 @@
+import type { Translations } from "./en";
+
+/** Arabic (RTL) translations. */
+const ar: Translations = {
+ common: {
+ cancel: "إلغاء",
+ save: "حفظ",
+ delete: "حذف",
+ edit: "تعديل",
+ close: "إغلاق",
+ back: "رجوع",
+ next: "التالي",
+ search: "بحث",
+ loading: "جارٍ التحميل…",
+ error: "خطأ",
+ retry: "إعادة المحاولة",
+ yes: "نعم",
+ no: "لا",
+ actions: "إجراءات",
+ status: "الحالة",
+ settings: "الإعدادات",
+ signOut: "تسجيل الخروج",
+ },
+ auth: {
+ signIn: "تسجيل الدخول",
+ signInTitle: "تسجيل الدخول إلى EnCoach",
+ email: "البريد الإلكتروني",
+ password: "كلمة المرور",
+ forgotPassword: "هل نسيت كلمة المرور؟",
+ needAccount: "ليس لديك حساب؟",
+ signUp: "إنشاء حساب",
+ invalidCredentials: "البريد الإلكتروني أو كلمة المرور غير صحيحة",
+ },
+ nav: {
+ dashboard: "لوحة التحكم",
+ courses: "الدورات",
+ students: "الطلاب",
+ teachers: "المعلمون",
+ exams: "الاختبارات",
+ reports: "التقارير",
+ settings: "الإعدادات",
+ profile: "الملف الشخصي",
+ privacy: "مركز الخصوصية",
+ reviewQueue: "قائمة المراجعة",
+ aiPrompts: "تعليمات الذكاء الاصطناعي",
+ aiFeedback: "ملاحظات الذكاء الاصطناعي",
+ },
+ privacy: {
+ title: "مركز الخصوصية",
+ description:
+ "إدارة بياناتك الشخصية لدى EnCoach وفقاً للائحة حماية البيانات العامة وما يماثلها.",
+ exportTitle: "تنزيل بياناتك",
+ exportDescription:
+ "سنقوم بتجميع ملفك الشخصي، عضويات الكيانات، محاولات الاختبارات، الإجابات، ملاحظات الذكاء الاصطناعي، والتذاكر في ملف JSON واحد.",
+ exportButton: "تنزيل بياناتي",
+ exportPreparing: "جارٍ تحضير التصدير…",
+ exportSuccess: "تم تنزيل البيانات بنجاح",
+ deleteTitle: "حذف حسابي",
+ deleteDescription:
+ "سيتم إخفاء هوية ملفك الشخصي وإزالة الحقول الشخصية من سجلاتنا. سيتم الاحتفاظ بمحاولات الاختبارات (دون معلومات تعريفية) لسلامة الإحصائيات. لا يمكن التراجع عن هذا الإجراء.",
+ deleteButton: "محو حسابي",
+ deleteErasing: "جارٍ المحو…",
+ confirmTitle: "هل تريد محو حسابك؟",
+ confirmDescription:
+ "سنقوم بإخفاء هوية بياناتك الشخصية وتعطيل تسجيل الدخول. ستبقى التحليلات المجمعة لكنها لن تكون مرتبطة بك.",
+ confirmTypeDelete: "اكتب DELETE للتأكيد",
+ confirmErased: "تم محو حسابك. جارٍ تسجيل الخروج…",
+ },
+ feedback: {
+ thumbsUp: "إعجاب",
+ thumbsDown: "عدم إعجاب",
+ whatWentWrong: "ما الخطأ الذي حدث؟",
+ commentPlaceholder: "مثال: إجابة خاطئة، صياغة مربكة، خارج الموضوع…",
+ commentRequired: "من فضلك أخبرنا بالخطأ.",
+ submit: "إرسال الملاحظات",
+ },
+ theme: {
+ title: "المظهر",
+ light: "فاتح",
+ dark: "داكن",
+ system: "النظام",
+ },
+};
+
+export default ar;
diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts
new file mode 100644
index 0000000..0e66845
--- /dev/null
+++ b/src/i18n/locales/en.ts
@@ -0,0 +1,97 @@
+/** English translations. Keep keys nested by feature area.
+ *
+ * We deliberately annotate the literal with `Translations` (not `as const`)
+ * so sibling locale files can widen their string values without fighting
+ * literal-string type mismatches.
+ */
+export interface Translations {
+ common: Record;
+ auth: Record;
+ nav: Record;
+ privacy: Record;
+ feedback: Record;
+ theme: Record;
+}
+
+const en: Translations = {
+ common: {
+ cancel: "Cancel",
+ save: "Save",
+ delete: "Delete",
+ edit: "Edit",
+ close: "Close",
+ back: "Back",
+ next: "Next",
+ search: "Search",
+ loading: "Loading…",
+ error: "Error",
+ retry: "Retry",
+ yes: "Yes",
+ no: "No",
+ actions: "Actions",
+ status: "Status",
+ settings: "Settings",
+ signOut: "Sign out",
+ },
+ auth: {
+ signIn: "Sign in",
+ signInTitle: "Sign in to EnCoach",
+ email: "Email",
+ password: "Password",
+ forgotPassword: "Forgot password?",
+ needAccount: "Don't have an account?",
+ signUp: "Sign up",
+ invalidCredentials: "Invalid email or password",
+ },
+ nav: {
+ dashboard: "Dashboard",
+ courses: "Courses",
+ students: "Students",
+ teachers: "Teachers",
+ exams: "Exams",
+ reports: "Reports",
+ settings: "Settings",
+ profile: "Profile",
+ privacy: "Privacy Center",
+ reviewQueue: "Review Queue",
+ aiPrompts: "AI Prompts",
+ aiFeedback: "AI Feedback",
+ },
+ privacy: {
+ title: "Privacy Center",
+ description:
+ "Manage your personal data held by EnCoach under GDPR and equivalent regulations.",
+ exportTitle: "Download your data",
+ exportDescription:
+ "We'll package your profile, entity memberships, exam attempts, answers, AI feedback, and tickets into a single JSON file.",
+ exportButton: "Download my data",
+ exportPreparing: "Preparing export…",
+ exportSuccess: "Data export downloaded",
+ deleteTitle: "Delete my account",
+ deleteDescription:
+ "This anonymises your profile and removes personal fields from our records. Exam attempts are retained (without identifying information) for statistical integrity. This action cannot be undone.",
+ deleteButton: "Erase my account",
+ deleteErasing: "Erasing…",
+ confirmTitle: "Erase your account?",
+ confirmDescription:
+ "We'll anonymise your personal data and deactivate your login. Aggregate analytics will remain but will no longer be linked to you.",
+ confirmTypeDelete: "Type DELETE to confirm",
+ confirmErased: "Your account has been erased. Signing out…",
+ },
+ feedback: {
+ thumbsUp: "Thumbs up",
+ thumbsDown: "Thumbs down",
+ whatWentWrong: "What went wrong?",
+ commentPlaceholder: "e.g. Wrong answer, confusing wording, off-topic…",
+ commentRequired: "Please tell us what was wrong.",
+ submit: "Submit feedback",
+ },
+ theme: {
+ title: "Theme",
+ light: "Light",
+ dark: "Dark",
+ system: "System",
+ },
+};
+
+export default en;
diff --git a/src/index.css b/src/index.css
index c0351c4..f316264 100644
--- a/src/index.css
+++ b/src/index.css
@@ -51,6 +51,14 @@
--sidebar-accent-foreground: 8 40% 78%;
--sidebar-border: 240 18% 20%;
--sidebar-ring: 8 50% 58%;
+
+ /* Chart palette — used by Recharts via hsl(var(--chart-N)). Kept on the
+ warm/cool axis so pairs read well together when stacked. */
+ --chart-1: 8 50% 54%;
+ --chart-2: 220 70% 52%;
+ --chart-3: 152 60% 42%;
+ --chart-4: 38 92% 50%;
+ --chart-5: 280 55% 55%;
}
.dark {
@@ -99,6 +107,12 @@
--sidebar-accent-foreground: 8 40% 75%;
--sidebar-border: 240 18% 13%;
--sidebar-ring: 8 50% 55%;
+
+ --chart-1: 8 52% 62%;
+ --chart-2: 220 65% 65%;
+ --chart-3: 152 50% 55%;
+ --chart-4: 38 75% 60%;
+ --chart-5: 280 55% 68%;
}
}
diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts
index 7a6e89a..1fcf691 100644
--- a/src/lib/api-client.ts
+++ b/src/lib/api-client.ts
@@ -1,4 +1,23 @@
-/** Base path or absolute URL for Odoo JSON API (dev: `/api` + Vite proxy). */
+/**
+ * Odoo REST client with transparent access-token rotation.
+ *
+ * Tokens live in ``localStorage`` under three keys:
+ * - ``encoach_token`` — short-lived access JWT (1h)
+ * - ``encoach_refresh_token`` — long-lived refresh JWT (7d)
+ * - ``encoach_token_exp`` — epoch seconds for the access token
+ *
+ * When a request receives ``401`` *and* a refresh token is present, the client
+ * silently rotates the pair via ``POST /api/auth/refresh`` and retries the
+ * original request exactly once. Multiple concurrent 401s coalesce onto the
+ * same refresh promise so we never fire more than one rotation in flight.
+ *
+ * Motivation: before this change, every expired access token forced a full
+ * re-login, which interrupted long admin dashboards (stats, reports) multiple
+ * times per hour. With the refresh loop, access tokens can be short-lived
+ * (1h) without hurting UX, giving us the security benefit of short access
+ * windows without an eager logout.
+ */
+
export const API_BASE_URL = (import.meta.env.VITE_API_BASE_URL?.trim() || "/api").replace(/\/$/, "");
const BASE_URL = API_BASE_URL;
@@ -21,120 +40,229 @@ export class ApiError extends Error {
}
}
-function getToken(): string | null {
- return localStorage.getItem("encoach_token");
+const ACCESS_KEY = "encoach_token";
+const REFRESH_KEY = "encoach_refresh_token";
+const EXP_KEY = "encoach_token_exp";
+
+function getAccessToken(): string | null {
+ return localStorage.getItem(ACCESS_KEY);
+}
+
+function getRefreshToken(): string | null {
+ return localStorage.getItem(REFRESH_KEY);
}
export function setToken(token: string): void {
- localStorage.setItem("encoach_token", token);
+ localStorage.setItem(ACCESS_KEY, token);
+}
+
+export function setRefreshToken(token: string | null | undefined): void {
+ if (token) {
+ localStorage.setItem(REFRESH_KEY, token);
+ } else {
+ localStorage.removeItem(REFRESH_KEY);
+ }
+}
+
+export function setTokenExpiry(epochSeconds: number | null | undefined): void {
+ if (epochSeconds && Number.isFinite(epochSeconds)) {
+ localStorage.setItem(EXP_KEY, String(Math.floor(epochSeconds)));
+ } else {
+ localStorage.removeItem(EXP_KEY);
+ }
+}
+
+/** Persist the full token bundle returned by /api/login or /api/auth/refresh. */
+export function persistTokenBundle(bundle: {
+ access_token?: string;
+ token?: string;
+ refresh_token?: string;
+ expires_in?: number;
+}): void {
+ const access = bundle.access_token || bundle.token;
+ if (access) setToken(access);
+ setRefreshToken(bundle.refresh_token || null);
+ if (bundle.expires_in) {
+ setTokenExpiry(Math.floor(Date.now() / 1000) + bundle.expires_in);
+ }
}
export function clearToken(): void {
- localStorage.removeItem("encoach_token");
+ localStorage.removeItem(ACCESS_KEY);
+ localStorage.removeItem(REFRESH_KEY);
+ localStorage.removeItem(EXP_KEY);
}
-async function handleResponse(response: Response): Promise {
- const data = await response.json().catch(() => null);
+// Shared refresh promise — any 401 that arrives while a refresh is in flight
+// waits on the existing request instead of firing a duplicate.
+let refreshPromise: Promise | null = null;
- if (response.status === 401) {
- const hadToken = !!getToken();
- clearToken();
- // Login failure is also 401 — do not hard-redirect when no session existed.
- if (hadToken) {
- window.location.href = "/login";
- }
- throw new ApiError(401, response.statusText, data);
+async function performRefresh(): Promise {
+ const refreshToken = getRefreshToken();
+ if (!refreshToken) return false;
+ try {
+ const res = await fetch(`${BASE_URL}/auth/refresh`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ refresh_token: refreshToken }),
+ });
+ if (!res.ok) return false;
+ const data: {
+ access_token?: string;
+ token?: string;
+ refresh_token?: string;
+ expires_in?: number;
+ } = await res.json().catch(() => ({} as never));
+ if (!data.access_token && !data.token) return false;
+ persistTokenBundle(data);
+ return true;
+ } catch {
+ return false;
}
-
- if (!response.ok) {
- throw new ApiError(response.status, response.statusText, data);
- }
-
- return data as T;
}
-function buildHeaders(extra?: Record): Record {
- const headers: Record = {
- "Content-Type": "application/json",
- ...extra,
- };
-
- const token = getToken();
- if (token) {
- headers["Authorization"] = `Bearer ${token}`;
+function refreshOnce(): Promise {
+ if (!refreshPromise) {
+ refreshPromise = performRefresh().finally(() => {
+ refreshPromise = null;
+ });
}
+ return refreshPromise;
+}
+function buildHeaders(extra?: Record, withJson = true): Record {
+ const headers: Record = { ...extra };
+ if (withJson) headers["Content-Type"] = headers["Content-Type"] || "application/json";
+
+ const token = getAccessToken();
+ if (token) headers["Authorization"] = `Bearer ${token}`;
return headers;
}
-function buildUrl(path: string, params?: Record): string {
+/**
+ * Query param values we know how to serialise into a URL. Arrays are joined
+ * with commas because that's what our Odoo controllers expect for multi-value
+ * filters (e.g. `?state=draft,confirmed`).
+ */
+export type QueryParamValue =
+ | string
+ | number
+ | boolean
+ | null
+ | undefined
+ | Array;
+
+/**
+ * Accept any object-shaped bag of query params. We intentionally use `object`
+ * here rather than `Record` because typed interfaces
+ * (e.g. `PaginationParams`) don't satisfy a `Record<...>` index signature by
+ * default, which would force every call-site to cast.
+ */
+export type QueryParams = object;
+
+function buildUrl(path: string, params?: QueryParams): string {
const url = new URL(`${BASE_URL}${path}`, window.location.origin);
if (params) {
- Object.entries(params).forEach(([key, value]) => {
- if (value !== undefined) {
- url.searchParams.set(key, String(value));
+ for (const [key, rawValue] of Object.entries(params as Record)) {
+ if (rawValue === undefined || rawValue === null) continue;
+ if (Array.isArray(rawValue)) {
+ if (rawValue.length === 0) continue;
+ url.searchParams.set(key, rawValue.map(v => String(v)).join(","));
+ continue;
}
- });
+ url.searchParams.set(key, String(rawValue));
+ }
}
return url.toString();
}
+async function parseResponse(response: Response): Promise {
+ const data = await response.json().catch(() => null);
+ if (!response.ok) throw new ApiError(response.status, response.statusText, data);
+ return data as T;
+}
+
+type RequestInitWithSkip = RequestInit & { _skipRetry?: boolean };
+
+async function performRequest(url: string, init: RequestInitWithSkip): Promise {
+ const response = await fetch(url, init);
+
+ // Auth/refresh endpoints opt out of the retry loop — otherwise a bad
+ // refresh token would recurse forever.
+ const isAuthEndpoint = url.includes("/auth/refresh") || url.includes("/login");
+
+ if (response.status === 401 && !init._skipRetry && !isAuthEndpoint) {
+ const hadRefresh = !!getRefreshToken();
+ if (hadRefresh) {
+ const rotated = await refreshOnce();
+ if (rotated) {
+ // Rebuild headers so the new access token is attached.
+ const newInit: RequestInitWithSkip = {
+ ...init,
+ _skipRetry: true,
+ headers: {
+ ...(init.headers as Record),
+ Authorization: `Bearer ${getAccessToken()}`,
+ },
+ };
+ return performRequest(url, newInit);
+ }
+ }
+ const hadAccess = !!getAccessToken();
+ clearToken();
+ if (hadAccess || hadRefresh) {
+ window.location.href = "/login";
+ }
+ throw new ApiError(401, response.statusText, await response.json().catch(() => null));
+ }
+
+ return parseResponse(response);
+}
+
export const api = {
- async get(path: string, params?: Record): Promise {
- const res = await fetch(buildUrl(path, params), {
+ async get(path: string, params?: QueryParams): Promise {
+ return performRequest(buildUrl(path, params), {
method: "GET",
headers: buildHeaders(),
});
- return handleResponse(res);
},
async post(path: string, body?: unknown): Promise {
- const res = await fetch(buildUrl(path), {
+ return performRequest(buildUrl(path), {
method: "POST",
headers: buildHeaders(),
body: body ? JSON.stringify(body) : undefined,
});
- return handleResponse(res);
},
async patch(path: string, body?: unknown): Promise {
- const res = await fetch(buildUrl(path), {
+ return performRequest(buildUrl(path), {
method: "PATCH",
headers: buildHeaders(),
body: body ? JSON.stringify(body) : undefined,
});
- return handleResponse(res);
},
async put(path: string, body?: unknown): Promise {
- const res = await fetch(buildUrl(path), {
+ return performRequest(buildUrl(path), {
method: "PUT",
headers: buildHeaders(),
body: body ? JSON.stringify(body) : undefined,
});
- return handleResponse(res);
},
async delete(path: string): Promise {
- const res = await fetch(buildUrl(path), {
+ return performRequest(buildUrl(path), {
method: "DELETE",
headers: buildHeaders(),
});
- return handleResponse(res);
},
async upload(path: string, formData: FormData): Promise {
- const token = getToken();
- const headers: Record = {};
- if (token) {
- headers["Authorization"] = `Bearer ${token}`;
- }
-
- const res = await fetch(buildUrl(path), {
+ return performRequest(buildUrl(path), {
method: "POST",
- headers,
+ headers: buildHeaders(undefined, false),
body: formData,
});
- return handleResponse(res);
},
};
diff --git a/src/main.tsx b/src/main.tsx
index 68176a7..5aee48d 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -1,5 +1,6 @@
import { createRoot } from "react-dom/client";
import App from "./App.tsx";
import "./index.css";
+import "./i18n";
createRoot(document.getElementById("root")!).render( );
diff --git a/src/pages/AdminDashboard.tsx b/src/pages/AdminDashboard.tsx
index 8bba668..fe15c17 100644
--- a/src/pages/AdminDashboard.tsx
+++ b/src/pages/AdminDashboard.tsx
@@ -31,9 +31,10 @@ interface PlatformStats {
export default function AdminDashboard() {
const { data: stats } = useQuery({
queryKey: ["platform", "stats"],
- queryFn: async () => {
- const res = await api.get<{ data: PlatformStats }>("/stats");
- return (res as { data: PlatformStats }).data ?? res;
+ queryFn: async (): Promise => {
+ const res = await api.get<{ data: PlatformStats } | PlatformStats>("/stats");
+ const wrapped = res as { data?: PlatformStats };
+ return wrapped.data ?? (res as PlatformStats);
},
staleTime: 30_000,
});
diff --git a/src/pages/EntitiesPage.tsx b/src/pages/EntitiesPage.tsx
index ef51a99..45cd37b 100644
--- a/src/pages/EntitiesPage.tsx
+++ b/src/pages/EntitiesPage.tsx
@@ -66,11 +66,11 @@ export default function EntitiesPage() {
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
});
- const raw = entitiesQ.data;
+ const raw = entitiesQ.data as unknown;
const entities: { id: number; name: string; code: string; type: string; user_count: number; role_count: number; active: boolean }[] = (() => {
if (!raw) return [];
if (Array.isArray(raw)) return raw as never[];
- const r = raw as Record;
+ const r = raw as { items?: unknown; data?: unknown };
const arr = (r.items ?? r.data ?? []) as never[];
return Array.isArray(arr) ? arr : [];
})();
diff --git a/src/pages/ExamPage.tsx b/src/pages/ExamPage.tsx
deleted file mode 100644
index 0bd5858..0000000
--- a/src/pages/ExamPage.tsx
+++ /dev/null
@@ -1,54 +0,0 @@
-import { Card, CardContent } from "@/components/ui/card";
-import { Badge } from "@/components/ui/badge";
-import { Button } from "@/components/ui/button";
-import { GraduationCap, Play, Sparkles } from "lucide-react";
-import AiTipBanner from "@/components/ai/AiTipBanner";
-
-export default function ExamPage() {
- return (
-
-
-
-
-
-
-
-
-
-
Ready to Start?
-
IELTS Academic Mock Exam
-
-
-
- Student
- Sarah Johnson
-
-
- Level
- B2
-
-
- Modules
- 4
-
-
- Duration
- 180 min
-
-
- Mode
- Practice
-
-
-
-
AI Pre-Exam Tip
-
Your last mock scored 7.5. To target 8.0, focus on time management in Writing Task 2 — you spent 45 min last time vs recommended 40 min.
-
-
- Begin Exam
-
-
-
-
- );
-}
diff --git a/src/pages/ExamsListPage.tsx b/src/pages/ExamsListPage.tsx
index 5066ad4..d99a622 100644
--- a/src/pages/ExamsListPage.tsx
+++ b/src/pages/ExamsListPage.tsx
@@ -37,12 +37,14 @@ export default function ExamsListPage() {
});
const sessionsQ = useInstitutionalExamSessions();
- const sessionItems = sessionsQ.data?.data ?? sessionsQ.data?.items ?? [];
- const sessions = Array.isArray(sessionItems) ? sessionItems : [];
+ const sessionItems = sessionsQ.data?.items ?? [];
+ const sessions: Record[] = Array.isArray(sessionItems)
+ ? (sessionItems as unknown as Record[])
+ : [];
const customExams = customQ.data?.items ?? [];
const q = search.toLowerCase();
- const filteredSessions = sessions.filter((s: Record) =>
+ const filteredSessions = sessions.filter((s) =>
s.name?.toLowerCase().includes(q) || s.course_name?.toLowerCase().includes(q),
);
@@ -140,7 +142,7 @@ export default function ExamsListPage() {
{filteredSessions.length === 0 && (
No exam sessions found.
)}
- {filteredSessions.map((s: Record, i: number) => (
+ {filteredSessions.map((s, i) => (
{i + 1}
{s.name}
diff --git a/src/pages/GenerationPage.tsx b/src/pages/GenerationPage.tsx
index 92aa759..1e8526e 100644
--- a/src/pages/GenerationPage.tsx
+++ b/src/pages/GenerationPage.tsx
@@ -63,7 +63,7 @@ import { mediaService, type Avatar } from "@/services/media.service";
import { examsService } from "@/services/exams.service";
import { api } from "@/lib/api-client";
import { useToast } from "@/hooks/use-toast";
-import type { ExamStructureConfig } from "@/types";
+import type { ExamStructure, ExamStructureConfig } from "@/types";
type ModuleKey = "reading" | "listening" | "writing" | "speaking" | "level" | "industry";
@@ -398,7 +398,7 @@ export default function GenerationPage() {
const createStructureMut = useMutation({
mutationFn: (data: { name: string; modules: string[] }) =>
- examsService.createStructure(data),
+ examsService.createStructure(data as unknown as Partial),
onSuccess: (created) => {
queryClient.invalidateQueries({ queryKey: ["exam-structures"] });
setExamStructure(String(created.id));
@@ -693,7 +693,7 @@ export default function GenerationPage() {
try {
const status = await mediaService.getVideoStatus(videoId);
if (status.status === "done" || status.status === "completed" || status.video_url) {
- updatedParts[pp.index] = { ...part, videoUrl: status.video_url || status.url || "" };
+ updatedParts[pp.index] = { ...part, videoUrl: status.video_url || "" };
changed = true;
toast({ title: "Video ready!", description: "Avatar video has been generated." });
} else if (status.status === "error" || status.status === "failed") {
@@ -726,7 +726,7 @@ export default function GenerationPage() {
totalMarks: st.totalMarks,
passages: mod === "reading" ? st.passages.map((p) => ({
text: p.text, category: p.category, type: p.type,
- exercises: p.exercises?.map((ex: Record) => ({
+ exercises: p.exercises?.map((ex) => ({
type: ex.type, prompt: ex.prompt, options: ex.options,
correct_answer: ex.correct_answer, explanation: ex.explanation,
instructions: ex.instructions, marks: ex.marks || 1,
@@ -735,7 +735,7 @@ export default function GenerationPage() {
})) : undefined,
sections: mod === "listening" ? st.listeningSections.map((s) => ({
type: s.type, context: s.context, audioUrl: s.audioUrl,
- exercises: s.exercises?.map((ex: Record) => ({
+ exercises: s.exercises?.map((ex) => ({
type: ex.type, prompt: ex.prompt, options: ex.options,
correct_answer: ex.correct_answer, explanation: ex.explanation,
instructions: ex.instructions, marks: ex.marks || 1,
diff --git a/src/pages/Index.tsx b/src/pages/Index.tsx
deleted file mode 100644
index 7130b54..0000000
--- a/src/pages/Index.tsx
+++ /dev/null
@@ -1,14 +0,0 @@
-// Update this page (the content is just a fallback if you fail to update the page)
-
-const Index = () => {
- return (
-
-
-
Welcome to Your Blank App
-
Start building your amazing project here!
-
-
- );
-};
-
-export default Index;
diff --git a/src/pages/PrivacyCenter.tsx b/src/pages/PrivacyCenter.tsx
new file mode 100644
index 0000000..6404265
--- /dev/null
+++ b/src/pages/PrivacyCenter.tsx
@@ -0,0 +1,173 @@
+import { useState } from "react";
+import { useNavigate } from "react-router-dom";
+import { Download, ShieldAlert, Trash2 } from "lucide-react";
+import { toast } from "sonner";
+
+import { Button } from "@/components/ui/button";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { gdprService } from "@/services/gdpr.service";
+import { useAuth } from "@/contexts/AuthContext";
+
+export default function PrivacyCenter() {
+ const navigate = useNavigate();
+ const { logout } = useAuth();
+ const [exporting, setExporting] = useState(false);
+ const [erasing, setErasing] = useState(false);
+ const [confirmOpen, setConfirmOpen] = useState(false);
+ const [confirmText, setConfirmText] = useState("");
+
+ const downloadExport = async () => {
+ try {
+ setExporting(true);
+ const payload = await gdprService.exportMyData();
+ const blob = new Blob([JSON.stringify(payload, null, 2)], {
+ type: "application/json",
+ });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = `encoach-data-export-${new Date()
+ .toISOString()
+ .slice(0, 10)}.json`;
+ document.body.appendChild(a);
+ a.click();
+ a.remove();
+ URL.revokeObjectURL(url);
+ toast.success("Data export downloaded");
+ } catch (err) {
+ toast.error(err instanceof Error ? err.message : "Export failed");
+ } finally {
+ setExporting(false);
+ }
+ };
+
+ const eraseAccount = async () => {
+ try {
+ setErasing(true);
+ await gdprService.eraseMyAccount();
+ toast.success("Your account has been erased. Signing out…");
+ setTimeout(async () => {
+ try {
+ await logout();
+ } catch {
+ // ignore — the server may have already invalidated our tokens
+ }
+ navigate("/login", { replace: true });
+ }, 1200);
+ } catch (err) {
+ toast.error(err instanceof Error ? err.message : "Erasure failed");
+ setErasing(false);
+ }
+ };
+
+ return (
+
+
+
+
+ Privacy Center
+
+
+ Manage your personal data held by EnCoach under GDPR and equivalent
+ regulations.
+
+
+
+
+
+ Download your data
+
+ We'll package your profile, entity memberships, exam attempts,
+ answers, AI feedback, and tickets into a single JSON file.
+
+
+
+
+
+ {exporting ? "Preparing export…" : "Download my data"}
+
+
+
+
+
+
+ Delete my account
+
+ This anonymises your profile and removes personal fields from our
+ records. Exam attempts are retained (without identifying
+ information) for statistical integrity. This action cannot be
+ undone.
+
+
+
+ setConfirmOpen(true)}
+ disabled={erasing}
+ >
+
+ {erasing ? "Erasing…" : "Erase my account"}
+
+
+
+
+
+
+
+ Erase your account?
+
+ We'll anonymise your personal data and deactivate your login.
+ Aggregate analytics (exam scores, attempt counts) will remain
+ but will no longer be linked to you.
+
+
+
+
+ Type DELETE to confirm:
+
+ setConfirmText(e.target.value)}
+ placeholder="DELETE"
+ />
+
+
+ setConfirmText("")}>
+ Cancel
+
+ {
+ setConfirmOpen(false);
+ await eraseAccount();
+ setConfirmText("");
+ }}
+ className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
+ >
+ Erase account
+
+
+
+
+
+ );
+}
diff --git a/src/pages/ProfilePage.tsx b/src/pages/ProfilePage.tsx
deleted file mode 100644
index 7005398..0000000
--- a/src/pages/ProfilePage.tsx
+++ /dev/null
@@ -1,75 +0,0 @@
-import { useState } from "react";
-import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
-import { Input } from "@/components/ui/input";
-import { Button } from "@/components/ui/button";
-import { Label } from "@/components/ui/label";
-import { User } from "lucide-react";
-import { api } from "@/lib/api-client";
-import { useMutation } from "@tanstack/react-query";
-import { useToast } from "@/hooks/use-toast";
-import { useAuth } from "@/contexts/AuthContext";
-
-export default function ProfilePage() {
- const { user } = useAuth();
- const { toast } = useToast();
- const nameParts = (user?.name || "Admin User").split(" ");
- const [first, setFirst] = useState(nameParts[0] || "");
- const [last, setLast] = useState(nameParts.slice(1).join(" ") || "");
- const [email, setEmail] = useState(user?.email || "");
- const [curPw, setCurPw] = useState("");
- const [newPw, setNewPw] = useState("");
- const [confirmPw, setConfirmPw] = useState("");
-
- const saveMut = useMutation({
- mutationFn: () => api.patch("/user", { first_name: first, last_name: last, email }),
- onSuccess: () => toast({ title: "Profile saved" }),
- onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
- });
-
- const pwMut = useMutation({
- mutationFn: () => api.post("/user/change-password", { current_password: curPw, new_password: newPw }),
- onSuccess: () => { setCurPw(""); setNewPw(""); setConfirmPw(""); toast({ title: "Password updated" }); },
- onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
- });
-
- return (
-
-
-
Profile
-
Manage your account information.
-
-
-
- Personal Information
-
-
-
- Email setEmail(e.target.value)} type="email" />
- saveMut.mutate()} disabled={saveMut.isPending}>
- {saveMut.isPending ? "Saving..." : "Save Changes"}
-
-
-
-
-
- Change Password
-
- Current Password setCurPw(e.target.value)} placeholder="••••••••" />
- New Password setNewPw(e.target.value)} placeholder="••••••••" />
- Confirm New Password setConfirmPw(e.target.value)} placeholder="••••••••" />
- {newPw && confirmPw && newPw !== confirmPw && Passwords do not match.
}
- pwMut.mutate()} disabled={pwMut.isPending || !curPw || !newPw || newPw !== confirmPw}>
- {pwMut.isPending ? "Updating..." : "Update Password"}
-
-
-
-
- );
-}
diff --git a/src/pages/RubricsPage.tsx b/src/pages/RubricsPage.tsx
index c0dc6ce..e5c984f 100644
--- a/src/pages/RubricsPage.tsx
+++ b/src/pages/RubricsPage.tsx
@@ -165,7 +165,7 @@ export default function RubricsPage() {
queryKey: ["rubrics"],
queryFn: () => examsService.listRubrics({}),
});
- const rubrics = (rubricsQ.data?.items ?? []) as RubricItem[];
+ const rubrics = (rubricsQ.data?.items ?? []) as unknown as RubricItem[];
const rubricGroupsQ = useQuery({
queryKey: ["rubric-groups"],
diff --git a/src/pages/admin/AIFeedbackTriage.tsx b/src/pages/admin/AIFeedbackTriage.tsx
new file mode 100644
index 0000000..fa12f4b
--- /dev/null
+++ b/src/pages/admin/AIFeedbackTriage.tsx
@@ -0,0 +1,348 @@
+import { useMemo, useState } from "react";
+import { CheckCircle2, Filter, MessageSquare, ThumbsDown, ThumbsUp } from "lucide-react";
+import { toast } from "sonner";
+
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import {
+ Dialog,
+ DialogContent,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { Skeleton } from "@/components/ui/skeleton";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import { Textarea } from "@/components/ui/textarea";
+import {
+ useAIFeedbackList,
+ useResolveAIFeedback,
+} from "@/hooks/queries/useAIFeedback";
+import type {
+ AIFeedback,
+ AIFeedbackStatus,
+ AIFeedbackSubjectType,
+} from "@/types/ai-feedback";
+
+const STATUS_COLORS: Record = {
+ open: "bg-amber-100 text-amber-900 border-amber-300",
+ acknowledged: "bg-sky-100 text-sky-900 border-sky-300",
+ fixed: "bg-emerald-100 text-emerald-900 border-emerald-300",
+ dismissed: "bg-slate-100 text-slate-700 border-slate-300",
+};
+
+type ResolveChoice = Exclude;
+
+function ResolveDialog({
+ feedback,
+ onClose,
+}: {
+ feedback: AIFeedback | null;
+ onClose: () => void;
+}) {
+ const resolve = useResolveAIFeedback();
+ const [status, setStatus] = useState("acknowledged");
+ const [notes, setNotes] = useState("");
+
+ const open = !!feedback;
+
+ return (
+ (!v ? onClose() : null)}>
+
+
+ Resolve feedback
+
+
+
+
+ Status
+
+
setStatus(v as ResolveChoice)}
+ >
+
+
+
+
+ Acknowledged
+ Fixed
+ Dismissed
+
+
+
+
+
+
+
+ Cancel
+
+ {
+ if (!feedback) return;
+ try {
+ await resolve.mutateAsync({
+ id: feedback.id,
+ status,
+ notes,
+ });
+ toast.success(`Marked ${status}`);
+ setNotes("");
+ onClose();
+ } catch (err) {
+ toast.error(
+ err instanceof Error ? err.message : "Resolve failed",
+ );
+ }
+ }}
+ disabled={resolve.isPending}
+ >
+
+ Save
+
+
+
+
+ );
+}
+
+export default function AIFeedbackTriage() {
+ const [statusFilter, setStatusFilter] = useState("open");
+ const [ratingFilter, setRatingFilter] = useState<"up" | "down" | "all">("down");
+ const [subjectFilter, setSubjectFilter] = useState<
+ AIFeedbackSubjectType | "all"
+ >("all");
+ const [selected, setSelected] = useState(null);
+
+ const params = useMemo(
+ () => ({
+ status: statusFilter === "all" ? undefined : statusFilter,
+ rating: ratingFilter === "all" ? undefined : ratingFilter,
+ subject_type: subjectFilter === "all" ? undefined : subjectFilter,
+ page: 0,
+ size: 50,
+ }),
+ [statusFilter, ratingFilter, subjectFilter],
+ );
+ const { data, isLoading } = useAIFeedbackList(params);
+ const items: AIFeedback[] = data?.items ?? [];
+
+ return (
+
+
+
+
+ AI feedback triage
+
+
+ Student thumbs up/down on AI output — use this to catch broken
+ prompts, bad questions, and low-quality coach replies.
+
+
+
+
+
+
+
+ Filters
+
+
+ Default view surfaces open thumbs-down reports, which are usually
+ the most actionable.
+
+
+
+
+
+ Status
+
+
+ setStatusFilter(v as AIFeedbackStatus | "all")
+ }
+ >
+
+
+
+
+ All
+ Open
+ Acknowledged
+ Fixed
+ Dismissed
+
+
+
+
+
+ Rating
+
+
setRatingFilter(v as "up" | "down" | "all")}
+ >
+
+
+
+
+ All
+ Thumbs down
+ Thumbs up
+
+
+
+
+
+ Type
+
+
+ setSubjectFilter(v as AIFeedbackSubjectType | "all")
+ }
+ >
+
+
+
+
+ All
+ Exam question
+ Coach reply
+ Explanation
+ Translation
+ Report narrative
+ Other
+
+
+
+
+
+
+
+
+ Feedback ({data?.total ?? 0})
+
+
+ {isLoading ? (
+
+ ) : items.length === 0 ? (
+
+ No feedback matches the current filters.
+
+ ) : (
+
+
+
+ When
+ User
+ Subject
+ Rating
+ Prompt
+ Comment
+ Status
+ Actions
+
+
+
+ {items.map((f) => (
+
+
+ {f.create_date
+ ? new Date(f.create_date).toLocaleString()
+ : "—"}
+
+ {f.user_name}
+
+ {f.subject_key}
+
+
+ {f.rating === "up" ? (
+
+
+ Up
+
+ ) : (
+
+
+ Down
+
+ )}
+
+
+ {f.prompt_key
+ ? `${f.prompt_key} v${f.prompt_version ?? "?"}`
+ : "—"}
+
+
+ {f.comment || — }
+
+
+
+ {f.status}
+
+
+
+ {f.status === "open" ? (
+ setSelected(f)}
+ >
+ Resolve
+
+ ) : (
+
+ {f.resolution_notes || "—"}
+
+ )}
+
+
+ ))}
+
+
+ )}
+
+
+
+
setSelected(null)} />
+
+ );
+}
diff --git a/src/pages/admin/AIPromptEditor.tsx b/src/pages/admin/AIPromptEditor.tsx
new file mode 100644
index 0000000..1b4c578
--- /dev/null
+++ b/src/pages/admin/AIPromptEditor.tsx
@@ -0,0 +1,528 @@
+import { useEffect, useMemo, useState } from "react";
+
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import {
+ Dialog,
+ DialogContent,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import { Skeleton } from "@/components/ui/skeleton";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import { Textarea } from "@/components/ui/textarea";
+import {
+ useAIPrompt,
+ useAIPromptKeys,
+ useAIPromptVersions,
+ useActivateAIPrompt,
+ useCreateAIPrompt,
+ useRenderAIPrompt,
+} from "@/hooks/queries/useAIPrompts";
+import type { AIPromptSummary } from "@/types/ai-prompt";
+import { CheckCircle2, FileText, History, Play, PlusCircle } from "lucide-react";
+import { toast } from "sonner";
+
+function SelectedKeyPanel({
+ selectedKey,
+ onSelectVersion,
+ activePromptId,
+}: {
+ selectedKey: string;
+ onSelectVersion: (id: number) => void;
+ activePromptId: number | null;
+}) {
+ const { data, isLoading } = useAIPromptVersions(selectedKey);
+ const activate = useActivateAIPrompt();
+
+ if (isLoading) {
+ return ;
+ }
+ const versions = data ?? [];
+ if (!versions.length) {
+ return (
+
+ No versions yet for this prompt.
+
+ );
+ }
+
+ return (
+
+
+
+ Version
+ Title
+ Author
+ Status
+ Actions
+
+
+
+ {versions.map((v) => (
+
+ v{v.version}
+ {v.title}
+ {v.author_name || "—"}
+
+ {v.is_active ? (
+
+ Active
+
+ ) : (
+ Archived
+ )}
+
+
+ onSelectVersion(v.id)}
+ >
+
+ Open
+
+ {!v.is_active ? (
+ {
+ try {
+ await activate.mutateAsync(v.id);
+ toast.success(`Activated v${v.version}`);
+ } catch (err) {
+ toast.error(
+ err instanceof Error ? err.message : "Activate failed",
+ );
+ }
+ }}
+ disabled={activate.isPending}
+ >
+
+ Activate
+
+ ) : null}
+
+
+ ))}
+
+
+ );
+}
+
+function PromptDetail({ promptId }: { promptId: number }) {
+ const { data, isLoading } = useAIPrompt(promptId);
+ const render = useRenderAIPrompt();
+
+ // Variables detected from the prompt body — keep local state keyed by name
+ // so the editor can show one input per placeholder.
+ const [sampleValues, setSampleValues] = useState>({});
+ const [rendered, setRendered] = useState("");
+
+ useEffect(() => {
+ setRendered("");
+ setSampleValues({});
+ }, [promptId]);
+
+ if (isLoading || !data) {
+ return ;
+ }
+
+ const variables = data.variables ?? [];
+
+ return (
+
+
+
+ {data.title}{" "}
+
+ ({data.key} v{data.version})
+
+
+ {data.description ? (
+
+ {data.description}
+
+ ) : null}
+
+
+
+
Template
+
+
+ {data.content}
+
+
+
+
+ {variables.length > 0 ? (
+
+
+ Sample variables
+
+
+ {variables.map((v) => (
+
+ {v}
+
+ setSampleValues((prev) => ({
+ ...prev,
+ [v]: e.target.value,
+ }))
+ }
+ placeholder={`Sample value for {${v}}`}
+ />
+
+ ))}
+
+
{
+ try {
+ const r = await render.mutateAsync({
+ id: data.id,
+ variables: sampleValues,
+ });
+ setRendered(r.rendered);
+ } catch (err) {
+ toast.error(
+ err instanceof Error ? err.message : "Render failed",
+ );
+ }
+ }}
+ disabled={render.isPending}
+ >
+
+ Render preview
+
+ {rendered ? (
+
+
+ {rendered}
+
+
+ ) : null}
+
+ ) : null}
+
+ );
+}
+
+function NewVersionDialog({
+ open,
+ onOpenChange,
+ defaultKey,
+ defaultContent,
+}: {
+ open: boolean;
+ onOpenChange: (v: boolean) => void;
+ defaultKey: string;
+ defaultContent: string;
+}) {
+ const create = useCreateAIPrompt();
+ const [key, setKey] = useState(defaultKey);
+ const [title, setTitle] = useState("");
+ const [description, setDescription] = useState("");
+ const [content, setContent] = useState(defaultContent);
+ const [activate, setActivate] = useState(true);
+
+ useEffect(() => {
+ if (open) {
+ setKey(defaultKey);
+ setContent(defaultContent);
+ setTitle("");
+ setDescription("");
+ setActivate(true);
+ }
+ }, [open, defaultKey, defaultContent]);
+
+ return (
+
+
+
+ New prompt version
+
+
+
+
Key
+
setKey(e.target.value.trim())}
+ placeholder="exam.mcq.generate"
+ />
+
+ Lowercase dotted identifier. Reuse an existing key to bump its
+ version.
+
+
+
+ Title
+ setTitle(e.target.value)}
+ placeholder="MCQ generation — CEFR B2 — v6"
+ />
+
+
+ Description (optional)
+
+
+ Template
+
+
+ setActivate(e.target.checked)}
+ />
+ Activate this version immediately
+
+
+
+ onOpenChange(false)}>
+ Cancel
+
+ {
+ if (!key || !title || !content.trim()) {
+ toast.error("Key, title, and content are required");
+ return;
+ }
+ try {
+ await create.mutateAsync({
+ key,
+ title,
+ description,
+ content,
+ activate,
+ });
+ toast.success("Prompt version created");
+ onOpenChange(false);
+ } catch (err) {
+ toast.error(
+ err instanceof Error ? err.message : "Create failed",
+ );
+ }
+ }}
+ disabled={create.isPending}
+ >
+ Save version
+
+
+
+
+ );
+}
+
+export default function AIPromptEditor() {
+ const [search, setSearch] = useState("");
+ const { data, isLoading } = useAIPromptKeys({ search, page: 1, size: 50 });
+ const [selectedKey, setSelectedKey] = useState(null);
+ const [selectedPromptId, setSelectedPromptId] = useState(null);
+ const [newOpen, setNewOpen] = useState(false);
+
+ const items: AIPromptSummary[] = data?.items ?? [];
+ const activePromptId = useMemo(() => {
+ if (!selectedKey) return null;
+ const match = items.find((it) => it.key === selectedKey);
+ return match?.id ?? null;
+ }, [items, selectedKey]);
+
+ // Autoselect first key on load.
+ useEffect(() => {
+ if (!selectedKey && items.length) {
+ setSelectedKey(items[0].key);
+ setSelectedPromptId(items[0].id);
+ }
+ }, [items, selectedKey]);
+
+ return (
+
+
+
+
+ AI prompt library
+
+
+ Versioned, auditable templates that the AI pipelines render at
+ runtime. Non-engineers can iterate here without shipping code.
+
+
+
setNewOpen(true)}>
+
+ New version
+
+
+
+
+
+ Prompt keys
+
+ Each row shows the latest version of a prompt key. Click a row to
+ inspect all versions.
+
+
+
+ setSearch(e.target.value)}
+ className="max-w-sm"
+ />
+ {isLoading ? (
+
+ ) : items.length === 0 ? (
+
+ No prompts yet. Click New version to add one.
+
+ ) : (
+
+
+
+ Key
+ Latest
+ Title
+ Variables
+ Status
+
+
+
+ {items.map((p) => (
+ {
+ setSelectedKey(p.key);
+ setSelectedPromptId(p.id);
+ }}
+ >
+ {p.key}
+
+ v{p.version}{" "}
+ {p.total_versions && p.total_versions > 1 ? (
+
+ (of {p.total_versions})
+
+ ) : null}
+
+ {p.title}
+
+
+ {p.variables.slice(0, 4).map((v) => (
+
+ {v}
+
+ ))}
+ {p.variables.length > 4 ? (
+
+ +{p.variables.length - 4}
+
+ ) : null}
+
+
+
+ {p.is_active ? (
+
+ Active
+
+ ) : (
+ No active
+ )}
+
+
+ ))}
+
+
+ )}
+
+
+
+ {selectedKey ? (
+
+
+
+
+
+ Versions of {selectedKey}
+
+
+
+
+
+
+
+
+ Preview
+
+ Inspect template body and try a dry-run render.
+
+
+
+ {selectedPromptId ? (
+
+ ) : (
+
+ Select a version from the list.
+
+ )}
+
+
+
+ ) : null}
+
+
+
+ );
+}
diff --git a/src/pages/admin/AdminGradebook.tsx b/src/pages/admin/AdminGradebook.tsx
index 79f0540..86661d5 100644
--- a/src/pages/admin/AdminGradebook.tsx
+++ b/src/pages/admin/AdminGradebook.tsx
@@ -24,7 +24,7 @@ export default function AdminGradebook() {
const { data: coursesData } = useCourses({ size: 200 });
const { data: subjectsData } = useSubjects();
const coursesList = coursesData?.items ?? [];
- const subjectsList = Array.isArray(subjectsData) ? subjectsData : subjectsData?.data ?? subjectsData?.items ?? [];
+ const subjectsList = Array.isArray(subjectsData) ? subjectsData : [];
const createMutation = useCreateGradingAssignment();
const updateMutation = useUpdateGradingAssignment();
const deleteMutation = useDeleteGradingAssignment();
diff --git a/src/pages/admin/AdminLessons.tsx b/src/pages/admin/AdminLessons.tsx
index ac4eb5d..d3a5be7 100644
--- a/src/pages/admin/AdminLessons.tsx
+++ b/src/pages/admin/AdminLessons.tsx
@@ -29,7 +29,7 @@ export default function AdminLessons() {
const { data: subjectsData } = useSubjects();
const courses = coursesData?.items ?? [];
const allBatches = batchesData?.items ?? [];
- const subjects = Array.isArray(subjectsData) ? subjectsData : subjectsData?.data ?? subjectsData?.items ?? [];
+ const subjects = Array.isArray(subjectsData) ? subjectsData : [];
const batchesForCourse = useMemo(
() => (form.course_id ? allBatches.filter((b) => b.course_id === Number(form.course_id)) : allBatches),
[allBatches, form.course_id],
diff --git a/src/pages/admin/AdminLmsDashboard.tsx b/src/pages/admin/AdminLmsDashboard.tsx
index 7468b51..e09eb5b 100644
--- a/src/pages/admin/AdminLmsDashboard.tsx
+++ b/src/pages/admin/AdminLmsDashboard.tsx
@@ -39,9 +39,10 @@ export default function AdminLmsDashboard() {
const { data: dbStats } = useQuery({
queryKey: ["dashboard", "stats"],
- queryFn: async () => {
- const res = await api.get<{ data: DashboardStats }>("/stats");
- return (res as { data: DashboardStats }).data ?? res;
+ queryFn: async (): Promise => {
+ const res = await api.get<{ data: DashboardStats } | DashboardStats>("/stats");
+ const wrapped = res as { data?: DashboardStats };
+ return wrapped.data ?? (res as DashboardStats);
},
staleTime: 30_000,
});
diff --git a/src/pages/admin/AdminTeachers.tsx b/src/pages/admin/AdminTeachers.tsx
index 812c001..73eed16 100644
--- a/src/pages/admin/AdminTeachers.tsx
+++ b/src/pages/admin/AdminTeachers.tsx
@@ -19,7 +19,7 @@ export default function AdminTeachers() {
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
const [email, setEmail] = useState("");
- const [gender, setGender] = useState("male");
+ const [gender, setGender] = useState<"male" | "female">("male");
const [specialization, setSpecialization] = useState("");
const { toast } = useToast();
const qc = useQueryClient();
@@ -157,7 +157,7 @@ export default function AdminTeachers() {
Email * setEmail(e.target.value)} />
Gender
-
+ setGender(v as "male" | "female")}>
Male
diff --git a/src/pages/admin/ExamReviewDetail.tsx b/src/pages/admin/ExamReviewDetail.tsx
new file mode 100644
index 0000000..34dfa0a
--- /dev/null
+++ b/src/pages/admin/ExamReviewDetail.tsx
@@ -0,0 +1,341 @@
+import { useState } from "react";
+import { Link, useNavigate, useParams } from "react-router-dom";
+import {
+ AlertTriangle,
+ ArrowLeft,
+ CheckCircle2,
+ Sparkles,
+ XCircle,
+} from "lucide-react";
+
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import {
+ Dialog,
+ DialogContent,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+import { Label } from "@/components/ui/label";
+import { Separator } from "@/components/ui/separator";
+import { Skeleton } from "@/components/ui/skeleton";
+import { Textarea } from "@/components/ui/textarea";
+import { toast } from "@/components/ui/sonner";
+
+import {
+ useApproveExamReview,
+ useExamReviewDetail,
+ useRejectExamReview,
+} from "@/hooks/queries/useExamReview";
+import type { ExamReviewQuestion } from "@/types/exam-review";
+
+const FAILING_THRESHOLD = 0.7;
+
+function QuestionCard({ q, index }: { q: ExamReviewQuestion; index: number }) {
+ const failing = (q.quality_score ?? 0) < FAILING_THRESHOLD;
+ return (
+
+
+
+
+
+ Q{index + 1}
+ ·
+
+ {q.skill}
+
+
+ {q.question_type}
+
+
+ {q.difficulty}
+
+ {q.ai_generated && (
+
+
+ AI
+
+ )}
+
+
+ {q.stem.length > 220 ? `${q.stem.slice(0, 220)}…` : q.stem}
+
+
+
= 0.85
+ ? "default"
+ : failing
+ ? "destructive"
+ : "secondary"
+ }
+ className="tabular-nums shrink-0"
+ >
+ {((q.quality_score ?? 0) * 100).toFixed(0)}%
+
+
+
+ {q.quality_report && Object.keys(q.quality_report).length > 0 && (
+
+ Quality report
+
+ {JSON.stringify(q.quality_report, null, 2)}
+
+
+ )}
+
+ );
+}
+
+export default function ExamReviewDetail() {
+ const { examId } = useParams<{ examId: string }>();
+ const navigate = useNavigate();
+ const parsedId = examId ? Number(examId) : undefined;
+
+ const { data: exam, isLoading } = useExamReviewDetail(parsedId);
+ const approve = useApproveExamReview();
+ const reject = useRejectExamReview();
+
+ const [approveNotes, setApproveNotes] = useState("");
+ const [rejectNotes, setRejectNotes] = useState("");
+ const [approveOpen, setApproveOpen] = useState(false);
+ const [rejectOpen, setRejectOpen] = useState(false);
+
+ if (isLoading || !exam || !parsedId) {
+ return (
+
+
+
+
+
+ );
+ }
+
+ const handleApprove = async () => {
+ try {
+ await approve.mutateAsync({ examId: parsedId, notes: approveNotes });
+ toast.success("Exam approved and published.");
+ setApproveOpen(false);
+ navigate("/admin/exam/review-queue");
+ } catch (err) {
+ toast.error(err instanceof Error ? err.message : "Approve failed");
+ }
+ };
+
+ const handleReject = async () => {
+ if (!rejectNotes.trim()) {
+ toast.error("Rejection notes are required.");
+ return;
+ }
+ try {
+ await reject.mutateAsync({ examId: parsedId, notes: rejectNotes });
+ toast.success("Exam rejected and returned to draft.");
+ setRejectOpen(false);
+ navigate("/admin/exam/review-queue");
+ } catch (err) {
+ toast.error(err instanceof Error ? err.message : "Reject failed");
+ }
+ };
+
+ const summary = exam.summary;
+ const alreadyReviewed = exam.status !== "pending_review";
+
+ return (
+
+
+
+
+
+ Back to queue
+
+
+
{exam.title}
+
+
+ {exam.status.replace("_", " ")}
+
+ {exam.subject_name && · {exam.subject_name} }
+ {exam.teacher_name && · Created by {exam.teacher_name} }
+
+
+
+ {!alreadyReviewed && (
+
+
+
+
+ Reject
+
+
+
+
+ Reject this exam?
+
+
+
+ Feedback for the author (required)
+
+
+
+ setRejectOpen(false)}
+ disabled={reject.isPending}
+ >
+ Cancel
+
+
+ Reject & return to draft
+
+
+
+
+
+
+
+
+ Approve & publish
+
+
+
+
+ Approve and publish?
+
+
+ Notes (optional)
+
+
+ setApproveOpen(false)}
+ disabled={approve.isPending}
+ >
+ Cancel
+
+
+ Publish exam
+
+
+
+
+
+ )}
+
+
+ {alreadyReviewed && exam.reviewed_by_name && (
+
+
+ Previously reviewed
+
+ {exam.reviewed_by_name}
+ {exam.reviewed_at ? ` · ${new Date(exam.reviewed_at).toLocaleString()}` : ""}
+
+
+ {exam.review_notes && (
+
+ {exam.review_notes}
+
+ )}
+
+ )}
+
+
+
+ Quality summary
+
+ Aggregated across every question in this exam.
+
+
+
+
+
+
Questions
+
+ {summary.question_count}
+
+
+
+
Average quality
+
+ {(summary.avg_quality_score * 100).toFixed(0)}%
+
+
+
+
Lowest score
+
+ {(summary.min_quality_score * 100).toFixed(0)}%
+
+
+
+
+
+ {summary.failing_count}
+
+
+
+
+
+
+
+
+
+ {exam.sections.length === 0 ? (
+
+ This exam has no sections yet.
+
+ ) : (
+ exam.sections.map((section) => (
+
+
+ {section.title}
+
+ {section.skill || "general"}
+
+
+ · {section.questions.length} questions
+
+
+
+ {section.questions.map((q, idx) => (
+
+ ))}
+
+
+ ))
+ )}
+
+
+ );
+}
diff --git a/src/pages/admin/ExamReviewQueue.tsx b/src/pages/admin/ExamReviewQueue.tsx
new file mode 100644
index 0000000..12c4a7d
--- /dev/null
+++ b/src/pages/admin/ExamReviewQueue.tsx
@@ -0,0 +1,205 @@
+import { useState } from "react";
+import { Link } from "react-router-dom";
+import {
+ AlertTriangle,
+ CheckCircle2,
+ FileText,
+ Search,
+ Sparkles,
+} from "lucide-react";
+
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Input } from "@/components/ui/input";
+import { Skeleton } from "@/components/ui/skeleton";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+
+import { useExamReviewQueue } from "@/hooks/queries/useExamReview";
+
+/**
+ * Admin-facing queue of AI-generated exams that failed the automated quality
+ * gate and need a human to sign off before they can be published to students.
+ *
+ * The list intentionally stays dense — each row shows only the signals an
+ * operator needs to prioritise (quality score, failing-question count, AI
+ * model used). Clicking through to the detail page reveals the full report.
+ */
+export default function ExamReviewQueue() {
+ const [search, setSearch] = useState("");
+ const [page, setPage] = useState(1);
+ const pageSize = 20;
+
+ const { data, isLoading } = useExamReviewQueue({
+ page,
+ size: pageSize,
+ search: search || undefined,
+ status: "pending_review",
+ });
+
+ const items = data?.items ?? [];
+ const total = data?.total ?? 0;
+ const pageCount = Math.max(1, Math.ceil(total / pageSize));
+
+ return (
+
+
+
+
Exam Review Queue
+
+ AI-generated exams that failed the automated quality gate. Review
+ each one, then approve to publish or reject back to draft.
+
+
+
+
+ {total} awaiting review
+
+
+
+
+
+
+
+
+ {
+ setPage(1);
+ setSearch(e.target.value);
+ }}
+ className="pl-9"
+ />
+
+
+
+
+ {isLoading ? (
+
+
+
+
+
+ ) : items.length === 0 ? (
+
+
+
+ Nothing in the review queue. Any time an AI-generated exam
+ fails the quality gate, it will show up here.
+
+
+ ) : (
+
+
+
+ Exam
+ Subject
+ Questions
+ Avg. Quality
+ Failing
+ Action
+
+
+
+ {items.map((exam) => {
+ const avg = exam.summary.avg_quality_score;
+ const failing = exam.summary.failing_count;
+ return (
+
+
+
+
+
+
+ {exam.title}
+
+
+ {exam.summary.ai_generated_count} AI-generated ·{" "}
+ {exam.teacher_name || "Unassigned"}
+
+
+
+
+
+ {exam.subject_name || "—"}
+
+
+ {exam.summary.question_count}
+
+
+ = 0.85
+ ? "default"
+ : avg >= 0.7
+ ? "secondary"
+ : "destructive"
+ }
+ className="tabular-nums"
+ >
+ {(avg * 100).toFixed(0)}%
+
+
+
+ {failing > 0 ? (
+
+
+ {failing}
+
+ ) : (
+ —
+ )}
+
+
+
+ Review
+
+
+
+ );
+ })}
+
+
+ )}
+
+
+
+ {pageCount > 1 && (
+
+
+ Page {page} of {pageCount} · {total} total
+
+
+ setPage((p) => Math.max(1, p - 1))}
+ >
+ Previous
+
+ = pageCount}
+ onClick={() => setPage((p) => Math.min(pageCount, p + 1))}
+ >
+ Next
+
+
+
+ )}
+
+ );
+}
diff --git a/src/pages/admin/IeltsExamValidation.tsx b/src/pages/admin/IeltsExamValidation.tsx
index 73532e2..1b4b590 100644
--- a/src/pages/admin/IeltsExamValidation.tsx
+++ b/src/pages/admin/IeltsExamValidation.tsx
@@ -19,7 +19,7 @@ import {
} from "@/components/ui/dialog";
import { Skeleton } from "@/components/ui/skeleton";
import { Separator } from "@/components/ui/separator";
-import { useIeltsValidation, usePublishIeltsExam, useAssignIeltsExam } from "@/hooks/queries/useExamTemplates";
+import { useIeltsExamValidation, usePublishIeltsExam, useAssignIeltsExam } from "@/hooks/queries/useExamTemplates";
import { useStudents, useBatches } from "@/hooks/queries/useLms";
import { ieltsExamService } from "@/services/ielts-exam.service";
import type { ExamValidationReport, ValidationCheck } from "@/types";
@@ -40,7 +40,7 @@ export default function IeltsExamValidation() {
const navigate = useNavigate();
const examId = Number(examIdParam);
- const validationQ = useIeltsValidation(Number.isFinite(examId) ? examId : 0);
+ const validationQ = useIeltsExamValidation(Number.isFinite(examId) ? examId : 0);
const publishMut = usePublishIeltsExam();
const assignMut = useAssignIeltsExam();
diff --git a/src/pages/admin/ResourceManager.tsx b/src/pages/admin/ResourceManager.tsx
index fdbe562..b14bf3e 100644
--- a/src/pages/admin/ResourceManager.tsx
+++ b/src/pages/admin/ResourceManager.tsx
@@ -176,7 +176,7 @@ function EditResourceDialog({
Status
-
+ setStatus(v as "pending" | "approved" | "rejected")}>
Pending
diff --git a/src/pages/admin/TaxonomyManager.tsx b/src/pages/admin/TaxonomyManager.tsx
index ff1a8b5..0190b36 100644
--- a/src/pages/admin/TaxonomyManager.tsx
+++ b/src/pages/admin/TaxonomyManager.tsx
@@ -50,7 +50,7 @@ export default function TaxonomyManager() {
const [showAddTopic, setShowAddTopic] = useState(null);
const [newTopicName, setNewTopicName] = useState("");
- const [newTopicDifficulty, setNewTopicDifficulty] = useState("medium");
+ const [newTopicDifficulty, setNewTopicDifficulty] = useState<"easy" | "medium" | "hard">("medium");
const [newTopicHours, setNewTopicHours] = useState("1");
const [editingSubjectId, setEditingSubjectId] = useState(null);
@@ -62,7 +62,7 @@ export default function TaxonomyManager() {
const [editingTopicId, setEditingTopicId] = useState(null);
const [editTopicName, setEditTopicName] = useState("");
- const [editTopicDifficulty, setEditTopicDifficulty] = useState("medium");
+ const [editTopicDifficulty, setEditTopicDifficulty] = useState<"easy" | "medium" | "hard">("medium");
const [editTopicHours, setEditTopicHours] = useState("1");
const [editTopicDesc, setEditTopicDesc] = useState("");
@@ -142,7 +142,7 @@ export default function TaxonomyManager() {
const startEditTopic = (t: { id: number; name: string; difficulty_level?: string; estimated_hours?: number; description?: string }) => {
setEditingTopicId(t.id);
setEditTopicName(t.name);
- setEditTopicDifficulty(t.difficulty_level ?? "medium");
+ setEditTopicDifficulty((t.difficulty_level as "easy" | "medium" | "hard") ?? "medium");
setEditTopicHours(String(t.estimated_hours ?? 1));
setEditTopicDesc(t.description ?? "");
};
@@ -333,7 +333,7 @@ export default function TaxonomyManager() {
{showAddTopic === domain.id && (
setNewTopicName(e.target.value)} className="h-8 text-sm" />
-
+ setNewTopicDifficulty(v as "easy" | "medium" | "hard")}>
Easy
@@ -352,7 +352,7 @@ export default function TaxonomyManager() {
setEditTopicName(e.target.value)} className="h-7 text-sm flex-1" placeholder="Topic name" />
-
+ setEditTopicDifficulty(v as "easy" | "medium" | "hard")}>
Easy
diff --git a/src/pages/student/AiIeltsCourse.tsx b/src/pages/student/AiIeltsCourse.tsx
index ebb8f8e..23b39c1 100644
--- a/src/pages/student/AiIeltsCourse.tsx
+++ b/src/pages/student/AiIeltsCourse.tsx
@@ -166,7 +166,7 @@ export default function AiIeltsCourse() {
const band = Number(targetBand || course?.target_level || 7);
createIelts.mutate(
{
- skill: skillsRanked[0]?.skill ?? "writing",
+ skill: (skillsRanked[0]?.skill ?? "writing") as "reading" | "listening" | "writing" | "speaking",
target_band: Number.isFinite(band) ? band : 7,
},
{
diff --git a/src/pages/student/ExamResults.tsx b/src/pages/student/ExamResults.tsx
index 34fe984..db6bf95 100644
--- a/src/pages/student/ExamResults.tsx
+++ b/src/pages/student/ExamResults.tsx
@@ -78,7 +78,7 @@ export default function ExamResults() {
const { data: results, isLoading, isError } = useQuery({
queryKey: ["exam-results", examId],
- queryFn: () => examSessionService.getResults(Number(examId)),
+ queryFn: () => examSessionService.getResults(Number(examId)),
enabled: !!examId,
});
diff --git a/src/pages/student/StudentAttendance.tsx b/src/pages/student/StudentAttendance.tsx
index 877d91b..4336e93 100644
--- a/src/pages/student/StudentAttendance.tsx
+++ b/src/pages/student/StudentAttendance.tsx
@@ -61,7 +61,7 @@ export default function StudentAttendance() {
{attendanceRecords.map(a => (
{a.date}
- {a.courseName}
+ {a.course_name}
{a.status}
))}
diff --git a/src/pages/student/StudentDiscussionBoard.tsx b/src/pages/student/StudentDiscussionBoard.tsx
index f83aef3..5d44c7f 100644
--- a/src/pages/student/StudentDiscussionBoard.tsx
+++ b/src/pages/student/StudentDiscussionBoard.tsx
@@ -77,7 +77,7 @@ export default function StudentDiscussionBoard() {
const [newTitle, setNewTitle] = useState("");
const [newContent, setNewContent] = useState("");
- const posts = postsData?.results ?? [];
+ const posts = postsData?.items ?? [];
const handleCreatePost = () => {
if (!selectedBoardId) return;
diff --git a/src/pages/student/StudentGrades.tsx b/src/pages/student/StudentGrades.tsx
index 418b2a1..a0f82a7 100644
--- a/src/pages/student/StudentGrades.tsx
+++ b/src/pages/student/StudentGrades.tsx
@@ -11,15 +11,15 @@ export default function StudentGrades() {
if (isLoading) return ;
const avgGrade = gradeRecords.length
- ? (gradeRecords.reduce((sum, g) => sum + (g.grade / g.maxGrade) * 100, 0) / gradeRecords.length).toFixed(1)
+ ? (gradeRecords.reduce((sum, g) => sum + (g.grade / g.max_grade) * 100, 0) / gradeRecords.length).toFixed(1)
: "0";
const highest = gradeRecords.length
- ? Math.max(...gradeRecords.map(g => (g.grade / g.maxGrade) * 100)).toFixed(1)
+ ? Math.max(...gradeRecords.map(g => (g.grade / g.max_grade) * 100)).toFixed(1)
: "0";
const chartData = gradeRecords.map(g => ({
- name: g.assignmentTitle.length > 15 ? g.assignmentTitle.slice(0, 15) + "…" : g.assignmentTitle,
- grade: Math.round((g.grade / g.maxGrade) * 100),
+ name: g.assignment_title.length > 15 ? g.assignment_title.slice(0, 15) + "…" : g.assignment_title,
+ grade: Math.round((g.grade / g.max_grade) * 100),
}));
return (
@@ -69,11 +69,11 @@ export default function StudentGrades() {
{gradeRecords.map(g => (
- {g.assignmentTitle}
- {g.courseName}
+ {g.assignment_title}
+ {g.course_name}
{g.type}
{g.date}
- {g.grade}/{g.maxGrade}
+ {g.grade}/{g.max_grade}
))}
diff --git a/src/pages/student/StudentMessages.tsx b/src/pages/student/StudentMessages.tsx
index 907a707..09f74d3 100644
--- a/src/pages/student/StudentMessages.tsx
+++ b/src/pages/student/StudentMessages.tsx
@@ -23,8 +23,8 @@ export default function StudentMessages() {
const { data: unreadData } = useUnreadMessageCount();
const sendMessage = useSendMessage();
- const inbox = inboxData?.results ?? [];
- const sent = sentData?.results ?? [];
+ const inbox = inboxData?.items ?? [];
+ const sent = sentData?.items ?? [];
const unreadCount = unreadData?.count ?? 0;
const [showCompose, setShowCompose] = useState(false);
diff --git a/src/pages/student/StudentTimetable.tsx b/src/pages/student/StudentTimetable.tsx
index 86aa2ac..65557b1 100644
--- a/src/pages/student/StudentTimetable.tsx
+++ b/src/pages/student/StudentTimetable.tsx
@@ -8,21 +8,21 @@ export default function StudentTimetable() {
const { data: timetableSessions = [], isLoading } = useTimetable();
if (isLoading) return ;
- const mySessions = timetableSessions.filter(s => ["c1", "c2", "c5", "c8"].includes(s.courseId));
+ const mySessions = timetableSessions.filter(s => ["c1", "c2", "c5", "c8"].includes(String(s.course_id)));
function getSessionAt(day: string, hour: string) {
- return mySessions.find(s => s.day === day && s.startTime <= hour && s.endTime > hour);
+ return mySessions.find(s => s.day === day && s.start_time <= hour && s.end_time > hour);
}
function isStart(day: string, hour: string) {
- return mySessions.find(s => s.day === day && s.startTime === hour);
+ return mySessions.find(s => s.day === day && s.start_time === hour);
}
function getSpan(session: (typeof mySessions)[0]) {
- const start = parseInt(session.startTime.split(":")[0]);
- const end = parseInt(session.endTime.split(":")[0]);
- const startMin = parseInt(session.startTime.split(":")[1]);
- const endMin = parseInt(session.endTime.split(":")[1]);
+ const start = parseInt(session.start_time.split(":")[0]);
+ const end = parseInt(session.end_time.split(":")[0]);
+ const startMin = parseInt(session.start_time.split(":")[1]);
+ const endMin = parseInt(session.end_time.split(":")[1]);
return (end - start) + (endMin > 0 ? 0.5 : 0) - (startMin > 0 ? 0.5 : 0);
}
@@ -55,8 +55,8 @@ export default function StudentTimetable() {
className="rounded-md p-2 text-xs text-white"
style={{ backgroundColor: session.color, minHeight: `${getSpan(session) * 48}px` }}
>
- {session.courseName}
- {session.startTime}-{session.endTime}
+ {session.course_name}
+ {session.start_time}-{session.end_time}
{session.room}
);
diff --git a/src/pages/teacher/ChapterDetail.tsx b/src/pages/teacher/ChapterDetail.tsx
index 5d2888e..7ca571c 100644
--- a/src/pages/teacher/ChapterDetail.tsx
+++ b/src/pages/teacher/ChapterDetail.tsx
@@ -57,7 +57,7 @@ export default function ChapterDetail() {
queryFn: () => resourcesService.list({
search: libSearch || undefined,
resource_type: libTypeFilter === "all" ? undefined : libTypeFilter,
- limit: 50,
+ size: 50,
}),
enabled: showUpload,
});
diff --git a/src/pages/teacher/TeacherAnnouncements.tsx b/src/pages/teacher/TeacherAnnouncements.tsx
index bce2a89..e1446e8 100644
--- a/src/pages/teacher/TeacherAnnouncements.tsx
+++ b/src/pages/teacher/TeacherAnnouncements.tsx
@@ -23,7 +23,7 @@ export default function TeacherAnnouncements() {
const { toast } = useToast();
const { data: announcements = [], isLoading } = useAnnouncements();
const { data: coursesData } = useCourses();
- const courses = coursesData?.results ?? [];
+ const courses = coursesData?.items ?? [];
const createAnnouncement = useCreateAnnouncement();
const publishAnnouncement = usePublishAnnouncement();
@@ -84,7 +84,7 @@ export default function TeacherAnnouncements() {
All Courses
{courses.map(c => (
- {c.name}
+ {c.title}
))}
diff --git a/src/pages/teacher/TeacherDiscussionBoard.tsx b/src/pages/teacher/TeacherDiscussionBoard.tsx
index 2fcc0c1..ea39410 100644
--- a/src/pages/teacher/TeacherDiscussionBoard.tsx
+++ b/src/pages/teacher/TeacherDiscussionBoard.tsx
@@ -89,7 +89,7 @@ export default function TeacherDiscussionBoard() {
const [newTitle, setNewTitle] = useState("");
const [newContent, setNewContent] = useState("");
- const posts = postsData?.results ?? [];
+ const posts = postsData?.items ?? [];
const handleCreatePost = () => {
if (!selectedBoardId) return;
diff --git a/src/services/ai-course.service.ts b/src/services/ai-course.service.ts
index 21b11c1..0e17ea8 100644
--- a/src/services/ai-course.service.ts
+++ b/src/services/ai-course.service.ts
@@ -1,4 +1,12 @@
import { api } from "@/lib/api-client";
+import type {
+ AICourseConfig,
+ AICourseTrack,
+ QualityGateResult,
+ IELTSValidationResult,
+} from "@/types/ai-course";
+
+export type { QualityGateResult, IELTSValidationResult };
export interface AiCourseCreateEnglishRequest {
cefr_level: string;
@@ -18,7 +26,11 @@ export interface AiCourseCreateResponse {
skill?: string;
}
-export interface QualityGateResult {
+/**
+ * Legacy flat shape returned by older backends. The canonical type is the
+ * structured {@link QualityGateResult} re-exported above.
+ */
+export interface QualityGateResultLegacy {
status: string;
readability_score: number;
cefr_alignment: boolean;
@@ -26,7 +38,7 @@ export interface QualityGateResult {
attempts: number;
}
-export interface IELTSValidationResult {
+export interface IELTSValidationResultLegacy {
type: string;
validation_results: Record
;
overall_passed: boolean;
@@ -40,10 +52,10 @@ export const aiCourseService = {
api.post("/ai-course/ielts/create", data),
getCourse: (courseId: number) =>
- api.get>(`/ai-course/${courseId}`),
+ api.get(`/ai-course/${courseId}`),
getTracks: (courseId: number) =>
- api.get(`/ai-course/${courseId}/tracks`),
+ api.get(`/ai-course/${courseId}/tracks`),
getQualityGate: (courseId: number) =>
api.get(`/ai-course/${courseId}/quality`),
diff --git a/src/services/ai-feedback.service.ts b/src/services/ai-feedback.service.ts
new file mode 100644
index 0000000..33aa098
--- /dev/null
+++ b/src/services/ai-feedback.service.ts
@@ -0,0 +1,54 @@
+import { api } from "@/lib/api-client";
+import type { PaginatedResponse } from "@/types/common";
+import type {
+ AIFeedback,
+ AIFeedbackStatus,
+ AIFeedbackSubjectType,
+ AIFeedbackSubmitInput,
+ AIFeedbackSummary,
+} from "@/types/ai-feedback";
+
+export const aiFeedbackService = {
+ async submit(input: AIFeedbackSubmitInput): Promise {
+ return api.post("/ai/feedback", input);
+ },
+
+ async summary(
+ subjectType: AIFeedbackSubjectType,
+ subjectId: number,
+ ): Promise {
+ return api.get("/ai/feedback/summary", {
+ subject_type: subjectType,
+ subject_id: subjectId,
+ });
+ },
+
+ async list(
+ params: {
+ status?: AIFeedbackStatus;
+ rating?: "up" | "down";
+ subject_type?: AIFeedbackSubjectType;
+ prompt_key?: string;
+ page?: number;
+ size?: number;
+ } = {},
+ ): Promise> {
+ const res = await api.get>("/ai/feedback", params);
+ const items = res.items ?? res.data ?? [];
+ return {
+ items,
+ total: res.total ?? items.length,
+ page: res.page ?? 1,
+ size: res.size ?? items.length,
+ pages: res.pages ?? 1,
+ };
+ },
+
+ async resolve(
+ id: number,
+ status: Exclude,
+ notes?: string,
+ ): Promise {
+ return api.post(`/ai/feedback/${id}/resolve`, { status, notes });
+ },
+};
diff --git a/src/services/ai-prompt.service.ts b/src/services/ai-prompt.service.ts
new file mode 100644
index 0000000..f04ebc7
--- /dev/null
+++ b/src/services/ai-prompt.service.ts
@@ -0,0 +1,61 @@
+import { api } from "@/lib/api-client";
+import type { PaginatedResponse } from "@/types/common";
+import type {
+ AIPrompt,
+ AIPromptCreateInput,
+ AIPromptRenderResponse,
+ AIPromptSummary,
+} from "@/types/ai-prompt";
+
+interface PromptListParams {
+ page?: number;
+ size?: number;
+ search?: string;
+}
+
+export const aiPromptService = {
+ async listKeys(
+ params: PromptListParams = {},
+ ): Promise> {
+ const res = await api.get>(
+ "/ai/prompts",
+ params,
+ );
+ const items = res.items ?? res.data ?? [];
+ return {
+ items,
+ total: res.total ?? items.length,
+ page: res.page ?? 1,
+ size: res.size ?? items.length,
+ pages: res.pages ?? 1,
+ };
+ },
+
+ async listVersions(key: string): Promise {
+ const res = await api.get>(
+ `/ai/prompts/${encodeURIComponent(key)}/versions`,
+ );
+ return res.items ?? res.data ?? [];
+ },
+
+ async get(id: number): Promise {
+ return api.get(`/ai/prompts/${id}`);
+ },
+
+ async create(input: AIPromptCreateInput): Promise {
+ return api.post("/ai/prompts", input);
+ },
+
+ async activate(id: number): Promise {
+ return api.post(`/ai/prompts/${id}/activate`, {});
+ },
+
+ async render(
+ id: number,
+ variables: Record,
+ ): Promise {
+ return api.post(`/ai/prompts/${id}/render`, {
+ variables,
+ });
+ },
+};
diff --git a/src/services/auth.service.ts b/src/services/auth.service.ts
index efd0a68..219d784 100644
--- a/src/services/auth.service.ts
+++ b/src/services/auth.service.ts
@@ -1,17 +1,36 @@
-import { api, setToken, clearToken } from "@/lib/api-client";
+import { api, setToken, clearToken, persistTokenBundle } from "@/lib/api-client";
import type { LoginRequest, LoginResponse, User, ResetPasswordRequest, CurrentUserResponse } from "@/types";
import type { ApiSuccessResponse } from "@/types";
export const authService = {
async login(data: LoginRequest): Promise {
// Odoo accepts `login` or `email` in JSON body
- const res = await api.post("/login", { ...data, email: data.login });
- setToken(res.token);
+ const res = await api.post("/login", { ...data, email: data.login });
+ // Persist the full bundle so the transparent refresh loop in api-client
+ // has everything it needs. Falls back to the legacy single-token field
+ // for older backends that only return ``token``.
+ if (res.access_token || res.refresh_token) {
+ persistTokenBundle({
+ access_token: res.access_token,
+ token: res.token,
+ refresh_token: res.refresh_token,
+ expires_in: res.expires_in,
+ });
+ } else {
+ setToken(res.token);
+ }
return res;
},
async logout(): Promise {
- await api.post("/logout").catch(() => {});
+ const refresh = localStorage.getItem("encoach_refresh_token") || undefined;
+ await api
+ .post("/logout", refresh ? { refresh_token: refresh } : undefined)
+ .catch(() => {});
clearToken();
},
diff --git a/src/services/exam-review.service.ts b/src/services/exam-review.service.ts
new file mode 100644
index 0000000..b8564e0
--- /dev/null
+++ b/src/services/exam-review.service.ts
@@ -0,0 +1,57 @@
+import { api } from "@/lib/api-client";
+import type { PaginatedResponse } from "@/types/common";
+import type {
+ ExamReviewDetail,
+ ExamReviewListItem,
+} from "@/types/exam-review";
+
+export interface ExamReviewQueueParams {
+ page?: number;
+ size?: number;
+ search?: string;
+ status?: "draft" | "pending_review" | "published" | "archived";
+ subject_id?: number;
+}
+
+/**
+ * Admin-facing review queue. Everything here assumes the caller has an admin
+ * JWT — the server is authoritative; the frontend just surfaces errors.
+ */
+export const examReviewService = {
+ async queue(
+ params: ExamReviewQueueParams = {},
+ ): Promise> {
+ const res = await api.get>(
+ "/exam/review/queue",
+ params,
+ );
+ const items = res.items ?? res.data ?? [];
+ return {
+ items,
+ total: res.total ?? items.length,
+ page: res.page ?? 1,
+ size: res.size ?? items.length,
+ pages: res.pages ?? 1,
+ };
+ },
+
+ async detail(examId: number): Promise {
+ return api.get(`/exam/review/${examId}`);
+ },
+
+ async approve(
+ examId: number,
+ notes?: string,
+ ): Promise {
+ return api.post(
+ `/exam/review/${examId}/approve`,
+ notes ? { notes } : {},
+ );
+ },
+
+ async reject(examId: number, notes: string): Promise {
+ return api.post(`/exam/review/${examId}/reject`, {
+ notes,
+ });
+ },
+};
diff --git a/src/services/exam-session.service.ts b/src/services/exam-session.service.ts
index 4d87f16..7164326 100644
--- a/src/services/exam-session.service.ts
+++ b/src/services/exam-session.service.ts
@@ -15,6 +15,6 @@ export const examSessionService = {
getStatus: (examId: number) =>
api.get<{ status: string; scores_available: boolean }>(`/exam/${examId}/status`),
- getResults: (examId: number) =>
- api.get(`/exam/${examId}/results`),
+ getResults: (examId: number) =>
+ api.get(`/exam/${examId}/results`),
};
diff --git a/src/services/gdpr.service.ts b/src/services/gdpr.service.ts
new file mode 100644
index 0000000..b7ad8c9
--- /dev/null
+++ b/src/services/gdpr.service.ts
@@ -0,0 +1,36 @@
+import { api } from "@/lib/api-client";
+
+export interface GdprExportPayload {
+ profile: Record;
+ partner: Record;
+ entity_memberships: unknown[];
+ exam_attempts: unknown[];
+ exam_answers: unknown[];
+ ai_feedback: unknown[];
+ ai_calls: unknown[];
+ tickets: unknown[];
+ coaching_sessions: unknown[];
+ exported_at: string;
+ export_format_version: string;
+}
+
+export interface GdprEraseResponse {
+ ok: boolean;
+ message: string;
+ summary: {
+ anonymised_partner_fields: string[];
+ deactivated_user: boolean;
+ deleted_feedback_count: number;
+ deleted_coaching_count: number;
+ retained_attempts_count: number;
+ };
+}
+
+export const gdprService = {
+ async exportMyData(): Promise {
+ return api.get("/gdpr/export");
+ },
+ async eraseMyAccount(): Promise {
+ return api.post("/gdpr/delete", { confirm: true });
+ },
+};
diff --git a/src/services/lms.service.ts b/src/services/lms.service.ts
index d5a167d..d12484f 100644
--- a/src/services/lms.service.ts
+++ b/src/services/lms.service.ts
@@ -130,9 +130,10 @@ export const lmsService = {
},
async listCourses(params?: PaginationParams & { status?: string }): Promise> {
- const raw = await api.get("/courses", params as Record);
+ const raw = await api.get("/courses", params);
const p = asPaginated>(raw);
- return { ...p, items: p.items.map(mapCourseRaw) };
+ const items = p.items.map(mapCourseRaw);
+ return { items, total: p.total, page: p.page, size: p.size, pages: p.pages };
},
async getCourse(id: number): Promise {
@@ -183,9 +184,10 @@ export const lmsService = {
},
async listBatches(params?: PaginationParams): Promise> {
- const raw = await api.get("/batches", params as Record);
+ const raw = await api.get("/batches", params);
const p = asPaginated>(raw);
- return { ...p, items: p.items.map(mapBatchRaw) };
+ const items = p.items.map(mapBatchRaw);
+ return { items, total: p.total, page: p.page, size: p.size, pages: p.pages };
},
async getBatch(id: number): Promise {
diff --git a/src/services/stats.service.ts b/src/services/stats.service.ts
index 5431dd1..8816d49 100644
--- a/src/services/stats.service.ts
+++ b/src/services/stats.service.ts
@@ -1,5 +1,5 @@
import { api } from "@/lib/api-client";
-import type { ExamSession, ExamStat, StatisticalData } from "@/types";
+import type { StatsExamSession as ExamSession, ExamStat, StatisticalData } from "@/types";
export const statsService = {
async getSessions(params?: Record): Promise {
diff --git a/src/services/taxonomy.service.ts b/src/services/taxonomy.service.ts
index 7c7bd87..c807848 100644
--- a/src/services/taxonomy.service.ts
+++ b/src/services/taxonomy.service.ts
@@ -11,7 +11,8 @@ export const taxonomyService = {
async getSubject(id: number): Promise {
const res = await api.get<{ data: Subject } | Subject>(`/subjects/${id}`);
- return (res as { data: Subject }).data ?? res;
+ const wrapped = res as { data?: Subject };
+ return (wrapped.data ?? (res as Subject));
},
async createSubject(data: Partial): Promise {
@@ -28,7 +29,8 @@ export const taxonomyService = {
async getTaxonomyTree(subjectId: number): Promise {
const res = await api.get<{ data: TaxonomyTree } | TaxonomyTree>(`/subjects/${subjectId}/taxonomy`);
- return (res as { data: TaxonomyTree }).data ?? res;
+ const wrapped = res as { data?: TaxonomyTree };
+ return (wrapped.data ?? (res as TaxonomyTree));
},
async importTaxonomy(subjectId: number, data: FormData): Promise {
diff --git a/src/services/users.service.ts b/src/services/users.service.ts
index 9eb65d0..9cb3b33 100644
--- a/src/services/users.service.ts
+++ b/src/services/users.service.ts
@@ -25,9 +25,10 @@ function mapPortalUser(raw: Record): User {
export const usersService = {
async list(params: UserListParams): Promise> {
- const raw = await api.get("/users/list", params as Record