settings page + inapp notifications integration + analytics page
This commit is contained in:
parent
25badbcca5
commit
fc983c055e
5
src/api/analytics.api.ts
Normal file
5
src/api/analytics.api.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import http from "./http";
|
||||
import type { DashboardResponse } from "../types/analytics.types";
|
||||
|
||||
export const getDashboard = () =>
|
||||
http.get<DashboardResponse>("/analytics/dashboard");
|
||||
22
src/api/notifications.api.ts
Normal file
22
src/api/notifications.api.ts
Normal 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");
|
||||
|
|
@ -31,6 +31,7 @@ import { AddQuestionPage } from "../pages/content-management/AddQuestionPage"
|
|||
import { UserLogPage } from "../pages/user-log/UserLogPage"
|
||||
import { IssuesPage } from "../pages/issues/IssuesPage"
|
||||
import { ProfilePage } from "../pages/ProfilePage"
|
||||
import { SettingsPage } from "../pages/SettingsPage"
|
||||
import { TeamManagementPage } from "../pages/team/TeamManagementPage"
|
||||
import { TeamMemberDetailPage } from "../pages/team/TeamMemberDetailPage"
|
||||
import { LoginPage } from "../pages/auth/LoginPage"
|
||||
|
|
@ -86,6 +87,7 @@ export function AppRoutes() {
|
|||
<Route path="/team" element={<TeamManagementPage />} />
|
||||
<Route path="/team/:id" element={<TeamMemberDetailPage />} />
|
||||
<Route path="/profile" element={<ProfilePage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
</Route>
|
||||
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
|
|
|
|||
|
|
@ -12,10 +12,11 @@ import {
|
|||
Users2,
|
||||
X,
|
||||
} from "lucide-react"
|
||||
import type { ComponentType } from "react"
|
||||
import { type ComponentType, useEffect, useState } from "react"
|
||||
import { NavLink } from "react-router-dom"
|
||||
import { cn } from "../../lib/utils"
|
||||
import { BrandLogo } from "../brand/BrandLogo"
|
||||
import { getUnreadCount } from "../../api/notifications.api"
|
||||
|
||||
type NavItem = {
|
||||
label: string
|
||||
|
|
@ -42,6 +43,24 @@ type 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 (
|
||||
<>
|
||||
{/* Mobile overlay */}
|
||||
|
|
@ -101,7 +120,14 @@ export function Sidebar({ isOpen, onClose }: SidebarProps) {
|
|||
<Icon className="h-4 w-4" />
|
||||
</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" />
|
||||
) : null}
|
||||
</>
|
||||
|
|
|
|||
254
src/components/topbar/NotificationDropdown.tsx
Normal file
254
src/components/topbar/NotificationDropdown.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,16 +1,19 @@
|
|||
"use client"
|
||||
|
||||
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 * as DropdownMenu from "@radix-ui/react-dropdown-menu"
|
||||
import { cn } from "../../lib/utils"
|
||||
import { NotificationDropdown } from "./NotificationDropdown"
|
||||
|
||||
type TopbarProps = {
|
||||
onMenuClick: () => void
|
||||
}
|
||||
|
||||
export function Topbar({ onMenuClick }: TopbarProps) {
|
||||
const navigate = useNavigate()
|
||||
const [shortName, setShortName] = useState("AA")
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -29,10 +32,10 @@ export function Topbar({ onMenuClick }: TopbarProps) {
|
|||
const handleOptionClick = (option: string) => {
|
||||
switch (option) {
|
||||
case "profile":
|
||||
console.log("Go to profile")
|
||||
navigate("/profile")
|
||||
break
|
||||
case "settings":
|
||||
console.log("Go to settings")
|
||||
navigate("/settings")
|
||||
break
|
||||
case "logout":
|
||||
localStorage.clear()
|
||||
|
|
@ -55,13 +58,7 @@ export function Topbar({ onMenuClick }: TopbarProps) {
|
|||
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Notifications */}
|
||||
<button
|
||||
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>
|
||||
<NotificationDropdown />
|
||||
|
||||
{/* Separator */}
|
||||
<div className="h-6 w-px bg-grayScale-200" />
|
||||
|
|
|
|||
152
src/hooks/useNotifications.ts
Normal file
152
src/hooks/useNotifications.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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">
|
||||
<Outlet />
|
||||
</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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,11 +1,15 @@
|
|||
// import type { UserProfileResponse } from "../types/user.types";
|
||||
import {
|
||||
Activity,
|
||||
// Activity,
|
||||
BadgeCheck,
|
||||
Coins,
|
||||
BookOpen,
|
||||
// Coins,
|
||||
DollarSign,
|
||||
TrendingUp,
|
||||
HelpCircle,
|
||||
TicketCheck,
|
||||
// TrendingUp,
|
||||
Users,
|
||||
Bell,
|
||||
UsersRound,
|
||||
} from "lucide-react"
|
||||
import {
|
||||
Area,
|
||||
|
|
@ -22,48 +26,24 @@ import {
|
|||
YAxis,
|
||||
} from "recharts"
|
||||
import { StatCard } from "../components/dashboard/StatCard"
|
||||
import { Button } from "../components/ui/button"
|
||||
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 { getDashboard } from "../api/analytics.api"
|
||||
import { useEffect, useState } from "react"
|
||||
import type { DashboardData } from "../types/analytics.types"
|
||||
|
||||
const userGrowth = [
|
||||
{ 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 PIE_COLORS = ["#9E2891", "#FFD23F", "#1DE9B6", "#C26FC0", "#6366F1", "#F97316", "#14B8A6", "#EF4444"]
|
||||
|
||||
const subscriptionStatus = [
|
||||
{ name: "Free Plan", value: 3125, color: "#9E2891" },
|
||||
{ name: "Monthly Plan", value: 5901, color: "#FFD23F" },
|
||||
{ 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
|
||||
function formatDate(dateStr: string) {
|
||||
const d = new Date(dateStr)
|
||||
return d.toLocaleDateString("en-US", { month: "short", day: "numeric" })
|
||||
}
|
||||
|
||||
export function DashboardPage() {
|
||||
const [userFirstName, setUserFirstName] = useState<string>("")
|
||||
const activeRange = "1Y"
|
||||
const [dashboard, setDashboard] = useState<DashboardData | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
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()
|
||||
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 (
|
||||
<div className="mx-auto w-full max-w-6xl">
|
||||
<div className="mb-2 text-sm font-semibold text-grayScale-500">Dashboard</div>
|
||||
|
|
@ -91,80 +109,98 @@ export function DashboardPage() {
|
|||
Welcome, {userFirstName || localStorage.getItem("user_first_name")}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-6">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-20 text-grayScale-500">Loading dashboard…</div>
|
||||
) : !dashboard ? (
|
||||
<div className="flex items-center justify-center py-20 text-destructive">Failed to load dashboard data.</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Stat Cards */}
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<StatCard
|
||||
icon={Users}
|
||||
label="Total Users"
|
||||
value="12,490"
|
||||
deltaLabel="-15%"
|
||||
deltaPositive={false}
|
||||
value={dashboard.users.total_users.toLocaleString()}
|
||||
deltaLabel={`+${dashboard.users.new_month} this month`}
|
||||
deltaPositive={dashboard.users.new_month > 0}
|
||||
/>
|
||||
<StatCard
|
||||
icon={BadgeCheck}
|
||||
label="Active Subscribers"
|
||||
value="3,200"
|
||||
deltaLabel="+35%"
|
||||
deltaPositive
|
||||
/>
|
||||
<StatCard
|
||||
icon={Activity}
|
||||
label="Monthly Active Users"
|
||||
value="521"
|
||||
deltaLabel="+41%"
|
||||
deltaPositive
|
||||
value={dashboard.subscriptions.active_subscriptions.toLocaleString()}
|
||||
deltaLabel={`+${dashboard.subscriptions.new_month} this month`}
|
||||
deltaPositive={dashboard.subscriptions.new_month > 0}
|
||||
/>
|
||||
<StatCard
|
||||
icon={DollarSign}
|
||||
label="Total Revenue (ETB)"
|
||||
value="927,004"
|
||||
deltaLabel="-20%"
|
||||
deltaPositive={false}
|
||||
value={dashboard.payments.total_revenue.toLocaleString()}
|
||||
deltaLabel={`${dashboard.payments.total_payments} payments`}
|
||||
deltaPositive={dashboard.payments.total_revenue > 0}
|
||||
/>
|
||||
<StatCard
|
||||
icon={Coins}
|
||||
label="Monthly Revenue (ETB)"
|
||||
value="81,290"
|
||||
deltaLabel="+35%"
|
||||
icon={TicketCheck}
|
||||
label="Issues"
|
||||
value={`${dashboard.issues.resolved_issues}/${dashboard.issues.total_issues}`}
|
||||
deltaLabel={`${(dashboard.issues.resolution_rate * 100).toFixed(1)}% resolved`}
|
||||
deltaPositive={dashboard.issues.resolution_rate > 0.5}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Secondary Stats */}
|
||||
<div className="mt-4 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<StatCard
|
||||
icon={BookOpen}
|
||||
label="Courses"
|
||||
value={dashboard.courses.total_courses.toLocaleString()}
|
||||
deltaLabel={`${dashboard.courses.total_sub_courses} sub-courses, ${dashboard.courses.total_videos} videos`}
|
||||
deltaPositive
|
||||
/>
|
||||
<StatCard
|
||||
icon={TrendingUp}
|
||||
label="Growth Rate"
|
||||
value="12.5%"
|
||||
deltaLabel="+5%"
|
||||
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>
|
||||
|
||||
{/* User Registrations Chart */}
|
||||
<div className="mt-5 grid gap-4">
|
||||
<Card className="shadow-none">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle>User Growth</CardTitle>
|
||||
<div className="mt-1 text-2xl font-semibold tracking-tight">5,730</div>
|
||||
<div className="text-xs font-medium text-mint-500">Last 12 Months +15.2%</div>
|
||||
<CardTitle>User Registrations</CardTitle>
|
||||
<div className="mt-1 text-2xl font-semibold tracking-tight">
|
||||
{dashboard.users.total_users.toLocaleString()}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 rounded-full bg-grayScale-100 p-1">
|
||||
{ranges.map((r) => (
|
||||
<button
|
||||
key={r}
|
||||
type="button"
|
||||
className={cn(
|
||||
"rounded-full px-3 py-1 text-xs font-semibold text-grayScale-500",
|
||||
r === activeRange && "bg-brand-500 text-white",
|
||||
)}
|
||||
>
|
||||
{r}
|
||||
</button>
|
||||
))}
|
||||
<div className="text-xs font-medium text-mint-500">
|
||||
+{dashboard.users.new_today} today · +{dashboard.users.new_week} this week
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-full bg-grayScale-100 px-3 py-1 text-xs font-semibold text-grayScale-500">
|
||||
Last 30 Days
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="h-[280px] p-6 pt-2">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={userGrowth} margin={{ left: 8, right: 8, top: 8, bottom: 0 }}>
|
||||
<AreaChart data={registrationData} 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} />
|
||||
|
|
@ -172,8 +208,8 @@ export function DashboardPage() {
|
|||
</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} />
|
||||
<XAxis dataKey="date" tickLine={false} axisLine={false} fontSize={12} />
|
||||
<YAxis tickLine={false} axisLine={false} fontSize={12} width={32} allowDecimals={false} />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
borderRadius: 12,
|
||||
|
|
@ -183,7 +219,7 @@ export function DashboardPage() {
|
|||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="users"
|
||||
dataKey="count"
|
||||
stroke="#9E2891"
|
||||
strokeWidth={2}
|
||||
fill="url(#fillBrand)"
|
||||
|
|
@ -194,30 +230,32 @@ export function DashboardPage() {
|
|||
</Card>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
{/* Subscription / Issue Status Pie */}
|
||||
<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>
|
||||
<CardTitle>
|
||||
{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={subscriptionStatus}
|
||||
data={subscriptionStatusData.length > 0 ? subscriptionStatusData : issueStatusData}
|
||||
dataKey="value"
|
||||
nameKey="name"
|
||||
innerRadius={55}
|
||||
outerRadius={80}
|
||||
paddingAngle={2}
|
||||
>
|
||||
{subscriptionStatus.map((entry) => (
|
||||
{(subscriptionStatusData.length > 0 ? subscriptionStatusData : issueStatusData).map(
|
||||
(entry) => (
|
||||
<Cell key={entry.name} fill={entry.color} />
|
||||
))}
|
||||
),
|
||||
)}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
|
|
@ -229,39 +267,47 @@ export function DashboardPage() {
|
|||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{subscriptionStatus.map((s) => (
|
||||
{(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="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()} Users</span>
|
||||
<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>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Revenue Chart */}
|
||||
<Card className="shadow-none">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Revenue Trend</CardTitle>
|
||||
<div className="mt-2 text-2xl font-semibold tracking-tight">ETB 923,417</div>
|
||||
<div className="text-xs font-medium text-grayScale-500">Last 6 Months (ETB)</div>
|
||||
<div className="mt-2 text-2xl font-semibold tracking-tight">
|
||||
ETB {dashboard.payments.total_revenue.toLocaleString()}
|
||||
</div>
|
||||
<div className="text-xs font-medium text-grayScale-500">Last 30 Days (ETB)</div>
|
||||
</div>
|
||||
<Button variant="ghost" className="text-brand-600 hover:text-brand-600">
|
||||
View Report
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="h-[220px] p-6 pt-2">
|
||||
<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" />
|
||||
<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} />
|
||||
<Tooltip
|
||||
formatter={(v) => [`${Number(v).toLocaleString()}`, "ETB"]}
|
||||
|
|
@ -271,15 +317,52 @@ export function DashboardPage() {
|
|||
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>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
522
src/pages/SettingsPage.tsx
Normal file
522
src/pages/SettingsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 { 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 (
|
||||
<div className="mx-auto w-full max-w-6xl">
|
||||
<div className="mb-4 text-sm font-semibold text-grayScale-500">Analytics</div>
|
||||
<Card className="shadow-none">
|
||||
<CardHeader>
|
||||
<CardTitle>Analytics</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-sm text-muted-foreground">Analytics module placeholder.</CardContent>
|
||||
<Card className={cn("shadow-none transition-shadow hover:shadow-md", className)}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs font-medium text-grayScale-500">{label}</div>
|
||||
<div className="mt-1 text-2xl font-semibold tracking-tight">{value}</div>
|
||||
</div>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="mx-auto w-full max-w-6xl">
|
||||
<div className="mb-4 text-sm font-semibold text-grayScale-500">Notifications</div>
|
||||
<Card className="shadow-none">
|
||||
<CardHeader>
|
||||
<CardTitle>Notifications</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-sm text-muted-foreground">Notifications module placeholder.</CardContent>
|
||||
</Card>
|
||||
<div
|
||||
className={cn(
|
||||
"group relative flex gap-4 rounded-xl border p-4 transition-all",
|
||||
notification.is_read
|
||||
? "border-transparent bg-white hover:bg-grayScale-50"
|
||||
: "border-brand-100 bg-brand-50/30 hover:bg-brand-50/50",
|
||||
)}
|
||||
>
|
||||
{/* 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>
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
105
src/types/analytics.types.ts
Normal file
105
src/types/analytics.types.ts
Normal 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
|
||||
}
|
||||
32
src/types/notification.types.ts
Normal file
32
src/types/notification.types.ts
Normal 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
|
||||
}
|
||||
|
|
@ -5,3 +5,4 @@ import react from '@vitejs/plugin-react'
|
|||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
})
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user