feat(i18n,rtl): full Arabic localization + RTL sweep across all layouts

Frontend
- i18n: install tailwindcss-rtl, Cairo font, RTL-aware direction in index.css.
- Language toggle: localize aria-label / menu label, persist choice, update
  document dir synchronously.
- Sidebar: add `side` prop so the drawer pins to the right in RTL; wire up
  AdminLmsLayout, RoleLayout (student/teacher) and AppSidebar to pass
  side = i18n.dir() === 'rtl' ? 'right' : 'left'.
- AdminLmsLayout: convert every nav item from hard-coded title to titleKey,
  translate group labels (incl. the collapsible Training), breadcrumbs,
  user menu (Profile / Settings / Logout), help button and toggle aria
  labels; replace physical mr-/right- utilities with logical me-/end-.
- AI components (AiTipBanner, AiInsightsPanel, AiAlertBanner, AiSearchBar,
  AiAssistantDrawer): apply dir="auto" at the container level, localize
  titles, loading / error / empty states.
- Dashboards (admin / student / teacher): wrap numeric values in <bdi>,
  localize dates via ar-EG, fix flex direction for KPI and assignment cards.
- UI primitives (breadcrumb, calendar, carousel, dropdown-menu, menubar,
  context-menu, pagination, sidebar): flip chevrons in RTL via a scoped
  CSS rule, swap pl-/pr-/ml-/mr- for ps-/pe-/ms-/me-.
- Add logical-direction helpers and bidirectional isolation classes.

Locales
- Expand en.ts and ar.ts with full `nav`, `sidebarGroup`, `breadcrumb`,
  `userMenu`, `chrome`, `ai`, and dashboard key sets; keep key parity.

API client
- `api-client.ts` reads the active language from localStorage/i18n and sends
  `Accept-Language` on every request so the backend can localize AI output.

Backend (encoach_ai)
- openai_service: add _LANGUAGE_NAMES, normalize_language, language-aware
  system prompt injection for every OpenAI call.
- coach_service + controllers (coach_controller, ai_controller): thread
  the requested language from headers / user locale down to OpenAIService.
- ai_feedback: fix latent registry error by pointing course_id at op.course
  instead of the non-existent encoach.course.

Other
- .gitignore: ignore runtime odoo logs and local caches.

Made-with: Cursor
This commit is contained in:
Yamen Ahmad
2026-04-19 18:13:16 +04:00
parent b02c2e7526
commit fbd58fa5a6
50 changed files with 1335 additions and 470 deletions

View File

@@ -17,7 +17,7 @@
<meta property="og:type" content="website" /> <meta property="og:type" content="website" />
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Inter:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" /> <link href="https://fonts.googleapis.com/css2?family=Cairo:wght@300;400;500;600;700;800&family=DM+Sans:wght@400;500;600;700&family=Inter:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

8
package-lock.json generated
View File

@@ -80,6 +80,7 @@
"lovable-tagger": "^1.1.13", "lovable-tagger": "^1.1.13",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",
"tailwindcss-rtl": "^0.9.0",
"typescript": "^5.8.3", "typescript": "^5.8.3",
"typescript-eslint": "^8.38.0", "typescript-eslint": "^8.38.0",
"vite": "^5.4.19", "vite": "^5.4.19",
@@ -7300,6 +7301,13 @@
"tailwindcss": ">=3.0.0 || insiders" "tailwindcss": ">=3.0.0 || insiders"
} }
}, },
"node_modules/tailwindcss-rtl": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/tailwindcss-rtl/-/tailwindcss-rtl-0.9.0.tgz",
"integrity": "sha512-y7yC8QXjluDBEFMSX33tV6xMYrf0B3sa+tOB5JSQb6/G6laBU313a+Z+qxu55M1Qyn8tDMttjomsA8IsJD+k+w==",
"dev": true,
"license": "MIT"
},
"node_modules/tailwindcss/node_modules/postcss-selector-parser": { "node_modules/tailwindcss/node_modules/postcss-selector-parser": {
"version": "6.1.2", "version": "6.1.2",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz",

View File

@@ -87,6 +87,7 @@
"lovable-tagger": "^1.1.13", "lovable-tagger": "^1.1.13",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",
"tailwindcss-rtl": "^0.9.0",
"typescript": "^5.8.3", "typescript": "^5.8.3",
"typescript-eslint": "^8.38.0", "typescript-eslint": "^8.38.0",
"vite": "^5.4.19", "vite": "^5.4.19",

View File

@@ -34,115 +34,123 @@ import {
Library, Activity, Warehouse, UserCog, Sparkles, Library, Activity, Warehouse, UserCog, Sparkles,
} from "lucide-react"; } from "lucide-react";
import React from "react"; import React from "react";
import { useTranslation } from "react-i18next";
// ============= Navigation Config ============= // ============= Navigation Config =============
interface NavItem { title: string; url: string; icon: LucideIcon } // Items store i18n keys (`titleKey`) rather than literal strings so Arabic
// language switches update the sidebar without the component having to
// re-mount.
interface NavItem { titleKey: string; url: string; icon: LucideIcon }
const overviewItems: NavItem[] = [ const overviewItems: NavItem[] = [
{ title: "Admin Dashboard", url: "/admin/dashboard", icon: LayoutDashboard }, { titleKey: "nav.adminDashboard", url: "/admin/dashboard", icon: LayoutDashboard },
{ title: "Platform Dashboard", url: "/admin/platform", icon: BarChart3 }, { titleKey: "nav.platformDashboard", url: "/admin/platform", icon: BarChart3 },
]; ];
const lmsItems: NavItem[] = [ const lmsItems: NavItem[] = [
{ title: "Courses", url: "/admin/courses", icon: BookOpen }, { titleKey: "nav.courses", url: "/admin/courses", icon: BookOpen },
{ title: "Students", url: "/admin/students", icon: Users }, { titleKey: "nav.students", url: "/admin/students", icon: Users },
{ title: "Teachers", url: "/admin/teachers", icon: GraduationCap }, { titleKey: "nav.teachers", url: "/admin/teachers", icon: GraduationCap },
{ title: "Batches", url: "/admin/batches", icon: Layers }, { titleKey: "nav.batches", url: "/admin/batches", icon: Layers },
{ title: "Timetable", url: "/admin/timetable", icon: Calendar }, { titleKey: "nav.timetable", url: "/admin/timetable", icon: Calendar },
{ title: "Reports", url: "/admin/reports", icon: BarChart3 }, { titleKey: "nav.reports", url: "/admin/reports", icon: BarChart3 },
]; ];
const academicItems: NavItem[] = [ const academicItems: NavItem[] = [
{ title: "Assignments", url: "/admin/assignments", icon: ClipboardList }, { titleKey: "nav.assignments", url: "/admin/assignments", icon: ClipboardList },
{ title: "Exams List", url: "/admin/examsList", icon: FileText }, { titleKey: "nav.examsList", url: "/admin/examsList", icon: FileText },
{ title: "Exam Structures", url: "/admin/exam-structures", icon: Layers }, { titleKey: "nav.examStructures", url: "/admin/exam-structures", icon: Layers },
{ title: "Rubrics", url: "/admin/rubrics", icon: BookOpen }, { titleKey: "nav.rubrics", url: "/admin/rubrics", icon: BookOpen },
{ title: "Generation", url: "/admin/generation", icon: Wand2 }, { titleKey: "nav.generation", url: "/admin/generation", icon: Wand2 },
{ title: "Review Queue", url: "/admin/exam/review-queue", icon: Sparkles }, { titleKey: "nav.reviewQueue", url: "/admin/exam/review-queue", icon: Sparkles },
{ title: "AI Prompts", url: "/admin/ai/prompts", icon: Wand2 }, { titleKey: "nav.aiPrompts", url: "/admin/ai/prompts", icon: Wand2 },
{ title: "AI Feedback", url: "/admin/ai/feedback", icon: Sparkles }, { titleKey: "nav.aiFeedback", url: "/admin/ai/feedback", icon: Sparkles },
{ title: "Approval Workflows", url: "/admin/approval-workflows", icon: GitBranch }, { titleKey: "nav.approvalWorkflows", url: "/admin/approval-workflows", icon: GitBranch },
]; ];
const adaptiveItems: NavItem[] = [ const adaptiveItems: NavItem[] = [
{ title: "Taxonomy", url: "/admin/taxonomy", icon: Target }, { titleKey: "nav.taxonomy", url: "/admin/taxonomy", icon: Target },
{ title: "Resources", url: "/admin/resources", icon: FolderOpen }, { titleKey: "nav.resources", url: "/admin/resources", icon: FolderOpen },
]; ];
const institutionalItems: NavItem[] = [ const institutionalItems: NavItem[] = [
{ title: "Academic Years", url: "/admin/academic-years", icon: CalendarDays }, { titleKey: "nav.academicYears", url: "/admin/academic-years", icon: CalendarDays },
{ title: "Departments", url: "/admin/departments", icon: Landmark }, { titleKey: "nav.departments", url: "/admin/departments", icon: Landmark },
{ title: "Admissions", url: "/admin/admissions", icon: UserPlus }, { titleKey: "nav.admissions", url: "/admin/admissions", icon: UserPlus },
{ title: "Admission Register", url: "/admin/admission-register", icon: ScrollText }, { titleKey: "nav.admissionRegister", url: "/admin/admission-register", icon: ScrollText },
{ title: "Exam Sessions", url: "/admin/exam-sessions", icon: FileText }, { titleKey: "nav.examSessions", url: "/admin/exam-sessions", icon: FileText },
{ title: "Marksheets", url: "/admin/marksheets", icon: Award }, { titleKey: "nav.marksheets", url: "/admin/marksheets", icon: Award },
{ title: "Student Leave", url: "/admin/student-leave", icon: CalendarOff }, { titleKey: "nav.studentLeave", url: "/admin/student-leave", icon: CalendarOff },
{ title: "Fees & Payments", url: "/admin/fees", icon: DollarSign }, { titleKey: "nav.fees", url: "/admin/fees", icon: DollarSign },
{ title: "Lessons", url: "/admin/lessons", icon: BookMarked }, { titleKey: "nav.lessons", url: "/admin/lessons", icon: BookMarked },
{ title: "Gradebook", url: "/admin/gradebook", icon: BarChartHorizontal }, { titleKey: "nav.gradebook", url: "/admin/gradebook", icon: BarChartHorizontal },
{ title: "Student Progress", url: "/admin/student-progress", icon: TrendingUp }, { titleKey: "nav.studentProgress", url: "/admin/student-progress", icon: TrendingUp },
{ title: "Library", url: "/admin/library", icon: Library }, { titleKey: "nav.library", url: "/admin/library", icon: Library },
{ title: "Activities", url: "/admin/activities", icon: Activity }, { titleKey: "nav.activities", url: "/admin/activities", icon: Activity },
{ title: "Facilities", url: "/admin/facilities", icon: Warehouse }, { titleKey: "nav.facilities", url: "/admin/facilities", icon: Warehouse },
]; ];
const managementItems: NavItem[] = [ const managementItems: NavItem[] = [
{ title: "Users", url: "/admin/users", icon: Users }, { titleKey: "nav.users", url: "/admin/users", icon: Users },
{ title: "Entities", url: "/admin/entities", icon: Building2 }, { titleKey: "nav.entities", url: "/admin/entities", icon: Building2 },
{ title: "Classrooms", url: "/admin/classrooms", icon: GraduationCap }, { titleKey: "nav.classrooms", url: "/admin/classrooms", icon: GraduationCap },
{ title: "User Roles", url: "/admin/user-roles", icon: UserCog }, { titleKey: "nav.userRoles", url: "/admin/user-roles", icon: UserCog },
{ title: "Roles & Permissions", url: "/admin/roles-permissions", icon: Settings }, { titleKey: "nav.rolesPermissions", url: "/admin/roles-permissions", icon: Settings },
{ title: "Authority Matrix", url: "/admin/authority-matrix", icon: Workflow }, { titleKey: "nav.authorityMatrix", url: "/admin/authority-matrix", icon: Workflow },
]; ];
const reportItems: NavItem[] = [ const reportItems: NavItem[] = [
{ title: "Student Performance", url: "/admin/student-performance", icon: BarChart3 }, { titleKey: "nav.studentPerformance", url: "/admin/student-performance", icon: BarChart3 },
{ title: "Stats Corporate", url: "/admin/stats-corporate", icon: Building2 }, { titleKey: "nav.statsCorporate", url: "/admin/stats-corporate", icon: Building2 },
{ title: "Record", url: "/admin/record", icon: History }, { titleKey: "nav.record", url: "/admin/record", icon: History },
]; ];
const trainingItems: NavItem[] = [ const trainingItems: NavItem[] = [
{ title: "Vocabulary", url: "/admin/training/vocabulary", icon: BookA }, { titleKey: "nav.vocabulary", url: "/admin/training/vocabulary", icon: BookA },
{ title: "Grammar", url: "/admin/training/grammar", icon: PenTool }, { titleKey: "nav.grammar", url: "/admin/training/grammar", icon: PenTool },
]; ];
const configItems: NavItem[] = [ const configItems: NavItem[] = [
{ title: "FAQ Manager", url: "/admin/faq", icon: FaqIcon }, { titleKey: "nav.faqManager", url: "/admin/faq", icon: FaqIcon },
{ title: "Notification Rules", url: "/admin/notification-rules", icon: Bell }, { titleKey: "nav.notificationRules", url: "/admin/notification-rules", icon: Bell },
{ title: "Approval Config", url: "/admin/approval-config", icon: Workflow }, { titleKey: "nav.approvalConfig", url: "/admin/approval-config", icon: Workflow },
]; ];
const supportItems: NavItem[] = [ const supportItems: NavItem[] = [
{ title: "Payment Record", url: "/admin/payment-record", icon: CreditCard }, { titleKey: "nav.paymentRecord", url: "/admin/payment-record", icon: CreditCard },
{ title: "Tickets", url: "/admin/tickets", icon: Ticket }, { titleKey: "nav.tickets", url: "/admin/tickets", icon: Ticket },
{ title: "Settings", url: "/admin/settings-platform", icon: Settings }, { titleKey: "nav.settings", url: "/admin/settings-platform", icon: Settings },
]; ];
// ============= Reusable sidebar group ============= // ============= Reusable sidebar group =============
function SidebarNavGroup({ label, items }: { label: string; items: NavItem[] }) { function SidebarNavGroup({ labelKey, items }: { labelKey: string; items: NavItem[] }) {
const { state } = useSidebar(); const { state } = useSidebar();
const { t } = useTranslation();
const collapsed = state === "collapsed"; const collapsed = state === "collapsed";
return ( return (
<SidebarGroup> <SidebarGroup>
<SidebarGroupLabel>{label}</SidebarGroupLabel> <SidebarGroupLabel>{t(labelKey)}</SidebarGroupLabel>
<SidebarGroupContent> <SidebarGroupContent>
<SidebarMenu> <SidebarMenu>
{items.map((item) => ( {items.map((item) => {
<SidebarMenuItem key={item.url}> const label = t(item.titleKey);
<SidebarMenuButton asChild tooltip={item.title}> return (
<NavLink <SidebarMenuItem key={item.url}>
to={item.url} <SidebarMenuButton asChild tooltip={label}>
end={item.url.endsWith("/dashboard") || item.url.endsWith("/platform")} <NavLink
className="flex items-center gap-2 px-2 py-1.5 rounded-md text-sm text-sidebar-foreground hover:bg-sidebar-accent transition-colors" to={item.url}
activeClassName="bg-sidebar-accent text-sidebar-accent-foreground font-medium" end={item.url.endsWith("/dashboard") || item.url.endsWith("/platform")}
> className="flex items-center gap-2 px-2 py-1.5 rounded-md text-sm text-sidebar-foreground hover:bg-sidebar-accent transition-colors"
<item.icon className="h-4 w-4 shrink-0" /> activeClassName="bg-sidebar-accent text-sidebar-accent-foreground font-medium"
{!collapsed && <span>{item.title}</span>} >
</NavLink> <item.icon className="h-4 w-4 shrink-0" />
</SidebarMenuButton> {!collapsed && <span>{label}</span>}
</SidebarMenuItem> </NavLink>
))} </SidebarMenuButton>
</SidebarMenuItem>
);
})}
</SidebarMenu> </SidebarMenu>
</SidebarGroupContent> </SidebarGroupContent>
</SidebarGroup> </SidebarGroup>
@@ -150,42 +158,52 @@ function SidebarNavGroup({ label, items }: { label: string; items: NavItem[] })
} }
// ============= Breadcrumbs ============= // ============= Breadcrumbs =============
const routeLabels: Record<string, string> = { // Map URL segments to i18n keys so breadcrumbs follow the active locale.
admin: "Admin", dashboard: "Dashboard", platform: "Platform", courses: "Courses", // Segments without an entry fall back to a Title-cased version of the slug.
students: "Students", teachers: "Teachers", batches: "Batches", timetable: "Timetable", const routeLabelKeys: Record<string, string> = {
reports: "Reports", assignments: "Assignments", examsList: "Exams List", admin: "breadcrumb.admin", dashboard: "breadcrumb.dashboard",
"exam-structures": "Exam Structures", rubrics: "Rubrics", generation: "Generation", platform: "breadcrumb.platform", courses: "nav.courses",
"review-queue": "Review Queue", review: "Review", prompts: "AI Prompts", ai: "AI", students: "nav.students", teachers: "nav.teachers", batches: "nav.batches",
feedback: "Feedback", timetable: "nav.timetable", reports: "nav.reports",
"approval-workflows": "Approval Workflows", users: "Users", entities: "Entities", assignments: "nav.assignments", examsList: "nav.examsList",
classrooms: "Classrooms", "student-performance": "Student Performance", "exam-structures": "nav.examStructures", rubrics: "nav.rubrics",
"stats-corporate": "Stats Corporate", record: "Record", training: "Training", generation: "nav.generation", "review-queue": "nav.reviewQueue",
vocabulary: "Vocabulary", grammar: "Grammar", "payment-record": "Payment Record", review: "breadcrumb.review", prompts: "nav.aiPrompts", ai: "breadcrumb.ai",
tickets: "Tickets", "settings-platform": "Settings", settings: "Settings", feedback: "breadcrumb.feedback",
profile: "Profile", exam: "Exam", "approval-workflows": "nav.approvalWorkflows", users: "nav.users",
"academic-years": "Academic Years", departments: "Departments", entities: "nav.entities", classrooms: "nav.classrooms",
admissions: "Admissions", "admission-register": "Admission Register", "student-performance": "nav.studentPerformance",
"exam-sessions": "Exam Sessions", marksheets: "Marksheets", "stats-corporate": "nav.statsCorporate", record: "nav.record",
faq: "FAQ Manager", "notification-rules": "Notification Rules", training: "sidebarGroup.training", vocabulary: "nav.vocabulary",
"approval-config": "Approval Config", grammar: "nav.grammar", "payment-record": "nav.paymentRecord",
"student-leave": "Student Leave", fees: "Fees & Payments", lessons: "Lessons", tickets: "nav.tickets", "settings-platform": "nav.settings",
gradebook: "Gradebook", "student-progress": "Student Progress", settings: "nav.settings", profile: "nav.profile", exam: "breadcrumb.exam",
library: "Library", activities: "Activities", facilities: "Facilities", "academic-years": "nav.academicYears", departments: "nav.departments",
admissions: "nav.admissions", "admission-register": "nav.admissionRegister",
"exam-sessions": "nav.examSessions", marksheets: "nav.marksheets",
faq: "nav.faqManager", "notification-rules": "nav.notificationRules",
"approval-config": "nav.approvalConfig",
"student-leave": "nav.studentLeave", fees: "nav.fees",
lessons: "nav.lessons", gradebook: "nav.gradebook",
"student-progress": "nav.studentProgress", library: "nav.library",
activities: "nav.activities", facilities: "nav.facilities",
}; };
function AppBreadcrumbs() { function AppBreadcrumbs() {
const location = useLocation(); const location = useLocation();
const { t } = useTranslation();
const segments = location.pathname.split("/").filter(Boolean); const segments = location.pathname.split("/").filter(Boolean);
return ( return (
<Breadcrumb> <Breadcrumb>
<BreadcrumbList> <BreadcrumbList>
<BreadcrumbItem> <BreadcrumbItem>
<BreadcrumbLink asChild><Link to="/admin/dashboard">Home</Link></BreadcrumbLink> <BreadcrumbLink asChild><Link to="/admin/dashboard">{t("common.home")}</Link></BreadcrumbLink>
</BreadcrumbItem> </BreadcrumbItem>
{segments.map((seg, i) => { {segments.map((seg, i) => {
const path = "/" + segments.slice(0, i + 1).join("/"); const path = "/" + segments.slice(0, i + 1).join("/");
const label = routeLabels[seg] || seg.charAt(0).toUpperCase() + seg.slice(1); const key = routeLabelKeys[seg];
const label = key ? t(key) : seg.charAt(0).toUpperCase() + seg.slice(1);
const isLast = i === segments.length - 1; const isLast = i === segments.length - 1;
return ( return (
<React.Fragment key={path}> <React.Fragment key={path}>
@@ -205,6 +223,7 @@ function AppBreadcrumbs() {
export default function AdminLmsLayout() { export default function AdminLmsLayout() {
const { user, logout } = useAuth(); const { user, logout } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const { t } = useTranslation();
const initials = user?.name?.split(" ").map(w => w[0]).join("").slice(0, 2).toUpperCase() ?? "??"; const initials = user?.name?.split(" ").map(w => w[0]).join("").slice(0, 2).toUpperCase() ?? "??";
@@ -220,7 +239,7 @@ export default function AdminLmsLayout() {
<div className="flex-1 flex flex-col min-w-0"> <div className="flex-1 flex flex-col min-w-0">
<header className="h-14 border-b bg-card flex items-center justify-between px-4 shrink-0"> <header className="h-14 border-b bg-card flex items-center justify-between px-4 shrink-0">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<SidebarTrigger /> <SidebarTrigger aria-label={t("chrome.toggleSidebar")} />
<AppBreadcrumbs /> <AppBreadcrumbs />
</div> </div>
<AiSearchBar /> <AiSearchBar />
@@ -228,7 +247,7 @@ export default function AdminLmsLayout() {
<LanguageToggle /> <LanguageToggle />
<ThemeToggle /> <ThemeToggle />
<NotificationDropdown /> <NotificationDropdown />
<Button variant="ghost" size="icon" onClick={() => navigate("/admin/tickets")} className="text-muted-foreground hover:text-foreground"> <Button variant="ghost" size="icon" aria-label={t("chrome.ticketsTooltip")} onClick={() => navigate("/admin/tickets")} className="text-muted-foreground hover:text-foreground">
<Ticket className="h-4 w-4" /> <Ticket className="h-4 w-4" />
</Button> </Button>
<DropdownMenu> <DropdownMenu>
@@ -241,19 +260,19 @@ export default function AdminLmsLayout() {
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48"> <DropdownMenuContent align="end" className="w-48">
<div className="px-3 py-2"> <div className="px-3 py-2">
<p className="text-sm font-medium">{user?.name ?? "User"}</p> <p className="text-sm font-medium">{user?.name ?? t("userMenu.userFallback")}</p>
<p className="text-xs text-muted-foreground">{user?.email ?? ""}</p> <p className="text-xs text-muted-foreground">{user?.email ?? ""}</p>
</div> </div>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem onClick={() => navigate("/admin/profile")}> <DropdownMenuItem onClick={() => navigate("/admin/profile")}>
<User className="mr-2 h-4 w-4" /> Profile <User className="me-2 h-4 w-4" /> {t("userMenu.profile")}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={() => navigate("/admin/settings-platform")}> <DropdownMenuItem onClick={() => navigate("/admin/settings-platform")}>
<Settings className="mr-2 h-4 w-4" /> Settings <Settings className="me-2 h-4 w-4" /> {t("userMenu.settings")}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem onClick={handleLogout}> <DropdownMenuItem onClick={handleLogout}>
<LogOut className="mr-2 h-4 w-4" /> Logout <LogOut className="me-2 h-4 w-4" /> {t("userMenu.logout")}
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
@@ -267,10 +286,10 @@ export default function AdminLmsLayout() {
<AiAssistantDrawer /> <AiAssistantDrawer />
<Link <Link
to="/admin/tickets" to="/admin/tickets"
className="fixed bottom-6 right-6 z-50 flex items-center gap-2 rounded-full bg-primary px-4 py-2.5 text-primary-foreground shadow-lg hover:opacity-90 transition-opacity" className="fixed bottom-6 end-6 z-50 flex items-center gap-2 rounded-full bg-primary px-4 py-2.5 text-primary-foreground shadow-lg hover:opacity-90 transition-opacity"
> >
<HelpCircle className="h-4 w-4" /> <HelpCircle className="h-4 w-4" />
<span className="text-sm font-medium">Need help?</span> <span className="text-sm font-medium">{t("chrome.needHelp")}</span>
</Link> </Link>
</SidebarProvider> </SidebarProvider>
); );
@@ -282,25 +301,27 @@ function AdminSidebar() {
const collapsed = state === "collapsed"; const collapsed = state === "collapsed";
const { user } = useAuth(); const { user } = useAuth();
const { hasAnyPermission, isAdmin } = usePermissions(); const { hasAnyPermission, isAdmin } = usePermissions();
const { t, i18n } = useTranslation();
const sidebarSide = i18n.dir() === "rtl" ? "right" : "left";
const showManagement = isAdmin || hasAnyPermission(["view_entities", "view_students", "view_teachers"]); const showManagement = isAdmin || hasAnyPermission(["view_entities", "view_students", "view_teachers"]);
const showReports = isAdmin || hasAnyPermission(["view_statistics", "view_student_performance", "view_entity_statistics"]); const showReports = isAdmin || hasAnyPermission(["view_statistics", "view_student_performance", "view_entity_statistics"]);
const showPayments = isAdmin || hasAnyPermission(["view_payment_record", "pay_entity"]); const showPayments = isAdmin || hasAnyPermission(["view_payment_record", "pay_entity"]);
return ( return (
<Sidebar collapsible="icon"> <Sidebar side={sidebarSide} collapsible="icon">
<SidebarHeader className="p-4"> <SidebarHeader className="p-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{collapsed ? ( {collapsed ? (
<img <img
src="/logo.png" src="/logo.png"
alt="EnCoach" alt={t("chrome.adminAlt")}
className="h-9 w-9 shrink-0 rounded-lg object-contain" className="h-9 w-9 shrink-0 rounded-lg object-contain"
/> />
) : ( ) : (
<img <img
src="/logo.png" src="/logo.png"
alt="EnCoach — Unlock your potential with AI powered platform" alt={t("chrome.adminAltLong")}
className="h-14 w-auto shrink-0 object-contain" className="h-14 w-auto shrink-0 object-contain"
/> />
)} )}
@@ -308,47 +329,50 @@ function AdminSidebar() {
</SidebarHeader> </SidebarHeader>
<SidebarSeparator /> <SidebarSeparator />
<SidebarContent> <SidebarContent>
<SidebarNavGroup label="Overview" items={overviewItems} /> <SidebarNavGroup labelKey="sidebarGroup.overview" items={overviewItems} />
<SidebarNavGroup label="LMS" items={lmsItems} /> <SidebarNavGroup labelKey="sidebarGroup.lms" items={lmsItems} />
<SidebarNavGroup label="Adaptive Learning" items={adaptiveItems} /> <SidebarNavGroup labelKey="sidebarGroup.adaptiveLearning" items={adaptiveItems} />
<SidebarNavGroup label="Institutional" items={institutionalItems} /> <SidebarNavGroup labelKey="sidebarGroup.institutional" items={institutionalItems} />
<SidebarNavGroup label="Academic" items={academicItems} /> <SidebarNavGroup labelKey="sidebarGroup.academic" items={academicItems} />
{showManagement && <SidebarNavGroup label="Management" items={managementItems} />} {showManagement && <SidebarNavGroup labelKey="sidebarGroup.management" items={managementItems} />}
{showReports && <SidebarNavGroup label="Reports" items={reportItems} />} {showReports && <SidebarNavGroup labelKey="sidebarGroup.reports" items={reportItems} />}
<SidebarNavGroup label="Configuration" items={configItems} /> <SidebarNavGroup labelKey="sidebarGroup.configuration" items={configItems} />
<SidebarGroup> <SidebarGroup>
<Collapsible defaultOpen className="group/collapsible"> <Collapsible defaultOpen className="group/collapsible">
<SidebarGroupLabel asChild> <SidebarGroupLabel asChild>
<CollapsibleTrigger className="flex w-full items-center justify-between"> <CollapsibleTrigger className="flex w-full items-center justify-between">
Training {t("sidebarGroup.training")}
{!collapsed && <ChevronDown className="h-4 w-4 transition-transform group-data-[state=open]/collapsible:rotate-180" />} {!collapsed && <ChevronDown className="h-4 w-4 transition-transform group-data-[state=open]/collapsible:rotate-180" />}
</CollapsibleTrigger> </CollapsibleTrigger>
</SidebarGroupLabel> </SidebarGroupLabel>
<CollapsibleContent> <CollapsibleContent>
<SidebarGroupContent> <SidebarGroupContent>
<SidebarMenu> <SidebarMenu>
{trainingItems.map((item) => ( {trainingItems.map((item) => {
<SidebarMenuItem key={item.url}> const label = t(item.titleKey);
<SidebarMenuButton asChild tooltip={item.title}> return (
<NavLink <SidebarMenuItem key={item.url}>
to={item.url} <SidebarMenuButton asChild tooltip={label}>
className="flex items-center gap-2 px-2 py-1.5 rounded-md text-sm text-sidebar-foreground hover:bg-sidebar-accent transition-colors" <NavLink
activeClassName="bg-sidebar-accent text-sidebar-accent-foreground font-medium" to={item.url}
> className="flex items-center gap-2 px-2 py-1.5 rounded-md text-sm text-sidebar-foreground hover:bg-sidebar-accent transition-colors"
<item.icon className="h-4 w-4 shrink-0" /> activeClassName="bg-sidebar-accent text-sidebar-accent-foreground font-medium"
{!collapsed && <span>{item.title}</span>} >
</NavLink> <item.icon className="h-4 w-4 shrink-0" />
</SidebarMenuButton> {!collapsed && <span>{label}</span>}
</SidebarMenuItem> </NavLink>
))} </SidebarMenuButton>
</SidebarMenuItem>
);
})}
</SidebarMenu> </SidebarMenu>
</SidebarGroupContent> </SidebarGroupContent>
</CollapsibleContent> </CollapsibleContent>
</Collapsible> </Collapsible>
</SidebarGroup> </SidebarGroup>
<SidebarNavGroup label="Support" items={showPayments ? supportItems : supportItems.filter(i => i.url !== "/admin/payment-record")} /> <SidebarNavGroup labelKey="sidebarGroup.support" items={showPayments ? supportItems : supportItems.filter(i => i.url !== "/admin/payment-record")} />
</SidebarContent> </SidebarContent>
<SidebarSeparator /> <SidebarSeparator />
<SidebarFooter className="p-3"> <SidebarFooter className="p-3">
@@ -358,7 +382,7 @@ function AdminSidebar() {
</div> </div>
{!collapsed && ( {!collapsed && (
<div className="flex flex-col min-w-0"> <div className="flex flex-col min-w-0">
<span className="text-sm font-medium truncate text-sidebar-foreground">{user?.name ?? "User"}</span> <span className="text-sm font-medium truncate text-sidebar-foreground">{user?.name ?? t("userMenu.userFallback")}</span>
<span className="text-xs text-muted-foreground truncate">{user?.email ?? ""}</span> <span className="text-xs text-muted-foreground truncate">{user?.email ?? ""}</span>
</div> </div>
)} )}

View File

@@ -5,6 +5,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { NavLink } from "@/components/NavLink"; import { NavLink } from "@/components/NavLink";
import { useLocation } from "react-router-dom"; import { useLocation } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { import {
Sidebar, SidebarContent, SidebarGroup, SidebarGroupContent, Sidebar, SidebarContent, SidebarGroup, SidebarGroupContent,
SidebarGroupLabel, SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarGroupLabel, SidebarMenu, SidebarMenuButton, SidebarMenuItem,
@@ -81,9 +82,11 @@ export function AppSidebar() {
const { state } = useSidebar(); const { state } = useSidebar();
const collapsed = state === "collapsed"; const collapsed = state === "collapsed";
const location = useLocation(); const location = useLocation();
const { i18n } = useTranslation();
const sidebarSide = i18n.dir() === "rtl" ? "right" : "left";
return ( return (
<Sidebar collapsible="icon"> <Sidebar side={sidebarSide} collapsible="icon">
<SidebarHeader className="p-4"> <SidebarHeader className="p-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="h-8 w-8 rounded-lg bg-primary flex items-center justify-center shrink-0"> <div className="h-8 w-8 rounded-lg bg-primary flex items-center justify-center shrink-0">

View File

@@ -1,6 +1,7 @@
import { Component, type ErrorInfo, type ReactNode } from "react"; import { Component, type ErrorInfo, type ReactNode } from "react";
import { withTranslation, type WithTranslation } from "react-i18next";
interface Props { interface Props extends WithTranslation {
children: ReactNode; children: ReactNode;
fallback?: ReactNode; fallback?: ReactNode;
} }
@@ -10,7 +11,12 @@ interface State {
error: Error | null; error: Error | null;
} }
export class ErrorBoundary extends Component<Props, State> { /**
* Error boundary is a classical React class component, so we bridge it to
* i18next via the `withTranslation` HOC. That keeps the tree-shaking of
* `react-i18next`'s hook path intact while giving us a `t` prop here.
*/
class ErrorBoundaryInner extends Component<Props, State> {
constructor(props: Props) { constructor(props: Props) {
super(props); super(props);
this.state = { hasError: false, error: null }; this.state = { hasError: false, error: null };
@@ -27,6 +33,7 @@ export class ErrorBoundary extends Component<Props, State> {
render() { render() {
if (this.state.hasError) { if (this.state.hasError) {
if (this.props.fallback) return this.props.fallback; if (this.props.fallback) return this.props.fallback;
const { t } = this.props;
return ( return (
<div className="min-h-screen flex items-center justify-center bg-background p-6"> <div className="min-h-screen flex items-center justify-center bg-background p-6">
@@ -36,13 +43,13 @@ export class ErrorBoundary extends Component<Props, State> {
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" /> <path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg> </svg>
</div> </div>
<h2 className="text-xl font-semibold">Something went wrong</h2> <h2 className="text-xl font-semibold">{t("errors.somethingWrong")}</h2>
<p className="text-muted-foreground text-sm"> <p className="text-muted-foreground text-sm">
An unexpected error occurred. Please try refreshing the page. {t("errors.unexpectedDescription")}
</p> </p>
{this.state.error && ( {this.state.error && (
<details className="text-left text-xs text-muted-foreground bg-muted rounded-lg p-3"> <details className="text-start text-xs text-muted-foreground bg-muted rounded-lg p-3">
<summary className="cursor-pointer font-medium">Error details</summary> <summary className="cursor-pointer font-medium">{t("errors.errorDetails")}</summary>
<pre className="mt-2 whitespace-pre-wrap break-words">{this.state.error.message}</pre> <pre className="mt-2 whitespace-pre-wrap break-words">{this.state.error.message}</pre>
</details> </details>
)} )}
@@ -50,7 +57,7 @@ export class ErrorBoundary extends Component<Props, State> {
onClick={() => window.location.reload()} onClick={() => window.location.reload()}
className="inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90" className="inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
> >
Refresh Page {t("errors.refreshPage")}
</button> </button>
</div> </div>
</div> </div>
@@ -60,3 +67,5 @@ export class ErrorBoundary extends Component<Props, State> {
return this.props.children; return this.props.children;
} }
} }
export const ErrorBoundary = withTranslation()(ErrorBoundaryInner);

View File

@@ -12,18 +12,23 @@ import {
import { SUPPORTED_LANGS, type SupportedLang } from "@/i18n"; import { SUPPORTED_LANGS, type SupportedLang } from "@/i18n";
export function LanguageToggle() { export function LanguageToggle() {
const { i18n } = useTranslation(); const { i18n, t } = useTranslation();
const current = (i18n.language?.split("-")[0] || "en") as SupportedLang; const current = (i18n.language?.split("-")[0] || "en") as SupportedLang;
return ( return (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" aria-label="Change language"> <Button
variant="ghost"
size="icon"
aria-label={t("language.change")}
title={t("language.change")}
>
<Languages className="h-5 w-5" /> <Languages className="h-5 w-5" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuLabel>Language</DropdownMenuLabel> <DropdownMenuLabel>{t("language.label")}</DropdownMenuLabel>
{SUPPORTED_LANGS.map((lang) => ( {SUPPORTED_LANGS.map((lang) => (
<DropdownMenuCheckboxItem <DropdownMenuCheckboxItem
key={lang.code} key={lang.code}

View File

@@ -1,35 +1,50 @@
import { Bell } from "lucide-react"; import { Bell } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenu, DropdownMenuContent, DropdownMenuItem,
DropdownMenuSeparator, DropdownMenuTrigger, DropdownMenuSeparator, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
export default function NotificationDropdown() { export default function NotificationDropdown() {
const { t } = useTranslation();
const notifications: { id: string; title: string; message: string; time: string; read: boolean; type: string }[] = []; const notifications: { id: string; title: string; message: string; time: string; read: boolean; type: string }[] = [];
const unread = notifications.filter((n) => !n.read).length; const unread = notifications.filter((n) => !n.read).length;
return ( return (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="relative text-muted-foreground hover:text-foreground"> <Button
variant="ghost"
size="icon"
aria-label={t("notifications.ariaLabel")}
title={t("notifications.ariaLabel")}
className="relative text-muted-foreground hover:text-foreground"
>
<Bell className="h-4 w-4" /> <Bell className="h-4 w-4" />
{unread > 0 && ( {unread > 0 && (
<span className="absolute -top-0.5 -right-0.5 h-4 w-4 rounded-full bg-destructive text-[10px] font-bold text-destructive-foreground flex items-center justify-center"> <span className="absolute -top-0.5 -end-0.5 h-4 w-4 rounded-full bg-destructive text-[10px] font-bold text-destructive-foreground flex items-center justify-center">
{unread} {unread}
</span> </span>
)} )}
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-80"> <DropdownMenuContent align="end" className="w-80">
<div className="px-3 py-2 font-semibold text-sm">Notifications</div> <div className="px-3 py-2 font-semibold text-sm">{t("notifications.title")}</div>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
{notifications.slice(0, 4).map((n) => ( {notifications.length === 0 ? (
<DropdownMenuItem key={n.id} className="flex flex-col items-start gap-0.5 py-2.5 cursor-pointer"> <div className="px-3 py-6 text-center text-xs text-muted-foreground">
<span className={`text-sm font-medium ${!n.read ? "text-foreground" : "text-muted-foreground"}`}>{n.title}</span> {t("notifications.empty")}
<span className="text-xs text-muted-foreground line-clamp-1">{n.message}</span> </div>
<span className="text-[10px] text-muted-foreground/70">{n.time}</span> ) : (
</DropdownMenuItem> notifications.slice(0, 4).map((n) => (
))} <DropdownMenuItem key={n.id} className="flex flex-col items-start gap-0.5 py-2.5 cursor-pointer">
<span className={`text-sm font-medium ${!n.read ? "text-foreground" : "text-muted-foreground"}`}>{n.title}</span>
<span className="text-xs text-muted-foreground line-clamp-1">{n.message}</span>
<span className="text-[10px] text-muted-foreground/70">{n.time}</span>
</DropdownMenuItem>
))
)}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
); );

View File

@@ -1,4 +1,5 @@
import { Outlet, Link, useNavigate, useLocation } from "react-router-dom"; import { Outlet, useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar"; import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
import { import {
Sidebar, SidebarContent, SidebarGroup, SidebarGroupContent, Sidebar, SidebarContent, SidebarGroup, SidebarGroupContent,
@@ -15,17 +16,21 @@ import {
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenu, DropdownMenuContent, DropdownMenuItem,
DropdownMenuSeparator, DropdownMenuTrigger, DropdownMenuSeparator, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { LogOut, User, Settings, LucideIcon } from "lucide-react"; import { LogOut, User, LucideIcon } from "lucide-react";
import React from "react";
/**
* Non-admin portal shell (student / teacher / etc.). Nav items hold i18n
* keys (`titleKey` / `labelKey`) rather than literal strings so they react
* to language changes without re-mounting the layout.
*/
export interface NavItem { export interface NavItem {
title: string; titleKey: string;
url: string; url: string;
icon: LucideIcon; icon: LucideIcon;
} }
export interface NavGroup { export interface NavGroup {
label: string; labelKey: string;
items: NavItem[]; items: NavItem[];
} }
@@ -36,30 +41,34 @@ interface RoleLayoutProps {
function SidebarNav({ navGroups }: { navGroups: NavGroup[] }) { function SidebarNav({ navGroups }: { navGroups: NavGroup[] }) {
const { state } = useSidebar(); const { state } = useSidebar();
const { t } = useTranslation();
const collapsed = state === "collapsed"; const collapsed = state === "collapsed";
return ( return (
<> <>
{navGroups.map((group) => ( {navGroups.map((group) => (
<SidebarGroup key={group.label}> <SidebarGroup key={group.labelKey}>
<SidebarGroupLabel>{group.label}</SidebarGroupLabel> <SidebarGroupLabel>{t(group.labelKey)}</SidebarGroupLabel>
<SidebarGroupContent> <SidebarGroupContent>
<SidebarMenu> <SidebarMenu>
{group.items.map((item) => ( {group.items.map((item) => {
<SidebarMenuItem key={item.url}> const label = t(item.titleKey);
<SidebarMenuButton asChild tooltip={item.title}> return (
<NavLink <SidebarMenuItem key={item.url}>
to={item.url} <SidebarMenuButton asChild tooltip={label}>
end={item.url.endsWith("/dashboard")} <NavLink
className="flex items-center gap-2 px-2 py-1.5 rounded-md text-sm text-sidebar-foreground hover:bg-sidebar-accent transition-colors" to={item.url}
activeClassName="bg-sidebar-accent text-sidebar-accent-foreground font-medium" end={item.url.endsWith("/dashboard")}
> className="flex items-center gap-2 px-2 py-1.5 rounded-md text-sm text-sidebar-foreground hover:bg-sidebar-accent transition-colors"
<item.icon className="h-4 w-4 shrink-0" /> activeClassName="bg-sidebar-accent text-sidebar-accent-foreground font-medium"
{!collapsed && <span>{item.title}</span>} >
</NavLink> <item.icon className="h-4 w-4 shrink-0" />
</SidebarMenuButton> {!collapsed && <span>{label}</span>}
</SidebarMenuItem> </NavLink>
))} </SidebarMenuButton>
</SidebarMenuItem>
);
})}
</SidebarMenu> </SidebarMenu>
</SidebarGroupContent> </SidebarGroupContent>
</SidebarGroup> </SidebarGroup>
@@ -71,6 +80,8 @@ function SidebarNav({ navGroups }: { navGroups: NavGroup[] }) {
export default function RoleLayout({ navGroups, role }: RoleLayoutProps) { export default function RoleLayout({ navGroups, role }: RoleLayoutProps) {
const { user, logout } = useAuth(); const { user, logout } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const { t, i18n } = useTranslation();
const sidebarSide = i18n.dir() === "rtl" ? "right" : "left";
const initials = user?.name?.split(" ").map(w => w[0]).join("").slice(0, 2).toUpperCase() ?? "??"; const initials = user?.name?.split(" ").map(w => w[0]).join("").slice(0, 2).toUpperCase() ?? "??";
@@ -79,20 +90,23 @@ export default function RoleLayout({ navGroups, role }: RoleLayoutProps) {
navigate("/login"); navigate("/login");
}; };
const roleLabel = t(`roles.${role}`, { defaultValue: role });
const portalTitle = `${roleLabel} ${t("chrome.portalSuffix")}`.trim();
return ( return (
<SidebarProvider> <SidebarProvider>
<div className="min-h-screen flex w-full"> <div className="min-h-screen flex w-full">
<Sidebar collapsible="icon"> <Sidebar side={sidebarSide} collapsible="icon">
<SidebarHeader className="p-4"> <SidebarHeader className="p-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<img <img
src="/logo.png" src="/logo.png"
alt="EnCoach" alt={t("chrome.adminAlt")}
className="h-9 w-9 shrink-0 rounded-lg object-contain group-data-[collapsible=icon]:block hidden" className="h-9 w-9 shrink-0 rounded-lg object-contain group-data-[collapsible=icon]:block hidden"
/> />
<img <img
src="/logo.png" src="/logo.png"
alt="EnCoach — Unlock your potential with AI powered platform" alt={t("chrome.adminAltLong")}
className="h-14 w-auto shrink-0 object-contain group-data-[collapsible=icon]:hidden" className="h-14 w-auto shrink-0 object-contain group-data-[collapsible=icon]:hidden"
/> />
</div> </div>
@@ -108,7 +122,9 @@ export default function RoleLayout({ navGroups, role }: RoleLayoutProps) {
<span className="text-xs font-semibold text-primary">{initials}</span> <span className="text-xs font-semibold text-primary">{initials}</span>
</div> </div>
<div className="flex flex-col min-w-0 group-data-[collapsible=icon]:hidden"> <div className="flex flex-col min-w-0 group-data-[collapsible=icon]:hidden">
<span className="text-sm font-medium truncate text-sidebar-foreground">{user?.name}</span> <span className="text-sm font-medium truncate text-sidebar-foreground">
{user?.name ?? t("userMenu.userFallback")}
</span>
<span className="text-xs text-muted-foreground truncate">{user?.email}</span> <span className="text-xs text-muted-foreground truncate">{user?.email}</span>
</div> </div>
</div> </div>
@@ -118,8 +134,8 @@ export default function RoleLayout({ navGroups, role }: RoleLayoutProps) {
<div className="flex-1 flex flex-col min-w-0"> <div className="flex-1 flex flex-col min-w-0">
<header className="h-14 border-b bg-card flex items-center justify-between px-4 shrink-0"> <header className="h-14 border-b bg-card flex items-center justify-between px-4 shrink-0">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<SidebarTrigger /> <SidebarTrigger aria-label={t("chrome.toggleSidebar")} />
<span className="text-sm font-medium text-muted-foreground capitalize">{role} Portal</span> <span className="text-sm font-medium text-muted-foreground">{portalTitle}</span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<LanguageToggle /> <LanguageToggle />
@@ -135,16 +151,16 @@ export default function RoleLayout({ navGroups, role }: RoleLayoutProps) {
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48"> <DropdownMenuContent align="end" className="w-48">
<div className="px-3 py-2"> <div className="px-3 py-2">
<p className="text-sm font-medium">{user?.name}</p> <p className="text-sm font-medium">{user?.name ?? t("userMenu.userFallback")}</p>
<p className="text-xs text-muted-foreground">{user?.email}</p> <p className="text-xs text-muted-foreground">{user?.email}</p>
</div> </div>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem onClick={() => navigate(`/${role}/profile`)}> <DropdownMenuItem onClick={() => navigate(`/${role}/profile`)}>
<User className="mr-2 h-4 w-4" /> Profile <User className="me-2 h-4 w-4" /> {t("userMenu.profile")}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem onClick={handleLogout}> <DropdownMenuItem onClick={handleLogout}>
<LogOut className="mr-2 h-4 w-4" /> Logout <LogOut className="me-2 h-4 w-4" /> {t("userMenu.logout")}
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>

View File

@@ -2,47 +2,52 @@ import RoleLayout, { NavGroup } from "./RoleLayout";
import ExamPopup from "./student/ExamPopup"; import ExamPopup from "./student/ExamPopup";
import { import {
LayoutDashboard, BookOpen, ClipboardList, BarChart3, LayoutDashboard, BookOpen, ClipboardList, BarChart3,
CalendarCheck, Calendar, User, Target, GraduationCap, ListChecks, CalendarCheck, Calendar, User, Target, ListChecks,
MessageSquare, Megaphone, Mail, TrendingUp, MessageSquare, Megaphone, Mail, TrendingUp,
} from "lucide-react"; } from "lucide-react";
/**
* Student portal shell. Nav items hold i18n keys which `RoleLayout`
* resolves via `useTranslation`, so switching language does not require
* re-mounting the layout.
*/
const navGroups: NavGroup[] = [ const navGroups: NavGroup[] = [
{ {
label: "Learning", labelKey: "sidebarGroup.learning",
items: [ items: [
{ title: "Dashboard", url: "/student/dashboard", icon: LayoutDashboard }, { titleKey: "nav.dashboard", url: "/student/dashboard", icon: LayoutDashboard },
{ title: "My Courses", url: "/student/courses", icon: BookOpen }, { titleKey: "nav.myCourses", url: "/student/courses", icon: BookOpen },
{ title: "Subject Registration", url: "/student/subject-registration", icon: ListChecks }, { titleKey: "nav.subjectRegistration", url: "/student/subject-registration", icon: ListChecks },
{ title: "Assignments", url: "/student/assignments", icon: ClipboardList }, { titleKey: "nav.assignments", url: "/student/assignments", icon: ClipboardList },
], ],
}, },
{ {
label: "Adaptive Learning", labelKey: "sidebarGroup.adaptiveLearning",
items: [ items: [
{ title: "My Subjects", url: "/student/subjects", icon: Target }, { titleKey: "nav.mySubjects", url: "/student/subjects", icon: Target },
], ],
}, },
{ {
label: "Progress", labelKey: "sidebarGroup.progress",
items: [ items: [
{ title: "Grades", url: "/student/grades", icon: BarChart3 }, { titleKey: "nav.grades", url: "/student/grades", icon: BarChart3 },
{ title: "Attendance", url: "/student/attendance", icon: CalendarCheck }, { titleKey: "nav.attendance", url: "/student/attendance", icon: CalendarCheck },
{ title: "Timetable", url: "/student/timetable", icon: Calendar }, { titleKey: "nav.timetable", url: "/student/timetable", icon: Calendar },
{ title: "My Journey", url: "/student/journey", icon: TrendingUp }, { titleKey: "nav.myJourney", url: "/student/journey", icon: TrendingUp },
], ],
}, },
{ {
label: "Communication", labelKey: "sidebarGroup.communication",
items: [ items: [
{ title: "Discussions", url: "/student/discussions", icon: MessageSquare }, { titleKey: "nav.discussions", url: "/student/discussions", icon: MessageSquare },
{ title: "Messages", url: "/student/messages", icon: Mail }, { titleKey: "nav.messages", url: "/student/messages", icon: Mail },
{ title: "Announcements", url: "/student/announcements", icon: Megaphone }, { titleKey: "nav.announcements", url: "/student/announcements", icon: Megaphone },
], ],
}, },
{ {
label: "Account", labelKey: "sidebarGroup.account",
items: [ items: [
{ title: "Profile", url: "/student/profile", icon: User }, { titleKey: "nav.profile", url: "/student/profile", icon: User },
], ],
}, },
]; ];

View File

@@ -1,39 +1,40 @@
import RoleLayout, { NavGroup } from "./RoleLayout"; import RoleLayout, { NavGroup } from "./RoleLayout";
import { import {
LayoutDashboard, BookOpen, ClipboardList, FileText, LayoutDashboard, BookOpen, ClipboardList,
CalendarCheck, Users, Calendar, User, MessageSquare, CalendarCheck, Users, Calendar, User, MessageSquare,
Megaphone, Wand2, Library, Megaphone, Library,
} from "lucide-react"; } from "lucide-react";
/** Teacher portal shell. See `StudentLayout` for the nav-config rationale. */
const navGroups: NavGroup[] = [ const navGroups: NavGroup[] = [
{ {
label: "Teaching", labelKey: "sidebarGroup.teaching",
items: [ items: [
{ title: "Dashboard", url: "/teacher/dashboard", icon: LayoutDashboard }, { titleKey: "nav.dashboard", url: "/teacher/dashboard", icon: LayoutDashboard },
{ title: "Courses", url: "/teacher/courses", icon: BookOpen }, { titleKey: "nav.courses", url: "/teacher/courses", icon: BookOpen },
{ title: "Resource Library", url: "/teacher/library", icon: Library }, { titleKey: "nav.resourceLibrary", url: "/teacher/library", icon: Library },
{ title: "Assignments", url: "/teacher/assignments", icon: ClipboardList }, { titleKey: "nav.assignments", url: "/teacher/assignments", icon: ClipboardList },
], ],
}, },
{ {
label: "Management", labelKey: "sidebarGroup.management",
items: [ items: [
{ title: "Attendance", url: "/teacher/attendance", icon: CalendarCheck }, { titleKey: "nav.attendance", url: "/teacher/attendance", icon: CalendarCheck },
{ title: "Students", url: "/teacher/students", icon: Users }, { titleKey: "nav.students", url: "/teacher/students", icon: Users },
{ title: "Timetable", url: "/teacher/timetable", icon: Calendar }, { titleKey: "nav.timetable", url: "/teacher/timetable", icon: Calendar },
], ],
}, },
{ {
label: "Communication", labelKey: "sidebarGroup.communication",
items: [ items: [
{ title: "Discussions", url: "/teacher/discussions", icon: MessageSquare }, { titleKey: "nav.discussions", url: "/teacher/discussions", icon: MessageSquare },
{ title: "Announcements", url: "/teacher/announcements", icon: Megaphone }, { titleKey: "nav.announcements", url: "/teacher/announcements", icon: Megaphone },
], ],
}, },
{ {
label: "Account", labelKey: "sidebarGroup.account",
items: [ items: [
{ title: "Profile", url: "/teacher/profile", icon: User }, { titleKey: "nav.profile", url: "/teacher/profile", icon: User },
], ],
}, },
]; ];

View File

@@ -1,6 +1,7 @@
import { Moon, Sun, Monitor } from "lucide-react"; import { Moon, Sun, Monitor } from "lucide-react";
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@@ -20,6 +21,7 @@ import {
export function ThemeToggle() { export function ThemeToggle() {
const { theme, setTheme, resolvedTheme } = useTheme(); const { theme, setTheme, resolvedTheme } = useTheme();
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
const { t } = useTranslation();
useEffect(() => { useEffect(() => {
setMounted(true); setMounted(true);
@@ -33,7 +35,8 @@ export function ThemeToggle() {
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
aria-label="Toggle theme" aria-label={t("theme.toggleLabel")}
title={t("theme.toggleLabel")}
className="text-muted-foreground hover:text-foreground" className="text-muted-foreground hover:text-foreground"
> >
{mounted ? ( {mounted ? (
@@ -49,16 +52,16 @@ export function ThemeToggle() {
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-36"> <DropdownMenuContent align="end" className="w-36">
<DropdownMenuItem onClick={() => setTheme("light")}> <DropdownMenuItem onClick={() => setTheme("light")}>
<Sun className="mr-2 h-4 w-4" /> Light <Sun className="me-2 h-4 w-4" /> {t("theme.light")}
{theme === "light" && <span className="ml-auto text-xs text-muted-foreground"></span>} {theme === "light" && <span className="ms-auto text-xs text-muted-foreground"></span>}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}> <DropdownMenuItem onClick={() => setTheme("dark")}>
<Moon className="mr-2 h-4 w-4" /> Dark <Moon className="me-2 h-4 w-4" /> {t("theme.dark")}
{theme === "dark" && <span className="ml-auto text-xs text-muted-foreground"></span>} {theme === "dark" && <span className="ms-auto text-xs text-muted-foreground"></span>}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}> <DropdownMenuItem onClick={() => setTheme("system")}>
<Monitor className="mr-2 h-4 w-4" /> System <Monitor className="me-2 h-4 w-4" /> {t("theme.system")}
{theme === "system" && <span className="ml-auto text-xs text-muted-foreground"></span>} {theme === "system" && <span className="ms-auto text-xs text-muted-foreground"></span>}
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>

View File

@@ -1,5 +1,6 @@
import { useState } from "react"; import { useState } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { AlertTriangle, X, Sparkles, Loader2 } from "lucide-react"; import { AlertTriangle, X, Sparkles, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { analyticsService } from "@/services/analytics.service"; import { analyticsService } from "@/services/analytics.service";
@@ -7,6 +8,7 @@ import { analyticsService } from "@/services/analytics.service";
export default function AiAlertBanner() { export default function AiAlertBanner() {
const [dismissedIds, setDismissedIds] = useState<Set<string>>(() => new Set()); const [dismissedIds, setDismissedIds] = useState<Set<string>>(() => new Set());
const [errorDismissed, setErrorDismissed] = useState(false); const [errorDismissed, setErrorDismissed] = useState(false);
const { t } = useTranslation();
const { data: resp, isLoading, isError, error } = useQuery({ const { data: resp, isLoading, isError, error } = useQuery({
queryKey: ["ai", "alerts"], queryKey: ["ai", "alerts"],
@@ -20,20 +22,20 @@ export default function AiAlertBanner() {
return ( return (
<div className="rounded-lg border border-warning/30 bg-warning/10 p-4 flex items-center gap-3"> <div className="rounded-lg border border-warning/30 bg-warning/10 p-4 flex items-center gap-3">
<Loader2 className="h-5 w-5 animate-spin text-warning shrink-0" /> <Loader2 className="h-5 w-5 animate-spin text-warning shrink-0" />
<p className="text-sm text-muted-foreground">Loading alerts</p> <p className="text-sm text-muted-foreground">{t("ai.loadingAlerts")}</p>
</div> </div>
); );
} }
if (isError && !errorDismissed) { if (isError && !errorDismissed) {
return ( return (
<div className="rounded-lg border border-warning/30 bg-warning/10 p-4 flex items-start gap-3"> <div className="rounded-lg border border-warning/30 bg-warning/10 p-4 flex items-start gap-3" dir="auto">
<AlertTriangle className="h-5 w-5 text-warning shrink-0 mt-0.5" /> <AlertTriangle className="h-5 w-5 text-warning shrink-0 mt-0.5" />
<div className="flex-1"> <div className="flex-1 min-w-0">
<p className="text-sm font-medium flex items-center gap-1"> <p className="text-sm font-medium flex items-center gap-1">
<Sparkles className="h-3 w-3 text-primary" /> Alerts unavailable <Sparkles className="h-3 w-3 text-primary shrink-0" /> {t("ai.alertsUnavailable")}
</p> </p>
<p className="text-sm text-muted-foreground mt-1">{error instanceof Error ? error.message : "Could not load alerts."}</p> <p className="text-sm text-muted-foreground mt-1">{error instanceof Error ? error.message : t("ai.couldNotLoadAlerts")}</p>
</div> </div>
<Button variant="ghost" size="icon" className="h-7 w-7 shrink-0" onClick={() => setErrorDismissed(true)}> <Button variant="ghost" size="icon" className="h-7 w-7 shrink-0" onClick={() => setErrorDismissed(true)}>
<X className="h-4 w-4" /> <X className="h-4 w-4" />
@@ -48,7 +50,7 @@ export default function AiAlertBanner() {
return ( return (
<div className="rounded-lg border border-muted bg-muted/20 p-4 flex items-start gap-3"> <div className="rounded-lg border border-muted bg-muted/20 p-4 flex items-start gap-3">
<Sparkles className="h-5 w-5 text-muted-foreground shrink-0 mt-0.5" /> <Sparkles className="h-5 w-5 text-muted-foreground shrink-0 mt-0.5" />
<p className="text-sm text-muted-foreground">No AI alerts right now.</p> <p className="text-sm text-muted-foreground">{t("ai.noAlertsRightNow")}</p>
</div> </div>
); );
} }
@@ -58,11 +60,11 @@ export default function AiAlertBanner() {
return ( return (
<div className="space-y-3"> <div className="space-y-3">
{visible.map((alert, idx) => ( {visible.map((alert, idx) => (
<div key={idx} className="rounded-lg border border-warning/30 bg-warning/10 p-4 flex items-start gap-3"> <div key={idx} className="rounded-lg border border-warning/30 bg-warning/10 p-4 flex items-start gap-3" dir="auto">
<AlertTriangle className="h-5 w-5 text-warning shrink-0 mt-0.5" /> <AlertTriangle className="h-5 w-5 text-warning shrink-0 mt-0.5" />
<div className="flex-1"> <div className="flex-1 min-w-0">
<p className="text-sm font-medium flex items-center gap-1"> <p className="text-sm font-medium flex items-center gap-1">
<Sparkles className="h-3 w-3 text-primary" /> {alert.title} <Sparkles className="h-3 w-3 text-primary shrink-0" /> {alert.title}
</p> </p>
<p className="text-sm text-muted-foreground mt-1">{alert.description}</p> <p className="text-sm text-muted-foreground mt-1">{alert.description}</p>
</div> </div>

View File

@@ -1,6 +1,7 @@
import { useState } from "react"; import { useMemo, useState } from "react";
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import { useLocation } from "react-router-dom"; import { useLocation } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet"; import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@@ -8,19 +9,27 @@ import { Sparkles, Send, Loader2 } from "lucide-react";
import { coachingService } from "@/services/coaching.service"; import { coachingService } from "@/services/coaching.service";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
const quickActions = [
"Platform health summary",
"Show dropout risks",
"Compare course performance",
"Suggest batch improvements",
];
export default function AiAssistantDrawer() { export default function AiAssistantDrawer() {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [messages, setMessages] = useState<{ role: "user" | "ai"; text: string }[]>([]); const [messages, setMessages] = useState<{ role: "user" | "ai"; text: string }[]>([]);
const [input, setInput] = useState(""); const [input, setInput] = useState("");
const location = useLocation(); const location = useLocation();
const { toast } = useToast(); const { toast } = useToast();
const { t } = useTranslation();
// Re-compute when language switches so quick-action chips show translated
// labels immediately. Chips are stored by label (what we send to the
// backend), so localizing the label is safe here — the backend treats
// them as free-form prompts.
const quickActions = useMemo(
() => [
t("ai.quickHealth"),
t("ai.quickDropouts"),
t("ai.quickCompare"),
t("ai.quickBatch"),
],
[t],
);
const chatMutation = useMutation({ const chatMutation = useMutation({
mutationFn: (message: string) => mutationFn: (message: string) =>
@@ -31,15 +40,12 @@ export default function AiAssistantDrawer() {
onError: (err: Error) => { onError: (err: Error) => {
toast({ toast({
variant: "destructive", variant: "destructive",
title: "Could not get a reply", title: t("ai.replyErrorTitle"),
description: err.message || "Something went wrong. Try again.", description: err.message || t("ai.replyErrorDesc"),
}); });
setMessages((prev) => [ setMessages((prev) => [
...prev, ...prev,
{ { role: "ai", text: t("ai.fallbackReply") },
role: "ai",
text: "Sorry, I could not reach the assistant. Please try again in a moment.",
},
]); ]);
}, },
}); });
@@ -56,8 +62,9 @@ export default function AiAssistantDrawer() {
<> <>
<button <button
onClick={() => setOpen(true)} onClick={() => setOpen(true)}
className="fixed bottom-20 right-6 z-50 flex h-12 w-12 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg hover:opacity-90 transition-opacity" className="fixed bottom-20 end-6 z-50 flex h-12 w-12 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg hover:opacity-90 transition-opacity"
aria-label="AI Assistant" aria-label={t("ai.assistantLabel")}
title={t("ai.assistantLabel")}
> >
<Sparkles className="h-5 w-5" /> <Sparkles className="h-5 w-5" />
</button> </button>
@@ -67,7 +74,7 @@ export default function AiAssistantDrawer() {
<SheetHeader> <SheetHeader>
<SheetTitle className="flex items-center gap-2"> <SheetTitle className="flex items-center gap-2">
<Sparkles className="h-5 w-5 text-primary" /> <Sparkles className="h-5 w-5 text-primary" />
EnCoach AI Assistant {t("ai.assistantTitle")}
</SheetTitle> </SheetTitle>
</SheetHeader> </SheetHeader>
@@ -90,8 +97,8 @@ export default function AiAssistantDrawer() {
{messages.length === 0 && ( {messages.length === 0 && (
<div className="text-center text-muted-foreground text-sm py-8"> <div className="text-center text-muted-foreground text-sm py-8">
<Sparkles className="h-8 w-8 mx-auto mb-3 text-primary/40" /> <Sparkles className="h-8 w-8 mx-auto mb-3 text-primary/40" />
<p>Ask me anything about the platform,</p> <p>{t("ai.emptyLine1")}</p>
<p>or click a quick action above.</p> <p>{t("ai.emptyLine2")}</p>
</div> </div>
)} )}
{messages.map((msg, i) => ( {messages.map((msg, i) => (
@@ -99,27 +106,27 @@ export default function AiAssistantDrawer() {
key={i} key={i}
className={`rounded-lg p-3 text-sm ${ className={`rounded-lg p-3 text-sm ${
msg.role === "user" msg.role === "user"
? "bg-primary text-primary-foreground ml-8" ? "bg-primary text-primary-foreground ms-8"
: "bg-muted mr-8" : "bg-muted me-8"
}`} }`}
> >
{msg.role === "ai" && ( {msg.role === "ai" && (
<Sparkles className="h-3 w-3 text-primary inline mr-1.5 -mt-0.5" /> <Sparkles className="h-3 w-3 text-primary inline me-1.5 -mt-0.5" />
)} )}
{msg.text} {msg.text}
</div> </div>
))} ))}
{chatMutation.isPending && ( {chatMutation.isPending && (
<div className="bg-muted rounded-lg p-3 text-sm mr-8 flex items-center gap-2"> <div className="bg-muted rounded-lg p-3 text-sm me-8 flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin text-primary" /> <Loader2 className="h-4 w-4 animate-spin text-primary" />
<span className="text-muted-foreground">Thinking...</span> <span className="text-muted-foreground">{t("ai.thinking")}</span>
</div> </div>
)} )}
</div> </div>
<div className="flex gap-2 pt-3 border-t mt-auto"> <div className="flex gap-2 pt-3 border-t mt-auto">
<Input <Input
placeholder="Ask anything..." placeholder={t("ai.placeholder")}
value={input} value={input}
onChange={(e) => setInput(e.target.value)} onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSend(input)} onKeyDown={(e) => e.key === "Enter" && handleSend(input)}
@@ -128,6 +135,8 @@ export default function AiAssistantDrawer() {
size="icon" size="icon"
onClick={() => handleSend(input)} onClick={() => handleSend(input)}
disabled={!input.trim() || chatMutation.isPending} disabled={!input.trim() || chatMutation.isPending}
aria-label={t("ai.send")}
title={t("ai.send")}
> >
<Send className="h-4 w-4" /> <Send className="h-4 w-4" />
</Button> </Button>

View File

@@ -1,5 +1,6 @@
import { useEffect, useMemo } from "react"; import { useEffect, useMemo } from "react";
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Sparkles, TrendingUp, AlertTriangle, Info, Loader2 } from "lucide-react"; import { Sparkles, TrendingUp, AlertTriangle, Info, Loader2 } from "lucide-react";
import { analyticsService, type AiInsightItem } from "@/services/analytics.service"; import { analyticsService, type AiInsightItem } from "@/services/analytics.service";
@@ -35,14 +36,15 @@ interface Props {
export default function AiInsightsPanel({ data = EMPTY_PAYLOAD }: Props) { export default function AiInsightsPanel({ data = EMPTY_PAYLOAD }: Props) {
const { toast } = useToast(); const { toast } = useToast();
const { t } = useTranslation();
const payloadKey = useMemo(() => JSON.stringify(data), [data]); const payloadKey = useMemo(() => JSON.stringify(data), [data]);
const mutation = useMutation({ const mutation = useMutation({
mutationFn: (payload: Record<string, unknown>) => analyticsService.getInsights(payload), mutationFn: (payload: Record<string, unknown>) => analyticsService.getInsights(payload),
onError: (err: Error) => { onError: (err: Error) => {
toast({ toast({
title: "Insights unavailable", title: t("ai.insightsUnavailable"),
description: err.message || "Could not load AI insights.", description: err.message || t("ai.couldNotLoadInsights"),
variant: "destructive", variant: "destructive",
}); });
}, },
@@ -60,21 +62,21 @@ export default function AiInsightsPanel({ data = EMPTY_PAYLOAD }: Props) {
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<CardTitle className="text-base font-semibold flex items-center gap-2"> <CardTitle className="text-base font-semibold flex items-center gap-2">
<Sparkles className="h-4 w-4 text-primary" /> <Sparkles className="h-4 w-4 text-primary" />
AI Platform Insights {t("ai.platformInsightsTitle")}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{mutation.isPending && ( {mutation.isPending && (
<div className="flex items-center gap-2 text-sm text-muted-foreground py-8 justify-center"> <div className="flex items-center gap-2 text-sm text-muted-foreground py-8 justify-center">
<Loader2 className="h-5 w-5 animate-spin text-primary" /> <Loader2 className="h-5 w-5 animate-spin text-primary" />
Loading insights {t("ai.loadingInsights")}
</div> </div>
)} )}
{mutation.isError && !mutation.isPending && ( {mutation.isError && !mutation.isPending && (
<p className="text-sm text-muted-foreground py-4 text-center">Could not load insights.</p> <p className="text-sm text-muted-foreground py-4 text-center">{t("ai.couldNotLoadInsights")}</p>
)} )}
{mutation.isSuccess && items.length === 0 && ( {mutation.isSuccess && items.length === 0 && (
<p className="text-sm text-muted-foreground py-4 text-center">No insights available for this view.</p> <p className="text-sm text-muted-foreground py-4 text-center">{t("ai.noInsightsAvailable")}</p>
)} )}
{!mutation.isPending && items.length > 0 && ( {!mutation.isPending && items.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
@@ -82,10 +84,10 @@ export default function AiInsightsPanel({ data = EMPTY_PAYLOAD }: Props) {
const Icon = insightIcon(item.severity); const Icon = insightIcon(item.severity);
const color = insightColor(item.severity); const color = insightColor(item.severity);
return ( return (
<div key={idx} className="rounded-lg border bg-muted/30 p-4"> <div key={idx} className="rounded-lg border bg-muted/30 p-4" dir="auto">
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
<Icon className={`h-4 w-4 ${color}`} /> <Icon className={`h-4 w-4 shrink-0 ${color}`} />
<span className="text-sm font-semibold">{item.title}</span> <span className="text-sm font-semibold min-w-0">{item.title}</span>
</div> </div>
<p className="text-sm text-muted-foreground">{item.description}</p> <p className="text-sm text-muted-foreground">{item.description}</p>
{item.recommendation && ( {item.recommendation && (

View File

@@ -1,5 +1,6 @@
import { useState } from "react"; import { useState } from "react";
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Sparkles, Search, Loader2, X } from "lucide-react"; import { Sparkles, Search, Loader2, X } from "lucide-react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
@@ -10,14 +11,15 @@ export default function AiSearchBar() {
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const navigate = useNavigate(); const navigate = useNavigate();
const { toast } = useToast(); const { toast } = useToast();
const { t } = useTranslation();
const searchMutation = useMutation({ const searchMutation = useMutation({
mutationFn: (q: string) => analyticsService.search(q), mutationFn: (q: string) => analyticsService.search(q),
onError: (err: Error) => { onError: (err: Error) => {
toast({ toast({
variant: "destructive", variant: "destructive",
title: "Search failed", title: t("chrome.aiSearchFailedTitle"),
description: err.message || "Could not complete AI search.", description: err.message || t("chrome.aiSearchFailedDesc"),
}); });
}, },
}); });
@@ -32,11 +34,11 @@ export default function AiSearchBar() {
return ( return (
<div className="relative max-w-md w-full"> <div className="relative max-w-md w-full">
<div className="relative"> <div className="relative">
<Sparkles className="absolute left-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-primary" /> <Sparkles className="absolute start-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-primary" />
<Search className="absolute left-7 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" /> <Search className="absolute start-7 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input <Input
placeholder="Ask anything... e.g. 'Show students with low attendance'" placeholder={t("chrome.aiSearchPlaceholder")}
className="pl-12 pr-8 h-9 text-sm bg-muted/50 border-0 focus-visible:ring-1" className="ps-12 pe-8 h-9 text-sm bg-muted/50 border-0 focus-visible:ring-1"
value={query} value={query}
onChange={(e) => { onChange={(e) => {
setQuery(e.target.value); setQuery(e.target.value);
@@ -50,7 +52,7 @@ export default function AiSearchBar() {
setQuery(""); setQuery("");
searchMutation.reset(); searchMutation.reset();
}} }}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground" className="absolute end-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
> >
<X className="h-3.5 w-3.5" /> <X className="h-3.5 w-3.5" />
</button> </button>
@@ -58,11 +60,11 @@ export default function AiSearchBar() {
</div> </div>
{(searchMutation.isPending || result !== undefined) && ( {(searchMutation.isPending || result !== undefined) && (
<div className="absolute top-full mt-1 left-0 right-0 z-50 rounded-lg border bg-popover p-3 shadow-md"> <div className="absolute top-full mt-1 start-0 end-0 z-50 rounded-lg border bg-popover p-3 shadow-md">
{searchMutation.isPending ? ( {searchMutation.isPending ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground"> <div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin text-primary" /> <Loader2 className="h-4 w-4 animate-spin text-primary" />
AI is searching... {t("chrome.aiSearching")}
</div> </div>
) : result?.answer ? ( ) : result?.answer ? (
<div className="text-sm space-y-2 max-h-64 overflow-y-auto"> <div className="text-sm space-y-2 max-h-64 overflow-y-auto">
@@ -72,7 +74,7 @@ export default function AiSearchBar() {
</div> </div>
{result.suggestions?.length > 0 && ( {result.suggestions?.length > 0 && (
<div className="border-t pt-2 space-y-1"> <div className="border-t pt-2 space-y-1">
<p className="text-xs font-semibold text-primary">Related queries</p> <p className="text-xs font-semibold text-primary">{t("chrome.aiRelatedQueries")}</p>
{result.suggestions.map((s, i) => ( {result.suggestions.map((s, i) => (
<button <button
key={i} key={i}
@@ -99,9 +101,7 @@ export default function AiSearchBar() {
) : ( ) : (
<div className="text-sm flex items-start gap-2"> <div className="text-sm flex items-start gap-2">
<Sparkles className="h-4 w-4 text-primary shrink-0 mt-0.5" /> <Sparkles className="h-4 w-4 text-primary shrink-0 mt-0.5" />
<p> <p>{t("chrome.aiNoResults", { q: query })}</p>
No results for &quot;{query}&quot;. Try a different question or keyword.
</p>
</div> </div>
)} )}
</div> </div>

View File

@@ -1,5 +1,6 @@
import { useState } from "react"; import { useState } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { Sparkles, X, Loader2 } from "lucide-react"; import { Sparkles, X, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { coachingService } from "@/services/coaching.service"; import { coachingService } from "@/services/coaching.service";
@@ -12,6 +13,7 @@ interface Props {
export default function AiTipBanner({ context = "dashboard", variant = "tip", dismissible = true }: Props) { export default function AiTipBanner({ context = "dashboard", variant = "tip", dismissible = true }: Props) {
const [dismissed, setDismissed] = useState(false); const [dismissed, setDismissed] = useState(false);
const { t } = useTranslation();
const { data, isLoading, isError, error } = useQuery({ const { data, isLoading, isError, error } = useQuery({
queryKey: ["ai", "tip", context], queryKey: ["ai", "tip", context],
@@ -22,23 +24,30 @@ export default function AiTipBanner({ context = "dashboard", variant = "tip", di
const bgClass = variant === "recommendation" ? "bg-accent/50 border-accent" : variant === "insight" ? "bg-primary/5 border-primary/20" : "bg-muted/50 border-muted-foreground/10"; const bgClass = variant === "recommendation" ? "bg-accent/50 border-accent" : variant === "insight" ? "bg-primary/5 border-primary/20" : "bg-muted/50 border-muted-foreground/10";
const variantLabel =
variant === "insight"
? t("ai.insightLabel")
: variant === "recommendation"
? t("ai.recommendationLabel")
: t("ai.tipLabel");
if (isLoading) { if (isLoading) {
return ( return (
<div className={`rounded-lg border ${bgClass} p-3 flex items-center gap-3`}> <div className={`rounded-lg border ${bgClass} p-3 flex items-center gap-3`}>
<Loader2 className="h-4 w-4 animate-spin text-primary shrink-0" /> <Loader2 className="h-4 w-4 animate-spin text-primary shrink-0" />
<span className="text-sm text-muted-foreground">Loading AI tip</span> <span className="text-sm text-muted-foreground">{t("ai.loadingTip")}</span>
</div> </div>
); );
} }
if (isError || !data) { if (isError || !data) {
return ( return (
<div className={`rounded-lg border ${bgClass} p-3 flex items-start gap-3`}> <div className={`rounded-lg border ${bgClass} p-3 flex items-start gap-3`} dir="auto">
<Sparkles className="h-4 w-4 text-primary shrink-0 mt-0.5" /> <Sparkles className="h-4 w-4 text-primary shrink-0 mt-0.5" />
<div className="flex-1"> <div className="flex-1 min-w-0">
<span className="text-xs font-semibold text-primary">AI Tip</span> <span className="block text-xs font-semibold text-primary">{t("ai.tipLabel")}</span>
<p className="text-sm text-muted-foreground mt-0.5"> <p className="text-sm text-muted-foreground mt-0.5">
{isError ? (error instanceof Error ? error.message : "Could not load tip.") : "No tip available."} {isError ? (error instanceof Error ? error.message : t("ai.couldNotLoadTip")) : t("ai.noTipAvailable")}
</p> </p>
</div> </div>
{dismissible && ( {dismissible && (
@@ -52,25 +61,25 @@ export default function AiTipBanner({ context = "dashboard", variant = "tip", di
if (!data.tip?.trim()) { if (!data.tip?.trim()) {
return ( return (
<div className={`rounded-lg border ${bgClass} p-3 flex items-start gap-3`}> <div className={`rounded-lg border ${bgClass} p-3 flex items-start gap-3`} dir="auto">
<Sparkles className="h-4 w-4 text-primary shrink-0 mt-0.5" /> <Sparkles className="h-4 w-4 text-primary shrink-0 mt-0.5" />
<div className="flex-1"> <div className="flex-1 min-w-0">
<span className="text-xs font-semibold text-primary">AI {variant === "tip" ? "Tip" : variant === "insight" ? "Insight" : "Recommendation"}</span> <span className="block text-xs font-semibold text-primary">{variantLabel}</span>
<p className="text-sm text-muted-foreground mt-0.5">No tip for this context yet.</p> <p className="text-sm text-muted-foreground mt-0.5">{t("ai.noTipForContext")}</p>
</div> </div>
</div> </div>
); );
} }
const label = data.category && data.category !== "general" const label = data.category && data.category !== "general"
? `AI ${data.category.charAt(0).toUpperCase() + data.category.slice(1)} Tip` ? t("ai.categoryTipLabel", { category: data.category.charAt(0).toUpperCase() + data.category.slice(1) })
: `AI ${variant === "tip" ? "Tip" : variant === "insight" ? "Insight" : "Recommendation"}`; : variantLabel;
return ( return (
<div className={`rounded-lg border ${bgClass} p-3 flex items-start gap-3 animate-in fade-in slide-in-from-top-2 duration-300`}> <div className={`rounded-lg border ${bgClass} p-3 flex items-start gap-3 animate-in fade-in slide-in-from-top-2 duration-300`} dir="auto">
<Sparkles className="h-4 w-4 text-primary shrink-0 mt-0.5" /> <Sparkles className="h-4 w-4 text-primary shrink-0 mt-0.5" />
<div className="flex-1"> <div className="flex-1 min-w-0">
<span className="text-xs font-semibold text-primary">{label}</span> <span className="block text-xs font-semibold text-primary">{label}</span>
<p className="text-sm text-muted-foreground mt-0.5">{data.tip}</p> <p className="text-sm text-muted-foreground mt-0.5">{data.tip}</p>
</div> </div>
{dismissible && ( {dismissible && (

View File

@@ -1,6 +1,7 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
@@ -10,6 +11,7 @@ import type { StudentExamAssignment } from "@/types";
export default function ExamPopup() { export default function ExamPopup() {
const navigate = useNavigate(); const navigate = useNavigate();
const { t, i18n } = useTranslation();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [dismissed, setDismissed] = useState<Set<number>>(new Set()); const [dismissed, setDismissed] = useState<Set<number>>(new Set());
@@ -30,11 +32,14 @@ export default function ExamPopup() {
if (pendingExams.length === 0) return null; if (pendingExams.length === 0) return null;
// Localize dates with the active language so Arabic users see ar-EG
// month names instead of "Apr 19, 2026". The locale is taken from i18n.
const dateLocale = i18n.language?.startsWith("ar") ? "ar-EG" : "en-US";
const formatDate = (iso: string | null) => { const formatDate = (iso: string | null) => {
if (!iso) return "—"; if (!iso) return "—";
const d = new Date(iso); const d = new Date(iso);
return d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }) + return d.toLocaleDateString(dateLocale, { month: "short", day: "numeric", year: "numeric" }) +
" " + d.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" }); " " + d.toLocaleTimeString(dateLocale, { hour: "2-digit", minute: "2-digit" });
}; };
const handleStart = (exam: StudentExamAssignment) => { const handleStart = (exam: StudentExamAssignment) => {
@@ -54,10 +59,10 @@ export default function ExamPopup() {
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<AlertCircle className="h-5 w-5 text-primary" /> <AlertCircle className="h-5 w-5 text-primary" />
Upcoming Exams ({pendingExams.length}) {t("examPopup.title", { n: pendingExams.length })}
</DialogTitle> </DialogTitle>
</DialogHeader> </DialogHeader>
<div className="space-y-3 max-h-[60vh] overflow-y-auto pr-1"> <div className="space-y-3 max-h-[60vh] overflow-y-auto pe-1">
{pendingExams.map((exam) => ( {pendingExams.map((exam) => (
<div key={exam.id} className="border rounded-lg p-4 space-y-3"> <div key={exam.id} className="border rounded-lg p-4 space-y-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -66,7 +71,7 @@ export default function ExamPopup() {
variant={exam.schedule_state === "active" ? "default" : "secondary"} variant={exam.schedule_state === "active" ? "default" : "secondary"}
className="capitalize text-xs" className="capitalize text-xs"
> >
{exam.schedule_state === "active" ? "Active Now" : exam.schedule_state} {exam.schedule_state === "active" ? t("examPopup.activeNow") : exam.schedule_state}
</Badge> </Badge>
</div> </div>
@@ -77,11 +82,11 @@ export default function ExamPopup() {
<div className="flex items-center gap-4 text-xs text-muted-foreground"> <div className="flex items-center gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<Calendar className="h-3 w-3" /> <Calendar className="h-3 w-3" />
From: {formatDate(exam.start_date)} {t("examPopup.from")} {formatDate(exam.start_date)}
</span> </span>
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<Clock className="h-3 w-3" /> <Clock className="h-3 w-3" />
To: {formatDate(exam.end_date)} {t("examPopup.to")} {formatDate(exam.end_date)}
</span> </span>
</div> </div>
@@ -93,20 +98,20 @@ export default function ExamPopup() {
className="gap-1.5" className="gap-1.5"
> >
<PlayCircle className="h-3.5 w-3.5" /> <PlayCircle className="h-3.5 w-3.5" />
{exam.can_start ? "Start Exam" : "Not Available Yet"} {exam.can_start ? t("examPopup.startExam") : t("examPopup.notAvailableYet")}
</Button> </Button>
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"
onClick={() => handleDismiss(exam.id)} onClick={() => handleDismiss(exam.id)}
> >
Dismiss {t("common.dismiss")}
</Button> </Button>
{!exam.can_start && ( {!exam.can_start && (
<span className="text-[11px] text-muted-foreground italic ml-auto"> <span className="text-[11px] text-muted-foreground italic ms-auto">
{exam.schedule_state === "planned" {exam.schedule_state === "planned"
? "Exam will be available once it becomes active" ? t("examPopup.willBeAvailable")
: "Exam is not currently active"} : t("examPopup.notActive")}
</span> </span>
)} )}
</div> </div>

View File

@@ -60,7 +60,12 @@ const BreadcrumbPage = React.forwardRef<HTMLSpanElement, React.ComponentPropsWit
BreadcrumbPage.displayName = "BreadcrumbPage"; BreadcrumbPage.displayName = "BreadcrumbPage";
const BreadcrumbSeparator = ({ children, className, ...props }: React.ComponentProps<"li">) => ( const BreadcrumbSeparator = ({ children, className, ...props }: React.ComponentProps<"li">) => (
<li role="presentation" aria-hidden="true" className={cn("[&>svg]:size-3.5", className)} {...props}> <li
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:size-3.5 rtl:[&>svg]:rotate-180", className)}
{...props}
>
{children ?? <ChevronRight />} {children ?? <ChevronRight />}
</li> </li>
); );

View File

@@ -42,8 +42,8 @@ function Calendar({ className, classNames, showOutsideDays = true, ...props }: C
...classNames, ...classNames,
}} }}
components={{ components={{
IconLeft: ({ ..._props }) => <ChevronLeft className="h-4 w-4" />, IconLeft: ({ ..._props }) => <ChevronLeft className="h-4 w-4 rtl:rotate-180" />,
IconRight: ({ ..._props }) => <ChevronRight className="h-4 w-4" />, IconRight: ({ ..._props }) => <ChevronRight className="h-4 w-4 rtl:rotate-180" />,
}} }}
{...props} {...props}
/> />

View File

@@ -185,7 +185,7 @@ const CarouselPrevious = React.forwardRef<HTMLButtonElement, React.ComponentProp
onClick={scrollPrev} onClick={scrollPrev}
{...props} {...props}
> >
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4 rtl:rotate-180" />
<span className="sr-only">Previous slide</span> <span className="sr-only">Previous slide</span>
</Button> </Button>
); );
@@ -213,7 +213,7 @@ const CarouselNext = React.forwardRef<HTMLButtonElement, React.ComponentProps<ty
onClick={scrollNext} onClick={scrollNext}
{...props} {...props}
> >
<ArrowRight className="h-4 w-4" /> <ArrowRight className="h-4 w-4 rtl:rotate-180" />
<span className="sr-only">Next slide</span> <span className="sr-only">Next slide</span>
</Button> </Button>
); );

View File

@@ -32,7 +32,7 @@ const ContextMenuSubTrigger = React.forwardRef<
{...props} {...props}
> >
{children} {children}
<ChevronRight className="ml-auto h-4 w-4" /> <ChevronRight className="ms-auto h-4 w-4 rtl:rotate-180" />
</ContextMenuPrimitive.SubTrigger> </ContextMenuPrimitive.SubTrigger>
)); ));
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName; ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName;

View File

@@ -32,7 +32,7 @@ const DropdownMenuSubTrigger = React.forwardRef<
{...props} {...props}
> >
{children} {children}
<ChevronRight className="ml-auto h-4 w-4" /> <ChevronRight className="ms-auto h-4 w-4 rtl:rotate-180" />
</DropdownMenuPrimitive.SubTrigger> </DropdownMenuPrimitive.SubTrigger>
)); ));
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName; DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;

View File

@@ -57,7 +57,7 @@ const MenubarSubTrigger = React.forwardRef<
{...props} {...props}
> >
{children} {children}
<ChevronRight className="ml-auto h-4 w-4" /> <ChevronRight className="ms-auto h-4 w-4 rtl:rotate-180" />
</MenubarPrimitive.SubTrigger> </MenubarPrimitive.SubTrigger>
)); ));
MenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName; MenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName;

View File

@@ -47,17 +47,17 @@ const PaginationLink = ({ className, isActive, size = "icon", ...props }: Pagina
PaginationLink.displayName = "PaginationLink"; PaginationLink.displayName = "PaginationLink";
const PaginationPrevious = ({ className, ...props }: React.ComponentProps<typeof PaginationLink>) => ( const PaginationPrevious = ({ className, ...props }: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink aria-label="Go to previous page" size="default" className={cn("gap-1 pl-2.5", className)} {...props}> <PaginationLink aria-label="Go to previous page" size="default" className={cn("gap-1 ps-2.5", className)} {...props}>
<ChevronLeft className="h-4 w-4" /> <ChevronLeft className="h-4 w-4 rtl:rotate-180" />
<span>Previous</span> <span>Previous</span>
</PaginationLink> </PaginationLink>
); );
PaginationPrevious.displayName = "PaginationPrevious"; PaginationPrevious.displayName = "PaginationPrevious";
const PaginationNext = ({ className, ...props }: React.ComponentProps<typeof PaginationLink>) => ( const PaginationNext = ({ className, ...props }: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink aria-label="Go to next page" size="default" className={cn("gap-1 pr-2.5", className)} {...props}> <PaginationLink aria-label="Go to next page" size="default" className={cn("gap-1 pe-2.5", className)} {...props}>
<span>Next</span> <span>Next</span>
<ChevronRight className="h-4 w-4" /> <ChevronRight className="h-4 w-4 rtl:rotate-180" />
</PaginationLink> </PaginationLink>
); );
PaginationNext.displayName = "PaginationNext"; PaginationNext.displayName = "PaginationNext";

View File

@@ -217,7 +217,7 @@ const Sidebar = React.forwardRef<
Sidebar.displayName = "Sidebar"; Sidebar.displayName = "Sidebar";
const SidebarTrigger = React.forwardRef<React.ElementRef<typeof Button>, React.ComponentProps<typeof Button>>( const SidebarTrigger = React.forwardRef<React.ElementRef<typeof Button>, React.ComponentProps<typeof Button>>(
({ className, onClick, ...props }, ref) => { ({ className, onClick, "aria-label": ariaLabel, ...props }, ref) => {
const { toggleSidebar } = useSidebar(); const { toggleSidebar } = useSidebar();
return ( return (
@@ -226,6 +226,7 @@ const SidebarTrigger = React.forwardRef<React.ElementRef<typeof Button>, React.C
data-sidebar="trigger" data-sidebar="trigger"
variant="ghost" variant="ghost"
size="icon" size="icon"
aria-label={ariaLabel ?? "Toggle sidebar"}
className={cn("h-7 w-7", className)} className={cn("h-7 w-7", className)}
onClick={(event) => { onClick={(event) => {
onClick?.(event); onClick?.(event);
@@ -233,8 +234,8 @@ const SidebarTrigger = React.forwardRef<React.ElementRef<typeof Button>, React.C
}} }}
{...props} {...props}
> >
<PanelLeft /> <PanelLeft className="rtl:rotate-180" />
<span className="sr-only">Toggle Sidebar</span> <span className="sr-only">{ariaLabel ?? "Toggle sidebar"}</span>
</Button> </Button>
); );
}, },

View File

@@ -1,6 +1,6 @@
import type { Translations } from "./en"; import type { Translations } from "./en";
/** Arabic (RTL) translations. */ /** Arabic (RTL) translations. Keep key parity with `en.ts`. */
const ar: Translations = { const ar: Translations = {
common: { common: {
cancel: "إلغاء", cancel: "إلغاء",
@@ -20,30 +20,293 @@ const ar: Translations = {
status: "الحالة", status: "الحالة",
settings: "الإعدادات", settings: "الإعدادات",
signOut: "تسجيل الخروج", signOut: "تسجيل الخروج",
viewAll: "عرض الكل",
dismiss: "تجاهل",
pending: "قيد الانتظار",
active: "نشط",
inactive: "غير نشط",
available: "متاح",
unavailable: "غير متاح",
name: "الاسم",
email: "البريد الإلكتروني",
user: "المستخدم",
home: "الرئيسية",
}, },
auth: { auth: {
signIn: "تسجيل الدخول", signIn: "تسجيل الدخول",
signInTitle: "تسجيل الدخول إلى EnCoach", signInTitle: "تسجيل الدخول إلى EnCoach",
welcomeBack: "مرحباً بعودتك",
signInDescription: "سجّل الدخول إلى حسابك للمتابعة",
email: "البريد الإلكتروني", email: "البريد الإلكتروني",
emailPlaceholder: "you@example.com",
password: "كلمة المرور", password: "كلمة المرور",
rememberMe: "تذكرني",
forgotPassword: "هل نسيت كلمة المرور؟", forgotPassword: "هل نسيت كلمة المرور؟",
needAccount: "ليس لديك حساب؟", needAccount: "ليس لديك حساب؟",
signUp: "إنشاء حساب", signUp: "إنشاء حساب",
invalidCredentials: "البريد الإلكتروني أو كلمة المرور غير صحيحة", invalidCredentials: "البريد الإلكتروني أو كلمة المرور غير صحيحة",
missingCredentials: "يرجى إدخال البريد الإلكتروني وكلمة المرور",
loginFailedTitle: "فشل تسجيل الدخول",
errorTitle: "خطأ",
}, },
nav: { nav: {
adminDashboard: "لوحة الإدارة",
platformDashboard: "لوحة المنصّة",
dashboard: "لوحة التحكم", dashboard: "لوحة التحكم",
courses: "الدورات", courses: "الدورات",
myCourses: "دوراتي",
students: "الطلاب", students: "الطلاب",
teachers: "المعلمون", teachers: "المعلمون",
exams: "الاختبارات", batches: "الدفعات",
timetable: "الجدول الدراسي",
reports: "التقارير", reports: "التقارير",
settings: "الإعدادات", assignments: "الواجبات",
profile: "الملف الشخصي", examsList: "قائمة الاختبارات",
privacy: "مركز الخصوصية", examStructures: "هياكل الاختبارات",
rubrics: "معايير التقييم",
generation: "التوليد",
reviewQueue: "قائمة المراجعة", reviewQueue: "قائمة المراجعة",
aiPrompts: "تعليمات الذكاء الاصطناعي", aiPrompts: "تعليمات الذكاء الاصطناعي",
aiFeedback: "ملاحظات الذكاء الاصطناعي", aiFeedback: "ملاحظات الذكاء الاصطناعي",
approvalWorkflows: "سير عمل الموافقات",
taxonomy: "التصنيف",
resources: "الموارد",
resourceLibrary: "مكتبة الموارد",
academicYears: "السنوات الأكاديمية",
departments: "الأقسام",
admissions: "القبول",
admissionRegister: "سجل القبول",
examSessions: "جلسات الاختبارات",
marksheets: "كشوفات الدرجات",
studentLeave: "إجازات الطلاب",
fees: "الرسوم والمدفوعات",
lessons: "الدروس",
gradebook: "سجل الدرجات",
studentProgress: "تقدم الطالب",
library: "المكتبة",
activities: "الأنشطة",
facilities: "المرافق",
users: "المستخدمون",
entities: "الكيانات",
classrooms: "الفصول",
userRoles: "أدوار المستخدمين",
rolesPermissions: "الأدوار والصلاحيات",
authorityMatrix: "مصفوفة الصلاحيات",
studentPerformance: "أداء الطلاب",
statsCorporate: "إحصاءات الشركات",
record: "السجل",
vocabulary: "المفردات",
grammar: "القواعد",
faqManager: "إدارة الأسئلة الشائعة",
notificationRules: "قواعد الإشعارات",
approvalConfig: "إعدادات الموافقات",
paymentRecord: "سجل المدفوعات",
tickets: "التذاكر",
settings: "الإعدادات",
profile: "الملف الشخصي",
privacy: "مركز الخصوصية",
subjectRegistration: "تسجيل المواد",
mySubjects: "موادي",
grades: "الدرجات",
attendance: "الحضور",
myJourney: "رحلتي",
discussions: "المناقشات",
messages: "الرسائل",
announcements: "الإعلانات",
},
sidebarGroup: {
overview: "نظرة عامة",
lms: "نظام التعلم",
adaptiveLearning: "التعلم التكيّفي",
institutional: "المؤسسي",
academic: "الأكاديمي",
management: "الإدارة",
reports: "التقارير",
configuration: "الإعدادات",
training: "التدريب",
support: "الدعم",
learning: "التعلم",
progress: "التقدم",
communication: "التواصل",
account: "الحساب",
teaching: "التدريس",
},
breadcrumb: {
home: "الرئيسية",
admin: "الإدارة",
dashboard: "لوحة التحكم",
platform: "المنصّة",
exam: "الاختبار",
review: "مراجعة",
ai: "الذكاء الاصطناعي",
feedback: "ملاحظات",
},
userMenu: {
profile: "الملف الشخصي",
settings: "الإعدادات",
logout: "تسجيل الخروج",
userFallback: "مستخدم",
},
chrome: {
needHelp: "هل تحتاج مساعدة؟",
ticketsTooltip: "تذاكر الدعم",
aiSearchPlaceholder: "اسأل عن أي شيء… مثال: «اعرض الطلاب ذوي الحضور المنخفض»",
aiSearching: "الذكاء الاصطناعي يبحث…",
aiRelatedQueries: "استفسارات ذات صلة",
aiNoResults: "لا توجد نتائج لـ «{{q}}». جرّب سؤالاً أو كلمة مفتاحية مختلفة.",
aiSearchFailedTitle: "فشل البحث",
aiSearchFailedDesc: "تعذّر إكمال بحث الذكاء الاصطناعي.",
portalSuffix: "البوابة",
adminAlt: "EnCoach",
adminAltLong: "EnCoach — أطلق إمكاناتك مع منصّة مدعومة بالذكاء الاصطناعي",
toggleSidebar: "إظهار/إخفاء الشريط الجانبي",
},
notifications: {
title: "الإشعارات",
empty: "ليست لديك إشعارات جديدة.",
ariaLabel: "الإشعارات",
},
theme: {
title: "المظهر",
light: "فاتح",
dark: "داكن",
system: "النظام",
toggleLabel: "تبديل المظهر",
},
language: {
change: "تغيير اللغة",
label: "اللغة",
},
roles: {
student: "طالب",
teacher: "معلّم",
admin: "مسؤول",
developer: "مطوّر",
corporate: "شركة",
mastercorporate: "شركة رئيسية",
agent: "وكيل",
},
dashboard: {
greetingFallback: "الطالب",
},
studentDash: {
welcome: "مرحباً بعودتك، {{name}}!",
subtitle: "إليك نظرة عامة على تقدّمك الدراسي.",
enrolledCourses: "الدورات المسجّلة",
overallProgress: "التقدم العام",
averageGrade: "متوسط الدرجات",
totalChapters: "إجمالي الفصول",
myCourses: "دوراتي",
noEnrolledCourses: "لم تسجّل في أي دورة بعد.",
chaptersMaterials: "{{chapters}} فصل · {{materials}} مادة",
quickActions: "إجراءات سريعة",
enrollToStart: "سجّل في دورة للبدء.",
continue: "متابعة",
start: "ابدأ",
chaptersDone: "{{done}}/{{total}} فصل مكتمل",
recentGrades: "آخر الدرجات",
noGradesYet: "لا توجد درجات بعد.",
},
teacherDash: {
title: "لوحة المعلّم",
subtitle: "نظرة عامة على نشاطك التدريسي.",
activeCourses: "الدورات النشطة",
totalStudents: "إجمالي الطلاب",
pendingGrading: "تقييمات قيد الانتظار",
avgPassRate: "متوسط نسبة النجاح",
myCourses: "دوراتي",
colCourse: "الدورة",
colStudents: "الطلاب",
colStatus: "الحالة",
recentActivity: "النشاط الأخير",
submitted: "تم الإرسال {{when}}",
pending: "قيد الانتظار",
},
adminDash: {
title: "لوحة الإدارة",
subtitle: "نظرة عامة على المنصّة والمؤشرات الرئيسية.",
addStudent: "إضافة طالب",
newCourse: "دورة جديدة",
totalStudents: "إجمالي الطلاب",
activeCourses: "الدورات النشطة",
teachers: "المعلمون",
activeBatches: "الدفعات النشطة",
openTickets: "تذاكر مفتوحة",
revenue: "الإيرادات",
departments: "الأقسام",
classrooms: "الفصول",
subjects: "المواد",
resources: "الموارد",
coursesOverview: "نظرة عامة على الدورات",
batchCapacity: "سعة الدفعات",
noCourseData: "لا توجد بيانات دورات متاحة.",
noBatchData: "لا توجد بيانات دفعات متاحة.",
quickSummary: "ملخّص سريع",
colModule: "الوحدة",
colCount: "العدد",
colStatus: "الحالة",
exams: "الاختبارات",
examSessionsCount: "{{n}} جلسة",
assignments: "الواجبات",
acrossCourses: "عبر جميع الدورات",
supportTickets: "تذاكر الدعم",
ticketsOpenCount: "{{n}} مفتوحة",
payments: "المدفوعات",
revenueTotal: "{{amount}} إجمالاً",
chartCapacity: "السعة",
chartEnrolled: "المسجّلون",
},
examPopup: {
title: "اختبارات قادمة ({{n}})",
activeNow: "متاح الآن",
from: "من:",
to: "إلى:",
startExam: "ابدأ الاختبار",
notAvailableYet: "غير متاح بعد",
willBeAvailable: "سيتاح الاختبار عندما يصبح نشطاً",
notActive: "الاختبار غير نشط حالياً",
},
errors: {
somethingWrong: "حدث خطأ ما",
unexpectedDescription: "حدث خطأ غير متوقع. يرجى تحديث الصفحة والمحاولة مرة أخرى.",
errorDetails: "تفاصيل الخطأ",
refreshPage: "تحديث الصفحة",
notFoundCode: "404",
notFoundMessage: "عذراً! الصفحة غير موجودة",
returnHome: "العودة إلى الرئيسية",
},
ai: {
assistantLabel: "مساعد الذكاء الاصطناعي",
assistantTitle: "مساعد EnCoach الذكي",
placeholder: "اسأل عن أي شيء…",
send: "إرسال",
thinking: "يفكّر…",
emptyLine1: "اسألني عن أي شيء يخص المنصّة،",
emptyLine2: "أو اختر إجراءً سريعاً من الأعلى.",
fallbackReply: "عذراً، تعذّر الوصول إلى المساعد. يرجى المحاولة مرة أخرى بعد قليل.",
replyErrorTitle: "تعذّر الحصول على ردّ",
replyErrorDesc: "حدث خطأ ما. يرجى المحاولة مرة أخرى.",
quickHealth: "ملخّص حالة المنصّة",
quickDropouts: "عرض مخاطر التسرّب",
quickCompare: "مقارنة أداء الدورات",
quickBatch: "اقتراحات لتحسين الدفعات",
tipLabel: "نصيحة الذكاء الاصطناعي",
insightLabel: "رؤية الذكاء الاصطناعي",
recommendationLabel: "توصية الذكاء الاصطناعي",
categoryTipLabel: "نصيحة {{category}} من الذكاء الاصطناعي",
loadingTip: "جارٍ تحميل النصيحة…",
loadingInsights: "جارٍ تحميل الرؤى…",
loadingAlerts: "جارٍ تحميل التنبيهات…",
couldNotLoadTip: "تعذّر تحميل النصيحة.",
couldNotLoadInsights: "تعذّر تحميل الرؤى.",
couldNotLoadAlerts: "تعذّر تحميل التنبيهات.",
noTipAvailable: "لا توجد نصيحة متاحة.",
noTipForContext: "لا توجد نصيحة لهذا السياق بعد.",
noInsightsAvailable: "لا توجد رؤى متاحة لهذا العرض.",
noAlertsRightNow: "لا توجد تنبيهات ذكاء اصطناعي حالياً.",
alertsUnavailable: "التنبيهات غير متاحة",
insightsUnavailable: "الرؤى غير متاحة",
platformInsightsTitle: "رؤى المنصّة من الذكاء الاصطناعي",
}, },
privacy: { privacy: {
title: "مركز الخصوصية", title: "مركز الخصوصية",
@@ -74,12 +337,6 @@ const ar: Translations = {
commentRequired: "من فضلك أخبرنا بالخطأ.", commentRequired: "من فضلك أخبرنا بالخطأ.",
submit: "إرسال الملاحظات", submit: "إرسال الملاحظات",
}, },
theme: {
title: "المظهر",
light: "فاتح",
dark: "داكن",
system: "النظام",
},
}; };
export default ar; export default ar;

View File

@@ -3,14 +3,33 @@
* We deliberately annotate the literal with `Translations` (not `as const`) * We deliberately annotate the literal with `Translations` (not `as const`)
* so sibling locale files can widen their string values without fighting * so sibling locale files can widen their string values without fighting
* literal-string type mismatches. * literal-string type mismatches.
*
* Adding a new key: add it here FIRST (English is the source of truth), then
* mirror it in `ar.ts`. The `Translations` interface is intentionally
* permissive (`Record<string, string>` per group) so we don't have to update
* a giant type every time we add a string.
*/ */
export interface Translations { export interface Translations {
common: Record<string, string>; common: Record<string, string>;
auth: Record<string, string>; auth: Record<string, string>;
nav: Record<string, string>; nav: Record<string, string>;
sidebarGroup: Record<string, string>;
breadcrumb: Record<string, string>;
userMenu: Record<string, string>;
chrome: Record<string, string>;
notifications: Record<string, string>;
theme: Record<string, string>;
language: Record<string, string>;
roles: Record<string, string>;
dashboard: Record<string, string>;
studentDash: Record<string, string>;
teacherDash: Record<string, string>;
adminDash: Record<string, string>;
examPopup: Record<string, string>;
errors: Record<string, string>;
ai: Record<string, string>;
privacy: Record<string, string>; privacy: Record<string, string>;
feedback: Record<string, string>; feedback: Record<string, string>;
theme: Record<string, string>;
} }
const en: Translations = { const en: Translations = {
@@ -32,30 +51,293 @@ const en: Translations = {
status: "Status", status: "Status",
settings: "Settings", settings: "Settings",
signOut: "Sign out", signOut: "Sign out",
viewAll: "View All",
dismiss: "Dismiss",
pending: "Pending",
active: "Active",
inactive: "Inactive",
available: "Available",
unavailable: "Unavailable",
name: "Name",
email: "Email",
user: "User",
home: "Home",
}, },
auth: { auth: {
signIn: "Sign in", signIn: "Sign in",
signInTitle: "Sign in to EnCoach", signInTitle: "Sign in to EnCoach",
welcomeBack: "Welcome back",
signInDescription: "Sign in to your account to continue",
email: "Email", email: "Email",
emailPlaceholder: "you@example.com",
password: "Password", password: "Password",
rememberMe: "Remember me",
forgotPassword: "Forgot password?", forgotPassword: "Forgot password?",
needAccount: "Don't have an account?", needAccount: "Don't have an account?",
signUp: "Sign up", signUp: "Sign up",
invalidCredentials: "Invalid email or password", invalidCredentials: "Invalid email or password",
missingCredentials: "Please enter email and password",
loginFailedTitle: "Login Failed",
errorTitle: "Error",
}, },
nav: { nav: {
adminDashboard: "Admin Dashboard",
platformDashboard: "Platform Dashboard",
dashboard: "Dashboard", dashboard: "Dashboard",
courses: "Courses", courses: "Courses",
myCourses: "My Courses",
students: "Students", students: "Students",
teachers: "Teachers", teachers: "Teachers",
exams: "Exams", batches: "Batches",
timetable: "Timetable",
reports: "Reports", reports: "Reports",
settings: "Settings", assignments: "Assignments",
profile: "Profile", examsList: "Exams List",
privacy: "Privacy Center", examStructures: "Exam Structures",
rubrics: "Rubrics",
generation: "Generation",
reviewQueue: "Review Queue", reviewQueue: "Review Queue",
aiPrompts: "AI Prompts", aiPrompts: "AI Prompts",
aiFeedback: "AI Feedback", aiFeedback: "AI Feedback",
approvalWorkflows: "Approval Workflows",
taxonomy: "Taxonomy",
resources: "Resources",
resourceLibrary: "Resource Library",
academicYears: "Academic Years",
departments: "Departments",
admissions: "Admissions",
admissionRegister: "Admission Register",
examSessions: "Exam Sessions",
marksheets: "Marksheets",
studentLeave: "Student Leave",
fees: "Fees & Payments",
lessons: "Lessons",
gradebook: "Gradebook",
studentProgress: "Student Progress",
library: "Library",
activities: "Activities",
facilities: "Facilities",
users: "Users",
entities: "Entities",
classrooms: "Classrooms",
userRoles: "User Roles",
rolesPermissions: "Roles & Permissions",
authorityMatrix: "Authority Matrix",
studentPerformance: "Student Performance",
statsCorporate: "Stats Corporate",
record: "Record",
vocabulary: "Vocabulary",
grammar: "Grammar",
faqManager: "FAQ Manager",
notificationRules: "Notification Rules",
approvalConfig: "Approval Config",
paymentRecord: "Payment Record",
tickets: "Tickets",
settings: "Settings",
profile: "Profile",
privacy: "Privacy Center",
subjectRegistration: "Subject Registration",
mySubjects: "My Subjects",
grades: "Grades",
attendance: "Attendance",
myJourney: "My Journey",
discussions: "Discussions",
messages: "Messages",
announcements: "Announcements",
},
sidebarGroup: {
overview: "Overview",
lms: "LMS",
adaptiveLearning: "Adaptive Learning",
institutional: "Institutional",
academic: "Academic",
management: "Management",
reports: "Reports",
configuration: "Configuration",
training: "Training",
support: "Support",
learning: "Learning",
progress: "Progress",
communication: "Communication",
account: "Account",
teaching: "Teaching",
},
breadcrumb: {
home: "Home",
admin: "Admin",
dashboard: "Dashboard",
platform: "Platform",
exam: "Exam",
review: "Review",
ai: "AI",
feedback: "Feedback",
},
userMenu: {
profile: "Profile",
settings: "Settings",
logout: "Logout",
userFallback: "User",
},
chrome: {
needHelp: "Need help?",
ticketsTooltip: "Support tickets",
aiSearchPlaceholder: "Ask anything... e.g. 'Show students with low attendance'",
aiSearching: "AI is searching…",
aiRelatedQueries: "Related queries",
aiNoResults: "No results for \"{{q}}\". Try a different question or keyword.",
aiSearchFailedTitle: "Search failed",
aiSearchFailedDesc: "Could not complete AI search.",
portalSuffix: "Portal",
adminAlt: "EnCoach",
adminAltLong: "EnCoach — Unlock your potential with AI powered platform",
toggleSidebar: "Toggle sidebar",
},
notifications: {
title: "Notifications",
empty: "You're all caught up.",
ariaLabel: "Notifications",
},
theme: {
title: "Theme",
light: "Light",
dark: "Dark",
system: "System",
toggleLabel: "Toggle theme",
},
language: {
change: "Change language",
label: "Language",
},
roles: {
student: "student",
teacher: "teacher",
admin: "admin",
developer: "developer",
corporate: "corporate",
mastercorporate: "master corporate",
agent: "agent",
},
dashboard: {
greetingFallback: "Student",
},
studentDash: {
welcome: "Welcome back, {{name}}!",
subtitle: "Here's an overview of your learning progress.",
enrolledCourses: "Enrolled Courses",
overallProgress: "Overall Progress",
averageGrade: "Average Grade",
totalChapters: "Total Chapters",
myCourses: "My Courses",
noEnrolledCourses: "No enrolled courses yet.",
chaptersMaterials: "{{chapters}} chapters · {{materials}} materials",
quickActions: "Quick Actions",
enrollToStart: "Enroll in a course to get started.",
continue: "Continue",
start: "Start",
chaptersDone: "{{done}}/{{total}} chapters done",
recentGrades: "Recent Grades",
noGradesYet: "No grades yet.",
},
teacherDash: {
title: "Teacher Dashboard",
subtitle: "Overview of your teaching activities.",
activeCourses: "Active Courses",
totalStudents: "Total Students",
pendingGrading: "Pending Grading",
avgPassRate: "Avg. Pass Rate",
myCourses: "My Courses",
colCourse: "Course",
colStudents: "Students",
colStatus: "Status",
recentActivity: "Recent Activity",
submitted: "Submitted {{when}}",
pending: "Pending",
},
adminDash: {
title: "Admin Dashboard",
subtitle: "Platform overview and key metrics.",
addStudent: "Add Student",
newCourse: "New Course",
totalStudents: "Total Students",
activeCourses: "Active Courses",
teachers: "Teachers",
activeBatches: "Active Batches",
openTickets: "Open Tickets",
revenue: "Revenue",
departments: "Departments",
classrooms: "Classrooms",
subjects: "Subjects",
resources: "Resources",
coursesOverview: "Courses Overview",
batchCapacity: "Batch Capacity",
noCourseData: "No course data available.",
noBatchData: "No batch data available.",
quickSummary: "Quick Summary",
colModule: "Module",
colCount: "Count",
colStatus: "Status",
exams: "Exams",
examSessionsCount: "{{n}} sessions",
assignments: "Assignments",
acrossCourses: "across courses",
supportTickets: "Support Tickets",
ticketsOpenCount: "{{n}} open",
payments: "Payments",
revenueTotal: "{{amount}} total",
chartCapacity: "Capacity",
chartEnrolled: "Enrolled",
},
examPopup: {
title: "Upcoming Exams ({{n}})",
activeNow: "Active Now",
from: "From:",
to: "To:",
startExam: "Start Exam",
notAvailableYet: "Not Available Yet",
willBeAvailable: "Exam will be available once it becomes active",
notActive: "Exam is not currently active",
},
errors: {
somethingWrong: "Something went wrong",
unexpectedDescription: "An unexpected error occurred. Please try refreshing the page.",
errorDetails: "Error details",
refreshPage: "Refresh Page",
notFoundCode: "404",
notFoundMessage: "Oops! Page not found",
returnHome: "Return to Home",
},
ai: {
assistantLabel: "AI Assistant",
assistantTitle: "EnCoach AI Assistant",
placeholder: "Ask anything...",
send: "Send",
thinking: "Thinking…",
emptyLine1: "Ask me anything about the platform,",
emptyLine2: "or click a quick action above.",
fallbackReply: "Sorry, I could not reach the assistant. Please try again in a moment.",
replyErrorTitle: "Could not get a reply",
replyErrorDesc: "Something went wrong. Try again.",
quickHealth: "Platform health summary",
quickDropouts: "Show dropout risks",
quickCompare: "Compare course performance",
quickBatch: "Suggest batch improvements",
tipLabel: "AI Tip",
insightLabel: "AI Insight",
recommendationLabel: "AI Recommendation",
categoryTipLabel: "AI {{category}} Tip",
loadingTip: "Loading AI tip…",
loadingInsights: "Loading insights…",
loadingAlerts: "Loading alerts…",
couldNotLoadTip: "Could not load tip.",
couldNotLoadInsights: "Could not load insights.",
couldNotLoadAlerts: "Could not load alerts.",
noTipAvailable: "No tip available.",
noTipForContext: "No tip for this context yet.",
noInsightsAvailable: "No insights available for this view.",
noAlertsRightNow: "No AI alerts right now.",
alertsUnavailable: "Alerts unavailable",
insightsUnavailable: "Insights unavailable",
platformInsightsTitle: "AI Platform Insights",
}, },
privacy: { privacy: {
title: "Privacy Center", title: "Privacy Center",
@@ -86,12 +368,6 @@ const en: Translations = {
commentRequired: "Please tell us what was wrong.", commentRequired: "Please tell us what was wrong.",
submit: "Submit feedback", submit: "Submit feedback",
}, },
theme: {
title: "Theme",
light: "Light",
dark: "Dark",
system: "System",
},
}; };
export default en; export default en;

View File

@@ -134,3 +134,103 @@
font-family: 'JetBrains Mono', monospace; font-family: 'JetBrains Mono', monospace;
} }
} }
/* -------------------------------------------------------------------------
* RTL / Arabic support
*
* `tailwindcss-rtl` handles the bulk of the work: physical margin / padding /
* position / border / rounded / text-align / space-x utilities are mirrored
* automatically when `html[dir="rtl"]` is active.
*
* Below we cover the things Tailwind can't: the webfont (Inter has weak
* Arabic glyphs, so swap in Cairo), recharts tooltips/axes which have their
* own DOM, and a few icons that should NOT mirror (arrows used as pure
* visual affordance e.g. ChevronRight in a dropdown).
* ------------------------------------------------------------------------- */
html[dir="rtl"] body,
html[dir="rtl"] h1,
html[dir="rtl"] h2,
html[dir="rtl"] h3,
html[dir="rtl"] h4,
html[dir="rtl"] h5,
html[dir="rtl"] h6,
html[dir="rtl"] input,
html[dir="rtl"] textarea,
html[dir="rtl"] button,
html[dir="rtl"] select {
font-family: 'Cairo', 'Inter', system-ui, sans-serif;
}
html[dir="rtl"] code,
html[dir="rtl"] pre {
font-family: 'JetBrains Mono', monospace;
}
html[dir="rtl"] .recharts-wrapper,
html[dir="rtl"] .recharts-legend-wrapper {
direction: ltr;
}
/* Breadcrumb and submenu chevrons: always pointing "forward" in reading
direction. Lucide's ChevronRight is ">" which is forward in LTR, but in
RTL the same arrow must become "<". We flip via CSS so no component has
to know about direction. */
html[dir="rtl"] nav[aria-label="breadcrumb"] li[role="presentation"] > svg,
html[dir="rtl"] [data-radix-menu-content] [role="menuitem"] > svg:last-child,
html[dir="rtl"] [data-radix-popper-content-wrapper] [role="menuitem"] > svg:last-child {
transform: rotate(180deg);
}
/* Numbers, percentages, emails, URLs, dates should stay LTR-isolated even
when embedded in Arabic paragraphs, otherwise slashes / percent signs /
dashes drift to the wrong side and the value becomes unreadable.
<bdi> already gives us this per-element; the helper classes below are
for places where adding a wrapper element is awkward. */
.ltr-nums,
.dir-ltr {
direction: ltr;
unicode-bidi: isolate;
}
/* Pure-number cells & badges align to the end (right in LTR, left in RTL)
but the digits themselves still read left-to-right. */
html[dir="rtl"] .numeric {
text-align: end;
direction: ltr;
unicode-bidi: isolate;
}
/* Some lucide icons are pure affordances (play, external-link, send) and
should NOT be mirrored even if tailwindcss-rtl would otherwise flip the
button that contains them. Authors opt-in with data-no-flip. */
html[dir="rtl"] [data-no-flip] {
transform: none !important;
}
/* Keep code snippets, identifiers, and file paths readable in RTL. */
html[dir="rtl"] code,
html[dir="rtl"] pre,
html[dir="rtl"] kbd,
html[dir="rtl"] samp,
html[dir="rtl"] [data-monospace] {
direction: ltr;
unicode-bidi: isolate;
text-align: start;
}
/* Scrollbars inside horizontally-scrollable tables already flip naturally
in RTL, but the shadow Radix portals (dropdowns, tooltips, dialogs) use
`right-0` / `left-0` positioning computed in JS. For safety, make sure
they inherit the document direction. */
html[dir="rtl"] [data-radix-popper-content-wrapper] {
direction: rtl;
}
/* Inputs with dir="ltr" (email, password, URL) override but keep the
placeholder aligned to the visual start (right in RTL, left in LTR). */
html[dir="rtl"] input[dir="ltr"]::placeholder,
html[dir="rtl"] textarea[dir="ltr"]::placeholder {
text-align: right;
}

View File

@@ -130,12 +130,32 @@ function refreshOnce(): Promise<boolean> {
return refreshPromise; return refreshPromise;
} }
function getCurrentLanguage(): string {
try {
const stored = localStorage.getItem("encoach-lang");
if (stored) return stored;
} catch {
// localStorage unavailable (SSR, sandboxing, etc.)
}
if (typeof navigator !== "undefined" && navigator.language) {
return navigator.language.split("-")[0];
}
return "en";
}
function buildHeaders(extra?: Record<string, string>, withJson = true): Record<string, string> { function buildHeaders(extra?: Record<string, string>, withJson = true): Record<string, string> {
const headers: Record<string, string> = { ...extra }; const headers: Record<string, string> = { ...extra };
if (withJson) headers["Content-Type"] = headers["Content-Type"] || "application/json"; if (withJson) headers["Content-Type"] = headers["Content-Type"] || "application/json";
const token = getAccessToken(); const token = getAccessToken();
if (token) headers["Authorization"] = `Bearer ${token}`; if (token) headers["Authorization"] = `Bearer ${token}`;
// Tell the backend which UI language the user is on so AI-generated content
// (coaching tips, insights, alerts, narratives) can be returned in the same
// language instead of always defaulting to English.
if (!headers["Accept-Language"]) {
headers["Accept-Language"] = getCurrentLanguage();
}
return headers; return headers;
} }

View File

@@ -1355,11 +1355,11 @@ export default function ExamStructuresPage() {
const examType = getExamTypeBadge(s); const examType = getExamTypeBadge(s);
return ( return (
<Card key={s.id} className="border-0 shadow-sm hover:shadow-md transition-shadow cursor-pointer" onClick={() => setEditTarget(s)}> <Card key={s.id} className="border-0 shadow-sm hover:shadow-md transition-shadow cursor-pointer" onClick={() => setEditTarget(s)}>
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<CardTitle className="text-base font-semibold flex items-center gap-2"> <CardTitle className="text-base font-semibold flex items-center gap-2">
<Layers className="h-4 w-4 text-primary" />{s.name} <Layers className="h-4 w-4 text-primary" />{s.name}
</CardTitle> </CardTitle>
<div className="flex gap-1"> <div className="flex gap-1">
<Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground hover:text-primary" <Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground hover:text-primary"
onClick={(e) => { e.stopPropagation(); setEditTarget(s); }}> onClick={(e) => { e.stopPropagation(); setEditTarget(s); }}>
@@ -1370,9 +1370,9 @@ export default function ExamStructuresPage() {
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</Button> </Button>
</div> </div>
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="flex items-center gap-3 text-sm text-muted-foreground mb-3 flex-wrap"> <div className="flex items-center gap-3 text-sm text-muted-foreground mb-3 flex-wrap">
{s.entity_name && <span>Entity: <span className="text-foreground font-medium">{s.entity_name}</span></span>} {s.entity_name && <span>Entity: <span className="text-foreground font-medium">{s.entity_name}</span></span>}
{s.industry && <span>Industry: <span className="text-foreground font-medium">{s.industry}</span></span>} {s.industry && <span>Industry: <span className="text-foreground font-medium">{s.industry}</span></span>}
@@ -1382,9 +1382,9 @@ export default function ExamStructuresPage() {
{(Array.isArray(s.modules) ? s.modules : []).map((m) => ( {(Array.isArray(s.modules) ? s.modules : []).map((m) => (
<Badge key={m} variant="secondary" className="capitalize text-xs">{m}</Badge> <Badge key={m} variant="secondary" className="capitalize text-xs">{m}</Badge>
))} ))}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
); );
})} })}
</div> </div>

View File

@@ -48,7 +48,7 @@ export default function ForgotPassword() {
) : null} ) : null}
<div className="mt-6 text-center"> <div className="mt-6 text-center">
<Link to="/login" className="inline-flex items-center gap-1 text-sm text-primary hover:underline"> <Link to="/login" className="inline-flex items-center gap-1 text-sm text-primary hover:underline">
<ArrowLeft className="h-3 w-3" /> Back to sign in <ArrowLeft className="h-3 w-3 rtl:rotate-180" /> Back to sign in
</Link> </Link>
</div> </div>
</CardContent> </CardContent>

View File

@@ -1,5 +1,6 @@
import { useState } from "react"; import { useState } from "react";
import { Link, useNavigate } from "react-router-dom"; import { Link, useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
@@ -10,6 +11,7 @@ import { useAuth } from "@/contexts/AuthContext";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { ApiError } from "@/lib/api-client"; import { ApiError } from "@/lib/api-client";
import type { UserRole } from "@/types/auth"; import type { UserRole } from "@/types/auth";
import { LanguageToggle } from "@/components/LanguageToggle";
/** Keep in sync with `ProtectedRoute` post-login targets */ /** Keep in sync with `ProtectedRoute` post-login targets */
function getRoleDashboard(role: string): string { function getRoleDashboard(role: string): string {
@@ -48,11 +50,16 @@ export default function Login() {
const navigate = useNavigate(); const navigate = useNavigate();
const { login } = useAuth(); const { login } = useAuth();
const { toast } = useToast(); const { toast } = useToast();
const { t } = useTranslation();
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!email || !password) { if (!email || !password) {
toast({ title: "Error", description: "Please enter email and password", variant: "destructive" }); toast({
title: t("auth.errorTitle"),
description: t("auth.missingCredentials"),
variant: "destructive",
});
return; return;
} }
@@ -61,7 +68,11 @@ export default function Login() {
const user = await login(email, password); const user = await login(email, password);
navigate(getRoleDashboard(user.user_type)); navigate(getRoleDashboard(user.user_type));
} catch (err: unknown) { } catch (err: unknown) {
toast({ title: "Login Failed", description: loginErrorMessage(err), variant: "destructive" }); toast({
title: t("auth.loginFailedTitle"),
description: loginErrorMessage(err),
variant: "destructive",
});
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -69,6 +80,9 @@ export default function Login() {
return ( return (
<div className="min-h-screen flex items-center justify-center bg-background p-4"> <div className="min-h-screen flex items-center justify-center bg-background p-4">
<div className="absolute top-4 right-4">
<LanguageToggle />
</div>
<div className="w-full max-w-md"> <div className="w-full max-w-md">
<div className="flex items-center justify-center mb-8"> <div className="flex items-center justify-center mb-8">
<img <img
@@ -80,24 +94,25 @@ export default function Login() {
<Card className="shadow-lg border-0 bg-card"> <Card className="shadow-lg border-0 bg-card">
<CardHeader className="text-center pb-4"> <CardHeader className="text-center pb-4">
<CardTitle className="text-xl">Welcome back</CardTitle> <CardTitle className="text-xl">{t("auth.welcomeBack")}</CardTitle>
<CardDescription>Sign in to your account to continue</CardDescription> <CardDescription>{t("auth.signInDescription")}</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="email">Email</Label> <Label htmlFor="email">{t("auth.email")}</Label>
<Input <Input
id="email" id="email"
type="text" type="text"
placeholder="you@example.com" placeholder={t("auth.emailPlaceholder")}
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
disabled={loading} disabled={loading}
dir="ltr"
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="password">Password</Label> <Label htmlFor="password">{t("auth.password")}</Label>
<div className="relative"> <div className="relative">
<Input <Input
id="password" id="password"
@@ -106,11 +121,13 @@ export default function Login() {
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
disabled={loading} disabled={loading}
dir="ltr"
/> />
<button <button
type="button" type="button"
onClick={() => setShowPassword(!showPassword)} onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground" aria-label={showPassword ? t("auth.password") : t("auth.password")}
className="absolute end-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
> >
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />} {showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button> </button>
@@ -119,19 +136,25 @@ export default function Login() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Checkbox id="remember" /> <Checkbox id="remember" />
<Label htmlFor="remember" className="text-sm font-normal cursor-pointer">Remember me</Label> <Label htmlFor="remember" className="text-sm font-normal cursor-pointer">
{t("auth.rememberMe")}
</Label>
</div> </div>
<Link to="/forgot-password" className="text-sm text-primary hover:underline">Forgot password?</Link> <Link to="/forgot-password" className="text-sm text-primary hover:underline">
{t("auth.forgotPassword")}
</Link>
</div> </div>
<Button type="submit" className="w-full" disabled={loading}> <Button type="submit" className="w-full" disabled={loading}>
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} {loading && <Loader2 className="me-2 h-4 w-4 animate-spin" />}
Sign in {t("auth.signIn")}
</Button> </Button>
</form> </form>
<p className="mt-6 text-center text-sm text-muted-foreground"> <p className="mt-6 text-center text-sm text-muted-foreground">
Don't have an account?{" "} {t("auth.needAccount")}{" "}
<Link to="/register" className="text-primary font-medium hover:underline">Sign up</Link> <Link to="/register" className="text-primary font-medium hover:underline">
{t("auth.signUp")}
</Link>
</p> </p>
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -1,8 +1,10 @@
import { useLocation } from "react-router-dom"; import { useLocation } from "react-router-dom";
import { useEffect } from "react"; import { useEffect } from "react";
import { useTranslation } from "react-i18next";
const NotFound = () => { const NotFound = () => {
const location = useLocation(); const location = useLocation();
const { t } = useTranslation();
useEffect(() => { useEffect(() => {
console.error("404 Error: User attempted to access non-existent route:", location.pathname); console.error("404 Error: User attempted to access non-existent route:", location.pathname);
@@ -11,10 +13,10 @@ const NotFound = () => {
return ( return (
<div className="flex min-h-screen items-center justify-center bg-muted"> <div className="flex min-h-screen items-center justify-center bg-muted">
<div className="text-center"> <div className="text-center">
<h1 className="mb-4 text-4xl font-bold">404</h1> <h1 className="mb-4 text-4xl font-bold">{t("errors.notFoundCode")}</h1>
<p className="mb-4 text-xl text-muted-foreground">Oops! Page not found</p> <p className="mb-4 text-xl text-muted-foreground">{t("errors.notFoundMessage")}</p>
<a href="/" className="text-primary underline hover:text-primary/90"> <a href="/" className="text-primary underline hover:text-primary/90">
Return to Home {t("errors.returnHome")}
</a> </a>
</div> </div>
</div> </div>

View File

@@ -35,7 +35,7 @@ export default function AdminBatchDetail() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Button variant="ghost" size="icon" asChild><Link to="/admin/batches"><ArrowLeft className="h-4 w-4" /></Link></Button> <Button variant="ghost" size="icon" asChild><Link to="/admin/batches"><ArrowLeft className="h-4 w-4 rtl:rotate-180" /></Link></Button>
<div> <div>
<h1 className="text-2xl font-bold">{batch.name}</h1> <h1 className="text-2xl font-bold">{batch.name}</h1>
<p className="text-muted-foreground">{batch.course_name} · {batch.teacher_name}</p> <p className="text-muted-foreground">{batch.course_name} · {batch.teacher_name}</p>

View File

@@ -3,6 +3,7 @@ import { Button } from "@/components/ui/button";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Users, BookOpen, GraduationCap, Layers, Ticket, DollarSign, Plus, BarChart3, FolderOpen } from "lucide-react"; import { Users, BookOpen, GraduationCap, Layers, Ticket, DollarSign, Plus, BarChart3, FolderOpen } from "lucide-react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useCourses, useBatches, useStudents, useTeachers } from "@/hooks/queries"; import { useCourses, useBatches, useStudents, useTeachers } from "@/hooks/queries";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { api } from "@/lib/api-client"; import { api } from "@/lib/api-client";
@@ -32,6 +33,7 @@ interface DashboardStats {
} }
export default function AdminLmsDashboard() { export default function AdminLmsDashboard() {
const { t } = useTranslation();
const { data: coursesData, isLoading: lc } = useCourses(); const { data: coursesData, isLoading: lc } = useCourses();
const { data: studentsData, isLoading: ls } = useStudents({ size: 500 }); const { data: studentsData, isLoading: ls } = useStudents({ size: 500 });
const { data: teachersData, isLoading: lt } = useTeachers({ size: 500 }); const { data: teachersData, isLoading: lt } = useTeachers({ size: 500 });
@@ -58,12 +60,12 @@ export default function AdminLmsDashboard() {
const openTickets = dbStats?.open_tickets ?? 0; const openTickets = dbStats?.open_tickets ?? 0;
const statCards = [ const statCards = [
{ label: "Total Students", value: String(students.length), icon: Users, color: "text-primary" }, { label: t("adminDash.totalStudents"), value: String(students.length), icon: Users, color: "text-primary" },
{ label: "Active Courses", value: `${courses.filter(c => c.status === "active").length} / ${courses.length}`, icon: BookOpen, color: "text-info" }, { label: t("adminDash.activeCourses"), value: `${courses.filter(c => c.status === "active").length} / ${courses.length}`, icon: BookOpen, color: "text-info" },
{ label: "Teachers", value: String(teachers.length), icon: GraduationCap, color: "text-success" }, { label: t("adminDash.teachers"), value: String(teachers.length), icon: GraduationCap, color: "text-success" },
{ label: "Active Batches", value: `${batches.filter(b => b.status === "active").length} / ${batches.length}`, icon: Layers, color: "text-warning" }, { label: t("adminDash.activeBatches"), value: `${batches.filter(b => b.status === "active").length} / ${batches.length}`, icon: Layers, color: "text-warning" },
{ label: "Open Tickets", value: String(openTickets), icon: Ticket, color: "text-destructive" }, { label: t("adminDash.openTickets"), value: String(openTickets), icon: Ticket, color: "text-destructive" },
{ label: "Revenue", value: `$${revenue.toLocaleString()}`, icon: DollarSign, color: "text-success" }, { label: t("adminDash.revenue"), value: `$${revenue.toLocaleString()}`, icon: DollarSign, color: "text-success" },
]; ];
const courseChartData = courses.map(c => { const courseChartData = courses.map(c => {
@@ -82,12 +84,16 @@ export default function AdminLmsDashboard() {
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-2xl font-bold">Admin Dashboard</h1> <h1 className="text-2xl font-bold">{t("adminDash.title")}</h1>
<p className="text-muted-foreground">Platform overview and key metrics.</p> <p className="text-muted-foreground">{t("adminDash.subtitle")}</p>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Button variant="outline" asChild><Link to="/admin/students"><Plus className="mr-1 h-3 w-3" />Add Student</Link></Button> <Button variant="outline" asChild>
<Button asChild><Link to="/admin/courses"><Plus className="mr-1 h-3 w-3" />New Course</Link></Button> <Link to="/admin/students"><Plus className="me-1 h-3 w-3" />{t("adminDash.addStudent")}</Link>
</Button>
<Button asChild>
<Link to="/admin/courses"><Plus className="me-1 h-3 w-3" />{t("adminDash.newCourse")}</Link>
</Button>
</div> </div>
</div> </div>
@@ -99,7 +105,7 @@ export default function AdminLmsDashboard() {
<Card key={s.label}> <Card key={s.label}>
<CardContent className="pt-4 pb-4"> <CardContent className="pt-4 pb-4">
<s.icon className={`h-5 w-5 ${s.color} mb-1`} /> <s.icon className={`h-5 w-5 ${s.color} mb-1`} />
<p className="text-lg font-bold">{s.value}</p> <p className="text-lg font-bold"><bdi>{s.value}</bdi></p>
<p className="text-xs text-muted-foreground">{s.label}</p> <p className="text-xs text-muted-foreground">{s.label}</p>
</CardContent> </CardContent>
</Card> </Card>
@@ -108,16 +114,16 @@ export default function AdminLmsDashboard() {
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4"> <div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
{[ {[
{ label: "Departments", value: dbStats?.total_departments ?? 0, icon: FolderOpen }, { label: t("adminDash.departments"), value: dbStats?.total_departments ?? 0, icon: FolderOpen },
{ label: "Classrooms", value: dbStats?.total_classrooms ?? 0, icon: BarChart3 }, { label: t("adminDash.classrooms"), value: dbStats?.total_classrooms ?? 0, icon: BarChart3 },
{ label: "Subjects", value: dbStats?.total_subjects ?? 0, icon: BookOpen }, { label: t("adminDash.subjects"), value: dbStats?.total_subjects ?? 0, icon: BookOpen },
{ label: "Resources", value: dbStats?.total_resources ?? 0, icon: Layers }, { label: t("adminDash.resources"), value: dbStats?.total_resources ?? 0, icon: Layers },
].map(s => ( ].map(s => (
<Card key={s.label}> <Card key={s.label}>
<CardContent className="pt-3 pb-3 flex items-center gap-3"> <CardContent className="pt-3 pb-3 flex items-center gap-3">
<s.icon className="h-4 w-4 text-muted-foreground" /> <s.icon className="h-4 w-4 text-muted-foreground shrink-0" />
<div> <div className="min-w-0">
<p className="text-sm font-semibold">{s.value}</p> <p className="text-sm font-semibold"><bdi>{s.value}</bdi></p>
<p className="text-xs text-muted-foreground">{s.label}</p> <p className="text-xs text-muted-foreground">{s.label}</p>
</div> </div>
</CardContent> </CardContent>
@@ -127,7 +133,7 @@ export default function AdminLmsDashboard() {
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card> <Card>
<CardHeader><CardTitle className="text-lg">Courses Overview</CardTitle></CardHeader> <CardHeader><CardTitle className="text-lg">{t("adminDash.coursesOverview")}</CardTitle></CardHeader>
<CardContent> <CardContent>
{courseChartData.length > 0 ? ( {courseChartData.length > 0 ? (
<ResponsiveContainer width="100%" height={220}> <ResponsiveContainer width="100%" height={220}>
@@ -136,18 +142,18 @@ export default function AdminLmsDashboard() {
<XAxis dataKey="course" className="fill-muted-foreground" tick={{ fontSize: 10 }} /> <XAxis dataKey="course" className="fill-muted-foreground" tick={{ fontSize: 10 }} />
<YAxis className="fill-muted-foreground" tick={{ fontSize: 12 }} /> <YAxis className="fill-muted-foreground" tick={{ fontSize: 12 }} />
<Tooltip /> <Tooltip />
<Bar dataKey="capacity" name="Capacity" fill="hsl(var(--primary))" radius={[4, 4, 0, 0]} /> <Bar dataKey="capacity" name={t("adminDash.chartCapacity")} fill="hsl(var(--primary))" radius={[4, 4, 0, 0]} />
<Bar dataKey="enrolled" name="Enrolled" fill="hsl(var(--info))" radius={[4, 4, 0, 0]} /> <Bar dataKey="enrolled" name={t("adminDash.chartEnrolled")} fill="hsl(var(--info))" radius={[4, 4, 0, 0]} />
</BarChart> </BarChart>
</ResponsiveContainer> </ResponsiveContainer>
) : ( ) : (
<p className="text-sm text-muted-foreground py-8 text-center">No course data available.</p> <p className="text-sm text-muted-foreground py-8 text-center">{t("adminDash.noCourseData")}</p>
)} )}
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card>
<CardHeader><CardTitle className="text-lg">Batch Capacity</CardTitle></CardHeader> <CardHeader><CardTitle className="text-lg">{t("adminDash.batchCapacity")}</CardTitle></CardHeader>
<CardContent> <CardContent>
{batchChartData.length > 0 ? ( {batchChartData.length > 0 ? (
<ResponsiveContainer width="100%" height={220}> <ResponsiveContainer width="100%" height={220}>
@@ -156,11 +162,11 @@ export default function AdminLmsDashboard() {
<XAxis dataKey="batch" className="fill-muted-foreground" tick={{ fontSize: 10 }} /> <XAxis dataKey="batch" className="fill-muted-foreground" tick={{ fontSize: 10 }} />
<YAxis className="fill-muted-foreground" tick={{ fontSize: 12 }} /> <YAxis className="fill-muted-foreground" tick={{ fontSize: 12 }} />
<Tooltip /> <Tooltip />
<Bar dataKey="capacity" name="Capacity" fill="hsl(var(--info))" radius={[4, 4, 0, 0]} /> <Bar dataKey="capacity" name={t("adminDash.chartCapacity")} fill="hsl(var(--info))" radius={[4, 4, 0, 0]} />
</BarChart> </BarChart>
</ResponsiveContainer> </ResponsiveContainer>
) : ( ) : (
<p className="text-sm text-muted-foreground py-8 text-center">No batch data available.</p> <p className="text-sm text-muted-foreground py-8 text-center">{t("adminDash.noBatchData")}</p>
)} )}
</CardContent> </CardContent>
</Card> </Card>
@@ -168,31 +174,43 @@ export default function AdminLmsDashboard() {
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between pb-2"> <CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-lg">Quick Summary</CardTitle> <CardTitle className="text-lg">{t("adminDash.quickSummary")}</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<Table> <Table>
<TableHeader><TableRow><TableHead>Module</TableHead><TableHead className="text-right">Count</TableHead><TableHead>Status</TableHead></TableRow></TableHeader> <TableHeader>
<TableRow>
<TableHead>{t("adminDash.colModule")}</TableHead>
<TableHead className="text-end">{t("adminDash.colCount")}</TableHead>
<TableHead>{t("adminDash.colStatus")}</TableHead>
</TableRow>
</TableHeader>
<TableBody> <TableBody>
<TableRow> <TableRow>
<TableCell className="font-medium">Exams</TableCell> <TableCell className="font-medium">{t("adminDash.exams")}</TableCell>
<TableCell className="text-right">{dbStats?.total_exams ?? 0}</TableCell> <TableCell className="text-end"><bdi>{dbStats?.total_exams ?? 0}</bdi></TableCell>
<TableCell className="text-muted-foreground">{dbStats?.total_exam_sessions ?? 0} sessions</TableCell> <TableCell className="text-muted-foreground">
{t("adminDash.examSessionsCount", { n: dbStats?.total_exam_sessions ?? 0 })}
</TableCell>
</TableRow> </TableRow>
<TableRow> <TableRow>
<TableCell className="font-medium">Assignments</TableCell> <TableCell className="font-medium">{t("adminDash.assignments")}</TableCell>
<TableCell className="text-right">{dbStats?.total_assignments ?? 0}</TableCell> <TableCell className="text-end"><bdi>{dbStats?.total_assignments ?? 0}</bdi></TableCell>
<TableCell className="text-muted-foreground">across courses</TableCell> <TableCell className="text-muted-foreground">{t("adminDash.acrossCourses")}</TableCell>
</TableRow> </TableRow>
<TableRow> <TableRow>
<TableCell className="font-medium">Support Tickets</TableCell> <TableCell className="font-medium">{t("adminDash.supportTickets")}</TableCell>
<TableCell className="text-right">{dbStats?.total_tickets ?? 0}</TableCell> <TableCell className="text-end"><bdi>{dbStats?.total_tickets ?? 0}</bdi></TableCell>
<TableCell className="text-muted-foreground">{openTickets} open</TableCell> <TableCell className="text-muted-foreground">
{t("adminDash.ticketsOpenCount", { n: openTickets })}
</TableCell>
</TableRow> </TableRow>
<TableRow> <TableRow>
<TableCell className="font-medium">Payments</TableCell> <TableCell className="font-medium">{t("adminDash.payments")}</TableCell>
<TableCell className="text-right">{dbStats?.total_payments ?? 0}</TableCell> <TableCell className="text-end"><bdi>{dbStats?.total_payments ?? 0}</bdi></TableCell>
<TableCell className="text-muted-foreground">${revenue.toLocaleString()} total</TableCell> <TableCell className="text-muted-foreground">
{t("adminDash.revenueTotal", { amount: `$${revenue.toLocaleString()}` })}
</TableCell>
</TableRow> </TableRow>
</TableBody> </TableBody>
</Table> </Table>

View File

@@ -57,7 +57,7 @@ export default function AdmissionDetail() {
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Button variant="ghost" size="icon" onClick={() => navigate("/admin/admissions")}> <Button variant="ghost" size="icon" onClick={() => navigate("/admin/admissions")}>
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4 rtl:rotate-180" />
</Button> </Button>
<div className="flex-1"> <div className="flex-1">
<h1 className="text-2xl font-bold">{admission.first_name} {admission.last_name}</h1> <h1 className="text-2xl font-bold">{admission.first_name} {admission.last_name}</h1>

View File

@@ -216,7 +216,7 @@ export default function ApprovalWorkflowConfig() {
<span className="h-5 w-5 rounded-full bg-primary/10 text-primary flex items-center justify-center text-xs font-medium">{step.order}</span> <span className="h-5 w-5 rounded-full bg-primary/10 text-primary flex items-center justify-center text-xs font-medium">{step.order}</span>
<span>{step.approver_name}</span> <span>{step.approver_name}</span>
</div> </div>
{i < wf.steps.length - 1 && <ArrowRight className="h-4 w-4 text-muted-foreground" />} {i < wf.steps.length - 1 && <ArrowRight className="h-4 w-4 text-muted-foreground rtl:rotate-180" />}
</div> </div>
))} ))}
</div> </div>

View File

@@ -155,7 +155,7 @@ export default function ExamReviewDetail() {
<div className="space-y-1"> <div className="space-y-1">
<Button asChild variant="ghost" size="sm" className="-ml-2"> <Button asChild variant="ghost" size="sm" className="-ml-2">
<Link to="/admin/exam/review-queue"> <Link to="/admin/exam/review-queue">
<ArrowLeft className="h-4 w-4 mr-1" /> Back to queue <ArrowLeft className="h-4 w-4 me-1 rtl:rotate-180" /> Back to queue
</Link> </Link>
</Button> </Button>
<h1 className="text-2xl font-bold tracking-tight">{exam.title}</h1> <h1 className="text-2xl font-bold tracking-tight">{exam.title}</h1>

View File

@@ -117,7 +117,7 @@ export default function DiagnosticTest() {
)} )}
<Button className="w-full" onClick={handleAnswer} disabled={!selectedAnswer || answerMutation.isPending}> <Button className="w-full" onClick={handleAnswer} disabled={!selectedAnswer || answerMutation.isPending}>
{answerMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} {answerMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Submit Answer <ArrowRight className="ml-1 h-4 w-4" /> Submit Answer <ArrowRight className="ms-1 h-4 w-4 rtl:rotate-180" />
</Button> </Button>
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -106,7 +106,7 @@ export default function LearningPlanPage() {
</div> </div>
{(item.status === "available" || item.status === "in_progress") && ( {(item.status === "available" || item.status === "in_progress") && (
<Button size="sm" onClick={() => navigate(`/student/topic/${item.topic_id}`)}> <Button size="sm" onClick={() => navigate(`/student/topic/${item.topic_id}`)}>
{item.status === "in_progress" ? "Continue" : "Start"} <ArrowRight className="ml-1 h-3 w-3" /> {item.status === "in_progress" ? "Continue" : "Start"} <ArrowRight className="ms-1 h-3 w-3 rtl:rotate-180" />
</Button> </Button>
)} )}
{item.status === "completed" && ( {item.status === "completed" && (

View File

@@ -41,7 +41,7 @@ export default function ProficiencyProfile() {
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Button variant="outline" onClick={() => navigate(`/student/diagnostic/${subjectId}`)}>Retake Diagnostic</Button> <Button variant="outline" onClick={() => navigate(`/student/diagnostic/${subjectId}`)}>Retake Diagnostic</Button>
<Button onClick={() => navigate(`/student/plan/${subjectId}`)}>Learning Plan <ArrowRight className="ml-1 h-4 w-4" /></Button> <Button onClick={() => navigate(`/student/plan/${subjectId}`)}>Learning Plan <ArrowRight className="ms-1 h-4 w-4 rtl:rotate-180" /></Button>
</div> </div>
</div> </div>

View File

@@ -28,7 +28,7 @@ export default function StudentCourseDetail() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Button variant="ghost" size="icon" asChild><Link to="/student/courses"><ArrowLeft className="h-4 w-4" /></Link></Button> <Button variant="ghost" size="icon" asChild><Link to="/student/courses"><ArrowLeft className="h-4 w-4 rtl:rotate-180" /></Link></Button>
<div> <div>
<h1 className="text-2xl font-bold">{course.title}</h1> <h1 className="text-2xl font-bold">{course.title}</h1>
<p className="text-muted-foreground">{course.code} · {chapters.length} chapters</p> <p className="text-muted-foreground">{course.code} · {chapters.length} chapters</p>

View File

@@ -4,6 +4,7 @@ import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress"; import { Progress } from "@/components/ui/progress";
import { BookOpen, ClipboardList, BarChart3, Calendar, ArrowRight, Play } from "lucide-react"; import { BookOpen, ClipboardList, BarChart3, Calendar, ArrowRight, Play } from "lucide-react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useMyEnrolledCourses, useGrades } from "@/hooks/queries"; import { useMyEnrolledCourses, useGrades } from "@/hooks/queries";
import { useAuth } from "@/contexts/AuthContext"; import { useAuth } from "@/contexts/AuthContext";
import AiStudyCoach from "@/components/ai/AiStudyCoach"; import AiStudyCoach from "@/components/ai/AiStudyCoach";
@@ -11,6 +12,7 @@ import AiTipBanner from "@/components/ai/AiTipBanner";
export default function StudentDashboard() { export default function StudentDashboard() {
const { user } = useAuth(); const { user } = useAuth();
const { t } = useTranslation();
const { data: enrolledData, isLoading: lc } = useMyEnrolledCourses(); const { data: enrolledData, isLoading: lc } = useMyEnrolledCourses();
const { data: gradesData, isLoading: lg } = useGrades(); const { data: gradesData, isLoading: lg } = useGrades();
const myCourses = enrolledData?.items ?? []; const myCourses = enrolledData?.items ?? [];
@@ -22,20 +24,20 @@ export default function StudentDashboard() {
const avgGrade = gradeRecords.length > 0 const avgGrade = gradeRecords.length > 0
? Math.round(gradeRecords.reduce((s, g) => s + (g.grade / g.max_grade) * 100, 0) / gradeRecords.length) ? Math.round(gradeRecords.reduce((s, g) => s + (g.grade / g.max_grade) * 100, 0) / gradeRecords.length)
: 0; : 0;
const firstName = user?.name?.split(" ")[0] || "Student"; const firstName = user?.name?.split(" ")[0] || t("dashboard.greetingFallback");
const stats = [ const stats = [
{ label: "Enrolled Courses", value: String(myCourses.length), icon: BookOpen, color: "text-primary" }, { label: t("studentDash.enrolledCourses"), value: String(myCourses.length), icon: BookOpen, color: "text-primary" },
{ label: "Overall Progress", value: `${avgProgress}%`, icon: ClipboardList, color: "text-warning" }, { label: t("studentDash.overallProgress"), value: `${avgProgress}%`, icon: ClipboardList, color: "text-warning" },
{ label: "Average Grade", value: gradeRecords.length > 0 ? `${avgGrade}%` : "N/A", icon: BarChart3, color: "text-success" }, { label: t("studentDash.averageGrade"), value: gradeRecords.length > 0 ? `${avgGrade}%` : "N/A", icon: BarChart3, color: "text-success" },
{ label: "Total Chapters", value: String(myCourses.reduce((s, c) => s + c.chapter_count, 0)), icon: Calendar, color: "text-info" }, { label: t("studentDash.totalChapters"), value: String(myCourses.reduce((s, c) => s + c.chapter_count, 0)), icon: Calendar, color: "text-info" },
]; ];
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
<h1 className="text-2xl font-bold">Welcome back, {firstName}!</h1> <h1 className="text-2xl font-bold">{t("studentDash.welcome", { name: firstName })}</h1>
<p className="text-muted-foreground">Here's an overview of your learning progress.</p> <p className="text-muted-foreground">{t("studentDash.subtitle")}</p>
</div> </div>
<AiTipBanner context="student-dashboard" variant="tip" /> <AiTipBanner context="student-dashboard" variant="tip" />
@@ -46,12 +48,12 @@ export default function StudentDashboard() {
{stats.map((s) => ( {stats.map((s) => (
<Card key={s.label}> <Card key={s.label}>
<CardContent className="pt-6"> <CardContent className="pt-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between gap-3">
<div> <div className="min-w-0">
<p className="text-sm text-muted-foreground">{s.label}</p> <p className="text-sm text-muted-foreground">{s.label}</p>
<p className="text-2xl font-bold">{s.value}</p> <p className="text-2xl font-bold"><bdi>{s.value}</bdi></p>
</div> </div>
<s.icon className={`h-8 w-8 ${s.color} opacity-80`} /> <s.icon className={`h-8 w-8 ${s.color} opacity-80 shrink-0`} />
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@@ -61,22 +63,28 @@ export default function StudentDashboard() {
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between pb-2"> <CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-lg">My Courses</CardTitle> <CardTitle className="text-lg">{t("studentDash.myCourses")}</CardTitle>
<Button variant="ghost" size="sm" asChild><Link to="/student/courses">View All <ArrowRight className="ml-1 h-3 w-3" /></Link></Button> <Button variant="ghost" size="sm" asChild>
<Link to="/student/courses">
{t("common.viewAll")} <ArrowRight className="ms-1 h-3 w-3 rtl:rotate-180" />
</Link>
</Button>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
{myCourses.length === 0 ? ( {myCourses.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4">No enrolled courses yet.</p> <p className="text-sm text-muted-foreground text-center py-4">{t("studentDash.noEnrolledCourses")}</p>
) : myCourses.map((c) => ( ) : myCourses.map((c) => (
<Link to={`/student/courses/${c.id}`} key={c.id} className="block"> <Link to={`/student/courses/${c.id}`} key={c.id} className="block">
<div className="flex items-center justify-between p-3 rounded-lg border hover:bg-muted/50 transition-colors"> <div className="flex items-center justify-between p-3 rounded-lg border hover:bg-muted/50 transition-colors">
<div className="min-w-0"> <div className="min-w-0">
<p className="font-medium text-sm truncate">{c.title || c.name}</p> <p className="font-medium text-sm truncate">{c.title || c.name}</p>
<p className="text-xs text-muted-foreground">{c.chapter_count} chapters · {c.total_materials} materials</p> <p className="text-xs text-muted-foreground">
{t("studentDash.chaptersMaterials", { chapters: c.chapter_count, materials: c.total_materials })}
</p>
</div> </div>
<div className="flex items-center gap-3 shrink-0"> <div className="flex items-center gap-3 shrink-0">
<div className="w-24"><Progress value={c.progress} className="h-2" /></div> <div className="w-24"><Progress value={c.progress} className="h-2" /></div>
<span className="text-xs font-medium w-8 text-right">{c.progress}%</span> <span className="text-xs font-medium w-8 text-end"><bdi>{c.progress}%</bdi></span>
</div> </div>
</div> </div>
</Link> </Link>
@@ -87,35 +95,45 @@ export default function StudentDashboard() {
<div className="space-y-6"> <div className="space-y-6">
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between pb-2"> <CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-lg">Quick Actions</CardTitle> <CardTitle className="text-lg">{t("studentDash.quickActions")}</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
{myCourses.slice(0, 3).map(c => ( {myCourses.slice(0, 3).map(c => (
<Link key={c.id} to={`/student/courses/${c.id}`} className="flex items-center gap-3 p-3 rounded-lg border hover:bg-muted/50 transition-colors"> <Link key={c.id} to={`/student/courses/${c.id}`} className="flex items-center gap-3 p-3 rounded-lg border hover:bg-muted/50 transition-colors">
<Play className="h-4 w-4 text-primary shrink-0" /> <Play className="h-4 w-4 text-primary shrink-0" />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{c.progress > 0 ? "Continue" : "Start"} {c.title || c.name}</p> <p className="text-sm font-medium truncate">
<p className="text-xs text-muted-foreground">{c.completed_chapters}/{c.chapter_count} chapters done</p> {c.progress > 0 ? t("studentDash.continue") : t("studentDash.start")} {c.title || c.name}
</p>
<p className="text-xs text-muted-foreground">
{t("studentDash.chaptersDone", { done: c.completed_chapters, total: c.chapter_count })}
</p>
</div> </div>
<Badge variant="outline">{c.progress}%</Badge> <Badge variant="outline"><bdi>{c.progress}%</bdi></Badge>
</Link> </Link>
))} ))}
{myCourses.length === 0 && <p className="text-sm text-muted-foreground text-center py-4">Enroll in a course to get started.</p>} {myCourses.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-4">{t("studentDash.enrollToStart")}</p>
)}
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between pb-2"> <CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-lg">Recent Grades</CardTitle> <CardTitle className="text-lg">{t("studentDash.recentGrades")}</CardTitle>
<Button variant="ghost" size="sm" asChild><Link to="/student/grades">View All <ArrowRight className="ml-1 h-3 w-3" /></Link></Button> <Button variant="ghost" size="sm" asChild>
<Link to="/student/grades">
{t("common.viewAll")} <ArrowRight className="ms-1 h-3 w-3 rtl:rotate-180" />
</Link>
</Button>
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
{recentGrades.length === 0 ? ( {recentGrades.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4">No grades yet.</p> <p className="text-sm text-muted-foreground text-center py-4">{t("studentDash.noGradesYet")}</p>
) : recentGrades.map((g) => ( ) : recentGrades.map((g) => (
<div key={g.id} className="flex items-center justify-between p-2 rounded border"> <div key={g.id} className="flex items-center justify-between p-2 rounded border gap-3">
<div><p className="text-sm font-medium">{g.assignment_title}</p><p className="text-xs text-muted-foreground">{g.course_name}</p></div> <div className="min-w-0"><p className="text-sm font-medium truncate" dir="auto">{g.assignment_title}</p><p className="text-xs text-muted-foreground truncate" dir="auto">{g.course_name}</p></div>
<span className="text-sm font-bold text-primary">{g.grade}/{g.max_grade}</span> <span className="text-sm font-bold text-primary shrink-0"><bdi>{g.grade}/{g.max_grade}</bdi></span>
</div> </div>
))} ))}
</CardContent> </CardContent>

View File

@@ -133,7 +133,7 @@ export default function StudentDiscussionBoard() {
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Button variant="ghost" onClick={() => setSelectedBoardId(null)}> <Button variant="ghost" onClick={() => setSelectedBoardId(null)}>
<ArrowLeft className="mr-2 h-4 w-4" /> Back to Boards <ArrowLeft className="me-2 h-4 w-4 rtl:rotate-180" /> Back to Boards
</Button> </Button>
<Dialog open={showNewPost} onOpenChange={setShowNewPost}> <Dialog open={showNewPost} onOpenChange={setShowNewPost}>
<Button onClick={() => setShowNewPost(true)}><MessageSquare className="mr-2 h-4 w-4" /> New Post</Button> <Button onClick={() => setShowNewPost(true)}><MessageSquare className="mr-2 h-4 w-4" /> New Post</Button>

View File

@@ -58,7 +58,7 @@ export default function SubjectSelection() {
View Profile View Profile
</Button> </Button>
<Button size="sm" className="flex-1" onClick={() => navigate(`/student/plan/${subject.id}`)}> <Button size="sm" className="flex-1" onClick={() => navigate(`/student/plan/${subject.id}`)}>
Learning Plan <ArrowRight className="ml-1 h-3 w-3" /> Learning Plan <ArrowRight className="ms-1 h-3 w-3 rtl:rotate-180" />
</Button> </Button>
</div> </div>
</> </>
@@ -66,7 +66,7 @@ export default function SubjectSelection() {
<> <>
<p className="text-sm text-muted-foreground">Take a diagnostic assessment to discover your proficiency level and get a personalized learning plan.</p> <p className="text-sm text-muted-foreground">Take a diagnostic assessment to discover your proficiency level and get a personalized learning plan.</p>
<Button className="w-full" onClick={() => navigate(`/student/diagnostic/${subject.id}`)}> <Button className="w-full" onClick={() => navigate(`/student/diagnostic/${subject.id}`)}>
Start Diagnostic <ArrowRight className="ml-1 h-4 w-4" /> Start Diagnostic <ArrowRight className="ms-1 h-4 w-4 rtl:rotate-180" />
</Button> </Button>
</> </>
)} )}

View File

@@ -32,7 +32,7 @@ export default function TeacherAssignmentDetail() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Button variant="ghost" size="icon" asChild><Link to="/teacher/assignments"><ArrowLeft className="h-4 w-4" /></Link></Button> <Button variant="ghost" size="icon" asChild><Link to="/teacher/assignments"><ArrowLeft className="h-4 w-4 rtl:rotate-180" /></Link></Button>
<div> <div>
<h1 className="text-2xl font-bold">{assignment.title}</h1> <h1 className="text-2xl font-bold">{assignment.title}</h1>
<p className="text-muted-foreground">{assignment.entity_name} · Due: {assignment.end_date}</p> <p className="text-muted-foreground">{assignment.entity_name} · Due: {assignment.end_date}</p>

View File

@@ -4,11 +4,13 @@ import { Badge } from "@/components/ui/badge";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { BookOpen, Users, ClipboardList, TrendingUp, ArrowRight } from "lucide-react"; import { BookOpen, Users, ClipboardList, TrendingUp, ArrowRight } from "lucide-react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useCourses, useAssignments } from "@/hooks/queries"; import { useCourses, useAssignments } from "@/hooks/queries";
import AiInsightsPanel from "@/components/ai/AiInsightsPanel"; import AiInsightsPanel from "@/components/ai/AiInsightsPanel";
import AiTipBanner from "@/components/ai/AiTipBanner"; import AiTipBanner from "@/components/ai/AiTipBanner";
export default function TeacherDashboard() { export default function TeacherDashboard() {
const { t } = useTranslation();
const { data: coursesData, isLoading: lc } = useCourses(); const { data: coursesData, isLoading: lc } = useCourses();
const { data: assignmentsData, isLoading: la } = useAssignments(); const { data: assignmentsData, isLoading: la } = useAssignments();
const courses = coursesData?.items ?? []; const courses = coursesData?.items ?? [];
@@ -21,17 +23,17 @@ export default function TeacherDashboard() {
const pendingGrading = (submissions as { id: string; studentName: string; submittedAt: string; status: string }[]).filter(s => s.status === "pending"); const pendingGrading = (submissions as { id: string; studentName: string; submittedAt: string; status: string }[]).filter(s => s.status === "pending");
const stats = [ const stats = [
{ label: "Active Courses", value: String(teacherCourses.filter(c => c.status === "active").length), icon: BookOpen, color: "text-primary" }, { label: t("teacherDash.activeCourses"), value: String(teacherCourses.filter(c => c.status === "active").length), icon: BookOpen, color: "text-primary" },
{ label: "Total Students", value: "55", icon: Users, color: "text-info" }, { label: t("teacherDash.totalStudents"), value: "55", icon: Users, color: "text-info" },
{ label: "Pending Grading", value: String(pendingGrading.length), icon: ClipboardList, color: "text-warning" }, { label: t("teacherDash.pendingGrading"), value: String(pendingGrading.length), icon: ClipboardList, color: "text-warning" },
{ label: "Avg. Pass Rate", value: "82%", icon: TrendingUp, color: "text-success" }, { label: t("teacherDash.avgPassRate"), value: "82%", icon: TrendingUp, color: "text-success" },
]; ];
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
<h1 className="text-2xl font-bold">Teacher Dashboard</h1> <h1 className="text-2xl font-bold">{t("teacherDash.title")}</h1>
<p className="text-muted-foreground">Overview of your teaching activities.</p> <p className="text-muted-foreground">{t("teacherDash.subtitle")}</p>
</div> </div>
<AiTipBanner context="teacher-dashboard" variant="recommendation" /> <AiTipBanner context="teacher-dashboard" variant="recommendation" />
@@ -42,9 +44,9 @@ export default function TeacherDashboard() {
{stats.map(s => ( {stats.map(s => (
<Card key={s.label}> <Card key={s.label}>
<CardContent className="pt-6"> <CardContent className="pt-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between gap-3">
<div><p className="text-sm text-muted-foreground">{s.label}</p><p className="text-2xl font-bold">{s.value}</p></div> <div className="min-w-0"><p className="text-sm text-muted-foreground">{s.label}</p><p className="text-2xl font-bold"><bdi>{s.value}</bdi></p></div>
<s.icon className={`h-8 w-8 ${s.color} opacity-80`} /> <s.icon className={`h-8 w-8 ${s.color} opacity-80 shrink-0`} />
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@@ -54,17 +56,27 @@ export default function TeacherDashboard() {
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between pb-2"> <CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-lg">My Courses</CardTitle> <CardTitle className="text-lg">{t("teacherDash.myCourses")}</CardTitle>
<Button variant="ghost" size="sm" asChild><Link to="/teacher/courses">View All <ArrowRight className="ml-1 h-3 w-3" /></Link></Button> <Button variant="ghost" size="sm" asChild>
<Link to="/teacher/courses">
{t("common.viewAll")} <ArrowRight className="ms-1 h-3 w-3 rtl:rotate-180" />
</Link>
</Button>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<Table> <Table>
<TableHeader><TableRow><TableHead>Course</TableHead><TableHead>Students</TableHead><TableHead>Status</TableHead></TableRow></TableHeader> <TableHeader>
<TableRow>
<TableHead>{t("teacherDash.colCourse")}</TableHead>
<TableHead>{t("teacherDash.colStudents")}</TableHead>
<TableHead>{t("teacherDash.colStatus")}</TableHead>
</TableRow>
</TableHeader>
<TableBody> <TableBody>
{teacherCourses.map(c => ( {teacherCourses.map(c => (
<TableRow key={c.id}> <TableRow key={c.id}>
<TableCell className="font-medium">{c.title}</TableCell> <TableCell className="font-medium" dir="auto">{c.title}</TableCell>
<TableCell>{c.enrolled}/{c.max_capacity}</TableCell> <TableCell><bdi>{c.enrolled}/{c.max_capacity}</bdi></TableCell>
<TableCell><Badge variant={c.status === "active" ? "default" : "secondary"} className="capitalize">{c.status}</Badge></TableCell> <TableCell><Badge variant={c.status === "active" ? "default" : "secondary"} className="capitalize">{c.status}</Badge></TableCell>
</TableRow> </TableRow>
))} ))}
@@ -76,26 +88,35 @@ export default function TeacherDashboard() {
<div className="space-y-6"> <div className="space-y-6">
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between pb-2"> <CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-lg">Pending Grading</CardTitle> <CardTitle className="text-lg">{t("teacherDash.pendingGrading")}</CardTitle>
<Button variant="ghost" size="sm" asChild><Link to="/teacher/assignments">View All <ArrowRight className="ml-1 h-3 w-3" /></Link></Button> <Button variant="ghost" size="sm" asChild>
<Link to="/teacher/assignments">
{t("common.viewAll")} <ArrowRight className="ms-1 h-3 w-3 rtl:rotate-180" />
</Link>
</Button>
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
{pendingGrading.slice(0, 4).map(s => ( {pendingGrading.slice(0, 4).map(s => (
<div key={s.id} className="flex items-center justify-between p-2 rounded border"> <div key={s.id} className="flex items-center justify-between p-2 rounded border">
<div><p className="text-sm font-medium">{s.studentName}</p><p className="text-xs text-muted-foreground">Submitted {s.submittedAt}</p></div> <div>
<Badge variant="secondary">Pending</Badge> <p className="text-sm font-medium">{s.studentName}</p>
<p className="text-xs text-muted-foreground">
{t("teacherDash.submitted", { when: s.submittedAt })}
</p>
</div>
<Badge variant="secondary">{t("teacherDash.pending")}</Badge>
</div> </div>
))} ))}
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card>
<CardHeader><CardTitle className="text-lg">Recent Activity</CardTitle></CardHeader> <CardHeader><CardTitle className="text-lg">{t("teacherDash.recentActivity")}</CardTitle></CardHeader>
<CardContent className="space-y-2"> <CardContent className="space-y-2">
{activityFeed.slice(0, 4).map(a => ( {activityFeed.slice(0, 4).map(a => (
<div key={a.id} className="text-sm"> <div key={a.id} className="text-sm">
<span className="font-medium">{a.user}</span> <span className="text-muted-foreground">{a.action}</span> <span>{a.target}</span> <span className="font-medium">{a.user}</span> <span className="text-muted-foreground">{a.action}</span> <span>{a.target}</span>
<span className="text-xs text-muted-foreground ml-2">· {a.time}</span> <span className="text-xs text-muted-foreground ms-2">· {a.time}</span>
</div> </div>
))} ))}
</CardContent> </CardContent>

View File

@@ -155,7 +155,7 @@ export default function TeacherDiscussionBoard() {
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Button variant="ghost" onClick={() => setSelectedBoardId(null)}> <Button variant="ghost" onClick={() => setSelectedBoardId(null)}>
<ArrowLeft className="mr-2 h-4 w-4" /> Back to Boards <ArrowLeft className="me-2 h-4 w-4 rtl:rotate-180" /> Back to Boards
</Button> </Button>
<Dialog open={showNewPost} onOpenChange={setShowNewPost}> <Dialog open={showNewPost} onOpenChange={setShowNewPost}>
<Button onClick={() => setShowNewPost(true)}><MessageSquare className="mr-2 h-4 w-4" /> New Post</Button> <Button onClick={() => setShowNewPost(true)}><MessageSquare className="mr-2 h-4 w-4" /> New Post</Button>

View File

@@ -96,5 +96,12 @@ export default {
}, },
}, },
}, },
plugins: [require("tailwindcss-animate")], plugins: [
require("tailwindcss-animate"),
// Mirrors physical utilities (ml-*, mr-*, pl-*, pr-*, left-*, right-*,
// text-left/right, border-l/r, rounded-l/r, space-x-*) under `dir="rtl"`
// so the whole app flips visually when Arabic is selected, without
// having to rewrite every component to use logical properties.
require("tailwindcss-rtl"),
],
} satisfies Config; } satisfies Config;