settings page + inapp notifications integration + analytics page

This commit is contained in:
Yared Yemane 2026-02-16 08:34:23 -08:00
parent 25badbcca5
commit fc983c055e
15 changed files with 2544 additions and 244 deletions

5
src/api/analytics.api.ts Normal file
View File

@ -0,0 +1,5 @@
import http from "./http";
import type { DashboardResponse } from "../types/analytics.types";
export const getDashboard = () =>
http.get<DashboardResponse>("/analytics/dashboard");

View File

@ -0,0 +1,22 @@
import http from "./http";
import type { GetNotificationsResponse, UnreadCountResponse } from "../types/notification.types";
export const getNotifications = (limit = 10, offset = 0) =>
http.get<GetNotificationsResponse>("/notifications", {
params: { limit, offset },
});
export const getUnreadCount = () =>
http.get<UnreadCountResponse>("/notifications/unread");
export const markAsRead = (id: string) =>
http.patch(`/notifications/${id}/read`);
export const markAsUnread = (id: string) =>
http.patch(`/notifications/${id}/unread`);
export const markAllRead = () =>
http.post("/notifications/mark-all-read");
export const markAllUnread = () =>
http.post("/notifications/mark-all-unread");

View File

@ -31,6 +31,7 @@ import { AddQuestionPage } from "../pages/content-management/AddQuestionPage"
import { UserLogPage } from "../pages/user-log/UserLogPage" import { UserLogPage } from "../pages/user-log/UserLogPage"
import { IssuesPage } from "../pages/issues/IssuesPage" import { IssuesPage } from "../pages/issues/IssuesPage"
import { ProfilePage } from "../pages/ProfilePage" import { ProfilePage } from "../pages/ProfilePage"
import { SettingsPage } from "../pages/SettingsPage"
import { TeamManagementPage } from "../pages/team/TeamManagementPage" import { TeamManagementPage } from "../pages/team/TeamManagementPage"
import { TeamMemberDetailPage } from "../pages/team/TeamMemberDetailPage" import { TeamMemberDetailPage } from "../pages/team/TeamMemberDetailPage"
import { LoginPage } from "../pages/auth/LoginPage" import { LoginPage } from "../pages/auth/LoginPage"
@ -86,6 +87,7 @@ export function AppRoutes() {
<Route path="/team" element={<TeamManagementPage />} /> <Route path="/team" element={<TeamManagementPage />} />
<Route path="/team/:id" element={<TeamMemberDetailPage />} /> <Route path="/team/:id" element={<TeamMemberDetailPage />} />
<Route path="/profile" element={<ProfilePage />} /> <Route path="/profile" element={<ProfilePage />} />
<Route path="/settings" element={<SettingsPage />} />
</Route> </Route>
<Route path="*" element={<NotFoundPage />} /> <Route path="*" element={<NotFoundPage />} />

View File

@ -12,10 +12,11 @@ import {
Users2, Users2,
X, X,
} from "lucide-react" } from "lucide-react"
import type { ComponentType } from "react" import { type ComponentType, useEffect, useState } from "react"
import { NavLink } from "react-router-dom" import { NavLink } from "react-router-dom"
import { cn } from "../../lib/utils" import { cn } from "../../lib/utils"
import { BrandLogo } from "../brand/BrandLogo" import { BrandLogo } from "../brand/BrandLogo"
import { getUnreadCount } from "../../api/notifications.api"
type NavItem = { type NavItem = {
label: string label: string
@ -42,6 +43,24 @@ type SidebarProps = {
} }
export function Sidebar({ isOpen, onClose }: SidebarProps) { export function Sidebar({ isOpen, onClose }: SidebarProps) {
const [unreadCount, setUnreadCount] = useState(0)
useEffect(() => {
const fetchUnread = async () => {
try {
const res = await getUnreadCount()
setUnreadCount(res.data.unread)
} catch {
// silently fail
}
}
fetchUnread()
window.addEventListener("notifications-updated", fetchUnread)
return () => window.removeEventListener("notifications-updated", fetchUnread)
}, [])
return ( return (
<> <>
{/* Mobile overlay */} {/* Mobile overlay */}
@ -101,7 +120,14 @@ export function Sidebar({ isOpen, onClose }: SidebarProps) {
<Icon className="h-4 w-4" /> <Icon className="h-4 w-4" />
</span> </span>
<span className="truncate">{item.label}</span> <span className="truncate">{item.label}</span>
{isActive ? ( {item.to === "/notifications" && unreadCount > 0 && (
<span className="ml-auto flex h-5 min-w-[20px] items-center justify-center rounded-full bg-destructive px-1.5 text-[10px] font-bold text-white">
{unreadCount > 99 ? "99+" : unreadCount}
</span>
)}
{item.to !== "/notifications" && isActive ? (
<span className="ml-auto h-6 w-1 rounded-full bg-brand-500" />
) : item.to === "/notifications" && unreadCount === 0 && isActive ? (
<span className="ml-auto h-6 w-1 rounded-full bg-brand-500" /> <span className="ml-auto h-6 w-1 rounded-full bg-brand-500" />
) : null} ) : null}
</> </>

View File

@ -0,0 +1,254 @@
import { useEffect, useRef, useState } from "react"
import { useNavigate } from "react-router-dom"
import {
Bell,
BellOff,
Info,
AlertCircle,
CheckCircle2,
Megaphone,
UserPlus,
CreditCard,
BookOpen,
Video,
ShieldAlert,
Loader2,
MailOpen,
Mail,
CheckCheck,
} from "lucide-react"
import { Badge } from "../ui/badge"
import { cn } from "../../lib/utils"
import { useNotifications } from "../../hooks/useNotifications"
import type { Notification } from "../../types/notification.types"
const TYPE_CONFIG: Record<string, { icon: React.ElementType; color: string; bg: string }> = {
announcement: { icon: Megaphone, color: "text-brand-600", bg: "bg-brand-100" },
system_alert: { icon: ShieldAlert, color: "text-amber-600", bg: "bg-amber-50" },
issue_created: { icon: AlertCircle, color: "text-red-500", bg: "bg-red-50" },
issue_status_updated: { icon: CheckCircle2, color: "text-sky-600", bg: "bg-sky-50" },
course_created: { icon: BookOpen, color: "text-indigo-600", bg: "bg-indigo-50" },
course_enrolled: { icon: BookOpen, color: "text-teal-600", bg: "bg-teal-50" },
sub_course_created: { icon: BookOpen, color: "text-violet-600", bg: "bg-violet-50" },
video_added: { icon: Video, color: "text-pink-600", bg: "bg-pink-50" },
user_deleted: { icon: UserPlus, color: "text-red-600", bg: "bg-red-50" },
admin_created: { icon: UserPlus, color: "text-brand-600", bg: "bg-brand-100" },
team_member_created: { icon: UserPlus, color: "text-emerald-600", bg: "bg-emerald-50" },
subscription_activated: { icon: CreditCard, color: "text-green-600", bg: "bg-green-50" },
payment_verified: { icon: CreditCard, color: "text-green-600", bg: "bg-green-50" },
knowledge_level_update: { icon: Info, color: "text-sky-600", bg: "bg-sky-50" },
assessment_assigned: { icon: BookOpen, color: "text-orange-600", bg: "bg-orange-50" },
}
const DEFAULT_TYPE_CONFIG = { icon: Bell, color: "text-grayScale-500", bg: "bg-grayScale-100" }
function formatTimestamp(ts: string) {
const date = new Date(ts)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMin = Math.floor(diffMs / 60_000)
const diffHr = Math.floor(diffMs / 3_600_000)
const diffDay = Math.floor(diffMs / 86_400_000)
if (diffMin < 1) return "Just now"
if (diffMin < 60) return `${diffMin}m ago`
if (diffHr < 24) return `${diffHr}h ago`
if (diffDay < 7) return `${diffDay}d ago`
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: date.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
})
}
function NotificationItem({
notification,
onMarkRead,
onMarkUnread,
}: {
notification: Notification
onMarkRead: (id: string) => void
onMarkUnread: (id: string) => void
}) {
const cfg = TYPE_CONFIG[notification.type] ?? DEFAULT_TYPE_CONFIG
const Icon = cfg.icon
return (
<button
type="button"
className={cn(
"group relative flex w-full gap-3 rounded-lg px-3 py-3 text-left transition-colors hover:bg-grayScale-100",
!notification.is_read && "bg-brand-100/30"
)}
onClick={() => {
if (!notification.is_read) onMarkRead(notification.id)
}}
>
{/* Unread dot */}
{!notification.is_read && (
<span className="absolute left-1 top-1/2 h-2 w-2 -translate-y-1/2 rounded-full bg-brand-500" />
)}
{/* Type icon */}
<span
className={cn(
"grid h-9 w-9 shrink-0 place-items-center rounded-lg",
cfg.bg
)}
>
<Icon className={cn("h-4 w-4", cfg.color)} />
</span>
{/* Content */}
<div className="min-w-0 flex-1">
<p
className={cn(
"text-sm leading-snug text-grayScale-800",
!notification.is_read && "font-semibold"
)}
>
{notification.payload.headline}
</p>
<p className="mt-0.5 line-clamp-2 text-xs text-grayScale-500">
{notification.payload.message}
</p>
<p className="mt-1 text-[11px] text-grayScale-400">
{formatTimestamp(notification.timestamp)}
</p>
</div>
{/* Read / Unread toggle */}
<button
type="button"
className="hidden shrink-0 self-center rounded-md p-1.5 text-grayScale-400 hover:bg-grayScale-200 hover:text-grayScale-600 group-hover:block"
onClick={(e) => {
e.stopPropagation()
if (notification.is_read) {
onMarkUnread(notification.id)
} else {
onMarkRead(notification.id)
}
}}
aria-label={notification.is_read ? "Mark as unread" : "Mark as read"}
>
{notification.is_read ? (
<Mail className="h-4 w-4" />
) : (
<MailOpen className="h-4 w-4" />
)}
</button>
</button>
)
}
export function NotificationDropdown() {
const [open, setOpen] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
const navigate = useNavigate()
const {
notifications,
unreadCount,
loading,
markOneRead,
markOneUnread,
markAllAsRead,
} = useNotifications()
// Click-outside handler
useEffect(() => {
function handleMouseDown(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false)
}
}
if (open) {
document.addEventListener("mousedown", handleMouseDown)
}
return () => document.removeEventListener("mousedown", handleMouseDown)
}, [open])
return (
<div ref={containerRef} className="relative">
{/* Bell button */}
<button
type="button"
className="relative grid h-10 w-10 place-items-center rounded-full border bg-white text-grayScale-500 transition-colors hover:text-brand-600"
aria-label="Notifications"
onClick={() => setOpen((prev) => !prev)}
>
<Bell className="h-5 w-5" />
{unreadCount > 0 && (
<span className="absolute -right-0.5 -top-0.5 flex h-[18px] min-w-[18px] items-center justify-center rounded-full bg-destructive px-1 text-[10px] font-bold text-white">
{unreadCount > 99 ? "99+" : unreadCount}
</span>
)}
</button>
{/* Dropdown panel */}
{open && (
<div className="animate-in fade-in-0 zoom-in-95 absolute right-0 top-12 z-50 w-[380px] rounded-xl bg-white shadow-lg ring-1 ring-black/5">
{/* Header */}
<div className="flex items-center justify-between border-b px-4 py-3">
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold text-grayScale-800">
Notifications
</h3>
{unreadCount > 0 && (
<Badge variant="default" className="px-1.5 py-0 text-[10px]">
{unreadCount}
</Badge>
)}
</div>
{unreadCount > 0 && (
<button
type="button"
className="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs font-medium text-grayScale-500 transition-colors hover:bg-grayScale-100 hover:text-grayScale-700"
onClick={markAllAsRead}
>
<CheckCheck className="h-3.5 w-3.5" />
Mark all read
</button>
)}
</div>
{/* Body */}
<div className="max-h-[480px] overflow-y-auto">
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-grayScale-400" />
</div>
) : notifications.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-2 py-12 text-grayScale-400">
<BellOff className="h-8 w-8" />
<p className="text-sm">No notifications</p>
</div>
) : (
<div className="p-1">
{notifications.map((n) => (
<NotificationItem
key={n.id}
notification={n}
onMarkRead={markOneRead}
onMarkUnread={markOneUnread}
/>
))}
</div>
)}
</div>
{/* Footer */}
<div className="border-t px-4 py-2.5">
<button
type="button"
className="w-full rounded-lg py-1.5 text-center text-sm font-medium text-brand-600 transition-colors hover:bg-grayScale-100"
onClick={() => {
setOpen(false)
navigate("/notifications")
}}
>
View all notifications
</button>
</div>
</div>
)}
</div>
)
}

View File

@ -1,16 +1,19 @@
"use client" "use client"
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { Bell, LogOut, Menu, Settings, UserCircle2 } from "lucide-react" import { useNavigate } from "react-router-dom"
import { LogOut, Menu, Settings, UserCircle2 } from "lucide-react"
import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar" import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar"
import * as DropdownMenu from "@radix-ui/react-dropdown-menu" import * as DropdownMenu from "@radix-ui/react-dropdown-menu"
import { cn } from "../../lib/utils" import { cn } from "../../lib/utils"
import { NotificationDropdown } from "./NotificationDropdown"
type TopbarProps = { type TopbarProps = {
onMenuClick: () => void onMenuClick: () => void
} }
export function Topbar({ onMenuClick }: TopbarProps) { export function Topbar({ onMenuClick }: TopbarProps) {
const navigate = useNavigate()
const [shortName, setShortName] = useState("AA") const [shortName, setShortName] = useState("AA")
useEffect(() => { useEffect(() => {
@ -29,10 +32,10 @@ export function Topbar({ onMenuClick }: TopbarProps) {
const handleOptionClick = (option: string) => { const handleOptionClick = (option: string) => {
switch (option) { switch (option) {
case "profile": case "profile":
console.log("Go to profile") navigate("/profile")
break break
case "settings": case "settings":
console.log("Go to settings") navigate("/settings")
break break
case "logout": case "logout":
localStorage.clear() localStorage.clear()
@ -55,13 +58,7 @@ export function Topbar({ onMenuClick }: TopbarProps) {
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{/* Notifications */} {/* Notifications */}
<button <NotificationDropdown />
type="button"
className="grid h-10 w-10 place-items-center rounded-full border bg-white text-grayScale-500 hover:text-brand-600 transition-colors"
aria-label="Notifications"
>
<Bell className="h-5 w-5" />
</button>
{/* Separator */} {/* Separator */}
<div className="h-6 w-px bg-grayScale-200" /> <div className="h-6 w-px bg-grayScale-200" />

View File

@ -0,0 +1,152 @@
import { useEffect, useState, useCallback, useRef } from "react"
import { getNotifications, getUnreadCount, markAsRead, markAsUnread, markAllRead } from "../api/notifications.api"
import type { Notification } from "../types/notification.types"
const MAX_DROPDOWN = 5
function getWsUrl() {
const base = import.meta.env.VITE_API_BASE_URL as string
const wsBase = base.replace(/^https/, "wss").replace(/^http/, "ws")
const token = localStorage.getItem("access_token") ?? ""
return `${wsBase}/ws/connect?token=${token}`
}
export function useNotifications() {
const [notifications, setNotifications] = useState<Notification[]>([])
const [unreadCount, setUnreadCount] = useState(0)
const [loading, setLoading] = useState(true)
const wsRef = useRef<WebSocket | null>(null)
const reconnectTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const mountedRef = useRef(true)
const dispatchUpdate = () => {
window.dispatchEvent(new Event("notifications-updated"))
}
const fetchData = useCallback(async () => {
try {
setLoading(true)
const [notifRes, countRes] = await Promise.all([
getNotifications(5, 0),
getUnreadCount(),
])
if (!mountedRef.current) return
setNotifications(notifRes.data.notifications ?? [])
setUnreadCount(countRes.data.unread)
} catch {
// silently fail
} finally {
if (mountedRef.current) setLoading(false)
}
}, [])
const connectWs = useCallback(() => {
if (wsRef.current) {
wsRef.current.close()
}
const ws = new WebSocket(getWsUrl())
wsRef.current = ws
ws.onmessage = (event) => {
try {
const raw = JSON.parse(event.data)
const notif: Notification = {
id: raw.id ?? crypto.randomUUID(),
recipient_id: raw.recipient_id ?? 0,
type: raw.type ?? "",
level: raw.level ?? "",
error_severity: raw.error_severity ?? "",
reciever: raw.reciever ?? "",
is_read: raw.is_read ?? false,
delivery_status: raw.delivery_status ?? "",
delivery_channel: raw.delivery_channel ?? "",
payload: {
headline: raw.payload?.headline ?? raw.payload?.title ?? raw.headline ?? raw.title ?? "",
message: raw.payload?.message ?? raw.payload?.body ?? raw.message ?? raw.body ?? "",
tags: raw.payload?.tags ?? raw.tags ?? null,
},
timestamp: raw.timestamp ?? raw.created_at ?? new Date().toISOString(),
expires: raw.expires ?? "",
image: raw.image ?? "",
}
setNotifications((prev) => [notif, ...prev].slice(0, MAX_DROPDOWN))
setUnreadCount((prev) => prev + 1)
dispatchUpdate()
} catch {
// ignore malformed messages
}
}
ws.onclose = () => {
if (!mountedRef.current) return
reconnectTimer.current = setTimeout(() => {
if (mountedRef.current) connectWs()
}, 5000)
}
}, [])
useEffect(() => {
mountedRef.current = true
fetchData()
connectWs()
return () => {
mountedRef.current = false
wsRef.current?.close()
if (reconnectTimer.current) clearTimeout(reconnectTimer.current)
}
}, [fetchData, connectWs])
const markOneRead = useCallback(async (id: string) => {
setNotifications((prev) =>
prev.map((n) => (n.id === id ? { ...n, is_read: true } : n))
)
setUnreadCount((prev) => Math.max(0, prev - 1))
dispatchUpdate()
try {
await markAsRead(id)
} catch {
// revert on failure
await fetchData()
}
}, [fetchData])
const markOneUnread = useCallback(async (id: string) => {
setNotifications((prev) =>
prev.map((n) => (n.id === id ? { ...n, is_read: false } : n))
)
setUnreadCount((prev) => prev + 1)
dispatchUpdate()
try {
await markAsUnread(id)
} catch {
await fetchData()
}
}, [fetchData])
const markAllAsRead = useCallback(async () => {
setNotifications((prev) => prev.map((n) => ({ ...n, is_read: true })))
setUnreadCount(0)
dispatchUpdate()
try {
await markAllRead()
} catch {
await fetchData()
}
}, [fetchData])
const refresh = useCallback(() => {
fetchData()
}, [fetchData])
return {
notifications,
unreadCount,
loading,
markOneRead,
markOneUnread,
markAllAsRead,
refresh,
}
}

View File

@ -22,6 +22,21 @@ export function AppLayout() {
<main className="min-w-0 flex-1 overflow-y-auto px-4 pb-8 pt-4 lg:px-6"> <main className="min-w-0 flex-1 overflow-y-auto px-4 pb-8 pt-4 lg:px-6">
<Outlet /> <Outlet />
</main> </main>
<footer className="border-t bg-grayScale-50 px-4 py-3 lg:px-6">
<div className="flex items-center justify-center gap-1.5 text-xs text-grayScale-400">
<span>Powered by</span>
<a
href="https://yaltopia.com"
target="_blank"
rel="noopener noreferrer"
className="font-semibold text-brand-500 transition-colors hover:text-brand-600"
>
Yaltopia
</a>
<span>·</span>
<span>© {new Date().getFullYear()}</span>
</div>
</footer>
</div> </div>
</div> </div>
) )

View File

@ -1,11 +1,15 @@
// import type { UserProfileResponse } from "../types/user.types";
import { import {
Activity, // Activity,
BadgeCheck, BadgeCheck,
Coins, BookOpen,
// Coins,
DollarSign, DollarSign,
TrendingUp, HelpCircle,
TicketCheck,
// TrendingUp,
Users, Users,
Bell,
UsersRound,
} from "lucide-react" } from "lucide-react"
import { import {
Area, Area,
@ -22,48 +26,24 @@ import {
YAxis, YAxis,
} from "recharts" } from "recharts"
import { StatCard } from "../components/dashboard/StatCard" import { StatCard } from "../components/dashboard/StatCard"
import { Button } from "../components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card" import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card"
import { cn } from "../lib/utils" // import { cn } from "../lib/utils"
import { getTeamMemberById } from "../api/team.api" import { getTeamMemberById } from "../api/team.api"
import { getDashboard } from "../api/analytics.api"
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import type { DashboardData } from "../types/analytics.types"
const userGrowth = [ const PIE_COLORS = ["#9E2891", "#FFD23F", "#1DE9B6", "#C26FC0", "#6366F1", "#F97316", "#14B8A6", "#EF4444"]
{ month: "Jan", users: 2400 },
{ month: "Feb", users: 2700 },
{ month: "Mar", users: 3100 },
{ month: "Apr", users: 1900 },
{ month: "May", users: 1900 },
{ month: "Jun", users: 2100 },
{ month: "Jul", users: 2050 },
{ month: "Aug", users: 2900 },
{ month: "Sep", users: 2000 },
{ month: "Oct", users: 2050 },
{ month: "Nov", users: 1850 },
{ month: "Dec", users: 1900 },
]
const subscriptionStatus = [ function formatDate(dateStr: string) {
{ name: "Free Plan", value: 3125, color: "#9E2891" }, const d = new Date(dateStr)
{ name: "Monthly Plan", value: 5901, color: "#FFD23F" }, return d.toLocaleDateString("en-US", { month: "short", day: "numeric" })
{ name: "3-Month Plan", value: 1203, color: "#1DE9B6" }, }
{ name: "6-Monthly Plan", value: 825, color: "#C26FC0" },
]
const revenueTrend = [
{ month: "Jan", value: 52000 },
{ month: "Feb", value: 30000 },
{ month: "Mar", value: 50000 },
{ month: "Apr", value: 28000 },
{ month: "May", value: 70000 },
{ month: "Jun", value: 76000 },
]
const ranges = ["1D", "1W", "1M", "3M", "6M", "1Y"] as const
export function DashboardPage() { export function DashboardPage() {
const [userFirstName, setUserFirstName] = useState<string>("") const [userFirstName, setUserFirstName] = useState<string>("")
const activeRange = "1Y" const [dashboard, setDashboard] = useState<DashboardData | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => { useEffect(() => {
const fetchUser = async () => { const fetchUser = async () => {
@ -81,9 +61,47 @@ export function DashboardPage() {
} }
} }
const fetchDashboard = async () => {
try {
const res = await getDashboard()
setDashboard(res.data as unknown as DashboardData)
} catch (err) {
console.error(err)
} finally {
setLoading(false)
}
}
fetchUser() fetchUser()
fetchDashboard()
}, []) }, [])
const registrationData =
dashboard?.users.registrations_last_30_days.map((d) => ({
date: formatDate(d.date),
count: d.count,
})) ?? []
const revenueData =
dashboard?.payments.revenue_last_30_days.map((d) => ({
date: formatDate(d.date),
revenue: d.revenue,
})) ?? []
const subscriptionStatusData =
dashboard?.subscriptions.by_status.map((s, i) => ({
name: s.label,
value: s.count,
color: PIE_COLORS[i % PIE_COLORS.length],
})) ?? []
const issueStatusData =
dashboard?.issues.by_status.map((s, i) => ({
name: s.label,
value: s.count,
color: PIE_COLORS[i % PIE_COLORS.length],
})) ?? []
return ( return (
<div className="mx-auto w-full max-w-6xl"> <div className="mx-auto w-full max-w-6xl">
<div className="mb-2 text-sm font-semibold text-grayScale-500">Dashboard</div> <div className="mb-2 text-sm font-semibold text-grayScale-500">Dashboard</div>
@ -91,134 +109,107 @@ export function DashboardPage() {
Welcome, {userFirstName || localStorage.getItem("user_first_name")} Welcome, {userFirstName || localStorage.getItem("user_first_name")}
</div> </div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-6"> {loading ? (
<StatCard <div className="flex items-center justify-center py-20 text-grayScale-500">Loading dashboard</div>
icon={Users} ) : !dashboard ? (
label="Total Users" <div className="flex items-center justify-center py-20 text-destructive">Failed to load dashboard data.</div>
value="12,490" ) : (
deltaLabel="-15%" <>
deltaPositive={false} {/* Stat Cards */}
/> <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<StatCard <StatCard
icon={BadgeCheck} icon={Users}
label="Active Subscribers" label="Total Users"
value="3,200" value={dashboard.users.total_users.toLocaleString()}
deltaLabel="+35%" deltaLabel={`+${dashboard.users.new_month} this month`}
deltaPositive deltaPositive={dashboard.users.new_month > 0}
/> />
<StatCard <StatCard
icon={Activity} icon={BadgeCheck}
label="Monthly Active Users" label="Active Subscribers"
value="521" value={dashboard.subscriptions.active_subscriptions.toLocaleString()}
deltaLabel="+41%" deltaLabel={`+${dashboard.subscriptions.new_month} this month`}
deltaPositive deltaPositive={dashboard.subscriptions.new_month > 0}
/> />
<StatCard <StatCard
icon={DollarSign} icon={DollarSign}
label="Total Revenue (ETB)" label="Total Revenue (ETB)"
value="927,004" value={dashboard.payments.total_revenue.toLocaleString()}
deltaLabel="-20%" deltaLabel={`${dashboard.payments.total_payments} payments`}
deltaPositive={false} deltaPositive={dashboard.payments.total_revenue > 0}
/> />
<StatCard <StatCard
icon={Coins} icon={TicketCheck}
label="Monthly Revenue (ETB)" label="Issues"
value="81,290" value={`${dashboard.issues.resolved_issues}/${dashboard.issues.total_issues}`}
deltaLabel="+35%" deltaLabel={`${(dashboard.issues.resolution_rate * 100).toFixed(1)}% resolved`}
deltaPositive deltaPositive={dashboard.issues.resolution_rate > 0.5}
/> />
<StatCard </div>
icon={TrendingUp}
label="Growth Rate"
value="12.5%"
deltaLabel="+5%"
deltaPositive
/>
</div>
<div className="mt-5 grid gap-4"> {/* Secondary Stats */}
<Card className="shadow-none"> <div className="mt-4 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<CardHeader className="pb-2"> <StatCard
<div className="flex items-center justify-between gap-3"> icon={BookOpen}
<div> label="Courses"
<CardTitle>User Growth</CardTitle> value={dashboard.courses.total_courses.toLocaleString()}
<div className="mt-1 text-2xl font-semibold tracking-tight">5,730</div> deltaLabel={`${dashboard.courses.total_sub_courses} sub-courses, ${dashboard.courses.total_videos} videos`}
<div className="text-xs font-medium text-mint-500">Last 12 Months +15.2%</div> deltaPositive
</div> />
<StatCard
icon={HelpCircle}
label="Questions"
value={dashboard.content.total_questions.toLocaleString()}
deltaLabel={`${dashboard.content.total_question_sets} question sets`}
deltaPositive
/>
<StatCard
icon={Bell}
label="Notifications"
value={dashboard.notifications.total_sent.toLocaleString()}
deltaLabel={`${dashboard.notifications.unread_count} unread`}
deltaPositive={dashboard.notifications.unread_count === 0}
/>
<StatCard
icon={UsersRound}
label="Team Members"
value={dashboard.team.total_members.toLocaleString()}
deltaLabel={`${dashboard.team.by_role.length} roles`}
deltaPositive
/>
</div>
<div className="flex items-center gap-1 rounded-full bg-grayScale-100 p-1"> {/* User Registrations Chart */}
{ranges.map((r) => ( <div className="mt-5 grid gap-4">
<button <Card className="shadow-none">
key={r} <CardHeader className="pb-2">
type="button" <div className="flex items-center justify-between gap-3">
className={cn( <div>
"rounded-full px-3 py-1 text-xs font-semibold text-grayScale-500", <CardTitle>User Registrations</CardTitle>
r === activeRange && "bg-brand-500 text-white", <div className="mt-1 text-2xl font-semibold tracking-tight">
)} {dashboard.users.total_users.toLocaleString()}
> </div>
{r} <div className="text-xs font-medium text-mint-500">
</button> +{dashboard.users.new_today} today · +{dashboard.users.new_week} this week
))} </div>
</div> </div>
</div> <div className="rounded-full bg-grayScale-100 px-3 py-1 text-xs font-semibold text-grayScale-500">
</CardHeader> Last 30 Days
<CardContent className="h-[280px] p-6 pt-2"> </div>
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={userGrowth} margin={{ left: 8, right: 8, top: 8, bottom: 0 }}>
<defs>
<linearGradient id="fillBrand" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#9E2891" stopOpacity={0.25} />
<stop offset="100%" stopColor="#9E2891" stopOpacity={0.02} />
</linearGradient>
</defs>
<CartesianGrid vertical={false} stroke="#E0E0E0" strokeDasharray="4 4" />
<XAxis dataKey="month" tickLine={false} axisLine={false} fontSize={12} />
<YAxis tickLine={false} axisLine={false} fontSize={12} width={32} />
<Tooltip
contentStyle={{
borderRadius: 12,
border: "1px solid #E0E0E0",
boxShadow: "0 10px 30px rgba(0,0,0,0.08)",
}}
/>
<Area
type="monotone"
dataKey="users"
stroke="#9E2891"
strokeWidth={2}
fill="url(#fillBrand)"
/>
</AreaChart>
</ResponsiveContainer>
</CardContent>
</Card>
<div className="grid gap-4 lg:grid-cols-2">
<Card className="shadow-none">
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle>Subscription Status</CardTitle>
<div className="rounded-full bg-grayScale-100 px-3 py-1 text-xs font-semibold text-grayScale-500">
Weekly
</div> </div>
</div> </CardHeader>
</CardHeader> <CardContent className="h-[280px] p-6 pt-2">
<CardContent className="grid gap-4 p-6 pt-2 md:grid-cols-2">
<div className="h-[180px]">
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<PieChart> <AreaChart data={registrationData} margin={{ left: 8, right: 8, top: 8, bottom: 0 }}>
<Pie <defs>
data={subscriptionStatus} <linearGradient id="fillBrand" x1="0" y1="0" x2="0" y2="1">
dataKey="value" <stop offset="0%" stopColor="#9E2891" stopOpacity={0.25} />
nameKey="name" <stop offset="100%" stopColor="#9E2891" stopOpacity={0.02} />
innerRadius={55} </linearGradient>
outerRadius={80} </defs>
paddingAngle={2} <CartesianGrid vertical={false} stroke="#E0E0E0" strokeDasharray="4 4" />
> <XAxis dataKey="date" tickLine={false} axisLine={false} fontSize={12} />
{subscriptionStatus.map((entry) => ( <YAxis tickLine={false} axisLine={false} fontSize={12} width={32} allowDecimals={false} />
<Cell key={entry.name} fill={entry.color} />
))}
</Pie>
<Tooltip <Tooltip
contentStyle={{ contentStyle={{
borderRadius: 12, borderRadius: 12,
@ -226,60 +217,152 @@ export function DashboardPage() {
boxShadow: "0 10px 30px rgba(0,0,0,0.08)", boxShadow: "0 10px 30px rgba(0,0,0,0.08)",
}} }}
/> />
</PieChart> <Area
type="monotone"
dataKey="count"
stroke="#9E2891"
strokeWidth={2}
fill="url(#fillBrand)"
/>
</AreaChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </CardContent>
</Card>
<div className="space-y-3"> <div className="grid gap-4 lg:grid-cols-2">
{subscriptionStatus.map((s) => ( {/* Subscription / Issue Status Pie */}
<div key={s.name} className="flex items-center justify-between gap-3 text-sm"> <Card className="shadow-none">
<div className="flex items-center gap-2"> <CardHeader className="pb-2">
<span className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: s.color }} /> <CardTitle>
<span className="text-grayScale-600">{s.name}</span> {subscriptionStatusData.length > 0 ? "Subscription Status" : "Issue Status"}
</CardTitle>
</CardHeader>
<CardContent className="grid gap-4 p-6 pt-2 md:grid-cols-2">
{(subscriptionStatusData.length > 0 ? subscriptionStatusData : issueStatusData).length > 0 ? (
<>
<div className="h-[180px]">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={subscriptionStatusData.length > 0 ? subscriptionStatusData : issueStatusData}
dataKey="value"
nameKey="name"
innerRadius={55}
outerRadius={80}
paddingAngle={2}
>
{(subscriptionStatusData.length > 0 ? subscriptionStatusData : issueStatusData).map(
(entry) => (
<Cell key={entry.name} fill={entry.color} />
),
)}
</Pie>
<Tooltip
contentStyle={{
borderRadius: 12,
border: "1px solid #E0E0E0",
boxShadow: "0 10px 30px rgba(0,0,0,0.08)",
}}
/>
</PieChart>
</ResponsiveContainer>
</div>
<div className="space-y-3">
{(subscriptionStatusData.length > 0 ? subscriptionStatusData : issueStatusData).map((s) => (
<div key={s.name} className="flex items-center justify-between gap-3 text-sm">
<div className="flex items-center gap-2">
<span
className="h-2.5 w-2.5 rounded-full"
style={{ backgroundColor: s.color }}
/>
<span className="text-grayScale-600">{s.name}</span>
</div>
<span className="font-semibold text-grayScale-600">{s.value.toLocaleString()}</span>
</div>
))}
</div>
</>
) : (
<div className="col-span-2 flex items-center justify-center py-10 text-sm text-grayScale-400">
No data available
</div> </div>
<span className="font-semibold text-grayScale-600">{s.value.toLocaleString()} Users</span> )}
</div> </CardContent>
))} </Card>
</div>
</CardContent>
</Card>
<Card className="shadow-none"> {/* Revenue Chart */}
<CardHeader className="pb-2"> <Card className="shadow-none">
<div className="flex items-center justify-between"> <CardHeader className="pb-2">
<div> <div className="flex items-center justify-between">
<CardTitle>Revenue Trend</CardTitle> <div>
<div className="mt-2 text-2xl font-semibold tracking-tight">ETB 923,417</div> <CardTitle>Revenue Trend</CardTitle>
<div className="text-xs font-medium text-grayScale-500">Last 6 Months (ETB)</div> <div className="mt-2 text-2xl font-semibold tracking-tight">
</div> ETB {dashboard.payments.total_revenue.toLocaleString()}
<Button variant="ghost" className="text-brand-600 hover:text-brand-600"> </div>
View Report <div className="text-xs font-medium text-grayScale-500">Last 30 Days (ETB)</div>
</Button> </div>
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="h-[220px] p-6 pt-2"> <CardContent className="h-[220px] p-6 pt-2">
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<BarChart data={revenueTrend} margin={{ left: 8, right: 8, top: 8 }}> <BarChart data={revenueData} margin={{ left: 8, right: 8, top: 8 }}>
<CartesianGrid vertical={false} stroke="#E0E0E0" strokeDasharray="4 4" /> <CartesianGrid vertical={false} stroke="#E0E0E0" strokeDasharray="4 4" />
<XAxis dataKey="month" tickLine={false} axisLine={false} fontSize={12} /> <XAxis dataKey="date" tickLine={false} axisLine={false} fontSize={12} />
<YAxis tickLine={false} axisLine={false} fontSize={12} width={42} /> <YAxis tickLine={false} axisLine={false} fontSize={12} width={42} />
<Tooltip <Tooltip
formatter={(v) => [`${Number(v).toLocaleString()}`, "ETB"]} formatter={(v) => [`${Number(v).toLocaleString()}`, "ETB"]}
contentStyle={{ contentStyle={{
borderRadius: 12, borderRadius: 12,
border: "1px solid #E0E0E0", border: "1px solid #E0E0E0",
boxShadow: "0 10px 30px rgba(0,0,0,0.08)", boxShadow: "0 10px 30px rgba(0,0,0,0.08)",
}} }}
/> />
<Bar dataKey="value" radius={[10, 10, 0, 0]} fill="#9E2891" /> <Bar dataKey="revenue" radius={[10, 10, 0, 0]} fill="#9E2891" />
</BarChart> </BarChart>
</ResponsiveContainer> </ResponsiveContainer>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
</div>
{/* Users by Role / Region / Knowledge Level */}
<div className="grid gap-4 lg:grid-cols-3">
{[
{ title: "Users by Role", data: dashboard.users.by_role },
{ title: "Users by Region", data: dashboard.users.by_region },
{ title: "Users by Knowledge Level", data: dashboard.users.by_knowledge_level },
].map(({ title, data }) => (
<Card key={title} className="shadow-none">
<CardHeader className="pb-2">
<CardTitle>{title}</CardTitle>
</CardHeader>
<CardContent className="p-6 pt-2">
{data.length > 0 ? (
<div className="space-y-3">
{data.map((item, i) => (
<div key={item.label} className="flex items-center justify-between gap-3 text-sm">
<div className="flex items-center gap-2">
<span
className="h-2.5 w-2.5 rounded-full"
style={{ backgroundColor: PIE_COLORS[i % PIE_COLORS.length] }}
/>
<span className="text-grayScale-600">{item.label}</span>
</div>
<span className="font-semibold text-grayScale-600">{item.count.toLocaleString()}</span>
</div>
))}
</div>
) : (
<div className="flex items-center justify-center py-6 text-sm text-grayScale-400">
No data available
</div>
)}
</CardContent>
</Card>
))}
</div>
</div>
</>
)}
</div> </div>
) )
} }

522
src/pages/SettingsPage.tsx Normal file
View File

@ -0,0 +1,522 @@
import { useEffect, useState } from "react";
import {
Bell,
Eye,
EyeOff,
Globe,
KeyRound,
Languages,
Loader2,
Lock,
Moon,
Palette,
Save,
Shield,
Sun,
User,
} from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card";
import { Input } from "../components/ui/input";
import { Button } from "../components/ui/button";
import { Select } from "../components/ui/select";
import { Separator } from "../components/ui/separator";
import { cn } from "../lib/utils";
import { getMyProfile } from "../api/users.api";
import type { UserProfileData } from "../types/user.types";
import { toast } from "sonner";
type SettingsTab = "profile" | "security" | "notifications" | "appearance";
const tabs: { id: SettingsTab; label: string; icon: typeof User }[] = [
{ id: "profile", label: "Profile", icon: User },
{ id: "security", label: "Security", icon: Shield },
{ id: "notifications", label: "Notifications", icon: Bell },
{ id: "appearance", label: "Appearance", icon: Palette },
];
function Toggle({
enabled,
onToggle,
}: {
enabled: boolean;
onToggle: () => void;
}) {
return (
<button
type="button"
role="switch"
aria-checked={enabled}
onClick={onToggle}
className={cn(
"relative inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:ring-offset-2",
enabled ? "bg-brand-500" : "bg-grayScale-200"
)}
>
<span
className={cn(
"pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow-sm transition-transform",
enabled ? "translate-x-6" : "translate-x-1"
)}
/>
</button>
);
}
function SettingRow({
icon: Icon,
title,
description,
children,
}: {
icon: typeof User;
title: string;
description: string;
children: React.ReactNode;
}) {
return (
<div className="flex items-center justify-between gap-4 rounded-lg px-3 py-4 transition-colors hover:bg-grayScale-100/50">
<div className="flex items-start gap-3">
<div className="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-grayScale-100 text-grayScale-400">
<Icon className="h-4 w-4" />
</div>
<div>
<p className="text-sm font-medium text-grayScale-600">{title}</p>
<p className="mt-0.5 text-xs text-grayScale-400">{description}</p>
</div>
</div>
<div className="shrink-0">{children}</div>
</div>
);
}
function LoadingSkeleton() {
return (
<div className="mx-auto w-full max-w-5xl space-y-6 px-4 py-8 sm:px-6">
<div className="animate-pulse space-y-6">
<div className="h-7 w-32 rounded-lg bg-grayScale-100" />
<div className="flex gap-2">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="h-10 w-28 rounded-lg bg-grayScale-100" />
))}
</div>
<div className="rounded-2xl border border-grayScale-100 p-6">
<div className="space-y-6">
{[1, 2, 3, 4].map((j) => (
<div key={j} className="flex items-center justify-between">
<div className="space-y-2">
<div className="h-4 w-32 rounded bg-grayScale-100" />
<div className="h-3 w-48 rounded bg-grayScale-100" />
</div>
<div className="h-10 w-48 rounded-lg bg-grayScale-100" />
</div>
))}
</div>
</div>
</div>
</div>
);
}
function ProfileTab({ profile }: { profile: UserProfileData }) {
const [firstName, setFirstName] = useState(profile.first_name);
const [lastName, setLastName] = useState(profile.last_name);
const [nickName, setNickName] = useState(profile.nick_name || "");
const [language, setLanguage] = useState(profile.preferred_language || "en");
const [saving, setSaving] = useState(false);
const handleSave = async () => {
setSaving(true);
try {
// placeholder — wire up to API when endpoint is ready
await new Promise((r) => setTimeout(r, 600));
toast.success("Profile settings saved");
} finally {
setSaving(false);
}
};
return (
<div className="space-y-6">
<Card className="border border-grayScale-100">
<div className="h-1 w-full bg-gradient-to-r from-brand-500 to-brand-600" />
<CardHeader className="pb-3">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-brand-500 to-brand-600 text-white shadow-sm">
<User className="h-4 w-4" />
</div>
<CardTitle className="text-base font-semibold tracking-tight text-grayScale-600">
Personal Information
</CardTitle>
</div>
</CardHeader>
<CardContent className="space-y-5 pb-6">
<div className="grid gap-5 sm:grid-cols-2">
<div className="space-y-1.5">
<label className="text-xs font-medium text-grayScale-500">First Name</label>
<Input value={firstName} onChange={(e) => setFirstName(e.target.value)} />
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-grayScale-500">Last Name</label>
<Input value={lastName} onChange={(e) => setLastName(e.target.value)} />
</div>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-grayScale-500">Nickname</label>
<Input value={nickName} onChange={(e) => setNickName(e.target.value)} />
</div>
</CardContent>
</Card>
<Card className="border border-grayScale-100">
<div className="h-1 w-full bg-gradient-to-r from-brand-400 to-brand-500" />
<CardHeader className="pb-3">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-brand-400 to-brand-500 text-white shadow-sm">
<Languages className="h-4 w-4" />
</div>
<CardTitle className="text-base font-semibold tracking-tight text-grayScale-600">
Preferences
</CardTitle>
</div>
</CardHeader>
<CardContent className="space-y-5 pb-6">
<div className="grid gap-5 sm:grid-cols-2">
<div className="space-y-1.5">
<label className="text-xs font-medium text-grayScale-500">Preferred Language</label>
<Select value={language} onChange={(e) => setLanguage(e.target.value)}>
<option value="en">English</option>
<option value="am">Amharic</option>
<option value="or">Afan Oromo</option>
<option value="ti">Tigrinya</option>
</Select>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-grayScale-500">Timezone</label>
<Select defaultValue="eat">
<option value="eat">East Africa Time (UTC+3)</option>
<option value="utc">UTC</option>
<option value="est">Eastern Time (UTC-5)</option>
<option value="pst">Pacific Time (UTC-8)</option>
</Select>
</div>
</div>
</CardContent>
</Card>
<div className="flex justify-end">
<Button onClick={handleSave} disabled={saving} className="min-w-[140px]">
{saving ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Save className="h-4 w-4" />
)}
{saving ? "Saving…" : "Save Changes"}
</Button>
</div>
</div>
);
}
function SecurityTab() {
const [showCurrent, setShowCurrent] = useState(false);
const [showNew, setShowNew] = useState(false);
const [showConfirm, setShowConfirm] = useState(false);
const [saving, setSaving] = useState(false);
const handleChangePassword = async () => {
setSaving(true);
try {
await new Promise((r) => setTimeout(r, 600));
toast.success("Password updated successfully");
} finally {
setSaving(false);
}
};
return (
<div className="space-y-6">
<Card className="border border-grayScale-100">
<div className="h-1 w-full bg-gradient-to-r from-brand-600 to-brand-500" />
<CardHeader className="pb-3">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-brand-600 to-brand-500 text-white shadow-sm">
<KeyRound className="h-4 w-4" />
</div>
<CardTitle className="text-base font-semibold tracking-tight text-grayScale-600">
Change Password
</CardTitle>
</div>
</CardHeader>
<CardContent className="space-y-5 pb-6">
<div className="space-y-1.5">
<label className="text-xs font-medium text-grayScale-500">Current Password</label>
<div className="relative">
<Input type={showCurrent ? "text" : "password"} placeholder="Enter current password" />
<button
type="button"
onClick={() => setShowCurrent(!showCurrent)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-grayScale-400 hover:text-grayScale-600"
>
{showCurrent ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
</div>
<div className="grid gap-5 sm:grid-cols-2">
<div className="space-y-1.5">
<label className="text-xs font-medium text-grayScale-500">New Password</label>
<div className="relative">
<Input type={showNew ? "text" : "password"} placeholder="Enter new password" />
<button
type="button"
onClick={() => setShowNew(!showNew)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-grayScale-400 hover:text-grayScale-600"
>
{showNew ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-grayScale-500">Confirm New Password</label>
<div className="relative">
<Input type={showConfirm ? "text" : "password"} placeholder="Confirm new password" />
<button
type="button"
onClick={() => setShowConfirm(!showConfirm)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-grayScale-400 hover:text-grayScale-600"
>
{showConfirm ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
</div>
</div>
<div className="flex justify-end">
<Button onClick={handleChangePassword} disabled={saving} className="min-w-[160px]">
{saving ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Lock className="h-4 w-4" />
)}
{saving ? "Updating…" : "Update Password"}
</Button>
</div>
</CardContent>
</Card>
<Card className="border border-grayScale-100">
<div className="h-1 w-full bg-gradient-to-r from-brand-500 to-brand-400" />
<CardHeader className="pb-3">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-brand-500 to-brand-400 text-white shadow-sm">
<Shield className="h-4 w-4" />
</div>
<CardTitle className="text-base font-semibold tracking-tight text-grayScale-600">
Two-Factor Authentication
</CardTitle>
</div>
</CardHeader>
<CardContent className="pb-6">
<SettingRow
icon={Shield}
title="Enable 2FA"
description="Add an extra layer of security to your account"
>
<Toggle enabled={false} onToggle={() => toast.info("2FA coming soon")} />
</SettingRow>
</CardContent>
</Card>
</div>
);
}
function NotificationsTab() {
const [emailNotifs, setEmailNotifs] = useState(true);
const [pushNotifs, setPushNotifs] = useState(true);
const [loginAlerts, setLoginAlerts] = useState(true);
const [weeklyDigest, setWeeklyDigest] = useState(false);
return (
<Card className="border border-grayScale-100">
<div className="h-1 w-full bg-gradient-to-r from-brand-500 to-brand-600" />
<CardHeader className="pb-3">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-brand-500 to-brand-600 text-white shadow-sm">
<Bell className="h-4 w-4" />
</div>
<CardTitle className="text-base font-semibold tracking-tight text-grayScale-600">
Notification Preferences
</CardTitle>
</div>
</CardHeader>
<CardContent className="space-y-1 pb-6">
<SettingRow
icon={Bell}
title="Email Notifications"
description="Receive important updates via email"
>
<Toggle enabled={emailNotifs} onToggle={() => setEmailNotifs(!emailNotifs)} />
</SettingRow>
<Separator />
<SettingRow
icon={Bell}
title="Push Notifications"
description="Get notified in the browser"
>
<Toggle enabled={pushNotifs} onToggle={() => setPushNotifs(!pushNotifs)} />
</SettingRow>
<Separator />
<SettingRow
icon={Shield}
title="Login Alerts"
description="Get notified when someone logs into your account"
>
<Toggle enabled={loginAlerts} onToggle={() => setLoginAlerts(!loginAlerts)} />
</SettingRow>
<Separator />
<SettingRow
icon={Globe}
title="Weekly Digest"
description="Receive a weekly summary of activity"
>
<Toggle enabled={weeklyDigest} onToggle={() => setWeeklyDigest(!weeklyDigest)} />
</SettingRow>
</CardContent>
</Card>
);
}
function AppearanceTab() {
const [theme, setTheme] = useState<"light" | "dark" | "system">("light");
return (
<Card className="border border-grayScale-100">
<div className="h-1 w-full bg-gradient-to-r from-brand-400 to-brand-600" />
<CardHeader className="pb-3">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-brand-400 to-brand-600 text-white shadow-sm">
<Palette className="h-4 w-4" />
</div>
<CardTitle className="text-base font-semibold tracking-tight text-grayScale-600">
Theme
</CardTitle>
</div>
</CardHeader>
<CardContent className="pb-6">
<div className="grid gap-3 sm:grid-cols-3">
{(
[
{ id: "light", label: "Light", icon: Sun },
{ id: "dark", label: "Dark", icon: Moon },
{ id: "system", label: "System", icon: Globe },
] as const
).map(({ id, label, icon: Icon }) => (
<button
key={id}
type="button"
onClick={() => setTheme(id)}
className={cn(
"flex flex-col items-center gap-2.5 rounded-xl border-2 px-4 py-5 transition-all",
theme === id
? "border-brand-500 bg-brand-100/30 text-brand-600 shadow-sm"
: "border-grayScale-100 bg-white text-grayScale-400 hover:border-grayScale-200 hover:bg-grayScale-100/40"
)}
>
<div
className={cn(
"flex h-10 w-10 items-center justify-center rounded-lg",
theme === id ? "bg-brand-500 text-white" : "bg-grayScale-100 text-grayScale-400"
)}
>
<Icon className="h-5 w-5" />
</div>
<span className="text-sm font-medium">{label}</span>
</button>
))}
</div>
</CardContent>
</Card>
);
}
export function SettingsPage() {
const [activeTab, setActiveTab] = useState<SettingsTab>("profile");
const [profile, setProfile] = useState<UserProfileData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchProfile = async () => {
try {
const res = await getMyProfile();
setProfile(res.data.data);
} catch (err) {
console.error("Failed to fetch profile", err);
setError("Failed to load settings. Please try again later.");
} finally {
setLoading(false);
}
};
fetchProfile();
}, []);
if (loading) return <LoadingSkeleton />;
if (error || !profile) {
return (
<div className="mx-auto w-full max-w-5xl px-4 py-16 sm:px-6">
<Card className="border-dashed">
<CardContent className="flex flex-col items-center gap-5 p-12">
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-grayScale-100">
<User className="h-10 w-10 text-grayScale-300" />
</div>
<div className="text-center">
<p className="text-lg font-semibold tracking-tight text-grayScale-600">
{error || "Settings not available"}
</p>
<p className="mt-1 text-sm text-grayScale-400">
Please check your connection and try again.
</p>
</div>
</CardContent>
</Card>
</div>
);
}
return (
<div className="mx-auto w-full max-w-5xl space-y-6 px-4 py-8 sm:px-6">
{/* Page header */}
<div>
<h1 className="text-2xl font-bold tracking-tight text-grayScale-600">Settings</h1>
<p className="mt-1 text-sm text-grayScale-400">
Manage your account preferences and configuration
</p>
</div>
{/* Tab navigation */}
<div className="flex gap-1 rounded-xl border border-grayScale-100 bg-grayScale-100/50 p-1">
{tabs.map(({ id, label, icon: Icon }) => (
<button
key={id}
type="button"
onClick={() => setActiveTab(id)}
className={cn(
"flex items-center gap-2 rounded-lg px-4 py-2.5 text-sm font-medium transition-all",
activeTab === id
? "bg-white text-brand-600 shadow-sm"
: "text-grayScale-400 hover:text-grayScale-600"
)}
>
<Icon className="h-4 w-4" />
<span className="hidden sm:inline">{label}</span>
</button>
))}
</div>
{/* Tab content */}
{activeTab === "profile" && <ProfileTab profile={profile} />}
{activeTab === "security" && <SecurityTab />}
{activeTab === "notifications" && <NotificationsTab />}
{activeTab === "appearance" && <AppearanceTab />}
</div>
);
}

View File

@ -1,17 +1,678 @@
import { useEffect, useState } from "react"
import {
Area,
AreaChart,
Bar,
BarChart,
CartesianGrid,
Cell,
// Legend,
Pie,
PieChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts"
import {
Users,
BadgeCheck,
DollarSign,
BookOpen,
HelpCircle,
Bell,
TicketCheck,
UsersRound,
TrendingUp,
TrendingDown,
CreditCard,
Video,
Layers,
FolderOpen,
RefreshCw,
ChevronDown,
} from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card" import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
import { Badge } from "../../components/ui/badge"
import { Button } from "../../components/ui/button"
import { cn } from "../../lib/utils"
import { getDashboard } from "../../api/analytics.api"
import type { DashboardData, LabelCount } from "../../types/analytics.types"
export function AnalyticsPage() { const PIE_COLORS = ["#9E2891", "#FFD23F", "#1DE9B6", "#C26FC0", "#6366F1", "#F97316", "#14B8A6", "#EF4444", "#8B5CF6", "#EC4899", "#06B6D4", "#84CC16"]
function formatDate(dateStr: string) {
const d = new Date(dateStr)
return d.toLocaleDateString("en-US", { month: "short", day: "numeric" })
}
function formatNumber(n: number) {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`
return n.toLocaleString()
}
function KpiCard({
icon: Icon,
label,
value,
sub,
trend,
className,
}: {
icon: React.ElementType
label: string
value: string
sub?: string
trend?: "up" | "down" | "neutral"
className?: string
}) {
return ( return (
<div className="mx-auto w-full max-w-6xl"> <Card className={cn("shadow-none transition-shadow hover:shadow-md", className)}>
<div className="mb-4 text-sm font-semibold text-grayScale-500">Analytics</div> <CardContent className="p-4">
<Card className="shadow-none"> <div className="flex items-start justify-between gap-3">
<CardHeader> <div className="min-w-0">
<CardTitle>Analytics</CardTitle> <div className="text-xs font-medium text-grayScale-500">{label}</div>
</CardHeader> <div className="mt-1 text-2xl font-semibold tracking-tight">{value}</div>
<CardContent className="text-sm text-muted-foreground">Analytics module placeholder.</CardContent> </div>
</Card> <div className="grid h-10 w-10 shrink-0 place-items-center rounded-xl bg-brand-100 text-brand-600">
<Icon className="h-5 w-5" />
</div>
</div>
{sub && (
<div
className={cn(
"mt-2 flex items-center gap-1 text-xs font-medium",
trend === "up" && "text-mint-500",
trend === "down" && "text-destructive",
(!trend || trend === "neutral") && "text-grayScale-400",
)}
>
{trend === "up" && <TrendingUp className="h-3 w-3" />}
{trend === "down" && <TrendingDown className="h-3 w-3" />}
{sub}
</div>
)}
</CardContent>
</Card>
)
}
function BreakdownList({
title,
data,
total,
}: {
title: string
data: LabelCount[]
total?: number
}) {
const computedTotal = total ?? data.reduce((s, d) => s + d.count, 0)
return (
<Card className="shadow-none">
<CardHeader className="pb-2">
<CardTitle className="text-sm">{title}</CardTitle>
</CardHeader>
<CardContent className="p-4 pt-0">
{data.length > 0 ? (
<div className="space-y-2.5">
{data.map((item, i) => {
const pct = computedTotal > 0 ? (item.count / computedTotal) * 100 : 0
return (
<div key={item.label}>
<div className="mb-1 flex items-center justify-between text-xs">
<div className="flex items-center gap-2">
<span
className="h-2 w-2 rounded-full"
style={{ backgroundColor: PIE_COLORS[i % PIE_COLORS.length] }}
/>
<span className="text-grayScale-600">{item.label}</span>
</div>
<span className="font-semibold text-grayScale-700">
{item.count.toLocaleString()}
<span className="ml-1 font-normal text-grayScale-400">({pct.toFixed(0)}%)</span>
</span>
</div>
<div className="h-1.5 w-full overflow-hidden rounded-full bg-grayScale-100">
<div
className="h-full rounded-full transition-all"
style={{
width: `${pct}%`,
backgroundColor: PIE_COLORS[i % PIE_COLORS.length],
}}
/>
</div>
</div>
)
})}
</div>
) : (
<div className="py-4 text-center text-xs text-grayScale-400">No data available</div>
)}
</CardContent>
</Card>
)
}
function DonutCard({
title,
data,
centerLabel,
centerValue,
}: {
title: string
data: { name: string; value: number; color: string }[]
centerLabel?: string
centerValue?: string
}) {
return (
<Card className="shadow-none">
<CardHeader className="pb-2">
<CardTitle className="text-sm">{title}</CardTitle>
</CardHeader>
<CardContent className="p-4 pt-0">
{data.length > 0 ? (
<div className="grid gap-4 md:grid-cols-2">
<div className="relative h-[170px]">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={data}
dataKey="value"
nameKey="name"
innerRadius={50}
outerRadius={72}
paddingAngle={2}
strokeWidth={0}
>
{data.map((entry) => (
<Cell key={entry.name} fill={entry.color} />
))}
</Pie>
<Tooltip
contentStyle={{
borderRadius: 12,
border: "1px solid #E0E0E0",
boxShadow: "0 10px 30px rgba(0,0,0,0.08)",
fontSize: 12,
}}
/>
</PieChart>
</ResponsiveContainer>
{centerLabel && (
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center">
<span className="text-lg font-semibold">{centerValue}</span>
<span className="text-[10px] text-grayScale-400">{centerLabel}</span>
</div>
)}
</div>
<div className="flex flex-col justify-center space-y-2">
{data.map((s) => (
<div key={s.name} className="flex items-center justify-between gap-2 text-xs">
<div className="flex items-center gap-2">
<span className="h-2 w-2 rounded-full" style={{ backgroundColor: s.color }} />
<span className="text-grayScale-600">{s.name}</span>
</div>
<span className="font-semibold text-grayScale-700">{s.value.toLocaleString()}</span>
</div>
))}
</div>
</div>
) : (
<div className="py-10 text-center text-xs text-grayScale-400">No data available</div>
)}
</CardContent>
</Card>
)
}
function Section({
title,
icon: Icon,
count,
defaultOpen = true,
children,
}: {
title: string
icon: React.ElementType
count?: number
defaultOpen?: boolean
children: React.ReactNode
}) {
const [open, setOpen] = useState(defaultOpen)
return (
<div className="rounded-xl border bg-white">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="flex w-full items-center gap-3 px-5 py-3.5 text-left transition-colors hover:bg-grayScale-50"
>
<div className="grid h-8 w-8 shrink-0 place-items-center rounded-lg bg-brand-100 text-brand-600">
<Icon className="h-4 w-4" />
</div>
<span className="flex-1 text-sm font-semibold text-grayScale-800">{title}</span>
{count !== undefined && (
<Badge variant="secondary" className="mr-2 text-[10px]">
{count}
</Badge>
)}
<ChevronDown
className={cn(
"h-4 w-4 text-grayScale-400 transition-transform duration-200",
open && "rotate-180",
)}
/>
</button>
<div
className={cn(
"grid transition-all duration-200",
open ? "grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0",
)}
>
<div className="overflow-hidden">
<div className="px-5 pb-5 pt-1">{children}</div>
</div>
</div>
</div> </div>
) )
} }
export function AnalyticsPage() {
const [dashboard, setDashboard] = useState<DashboardData | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(false)
const fetchData = async () => {
setLoading(true)
setError(false)
try {
const res = await getDashboard()
setDashboard(res.data as unknown as DashboardData)
} catch {
setError(true)
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchData()
}, [])
if (loading) {
return (
<div className="mx-auto w-full max-w-6xl">
<div className="mb-4 text-sm font-semibold text-grayScale-500">Analytics</div>
<div className="flex items-center justify-center py-20 text-grayScale-500">Loading analytics</div>
</div>
)
}
if (error || !dashboard) {
return (
<div className="mx-auto w-full max-w-6xl">
<div className="mb-4 text-sm font-semibold text-grayScale-500">Analytics</div>
<div className="flex flex-col items-center justify-center gap-3 py-20">
<span className="text-sm text-destructive">Failed to load analytics data.</span>
<Button variant="outline" size="sm" onClick={fetchData}>
<RefreshCw className="mr-2 h-4 w-4" />
Retry
</Button>
</div>
</div>
)
}
const { users, subscriptions, payments, courses, content, notifications, issues, team } = dashboard
const registrationData = users.registrations_last_30_days.map((d) => ({
date: formatDate(d.date),
count: d.count,
}))
const subscriptionData = subscriptions.new_subscriptions_last_30_days.map((d) => ({
date: formatDate(d.date),
count: d.count,
}))
const revenueData = payments.revenue_last_30_days.map((d) => ({
date: formatDate(d.date),
revenue: d.revenue,
}))
const issueStatusPie = issues.by_status.map((s, i) => ({
name: s.label,
value: s.count,
color: PIE_COLORS[i % PIE_COLORS.length],
}))
const subscriptionStatusPie = subscriptions.by_status.map((s, i) => ({
name: s.label,
value: s.count,
color: PIE_COLORS[i % PIE_COLORS.length],
}))
const notifByTypePie = notifications.by_type.slice(0, 8).map((s, i) => ({
name: s.label,
value: s.count,
color: PIE_COLORS[i % PIE_COLORS.length],
}))
const generatedAt = new Date(dashboard.generated_at).toLocaleString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
})
return (
<div className="mx-auto w-full max-w-6xl">
{/* Header */}
<div className="mb-5 flex items-end justify-between">
<div>
<div className="mb-1 text-sm font-semibold text-grayScale-500">Analytics</div>
<h1 className="text-2xl font-semibold tracking-tight">Platform Overview</h1>
</div>
<div className="flex items-center gap-3">
<span className="text-xs text-grayScale-400">Generated {generatedAt}</span>
<Button variant="outline" size="sm" onClick={fetchData}>
<RefreshCw className="mr-2 h-3.5 w-3.5" />
Refresh
</Button>
</div>
</div>
<div className="space-y-4">
{/* ─── Key Metrics ─── */}
<Section title="Key Metrics" icon={TrendingUp} defaultOpen>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<KpiCard
icon={Users}
label="Total Users"
value={formatNumber(users.total_users)}
sub={`+${users.new_today} today · +${users.new_week} this week · +${users.new_month} this month`}
trend={users.new_month > 0 ? "up" : "neutral"}
/>
<KpiCard
icon={BadgeCheck}
label="Active Subscriptions"
value={formatNumber(subscriptions.active_subscriptions)}
sub={`${subscriptions.total_subscriptions} total · +${subscriptions.new_month} this month`}
trend={subscriptions.new_month > 0 ? "up" : "neutral"}
/>
<KpiCard
icon={DollarSign}
label="Total Revenue"
value={`ETB ${formatNumber(payments.total_revenue)}`}
sub={`${payments.successful_payments}/${payments.total_payments} successful · Avg ETB ${payments.avg_transaction_value.toLocaleString()}`}
trend={payments.total_revenue > 0 ? "up" : "neutral"}
/>
<KpiCard
icon={TicketCheck}
label="Issue Resolution"
value={`${(issues.resolution_rate * 100).toFixed(1)}%`}
sub={`${issues.resolved_issues} resolved of ${issues.total_issues} total`}
trend={issues.resolution_rate >= 0.5 ? "up" : "down"}
/>
</div>
</Section>
{/* ─── Content & Platform ─── */}
<Section title="Content & Platform" icon={BookOpen} count={courses.total_courses + content.total_questions} defaultOpen>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<KpiCard
icon={FolderOpen}
label="Categories"
value={courses.total_categories.toLocaleString()}
sub={`${courses.total_courses} courses`}
trend="neutral"
/>
<KpiCard
icon={BookOpen}
label="Sub-Courses"
value={courses.total_sub_courses.toLocaleString()}
sub={`across ${courses.total_courses} courses`}
trend="neutral"
/>
<KpiCard
icon={Video}
label="Videos"
value={courses.total_videos.toLocaleString()}
trend="neutral"
/>
<KpiCard
icon={HelpCircle}
label="Questions"
value={content.total_questions.toLocaleString()}
sub={`${content.total_question_sets} question sets`}
trend="neutral"
/>
</div>
</Section>
{/* ─── Operations ─── */}
<Section title="Operations" icon={Bell} defaultOpen>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<KpiCard
icon={Bell}
label="Notifications Sent"
value={formatNumber(notifications.total_sent)}
sub={`${notifications.read_count} read · ${notifications.unread_count} unread`}
trend={notifications.unread_count === 0 ? "up" : "neutral"}
/>
<KpiCard
icon={UsersRound}
label="Team Members"
value={team.total_members.toLocaleString()}
sub={`${team.by_role.length} roles`}
trend="neutral"
/>
<KpiCard
icon={CreditCard}
label="Payments"
value={payments.total_payments.toLocaleString()}
sub={`${payments.successful_payments} successful`}
trend={payments.successful_payments > 0 ? "up" : "neutral"}
/>
<KpiCard
icon={Layers}
label="Question Sets"
value={content.total_question_sets.toLocaleString()}
sub={content.question_sets_by_type.map((q) => `${q.count} ${q.label.toLowerCase()}`).join(" · ")}
trend="neutral"
/>
</div>
</Section>
{/* ─── User Analytics ─── */}
<Section title="User Analytics" icon={Users} count={users.total_users} defaultOpen>
<Card className="shadow-none">
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<div>
<CardTitle>User Registrations</CardTitle>
<div className="mt-1 flex items-center gap-2">
<span className="text-2xl font-semibold tracking-tight">
{users.total_users.toLocaleString()}
</span>
<Badge variant="success" className="text-[10px]">
+{users.new_today} today
</Badge>
</div>
</div>
<Badge variant="secondary">Last 30 Days</Badge>
</div>
</CardHeader>
<CardContent className="h-[280px] p-6 pt-2">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={registrationData} margin={{ left: 8, right: 8, top: 8, bottom: 0 }}>
<defs>
<linearGradient id="gradUsers" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#9E2891" stopOpacity={0.25} />
<stop offset="100%" stopColor="#9E2891" stopOpacity={0.02} />
</linearGradient>
</defs>
<CartesianGrid vertical={false} stroke="#E0E0E0" strokeDasharray="4 4" />
<XAxis dataKey="date" tickLine={false} axisLine={false} fontSize={11} />
<YAxis tickLine={false} axisLine={false} fontSize={11} width={30} allowDecimals={false} />
<Tooltip
contentStyle={{
borderRadius: 12,
border: "1px solid #E0E0E0",
boxShadow: "0 10px 30px rgba(0,0,0,0.08)",
fontSize: 12,
}}
/>
<Area type="monotone" dataKey="count" stroke="#9E2891" strokeWidth={2} fill="url(#gradUsers)" />
</AreaChart>
</ResponsiveContainer>
</CardContent>
</Card>
<div className="mt-4 grid items-start gap-4 sm:grid-cols-2 lg:grid-cols-3">
<BreakdownList title="Users by Role" data={users.by_role} total={users.total_users} />
<BreakdownList title="Users by Status" data={users.by_status} total={users.total_users} />
<BreakdownList title="Users by Age Group" data={users.by_age_group} total={users.total_users} />
<BreakdownList title="Users by Knowledge Level" data={users.by_knowledge_level} total={users.total_users} />
<BreakdownList title="Users by Region" data={users.by_region} total={users.total_users} />
</div>
</Section>
{/* ─── Subscriptions & Revenue ─── */}
<Section title="Subscriptions & Revenue" icon={DollarSign} defaultOpen={false}>
<div className="grid gap-4 lg:grid-cols-2">
<Card className="shadow-none">
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<div>
<CardTitle>New Subscriptions</CardTitle>
<div className="mt-1 text-2xl font-semibold tracking-tight">
{subscriptions.total_subscriptions.toLocaleString()}
</div>
<div className="text-xs text-grayScale-400">
+{subscriptions.new_today} today · +{subscriptions.new_week} this week
</div>
</div>
<Badge variant="secondary">Last 30 Days</Badge>
</div>
</CardHeader>
<CardContent className="h-[240px] p-6 pt-2">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={subscriptionData} margin={{ left: 8, right: 8, top: 8, bottom: 0 }}>
<defs>
<linearGradient id="gradSub" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#6366F1" stopOpacity={0.2} />
<stop offset="100%" stopColor="#6366F1" stopOpacity={0.02} />
</linearGradient>
</defs>
<CartesianGrid vertical={false} stroke="#E0E0E0" strokeDasharray="4 4" />
<XAxis dataKey="date" tickLine={false} axisLine={false} fontSize={11} />
<YAxis tickLine={false} axisLine={false} fontSize={11} width={30} allowDecimals={false} />
<Tooltip
contentStyle={{
borderRadius: 12,
border: "1px solid #E0E0E0",
boxShadow: "0 10px 30px rgba(0,0,0,0.08)",
fontSize: 12,
}}
/>
<Area type="monotone" dataKey="count" stroke="#6366F1" strokeWidth={2} fill="url(#gradSub)" />
</AreaChart>
</ResponsiveContainer>
</CardContent>
</Card>
<Card className="shadow-none">
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<div>
<CardTitle>Revenue</CardTitle>
<div className="mt-1 text-2xl font-semibold tracking-tight">
ETB {payments.total_revenue.toLocaleString()}
</div>
<div className="text-xs text-grayScale-400">Daily revenue over last 30 days</div>
</div>
<Badge variant="secondary">Last 30 Days</Badge>
</div>
</CardHeader>
<CardContent className="h-[240px] p-6 pt-2">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={revenueData} margin={{ left: 8, right: 8, top: 8 }}>
<CartesianGrid vertical={false} stroke="#E0E0E0" strokeDasharray="4 4" />
<XAxis dataKey="date" tickLine={false} axisLine={false} fontSize={11} />
<YAxis tickLine={false} axisLine={false} fontSize={11} width={42} />
<Tooltip
formatter={(v) => [`ETB ${Number(v).toLocaleString()}`, "Revenue"]}
contentStyle={{
borderRadius: 12,
border: "1px solid #E0E0E0",
boxShadow: "0 10px 30px rgba(0,0,0,0.08)",
fontSize: 12,
}}
/>
<Bar dataKey="revenue" radius={[6, 6, 0, 0]} fill="#9E2891" />
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
</div>
<div className="mt-4 grid items-start gap-4 lg:grid-cols-3">
<DonutCard
title="Subscription Status"
data={subscriptionStatusPie}
centerValue={subscriptions.total_subscriptions.toString()}
centerLabel="Total"
/>
<BreakdownList title="Payments by Method" data={payments.by_method} total={payments.total_payments} />
<BreakdownList title="Payments by Status" data={payments.by_status} total={payments.total_payments} />
</div>
</Section>
{/* ─── Issues & Support ─── */}
<Section title="Issues & Support" icon={TicketCheck} count={issues.total_issues} defaultOpen={false}>
<div className="grid items-start gap-4 lg:grid-cols-3">
<DonutCard
title="Issue Status"
data={issueStatusPie}
centerValue={issues.total_issues.toString()}
centerLabel="Total"
/>
<BreakdownList title="Issues by Type" data={issues.by_type} total={issues.total_issues} />
<BreakdownList title="Issues by Status" data={issues.by_status} total={issues.total_issues} />
</div>
</Section>
{/* ─── Notifications ─── */}
<Section title="Notifications" icon={Bell} count={notifications.total_sent} defaultOpen={false}>
<div className="grid items-start gap-4 sm:grid-cols-2 lg:grid-cols-3">
<DonutCard
title="Notification Types"
data={notifByTypePie}
centerValue={notifications.total_sent.toString()}
centerLabel="Sent"
/>
<BreakdownList title="Notifications by Channel" data={notifications.by_channel} total={notifications.total_sent} />
<BreakdownList title="Notifications by Type" data={notifications.by_type} total={notifications.total_sent} />
</div>
</Section>
{/* ─── Content Breakdown ─── */}
<Section title="Content Breakdown" icon={HelpCircle} count={content.total_questions} defaultOpen={false}>
<div className="grid items-start gap-4 sm:grid-cols-2">
<BreakdownList title="Questions by Type" data={content.questions_by_type} />
<BreakdownList title="Question Sets by Type" data={content.question_sets_by_type} />
</div>
</Section>
{/* ─── Team ─── */}
<Section title="Team" icon={UsersRound} count={team.total_members} defaultOpen={false}>
<div className="grid items-start gap-4 sm:grid-cols-2">
<BreakdownList title="Team by Role" data={team.by_role} total={team.total_members} />
<BreakdownList title="Team by Status" data={team.by_status} total={team.total_members} />
</div>
</Section>
</div>
</div>
)
}

View File

@ -1,17 +1,440 @@
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card" import { useEffect, useState, useCallback } from "react"
import {
Bell,
BellOff,
AlertTriangle,
Info,
AlertCircle,
CheckCircle2,
ChevronLeft,
ChevronRight,
Megaphone,
UserPlus,
CreditCard,
BookOpen,
Video,
ShieldAlert,
Loader2,
MailOpen,
Mail,
CheckCheck,
MailX,
} from "lucide-react"
import { Card, CardContent } from "../../components/ui/card"
import { Badge } from "../../components/ui/badge"
import { Button } from "../../components/ui/button"
import { cn } from "../../lib/utils"
import {
getNotifications,
getUnreadCount,
markAsRead,
markAsUnread,
markAllRead,
markAllUnread,
} from "../../api/notifications.api"
import type { Notification } from "../../types/notification.types"
const PAGE_SIZE = 10
const TYPE_CONFIG: Record<string, { icon: React.ElementType; color: string; bg: string }> = {
announcement: { icon: Megaphone, color: "text-brand-600", bg: "bg-brand-100" },
system_alert: { icon: ShieldAlert, color: "text-amber-600", bg: "bg-amber-50" },
issue_created: { icon: AlertCircle, color: "text-red-500", bg: "bg-red-50" },
issue_status_updated: { icon: CheckCircle2, color: "text-sky-600", bg: "bg-sky-50" },
course_created: { icon: BookOpen, color: "text-indigo-600", bg: "bg-indigo-50" },
course_enrolled: { icon: BookOpen, color: "text-teal-600", bg: "bg-teal-50" },
sub_course_created: { icon: BookOpen, color: "text-violet-600", bg: "bg-violet-50" },
video_added: { icon: Video, color: "text-pink-600", bg: "bg-pink-50" },
user_deleted: { icon: UserPlus, color: "text-red-600", bg: "bg-red-50" },
admin_created: { icon: UserPlus, color: "text-brand-600", bg: "bg-brand-100" },
team_member_created: { icon: UserPlus, color: "text-emerald-600", bg: "bg-emerald-50" },
subscription_activated: { icon: CreditCard, color: "text-green-600", bg: "bg-green-50" },
payment_verified: { icon: CreditCard, color: "text-green-600", bg: "bg-green-50" },
knowledge_level_update: { icon: Info, color: "text-sky-600", bg: "bg-sky-50" },
assessment_assigned: { icon: BookOpen, color: "text-orange-600", bg: "bg-orange-50" },
}
const DEFAULT_TYPE_CONFIG = { icon: Bell, color: "text-grayScale-500", bg: "bg-grayScale-100" }
function getLevelBadge(level: string) {
switch (level) {
case "error":
case "critical":
return "destructive" as const
case "warning":
return "warning" as const
case "success":
return "success" as const
case "info":
default:
return "info" as const
}
}
function formatTimestamp(ts: string) {
const date = new Date(ts)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMin = Math.floor(diffMs / 60_000)
const diffHr = Math.floor(diffMs / 3_600_000)
const diffDay = Math.floor(diffMs / 86_400_000)
if (diffMin < 1) return "Just now"
if (diffMin < 60) return `${diffMin}m ago`
if (diffHr < 24) return `${diffHr}h ago`
if (diffDay < 7) return `${diffDay}d ago`
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: date.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
})
}
function formatTypeLabel(type: string) {
return type
.split("_")
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(" ")
}
function NotificationItem({
notification,
onToggleRead,
toggling,
}: {
notification: Notification
onToggleRead: (id: string, currentlyRead: boolean) => void
toggling: boolean
}) {
const config = TYPE_CONFIG[notification.type] ?? DEFAULT_TYPE_CONFIG
const Icon = config.icon
export function NotificationsPage() {
return ( return (
<div className="mx-auto w-full max-w-6xl"> <div
<div className="mb-4 text-sm font-semibold text-grayScale-500">Notifications</div> className={cn(
<Card className="shadow-none"> "group relative flex gap-4 rounded-xl border p-4 transition-all",
<CardHeader> notification.is_read
<CardTitle>Notifications</CardTitle> ? "border-transparent bg-white hover:bg-grayScale-50"
</CardHeader> : "border-brand-100 bg-brand-50/30 hover:bg-brand-50/50",
<CardContent className="text-sm text-muted-foreground">Notifications module placeholder.</CardContent> )}
</Card> >
{/* Unread dot */}
{!notification.is_read && (
<span className="absolute left-1.5 top-1.5 h-2 w-2 rounded-full bg-brand-500" />
)}
{/* Icon */}
<div
className={cn(
"grid h-10 w-10 shrink-0 place-items-center rounded-xl",
config.bg,
config.color,
)}
>
<Icon className="h-5 w-5" />
</div>
{/* Content */}
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span
className={cn(
"text-sm font-semibold",
notification.is_read ? "text-grayScale-600" : "text-grayScale-800",
)}
>
{notification.payload.headline}
</span>
<Badge variant={getLevelBadge(notification.level)} className="text-[10px] px-1.5 py-0">
{notification.level}
</Badge>
</div>
<p
className={cn(
"mt-0.5 text-sm leading-relaxed",
notification.is_read ? "text-grayScale-400" : "text-grayScale-600",
)}
>
{notification.payload.message}
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<span className="text-xs text-grayScale-400">
{formatTimestamp(notification.timestamp)}
</span>
<button
type="button"
disabled={toggling}
onClick={() => onToggleRead(notification.id, notification.is_read)}
className={cn(
"grid h-7 w-7 place-items-center rounded-lg transition-colors",
"opacity-0 group-hover:opacity-100 focus:opacity-100",
notification.is_read
? "text-grayScale-400 hover:bg-brand-50 hover:text-brand-600"
: "text-brand-500 hover:bg-brand-100 hover:text-brand-700",
toggling && "opacity-50",
)}
title={notification.is_read ? "Mark as unread" : "Mark as read"}
>
{toggling ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : notification.is_read ? (
<Mail className="h-3.5 w-3.5" />
) : (
<MailOpen className="h-3.5 w-3.5" />
)}
</button>
</div>
</div>
{/* Meta row */}
<div className="mt-2 flex flex-wrap items-center gap-2">
<Badge variant="secondary" className="text-[10px] px-2 py-0">
{formatTypeLabel(notification.type)}
</Badge>
<Badge variant="secondary" className="text-[10px] px-2 py-0">
{notification.delivery_channel}
</Badge>
{notification.delivery_status !== "delivered" && notification.delivery_status !== "pending" && (
<Badge variant="warning" className="text-[10px] px-2 py-0">
{notification.delivery_status}
</Badge>
)}
{notification.payload.tags && notification.payload.tags.length > 0 && (
notification.payload.tags.map((tag) => (
<Badge key={tag} variant="secondary" className="text-[10px] px-2 py-0">
{tag}
</Badge>
))
)}
</div>
</div>
</div> </div>
) )
} }
export function NotificationsPage() {
const [notifications, setNotifications] = useState<Notification[]>([])
const [totalCount, setTotalCount] = useState(0)
const [globalUnread, setGlobalUnread] = useState(0)
const [offset, setOffset] = useState(0)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(false)
const [togglingIds, setTogglingIds] = useState<Set<string>>(new Set())
const [bulkLoading, setBulkLoading] = useState(false)
const fetchData = useCallback(async (currentOffset: number) => {
setLoading(true)
setError(false)
try {
const [notifRes, unreadRes] = await Promise.all([
getNotifications(PAGE_SIZE, currentOffset),
getUnreadCount(),
])
setNotifications(notifRes.data.notifications ?? [])
setTotalCount(notifRes.data.total_count)
setGlobalUnread(unreadRes.data.unread)
} catch {
setError(true)
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchData(offset)
}, [offset, fetchData])
const handleToggleRead = useCallback(async (id: string, currentlyRead: boolean) => {
setTogglingIds((prev) => new Set(prev).add(id))
try {
if (currentlyRead) {
await markAsUnread(id)
} else {
await markAsRead(id)
}
setNotifications((prev) =>
prev.map((n) => (n.id === id ? { ...n, is_read: !currentlyRead } : n)),
)
setGlobalUnread((prev) => (currentlyRead ? prev + 1 : Math.max(0, prev - 1)))
} catch {
// silently fail — user can retry
} finally {
setTogglingIds((prev) => {
const next = new Set(prev)
next.delete(id)
return next
})
}
}, [])
const handleMarkAllRead = useCallback(async () => {
setBulkLoading(true)
try {
await markAllRead()
setNotifications((prev) => prev.map((n) => ({ ...n, is_read: true })))
setGlobalUnread(0)
} catch {
// silently fail
} finally {
setBulkLoading(false)
}
}, [])
const handleMarkAllUnread = useCallback(async () => {
setBulkLoading(true)
try {
await markAllUnread()
setNotifications((prev) => prev.map((n) => ({ ...n, is_read: false })))
setGlobalUnread(totalCount)
} catch {
// silently fail
} finally {
setBulkLoading(false)
}
}, [totalCount])
const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE))
const currentPage = Math.floor(offset / PAGE_SIZE) + 1
return (
<div className="mx-auto w-full max-w-3xl">
{/* Header */}
<div className="mb-5">
<div className="mb-1 text-sm font-semibold text-grayScale-500">Notifications</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<h1 className="text-2xl font-semibold tracking-tight">Notifications</h1>
{totalCount > 0 && (
<Badge variant="secondary">{totalCount}</Badge>
)}
{globalUnread > 0 && (
<Badge variant="default">{globalUnread} unread</Badge>
)}
</div>
{/* Bulk actions */}
{!loading && !error && notifications.length > 0 && (
<div className="flex items-center gap-2">
{globalUnread > 0 ? (
<Button
variant="outline"
size="sm"
disabled={bulkLoading}
onClick={handleMarkAllRead}
>
{bulkLoading ? (
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
) : (
<CheckCheck className="mr-2 h-3.5 w-3.5" />
)}
Mark all read
</Button>
) : (
<Button
variant="outline"
size="sm"
disabled={bulkLoading}
onClick={handleMarkAllUnread}
>
{bulkLoading ? (
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
) : (
<MailX className="mr-2 h-3.5 w-3.5" />
)}
Mark all unread
</Button>
)}
</div>
)}
</div>
</div>
{/* Loading */}
{loading && (
<div className="flex items-center justify-center py-20">
<Loader2 className="h-6 w-6 animate-spin text-brand-500" />
</div>
)}
{/* Error */}
{!loading && error && (
<Card className="shadow-none">
<CardContent className="flex flex-col items-center gap-3 py-16">
<AlertTriangle className="h-8 w-8 text-destructive" />
<span className="text-sm text-destructive">Failed to load notifications.</span>
<Button variant="outline" size="sm" onClick={() => fetchData(offset)}>
Retry
</Button>
</CardContent>
</Card>
)}
{/* Empty */}
{!loading && !error && notifications.length === 0 && (
<Card className="shadow-none">
<CardContent className="flex flex-col items-center gap-3 py-20">
<div className="grid h-14 w-14 place-items-center rounded-2xl bg-grayScale-100">
<BellOff className="h-7 w-7 text-grayScale-400" />
</div>
<span className="text-sm font-medium text-grayScale-500">No notifications yet</span>
<span className="text-xs text-grayScale-400">When you receive notifications, they'll appear here.</span>
</CardContent>
</Card>
)}
{/* Notification list */}
{!loading && !error && notifications.length > 0 && (
<>
<Card className="shadow-none">
<CardContent className="divide-y-0 p-2">
<div className="space-y-1">
{notifications.map((n) => (
<NotificationItem
key={n.id}
notification={n}
onToggleRead={handleToggleRead}
toggling={togglingIds.has(n.id)}
/>
))}
</div>
</CardContent>
</Card>
{/* Pagination */}
{totalPages > 1 && (
<div className="mt-4 flex items-center justify-between">
<span className="text-xs text-grayScale-400">
Showing {offset + 1}{Math.min(offset + PAGE_SIZE, totalCount)} of {totalCount}
</span>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="sm"
disabled={currentPage <= 1}
onClick={() => setOffset(Math.max(0, offset - PAGE_SIZE))}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="px-3 text-xs font-medium text-grayScale-600">
{currentPage} / {totalPages}
</span>
<Button
variant="outline"
size="sm"
disabled={currentPage >= totalPages}
onClick={() => setOffset(offset + PAGE_SIZE)}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</>
)}
</div>
)
}

View File

@ -0,0 +1,105 @@
export interface LabelCount {
label: string
count: number
}
export interface DateCount {
date: string
count: number
}
export interface DateRevenue {
date: string
revenue: number
}
export interface RevenuePlan {
label: string
revenue: number
}
export interface DashboardUsers {
total_users: number
new_today: number
new_week: number
new_month: number
by_role: LabelCount[]
by_status: LabelCount[]
by_age_group: LabelCount[]
by_knowledge_level: LabelCount[]
by_region: LabelCount[]
registrations_last_30_days: DateCount[]
}
export interface DashboardSubscriptions {
total_subscriptions: number
active_subscriptions: number
new_today: number
new_week: number
new_month: number
by_status: LabelCount[]
revenue_by_plan: RevenuePlan[]
new_subscriptions_last_30_days: DateCount[]
}
export interface DashboardPayments {
total_revenue: number
avg_transaction_value: number
total_payments: number
successful_payments: number
by_status: LabelCount[]
by_method: LabelCount[]
revenue_last_30_days: DateRevenue[]
}
export interface DashboardCourses {
total_categories: number
total_courses: number
total_sub_courses: number
total_videos: number
}
export interface DashboardContent {
total_questions: number
total_question_sets: number
questions_by_type: LabelCount[]
question_sets_by_type: LabelCount[]
}
export interface DashboardNotifications {
total_sent: number
read_count: number
unread_count: number
by_channel: LabelCount[]
by_type: LabelCount[]
}
export interface DashboardIssues {
total_issues: number
resolved_issues: number
resolution_rate: number
by_status: LabelCount[]
by_type: LabelCount[]
}
export interface DashboardTeam {
total_members: number
by_role: LabelCount[]
by_status: LabelCount[]
}
export interface DashboardData {
generated_at: string
users: DashboardUsers
subscriptions: DashboardSubscriptions
payments: DashboardPayments
courses: DashboardCourses
content: DashboardContent
notifications: DashboardNotifications
issues: DashboardIssues
team: DashboardTeam
}
export interface DashboardResponse {
data: DashboardData
}

View File

@ -0,0 +1,32 @@
export interface NotificationPayload {
headline: string
message: string
tags: string[] | null
}
export interface Notification {
id: string
recipient_id: number
type: string
level: string
error_severity: string
reciever: string
is_read: boolean
delivery_status: string
delivery_channel: string
payload: NotificationPayload
timestamp: string
expires: string
image: string
}
export interface GetNotificationsResponse {
notifications: Notification[]
total_count: number
limit: number
offset: number
}
export interface UnreadCountResponse {
unread: number
}

View File

@ -5,3 +5,4 @@ import react from '@vitejs/plugin-react'
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
}) })