Compare commits

..

5 Commits

Author SHA1 Message Date
7ffbc666b0 notification page fix 2026-03-27 04:52:17 -07:00
e882bd60be Merge remote-tracking branch 'origin/production'
Made-with: Cursor
2026-03-27 04:47:55 -07:00
e9c5f72218 minor UI fixes 2026-03-27 04:37:08 -07:00
d99142f70e Merge origin/main into main
Made-with: Cursor
2026-03-27 04:28:11 -07:00
7c1687787b minor integartion and UI fixes 2026-03-27 04:22:44 -07:00
45 changed files with 4344 additions and 1364 deletions

4
.env
View File

@ -1,3 +1,3 @@
# VITE_API_BASE_URL=https://api.yimaru.yaltopia.com/api/v1
VITE_API_BASE_URL= https://api.yimaruacademy.com/api/v1
VITE_API_BASE_URL=https://api.yimaruacademy.com/api/v1
# VITE_API_BASE_URL=http://localhost:8432/api/v1
VITE_GOOGLE_CLIENT_ID=

View File

@ -51,6 +51,7 @@ import type {
GetRatingsResponse,
GetRatingsParams,
GetVimeoSampleResponse,
CreateCourseVideoRequest,
} from "../types/course.types"
export const getCourseCategories = () =>
@ -65,6 +66,11 @@ export const getCoursesByCategory = (categoryId: number) =>
export const createCourse = (data: CreateCourseRequest) =>
http.post("/course-management/courses", data)
export const updateCourseThumbnail = (courseId: number, thumbnailUrl: string) =>
http.post(`/course-management/courses/${courseId}/thumbnail`, {
thumbnail_url: thumbnailUrl,
})
export const deleteCourse = (courseId: number) =>
http.delete(`/course-management/courses/${courseId}`)
@ -81,6 +87,11 @@ export const getSubCoursesByCourse = (courseId: number) =>
export const createSubCourse = (data: CreateSubCourseRequest) =>
http.post("/course-management/sub-courses", data)
export const updateSubCourseThumbnail = (subCourseId: number, thumbnailUrl: string) =>
http.post(`/course-management/sub-courses/${subCourseId}/thumbnail`, {
thumbnail_url: thumbnailUrl,
})
export const updateSubCourse = (subCourseId: number, data: UpdateSubCourseRequest) =>
http.patch(`/course-management/sub-courses/${subCourseId}`, data)
@ -97,6 +108,9 @@ export const getVideosBySubCourse = (subCourseId: number) =>
export const createSubCourseVideo = (data: CreateSubCourseVideoRequest) =>
http.post("/course-management/sub-course-videos", data)
export const createCourseVideo = (data: CreateCourseVideoRequest) =>
http.post("/course-management/videos", data)
export const updateSubCourseVideo = (videoId: number, data: UpdateSubCourseVideoRequest) =>
http.put(`/course-management/sub-course-videos/${videoId}`, data)
@ -227,6 +241,15 @@ export const deleteQuestion = (questionId: number) =>
export const updateQuestion = (questionId: number, data: CreateQuestionRequest) =>
http.put(`/questions/${questionId}`, data)
export interface SubmitAudioAnswerRequest {
question_id: number
question_set_id: number
object_key: string
}
export const submitAudioAnswer = (data: SubmitAudioAnswerRequest) =>
http.post("/questions/audio-answer", data)
export const deleteQuestionSet = (questionSetId: number) =>
http.delete(`/question-sets/${questionSetId}`)

61
src/api/files.api.ts Normal file
View File

@ -0,0 +1,61 @@
import http from "./http"
export type UploadMediaType = "image" | "audio" | "video"
export type UploadProvider = "MINIO" | "VIMEO"
export interface UploadMediaResponse {
message: string
data?: {
object_key?: string
url?: string
content_type?: string
media_type?: UploadMediaType
provider?: UploadProvider
vimeo_id?: string
embed_url?: string
}
success?: boolean
}
export interface ResolveFileUrlResponse {
message: string
data?: {
url?: string
}
success?: boolean
}
export interface UploadMediaOptions {
title?: string
description?: string
}
export const uploadMediaFile = (
mediaType: UploadMediaType,
file: File,
options?: UploadMediaOptions,
) => {
const formData = new FormData()
formData.append("media_type", mediaType)
formData.append("file", file)
if (mediaType === "video") {
if (options?.title) formData.append("title", options.title)
if (options?.description) formData.append("description", options.description)
}
// Let Axios set the correct multipart boundary.
return http.post<UploadMediaResponse>("/files/upload", formData)
}
export const uploadAudioFile = (file: File) => uploadMediaFile("audio", file)
export const uploadImageFile = (file: File) => uploadMediaFile("image", file)
export const uploadVideoFile = (file: File, options?: UploadMediaOptions) =>
uploadMediaFile("video", file, options)
export const resolveFileUrl = (key: string) =>
http.get<ResolveFileUrlResponse>("/files/url", {
params: { key },
})

View File

@ -2,9 +2,9 @@ import axios, { type AxiosInstance, type AxiosError, type InternalAxiosRequestCo
const http: AxiosInstance = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
headers: {
"Content-Type": "application/json",
},
// Do not force a Content-Type globally.
// Axios will set the correct header based on the request body (JSON vs multipart FormData).
headers: {},
});
let isRefreshing = false;

View File

@ -1,5 +1,10 @@
import http from "./http"
import type { GetTeamMembersResponse, GetTeamMemberResponse, CreateTeamMemberRequest } from "../types/team.types"
import type {
GetTeamMembersResponse,
GetTeamMemberResponse,
CreateTeamMemberRequest,
UpdateTeamMemberRequest,
} from "../types/team.types"
export const getTeamMembers = (page?: number, pageSize?: number) =>
http.get<GetTeamMembersResponse>("/team/members", {
@ -17,3 +22,6 @@ export const createTeamMember = (data: CreateTeamMemberRequest) =>
export const updateTeamMemberStatus = (id: number, status: string) =>
http.patch(`/team/members/${id}/status`, { status })
export const updateTeamMember = (id: number, data: UpdateTeamMemberRequest) =>
http.put(`/team/members/${id}`, data)

View File

@ -1,5 +1,12 @@
import http from "./http";
import { type UserProfileResponse, type GetUsersResponse, type UpdateProfileRequest, type UserSummaryResponse } from "../types/user.types";
import {
type UserProfileResponse,
type GetUsersResponse,
type UpdateProfileRequest,
type UserSummaryResponse,
type GetDeletionRequestsParams,
type GetDeletionRequestsResponse,
} from "../types/user.types";
export const getUsers = (
page?: number,
@ -53,3 +60,11 @@ export const updateProfile = (data: UpdateProfileRequest) =>
export const getUserSummary = () =>
http.get<UserSummaryResponse>("/users/summary");
export const getDeletionRequests = (params: GetDeletionRequestsParams) =>
http.get<GetDeletionRequestsResponse>("/admin/users/deletion-requests", { params });
export const updateUserProfilePicture = (id: number, profilePictureUrl: string) =>
http.post(`/user/${id}/profile-picture`, {
profile_picture_url: profilePictureUrl,
});

View File

@ -23,7 +23,7 @@ import { UserManagementLayout } from "../pages/user-management/UserManagementLay
import { UsersListPage } from "../pages/user-management/UsersListPage"
import { UserManagementDashboard } from "../pages/user-management/UserManagementDashboard"
import { UserGroupsPage } from "../pages/user-management/UserGroupsPage"
import { RegisterUserPage } from "../pages/user-management/RegisterUserPage"
import { DeletionRequestsPage } from "../pages/user-management/DeletionRequestsPage"
import { RoleManagementLayout } from "../pages/role-management/RoleManagementLayout"
import { RolesListPage } from "../pages/role-management/RolesListPage"
import { AddRolePage } from "../pages/role-management/AddRolePage"
@ -41,6 +41,10 @@ import { TeamMemberDetailPage } from "../pages/team/TeamMemberDetailPage"
import { LoginPage } from "../pages/auth/LoginPage"
import { ForgotPasswordPage } from "../pages/auth/ForgotPasswordPage"
import { VerificationPage } from "../pages/auth/VerificationPage"
import { AboutPage } from "../pages/AboutPage"
import { TermsPage } from "../pages/TermsPage"
import { PrivacyPage } from "../pages/PrivacyPage"
import { AccountDeletionPage } from "../pages/AccountDeletionPage"
export function AppRoutes() {
return (
@ -48,13 +52,17 @@ export function AppRoutes() {
<Route path="/login" element={<LoginPage />} />
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
<Route path="/verification" element={<VerificationPage />} />
<Route path="/about" element={<AboutPage />} />
<Route path="/terms" element={<TermsPage />} />
<Route path="/privacy" element={<PrivacyPage />} />
<Route path="/account-deletion" element={<AccountDeletionPage />} />
<Route element={<AppLayout />}>
<Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route path="/dashboard" element={<DashboardPage />} />
<Route path="/users" element={<UserManagementLayout />}>
<Route index element={<UserManagementDashboard />} />
<Route path="list" element={<UsersListPage />} />
<Route path="register" element={<RegisterUserPage />} />
<Route path="deletion-requests" element={<DeletionRequestsPage />} />
<Route path="groups" element={<UserGroupsPage />} />
<Route path=":id" element={<UserDetailPage />} />
</Route>

View File

@ -12,13 +12,13 @@ import {
BookOpen,
Video,
ShieldAlert,
Loader2,
MailOpen,
Mail,
CheckCheck,
} from "lucide-react"
import { Badge } from "../ui/badge"
import { cn } from "../../lib/utils"
import { SpinnerIcon } from "../ui/spinner-icon"
import { useNotifications } from "../../hooks/useNotifications"
import { getNotificationMessage, getNotificationTitle, type Notification } from "../../types/notification.types"
@ -212,7 +212,7 @@ export function NotificationDropdown() {
<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" />
<SpinnerIcon className="h-6 w-6" />
</div>
) : notifications.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-2 py-12 text-grayScale-400">

View File

@ -18,7 +18,7 @@ export const DropdownMenuSubTrigger = React.forwardRef<
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-md px-2 py-1.5 text-sm outline-none focus:bg-grayScale-100 data-[state=open]:bg-grayScale-100",
"flex cursor-default select-none items-center rounded-lg px-2.5 py-2 text-sm text-grayScale-600 outline-none transition-colors focus:bg-brand-100/40 data-[state=open]:bg-brand-100/40",
inset && "pl-8",
className,
)}
@ -37,7 +37,7 @@ export const DropdownMenuSubContent = React.forwardRef<
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-white p-1 text-grayScale-600 shadow-soft",
"z-50 min-w-[9rem] overflow-hidden rounded-xl border border-grayScale-200 bg-white p-1.5 text-grayScale-600 shadow-soft",
className,
)}
{...props}
@ -54,7 +54,7 @@ export const DropdownMenuContent = React.forwardRef<
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[10rem] overflow-hidden rounded-md border bg-white p-1 text-grayScale-600 shadow-soft",
"z-50 min-w-[11rem] overflow-hidden rounded-xl border border-grayScale-200 bg-white p-1.5 text-grayScale-600 shadow-soft",
className,
)}
{...props}
@ -70,7 +70,7 @@ export const DropdownMenuItem = React.forwardRef<
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-md px-2 py-1.5 text-sm outline-none transition-colors focus:bg-grayScale-100 data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
"relative flex cursor-default select-none items-center rounded-lg px-2.5 py-2 text-sm outline-none transition-colors focus:bg-brand-100/40 data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className,
)}
@ -86,7 +86,7 @@ export const DropdownMenuCheckboxItem = React.forwardRef<
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-md py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-grayScale-100 data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
"relative flex cursor-default select-none items-center rounded-lg py-2 pl-8 pr-2.5 text-sm outline-none transition-colors focus:bg-brand-100/40 data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
checked={checked}
@ -109,7 +109,7 @@ export const DropdownMenuRadioItem = React.forwardRef<
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-md py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-grayScale-100 data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
"relative flex cursor-default select-none items-center rounded-lg py-2 pl-8 pr-2.5 text-sm outline-none transition-colors focus:bg-brand-100/40 data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
{...props}
@ -130,7 +130,7 @@ export const DropdownMenuLabel = React.forwardRef<
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-xs font-semibold text-grayScale-500", inset && "pl-8", className)}
className={cn("px-2.5 py-1.5 text-xs font-semibold text-grayScale-500", inset && "pl-8", className)}
{...props}
/>
))

View File

@ -10,7 +10,7 @@ export const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
<div className="relative">
<select
className={cn(
"flex h-10 w-full appearance-none rounded-lg border bg-white px-3 py-2 pr-8 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
"flex h-11 w-full appearance-none rounded-xl border border-grayScale-200 bg-white px-3 py-2 pr-8 text-sm text-grayScale-600 shadow-sm ring-offset-background transition hover:bg-grayScale-50 focus-visible:border-brand-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-200 disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
ref={ref}

View File

@ -0,0 +1,12 @@
import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg"
import { cn } from "../../lib/utils"
interface SpinnerIconProps {
className?: string
alt?: string
}
export function SpinnerIcon({ className, alt = "Loading" }: SpinnerIconProps) {
return <img src={spinnerSrc} alt={alt} className={cn("animate-spin", className)} />
}

View File

@ -10,7 +10,7 @@ export interface StepperProps {
export function Stepper({ steps, currentStep, className }: StepperProps) {
return (
<div className={cn("flex items-center justify-between", className)}>
<div className={cn("flex w-full items-center", className)}>
{steps.map((step, index) => {
const stepNumber = index + 1
const isCompleted = stepNumber < currentStep
@ -18,13 +18,14 @@ export function Stepper({ steps, currentStep, className }: StepperProps) {
return (
<React.Fragment key={step}>
<div className="flex flex-1 items-center">
<div className="flex items-center">
<div className="flex flex-col items-center">
<div
className={cn(
"grid h-10 w-10 place-items-center rounded-full border-2 text-sm font-semibold transition-colors",
isCompleted && "border-brand-500 bg-brand-500 text-white",
isCurrent && "border-brand-500 bg-brand-50 text-brand-600",
// Active step should be visually prominent.
isCurrent && "border-brand-500 bg-brand-500 text-white",
!isCompleted && !isCurrent && "border-grayScale-300 bg-white text-grayScale-400",
)}
>
@ -44,8 +45,10 @@ export function Stepper({ steps, currentStep, className }: StepperProps) {
{index < steps.length - 1 && (
<div
className={cn(
"mx-4 h-0.5 flex-1",
isCompleted ? "bg-brand-500" : "bg-grayScale-200",
// Keep the connector visually continuous with the step circles.
"mx-2 h-0.5 flex-1",
// Color the track up to the current step.
isCompleted || isCurrent ? "bg-brand-500" : "bg-grayScale-200",
)}
/>
)}

View File

@ -78,6 +78,10 @@ export function useNotifications() {
}
}
ws.onerror = () => {
ws.close()
}
ws.onclose = () => {
if (!mountedRef.current) return
reconnectTimer.current = setTimeout(() => {

28
src/pages/AboutPage.tsx Normal file
View File

@ -0,0 +1,28 @@
import { InfoLayout } from "./legal/InfoLayout"
export function AboutPage() {
return (
<InfoLayout
title="About"
subtitle="Yimaru Academy provides modern, structured learning experiences and the administration tools to manage courses, question sets, speaking practices, and learner progress at scale."
>
<section className="space-y-3 rounded-xl border border-[#ece4f7] bg-gradient-to-br from-white to-[#fcf8ff] p-5 shadow-sm">
<h2 className="text-lg font-semibold text-grayScale-700">What is Yimaru Academy?</h2>
<p className="text-sm leading-relaxed text-grayScale-600">
Yimaru Academy is a digital learning platform focused on high-quality content delivery,
measurable learner outcomes, and efficient administration for training teams.
</p>
</section>
<section className="space-y-3 rounded-xl border border-[#ece4f7] bg-gradient-to-br from-white to-[#fcf8ff] p-5 shadow-sm">
<h2 className="text-lg font-semibold text-grayScale-700">Core Platform Capabilities</h2>
<ul className="list-disc space-y-2 pl-5 text-sm leading-relaxed text-grayScale-600">
<li>Manage course categories, sub-categories, courses, videos, and assessments.</li>
<li>Create practice question sets including multiple choice, short answer, and audio types.</li>
<li>Track learner progress and completion metrics across course structures.</li>
<li>Operate teams, roles, notifications, and user lifecycle from one dashboard.</li>
</ul>
</section>
</InfoLayout>
)
}

View File

@ -0,0 +1,50 @@
import { InfoLayout } from "./legal/InfoLayout"
export function AccountDeletionPage() {
return (
<InfoLayout
title="Account Deletion"
subtitle="Guidance on requesting and processing account deletion within Yimaru Academy."
>
<section className="space-y-3 rounded-xl border border-[#ece4f7] bg-gradient-to-br from-white to-[#fcf8ff] p-5 shadow-sm">
<h2 className="text-lg font-semibold text-grayScale-700">Before Deletion</h2>
<p className="text-sm leading-relaxed text-grayScale-600">
Confirm account ownership and review the potential impact on course history, progress
reporting, team assignments, and related records.
</p>
</section>
<section className="space-y-3 rounded-xl border border-[#ece4f7] bg-gradient-to-br from-white to-[#fcf8ff] p-5 shadow-sm">
<h2 className="text-lg font-semibold text-grayScale-700">Deletion Steps</h2>
<ol className="list-decimal space-y-2 pl-5 text-sm leading-relaxed text-grayScale-600">
<li>
Launch the <strong>Yimaru mobile app</strong> and <strong>sign in</strong> to the account you want to remove.
</li>
<li>
Open your <strong>Profile</strong> area from the main menu or bottom navigation bar.
</li>
<li>
Go into <strong>Account Settings</strong> (or <strong>Account Management</strong>, depending on your app version).
</li>
<li>
Tap the option labeled <strong>Delete Account</strong> or <strong>Request Account Deletion</strong>.
</li>
<li>
Review the warning details and complete the <strong>confirmation prompts</strong> shown on screen.
</li>
<li>
Finish the process by verifying identity through your <strong>PIN</strong> or <strong>biometric authentication</strong> if requested.
</li>
</ol>
</section>
<section className="space-y-3 rounded-xl border border-[#ece4f7] bg-gradient-to-br from-white to-[#fcf8ff] p-5 shadow-sm">
<h2 className="text-lg font-semibold text-grayScale-700">Post-Deletion Notes</h2>
<p className="text-sm leading-relaxed text-grayScale-600">
Some records may be retained where required for compliance, security, or legal obligations.
Deleted accounts typically cannot be restored.
</p>
</section>
</InfoLayout>
)
}

35
src/pages/PrivacyPage.tsx Normal file
View File

@ -0,0 +1,35 @@
import { InfoLayout } from "./legal/InfoLayout"
export function PrivacyPage() {
return (
<InfoLayout
title="Privacy Policy"
subtitle="This policy explains how Yimaru Academy handles personal, educational, and operational data across learning and administration workflows."
>
<section className="space-y-3 rounded-xl border border-[#ece4f7] bg-gradient-to-br from-white to-[#fcf8ff] p-5 shadow-sm">
<h2 className="text-lg font-semibold text-grayScale-700">Data We Process</h2>
<p className="text-sm leading-relaxed text-grayScale-600">
We process account information, course activity, assessment records, and administrative logs
to support platform functionality, progress reporting, and security monitoring.
</p>
</section>
<section className="space-y-3 rounded-xl border border-[#ece4f7] bg-gradient-to-br from-white to-[#fcf8ff] p-5 shadow-sm">
<h2 className="text-lg font-semibold text-grayScale-700">How Data Is Used</h2>
<ul className="list-disc space-y-2 pl-5 text-sm leading-relaxed text-grayScale-600">
<li>Deliver and personalize course experiences.</li>
<li>Track learner progress and learning outcomes.</li>
<li>Manage permissions, operational controls, and audit trails.</li>
</ul>
</section>
<section className="space-y-3 rounded-xl border border-[#ece4f7] bg-gradient-to-br from-white to-[#fcf8ff] p-5 shadow-sm">
<h2 className="text-lg font-semibold text-grayScale-700">Security and Retention</h2>
<p className="text-sm leading-relaxed text-grayScale-600">
Access controls, role-based restrictions, and logging are used to protect data integrity.
Data is retained according to organizational and legal requirements.
</p>
</section>
</InfoLayout>
)
}

View File

@ -1,35 +1,23 @@
import { useEffect, useState } from "react";
import {
Calendar,
CheckCircle2,
Clock,
Globe,
Loader2,
Mail,
MapPin,
Pencil,
Phone,
Save,
Shield,
User,
X,
XCircle,
Briefcase,
BookOpen,
Target,
Languages,
Heart,
MessageCircle,
} from "lucide-react";
import { useEffect, useState, type ChangeEvent } from "react";
import { BadgeCheck, Briefcase, CalendarDays, Mail, Phone, Shield, User } from "lucide-react";
import { Badge } from "../components/ui/badge";
import { Button } from "../components/ui/button";
import { Card, CardContent } from "../components/ui/card";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "../components/ui/dialog";
import { Input } from "../components/ui/input";
import { Select } from "../components/ui/select";
import { cn } from "../lib/utils";
import { getMyProfile, updateProfile } from "../api/users.api";
import type { UserProfileData, UpdateProfileRequest } from "../types/user.types";
import { Textarea } from "../components/ui/textarea";
import { FileUpload } from "../components/ui/file-upload";
import { getMyProfile } from "../api/users.api";
import { updateTeamMember } from "../api/team.api";
import { uploadImageFile } from "../api/files.api";
import { SpinnerIcon } from "../components/ui/spinner-icon";
import type { UpdateTeamMemberRequest } from "../types/team.types";
import { toast } from "sonner";
function formatDate(dateStr: string | null | undefined): string {
@ -52,6 +40,28 @@ function formatDateTime(dateStr: string | null | undefined): string {
});
}
interface TeamMeProfile {
id: number
first_name: string
last_name: string
email: string
phone_number: string
team_role: string
department: string
job_title: string
employment_type: string
hire_date: string
bio: string
status: string
email_verified: boolean
permissions: string[]
last_login: string | null
created_at: string
emergency_contact?: string
work_phone?: string
profile_picture_url?: string
}
function LoadingSkeleton() {
return (
<div className="w-full space-y-8 py-10">
@ -89,103 +99,26 @@ function LoadingSkeleton() {
);
}
function VerifiedIcon({ verified }: { verified: boolean }) {
return verified ? (
<div className="flex h-5 w-5 items-center justify-center rounded-full bg-mint-100">
<CheckCircle2 className="h-3.5 w-3.5 text-mint-500" />
</div>
) : (
<div className="flex h-5 w-5 items-center justify-center rounded-full bg-grayScale-100">
<XCircle className="h-3.5 w-3.5 text-grayScale-300" />
</div>
);
}
function ProgressRing({ percent }: { percent: number }) {
const radius = 18;
const circumference = 2 * Math.PI * radius;
const offset = circumference - (percent / 100) * circumference;
return (
<div className="relative inline-flex items-center justify-center">
<svg className="h-12 w-12 -rotate-90" viewBox="0 0 44 44">
<circle
cx="22"
cy="22"
r={radius}
fill="none"
stroke="currentColor"
strokeWidth="2.5"
className="text-grayScale-200"
/>
<circle
cx="22"
cy="22"
r={radius}
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeDasharray={circumference}
strokeDashoffset={offset}
className="text-brand-500 transition-all duration-700"
/>
</svg>
<span className="absolute text-[10px] font-bold text-brand-600">{percent}%</span>
</div>
);
}
function DetailItem({
icon: Icon,
label,
value,
extra,
editNode,
editing,
}: {
icon: typeof User;
label: string;
value: string;
extra?: React.ReactNode;
editNode?: React.ReactNode;
editing?: boolean;
}) {
return (
<div className="group flex items-start gap-3 rounded-xl px-3 py-3 transition-colors hover:bg-grayScale-50/80">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-grayScale-100/80 text-grayScale-400 transition-colors group-hover:bg-brand-500/90 group-hover:text-white">
<Icon className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<p className="text-[11px] font-medium uppercase tracking-wider text-grayScale-400">
{label}
</p>
{editing && editNode ? (
<div className="mt-1">{editNode}</div>
) : (
<div className="mt-0.5 flex items-center gap-2">
<p className="truncate text-sm font-medium text-grayScale-700">{value || "—"}</p>
{extra}
</div>
)}
</div>
</div>
);
}
export function ProfilePage() {
const [profile, setProfile] = useState<UserProfileData | null>(null);
const [profile, setProfile] = useState<TeamMeProfile | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [editing, setEditing] = useState(false);
const [saving, setSaving] = useState(false);
const [editForm, setEditForm] = useState<UpdateProfileRequest>({});
const [profilePictureFile, setProfilePictureFile] = useState<File | null>(null);
const [editForm, setEditForm] = useState<UpdateTeamMemberRequest>({
first_name: "",
last_name: "",
phone_number: "",
profile_picture_url: "",
bio: "",
});
useEffect(() => {
const fetchProfile = async () => {
try {
const res = await getMyProfile();
setProfile(res.data.data);
setProfile((res.data?.data ?? null) as unknown as TeamMeProfile | null);
} catch (err) {
console.error("Failed to fetch profile", err);
setError("Failed to load profile. Please try again later.");
@ -198,52 +131,68 @@ export function ProfilePage() {
const startEditing = () => {
if (!profile) return;
setEditForm({
const nextForm: UpdateTeamMemberRequest = {
first_name: profile.first_name ?? "",
last_name: profile.last_name ?? "",
nick_name: profile.nick_name ?? "",
gender: profile.gender ?? "",
birth_day: profile.birth_day ?? "",
age_group: profile.age_group ?? "",
education_level: profile.education_level ?? "",
country: profile.country ?? "",
region: profile.region ?? "",
occupation: profile.occupation ?? "",
learning_goal: profile.learning_goal ?? "",
language_goal: profile.language_goal ?? "",
language_challange: profile.language_challange ?? "",
favoutite_topic: profile.favoutite_topic ?? "",
preferred_language: profile.preferred_language ?? "",
});
phone_number: profile.phone_number ?? "",
profile_picture_url: profile.profile_picture_url ?? "",
bio: profile.bio ?? "",
};
setEditForm(nextForm);
setProfilePictureFile(null);
setEditing(true);
};
const cancelEditing = () => {
setProfilePictureFile(null);
setEditing(false);
setEditForm({});
};
const updateField = (field: keyof UpdateTeamMemberRequest, value: string) => {
setEditForm((prev) => ({ ...prev, [field]: value }));
};
const handleSave = async () => {
if (!profile) return;
let nextProfilePictureUrl = editForm.profile_picture_url ?? "";
if (profilePictureFile) {
try {
const uploadRes = await uploadImageFile(profilePictureFile);
const uploadedUrl = uploadRes.data?.data?.url?.trim();
if (!uploadedUrl) throw new Error("Missing uploaded image url");
nextProfilePictureUrl = uploadedUrl;
} catch (err) {
console.error("Failed to upload profile picture:", err);
toast.error("Failed to upload profile picture");
return;
}
}
const payload: UpdateTeamMemberRequest = {
bio: editForm.bio ?? "",
first_name: editForm.first_name ?? "",
last_name: editForm.last_name ?? "",
phone_number: editForm.phone_number ?? "",
profile_picture_url: nextProfilePictureUrl,
};
setSaving(true);
try {
await updateProfile(editForm);
const res = await getMyProfile();
setProfile(res.data.data);
await updateTeamMember(profile.id, payload);
const refreshed = await getMyProfile();
setProfile((refreshed.data?.data ?? null) as unknown as TeamMeProfile | null);
setEditing(false);
setEditForm({});
setProfilePictureFile(null);
toast.success("Profile updated successfully");
} catch (err) {
console.error("Failed to update profile", err);
toast.error("Failed to update profile. Please try again.");
console.error("Failed to update team member profile", err);
toast.error("Failed to update profile");
} finally {
setSaving(false);
}
};
const updateField = (field: keyof UpdateProfileRequest, value: string) => {
setEditForm((prev) => ({ ...prev, [field]: value }));
};
if (loading) return <LoadingSkeleton />;
if (error || !profile) {
@ -269,424 +218,216 @@ export function ProfilePage() {
}
const fullName = `${profile.first_name} ${profile.last_name}`;
const completionPct = profile.profile_completion_percentage ?? 0;
const initials = `${profile.first_name?.[0] ?? ""}${profile.last_name?.[0] ?? ""}`.toUpperCase();
return (
<div className="mx-auto w-full max-w-7xl space-y-6 pb-8">
{/* ─── Hero Card ─── */}
<div className="relative overflow-hidden rounded-3xl border border-grayScale-100 bg-white shadow-sm ring-1 ring-black/5">
{/* Tall dark gradient banner with content inside */}
<div className="relative flex min-h-[220px] flex-col justify-between bg-gradient-to-br from-[#1a1f4e] via-[#2d2b6b] to-[#3b3480] px-6 py-8 sm:px-8">
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top_right,rgba(255,255,255,0.08),transparent_60%)]" />
<div className="mx-auto w-full max-w-7xl rounded-2xl bg-[#f7f1f8] p-4 pb-8 sm:p-6">
<div className="overflow-hidden rounded-2xl border border-[#d9bddb] bg-white">
<div className="h-40 w-full bg-gradient-to-r from-[#d6aed6] via-[#e4cce4] to-[#cba0cd]" />
<div className="relative z-10 space-y-2">
<h2 className="text-2xl font-bold tracking-tight text-white sm:text-3xl">
Hello {profile.first_name}
</h2>
<p className="max-w-2xl text-sm leading-relaxed text-white/70">
Track your account status, keep profile details up to date, and manage your learning preferences from one place.
</p>
<div className="mt-4 flex flex-wrap items-center gap-2">
<span className="inline-flex items-center gap-1.5 rounded-full border border-white/20 bg-white/10 px-2.5 py-1 text-xs font-medium text-white/90">
<Shield className="h-3.5 w-3.5" />
{profile.role}
</span>
<span className="inline-flex items-center gap-1.5 rounded-full border border-white/20 bg-white/10 px-2.5 py-1 text-xs font-medium text-white/90">
<Clock className="h-3.5 w-3.5" />
Last login {formatDate(profile.last_login)}
</span>
<span className="inline-flex items-center gap-1.5 rounded-full border border-white/20 bg-white/10 px-2.5 py-1 text-xs font-medium text-white/90">
<Target className="h-3.5 w-3.5" />
{completionPct}% complete
</span>
<div className="grid gap-0 lg:grid-cols-[280px_minmax(0,1fr)]">
<aside className="border-r border-[#eadbea] bg-white px-5 pb-6">
<div className="-mt-16">
<div className="flex h-28 w-28 items-center justify-center rounded-full border-4 border-white bg-[#d6aed6] text-2xl font-bold text-[#6f2aa8]">
{initials}
</div>
<h2 className="mt-3 text-2xl font-bold text-grayScale-700">{fullName}</h2>
<p className="text-sm text-grayScale-400">{profile.job_title || "Team Member"}</p>
</div>
</div>
<div className="relative z-10 mt-6">
{!editing ? (
<Button
variant="outline"
size="sm"
className="h-8 gap-1.5 border-white/30 bg-white/10 px-3 text-xs font-medium text-white shadow-sm backdrop-blur-sm hover:bg-white/20 hover:text-white"
onClick={startEditing}
>
<Pencil className="h-3.5 w-3.5" />
Edit Profile
</Button>
) : (
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
className="h-8 gap-1.5 border-white/30 bg-white/10 px-3 text-xs text-white backdrop-blur-sm hover:bg-white/20 hover:text-white"
onClick={cancelEditing}
disabled={saving}
>
<X className="h-3.5 w-3.5" />
Cancel
</Button>
<Button
size="sm"
className="h-8 gap-1.5 bg-white text-xs text-[#1a1f4e] hover:bg-white/90"
onClick={handleSave}
disabled={saving}
>
{saving ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
<div className="mt-4">
<div className="flex w-full items-center justify-between rounded-lg border border-[#d9bddb] bg-[#f4e8f4] px-3 py-2">
<span className="text-sm font-medium text-[#6f2aa8]">Account Status</span>
<span className="text-sm font-semibold uppercase text-[#5e2390]">{profile.status}</span>
</div>
</div>
<div className="mt-5 space-y-5 text-sm">
<section>
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-grayScale-400">About</p>
<div className="space-y-2 text-grayScale-600">
<div className="flex items-center gap-2"><Briefcase className="h-4 w-4 text-[#6f2aa8]" />{profile.job_title || "Job title not set"}</div>
<div className="flex items-center gap-2"><Shield className="h-4 w-4 text-[#6f2aa8]" />{profile.team_role || "Role not set"}</div>
<div className="flex items-center gap-2"><CalendarDays className="h-4 w-4 text-[#6f2aa8]" />Hire date: {formatDate(profile.hire_date)}</div>
<div className="flex items-center gap-2"><BadgeCheck className="h-4 w-4 text-[#6f2aa8]" />{profile.department || "Department not set"}</div>
</div>
</section>
<section>
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-grayScale-400">Contact</p>
<div className="space-y-2 text-grayScale-600">
<div className="flex items-center gap-2"><Mail className="h-4 w-4 text-[#6f2aa8]" />{profile.email}</div>
<div className="flex items-center gap-2"><Phone className="h-4 w-4 text-[#6f2aa8]" />{profile.phone_number || "—"}</div>
</div>
</section>
<section>
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-grayScale-400">Access</p>
<p className="text-xs text-grayScale-500">Permissions from `/team/me`</p>
<div className="mt-2 flex flex-wrap gap-1.5">
{(profile.permissions ?? []).length === 0 ? (
<Badge className="bg-[#ecd9ec] text-[#6f2aa8]">No permissions listed</Badge>
) : (
<Save className="h-3.5 w-3.5" />
profile.permissions.map((permission) => (
<Badge key={permission} className="bg-[#ecd9ec] text-[#6f2aa8]">
{permission}
</Badge>
))
)}
{saving ? "Saving…" : "Save Changes"}
</Button>
</div>
)}
</div>
</div>
{/* Identity info below banner */}
<div className="bg-gradient-to-b from-white to-grayScale-50/40 px-6 py-5 sm:px-8">
{editing ? (
<div className="flex flex-wrap items-center gap-2">
<Input
className="h-9 w-40 text-sm font-semibold"
value={editForm.first_name ?? ""}
onChange={(e) => updateField("first_name", e.target.value)}
placeholder="First name"
/>
<Input
className="h-9 w-40 text-sm font-semibold"
value={editForm.last_name ?? ""}
onChange={(e) => updateField("last_name", e.target.value)}
placeholder="Last name"
/>
<span className="rounded-full bg-grayScale-50 px-2.5 py-0.5 text-xs font-medium text-grayScale-500">
#{profile.id}
</span>
</div>
</section>
</div>
) : (
<div className="flex flex-wrap items-center gap-2.5">
<h2 className="text-xl font-bold tracking-tight text-grayScale-800 sm:text-2xl">
{fullName}
</h2>
{profile.nick_name && (
<span className="text-sm font-medium text-grayScale-400">
@{profile.nick_name}
</span>
)}
<span className="rounded-full bg-grayScale-50 px-2.5 py-0.5 text-xs font-medium text-grayScale-500">
#{profile.id}
</span>
</div>
)}
</aside>
{/* Badges row */}
<div className="mt-3 flex flex-wrap items-center gap-2">
<Badge
className={cn(
"px-2.5 py-0.5 text-xs font-semibold",
profile.role === "ADMIN"
? "bg-brand-500/10 text-brand-600 border border-brand-500/20"
: "bg-grayScale-50 text-grayScale-600 border border-grayScale-200",
)}
>
<Shield className="mr-1 h-3 w-3" />
{profile.role}
</Badge>
<span
className={cn(
"inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-semibold",
profile.status === "ACTIVE"
? "bg-mint-50 text-mint-600"
: "bg-destructive/10 text-destructive",
)}
>
<span
className={cn(
"h-1.5 w-1.5 rounded-full",
profile.status === "ACTIVE" ? "bg-mint-500" : "bg-destructive",
)}
/>
{profile.status}
</span>
<span className="inline-flex items-center gap-1.5 rounded-full bg-grayScale-50 px-2.5 py-0.5 text-xs font-medium text-grayScale-500">
<Calendar className="h-3 w-3" />
Joined {formatDate(profile.created_at)}
</span>
</div>
<main className="bg-[#fdf8fd] px-5 py-6 sm:px-7">
<div className="space-y-5">
<Card className="border-[#d9bddb] bg-white shadow-none">
<CardContent className="p-5">
<div className="mb-3 flex items-center justify-between">
<h3 className="text-base font-semibold text-grayScale-700">Summary</h3>
<button
type="button"
onClick={startEditing}
className="inline-flex items-center rounded-md border border-[#d9bddb] bg-[#f4e8f4] px-3 py-1.5 text-sm font-medium text-[#6f2aa8] transition-colors hover:bg-[#ecd9ec] hover:text-[#5e2390] focus:outline-none focus-visible:ring-2 focus-visible:ring-[#c39bd4]"
>
Edit profile
</button>
</div>
<div className="space-y-2 text-sm text-grayScale-600">
<div className="flex items-center gap-2"><Briefcase className="h-4 w-4 text-[#6f2aa8]" />{profile.job_title || "Role-focused work item"}</div>
<div className="flex items-center gap-2"><Shield className="h-4 w-4 text-[#6f2aa8]" />{profile.team_role || "Team responsibility"}</div>
<div className="flex items-center gap-2"><Mail className="h-4 w-4 text-[#6f2aa8]" />{profile.email}</div>
<div className="flex items-center gap-2"><Phone className="h-4 w-4 text-[#6f2aa8]" />{profile.phone_number || "No phone number"}</div>
</div>
</CardContent>
</Card>
<Card className="border-[#d9bddb] bg-white shadow-none">
<CardContent className="p-5">
<h3 className="mb-3 text-base font-semibold text-grayScale-700">Employment</h3>
<div className="rounded-lg border border-[#dcc3df] bg-[#f4e8f4] p-3">
<p className="text-sm font-medium text-grayScale-700">{profile.department || "Department not set"}</p>
<p className="text-xs text-grayScale-500">Employment type: {profile.employment_type || "—"}</p>
</div>
</CardContent>
</Card>
<Card className="border-[#d9bddb] bg-white shadow-none">
<CardContent className="p-5">
<h3 className="mb-3 text-base font-semibold text-grayScale-700">More about me</h3>
<div className="flex flex-wrap gap-2">
<Badge className="bg-[#ecd9ec] text-[#6f2aa8]">Status {profile.status}</Badge>
<Badge className="bg-[#ecd9ec] text-[#6f2aa8]">Email {profile.email_verified ? "verified" : "not verified"}</Badge>
<Badge className="bg-[#ecd9ec] text-[#6f2aa8]">Joined {formatDate(profile.created_at)}</Badge>
<Badge className="bg-[#ecd9ec] text-[#6f2aa8]">Last login {formatDateTime(profile.last_login)}</Badge>
</div>
</CardContent>
</Card>
<Card className="border-[#d9bddb] bg-white shadow-none">
<CardContent className="grid gap-3 p-5 sm:grid-cols-2">
<div>
<p className="mb-1 text-xs font-medium text-grayScale-500">First Name</p>
<p className="text-sm font-medium text-grayScale-700">{profile.first_name}</p>
</div>
<div>
<p className="mb-1 text-xs font-medium text-grayScale-500">Last Name</p>
<p className="text-sm font-medium text-grayScale-700">{profile.last_name}</p>
</div>
<div>
<p className="mb-1 text-xs font-medium text-grayScale-500">Department</p>
<p className="text-sm font-medium text-grayScale-700">{profile.department || "—"}</p>
</div>
<div>
<p className="mb-1 text-xs font-medium text-grayScale-500">Team Role</p>
<p className="text-sm font-medium text-grayScale-700">{profile.team_role || "—"}</p>
</div>
<div>
<p className="mb-1 text-xs font-medium text-grayScale-500">Job Title</p>
<p className="text-sm font-medium text-grayScale-700">{profile.job_title || "—"}</p>
</div>
<div>
<p className="mb-1 text-xs font-medium text-grayScale-500">Hire Date</p>
<p className="text-sm font-medium text-grayScale-700">{formatDate(profile.hire_date) || "—"}</p>
</div>
<div>
<p className="mb-1 text-xs font-medium text-grayScale-500">Phone Number</p>
<p className="text-sm font-medium text-grayScale-700">{profile.phone_number || "—"}</p>
</div>
</CardContent>
</Card>
</div>
</main>
</div>
</div>
<Dialog open={editing} onOpenChange={(open) => !saving && setEditing(open)}>
<DialogContent className="max-h-[88vh] overflow-y-auto border-[#d9bddb] bg-[#fdf8fd] sm:max-w-3xl">
<DialogHeader>
<DialogTitle className="text-[#6f2aa8]">Edit profile</DialogTitle>
</DialogHeader>
{/* ─── Detail Cards Grid ─── */}
<div className="grid gap-6 lg:grid-cols-3">
{/* ── Contact & Personal ── */}
<Card className="overflow-hidden rounded-2xl border-grayScale-100 shadow-sm transition-shadow hover:shadow-md lg:col-span-2">
<div className="h-1 bg-gradient-to-r from-brand-500 to-brand-400" />
<CardContent className="p-0">
<div className="grid divide-y divide-grayScale-100 sm:grid-cols-2 sm:divide-x sm:divide-y-0">
{/* Contact */}
<div className="p-5">
<p className="mb-3 text-[11px] font-bold uppercase tracking-widest text-grayScale-400">
Contact
</p>
<div className="space-y-1">
<DetailItem
icon={Mail}
label="Email"
value={profile.email}
extra={<VerifiedIcon verified={profile.email_verified} />}
/>
<DetailItem
icon={Phone}
label="Phone"
value={profile.phone_number}
extra={<VerifiedIcon verified={profile.phone_verified} />}
/>
<DetailItem
icon={MapPin}
label="Location"
value={[profile.region, profile.country].filter(Boolean).join(", ") || "—"}
editing={editing}
editNode={
<div className="flex gap-2">
<Input
className="h-8 text-sm"
value={editForm.region ?? ""}
onChange={(e) => updateField("region", e.target.value)}
placeholder="Region"
/>
<Input
className="h-8 text-sm"
value={editForm.country ?? ""}
onChange={(e) => updateField("country", e.target.value)}
placeholder="Country"
/>
</div>
}
/>
<DetailItem
icon={Globe}
label="Preferred Language"
value={
{ en: "English", am: "Amharic", or: "Oromiffa", ti: "Tigrinya" }[
profile.preferred_language
] ?? profile.preferred_language ?? "—"
}
editing={editing}
editNode={
<Select
className="h-8 text-sm"
value={editForm.preferred_language ?? ""}
onChange={(e) => updateField("preferred_language", e.target.value)}
>
<option value="">Select</option>
<option value="en">English</option>
<option value="am">Amharic</option>
<option value="or">Oromiffa</option>
<option value="ti">Tigrinya</option>
</Select>
}
/>
</div>
</div>
{/* Personal */}
<div className="p-5">
<p className="mb-3 text-[11px] font-bold uppercase tracking-widest text-grayScale-400">
Personal
</p>
<div className="space-y-1">
<DetailItem
icon={Calendar}
label="Date of Birth"
value={formatDate(profile.birth_day)}
editing={editing}
editNode={
<Input
type="date"
className="h-8 text-sm"
value={editForm.birth_day ?? ""}
onChange={(e) => updateField("birth_day", e.target.value)}
/>
}
/>
<DetailItem
icon={User}
label="Gender"
value={profile.gender || "Not specified"}
editing={editing}
editNode={
<Select
className="h-8 text-sm"
value={editForm.gender ?? ""}
onChange={(e) => updateField("gender", e.target.value)}
>
<option value="">Select</option>
<option value="Male">Male</option>
<option value="Female">Female</option>
<option value="Other">Other</option>
</Select>
}
/>
<DetailItem
icon={User}
label="Age Group"
value={profile.age_group?.replace("_", "") || "—"}
editing={editing}
editNode={
<Select
className="h-8 text-sm"
value={editForm.age_group ?? ""}
onChange={(e) => updateField("age_group", e.target.value)}
>
<option value="">Select</option>
<option value="18_24">1824</option>
<option value="25_34">2534</option>
<option value="35_44">3544</option>
<option value="45_54">4554</option>
<option value="55_64">5564</option>
<option value="65+">65+</option>
</Select>
}
/>
<DetailItem
icon={Briefcase}
label="Occupation"
value={profile.occupation || "—"}
editing={editing}
editNode={
<Input
className="h-8 text-sm"
value={editForm.occupation ?? ""}
onChange={(e) => updateField("occupation", e.target.value)}
placeholder="Occupation"
/>
}
/>
<DetailItem
icon={BookOpen}
label="Education"
value={profile.education_level || "—"}
editing={editing}
editNode={
<Input
className="h-8 text-sm"
value={editForm.education_level ?? ""}
onChange={(e) => updateField("education_level", e.target.value)}
placeholder="Education level"
/>
}
/>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div>
<p className="mb-1 text-xs font-medium text-grayScale-500">First Name</p>
<Input value={editForm.first_name ?? ""} onChange={(e) => updateField("first_name", e.target.value)} />
</div>
<div>
<p className="mb-1 text-xs font-medium text-grayScale-500">Last Name</p>
<Input value={editForm.last_name ?? ""} onChange={(e) => updateField("last_name", e.target.value)} />
</div>
<div>
<p className="mb-1 text-xs font-medium text-grayScale-500">Phone Number</p>
<Input value={editForm.phone_number ?? ""} onChange={(e) => updateField("phone_number", e.target.value)} />
</div>
<div>
<p className="mb-1 text-xs font-medium text-grayScale-500">Profile Picture</p>
<div className="space-y-2">
<FileUpload
accept="image/*"
onFileSelect={setProfilePictureFile}
label="Upload profile picture"
description="JPEG, PNG, WEBP"
className="min-h-[90px] rounded-lg border-2 border-dashed border-grayScale-200 transition-colors hover:border-brand-400 hover:bg-brand-50/30"
/>
<Input
value={editForm.profile_picture_url ?? ""}
onChange={(e) => updateField("profile_picture_url", e.target.value)}
placeholder="Or paste image URL (https://...)"
/>
</div>
</div>
</CardContent>
</Card>
<div className="sm:col-span-2">
<p className="mb-1 text-xs font-medium text-grayScale-500">Bio</p>
<Textarea
value={editForm.bio ?? ""}
onChange={(e: ChangeEvent<HTMLTextAreaElement>) => updateField("bio", e.target.value)}
rows={4}
/>
</div>
</div>
{/* ── Right Sidebar ── */}
<div className="space-y-6 lg:sticky lg:top-24 lg:self-start">
{/* Profile Completion */}
<Card className="overflow-hidden rounded-2xl border-grayScale-100 shadow-sm transition-shadow hover:shadow-md">
<div className="h-1 bg-gradient-to-r from-brand-400 to-mint-400" />
<CardContent className="flex items-center gap-4 p-5">
<ProgressRing percent={completionPct} />
<div>
<p className="text-sm font-semibold text-grayScale-700">Profile Completion</p>
<p className="mt-0.5 text-xs text-grayScale-400">
{completionPct === 100 ? "All set!" : "Complete your profile for the best experience."}
</p>
</div>
</CardContent>
</Card>
{/* Activity */}
<Card className="overflow-hidden rounded-2xl border-grayScale-100 shadow-sm transition-shadow hover:shadow-md">
<div className="h-1 bg-gradient-to-r from-grayScale-300 to-grayScale-200" />
<CardContent className="space-y-4 p-5">
<p className="text-[11px] font-bold uppercase tracking-widest text-grayScale-400">
Activity
</p>
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-brand-50 text-brand-500">
<Clock className="h-4 w-4" />
</div>
<div>
<p className="text-xs font-medium text-grayScale-600">Last Login</p>
<p className="text-[11px] text-grayScale-400">{formatDateTime(profile.last_login)}</p>
</div>
</div>
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-grayScale-50 text-grayScale-400">
<User className="h-4 w-4" />
</div>
<div>
<p className="text-xs font-medium text-grayScale-600">Account Created</p>
<p className="text-[11px] text-grayScale-400">{formatDateTime(profile.created_at)}</p>
</div>
</div>
</CardContent>
</Card>
{/* Quick Account Info */}
<Card className="overflow-hidden rounded-2xl border-grayScale-100 shadow-sm transition-shadow hover:shadow-md">
<div className="h-1 bg-gradient-to-r from-brand-500 to-brand-600" />
<CardContent className="space-y-3 p-5">
<p className="text-[11px] font-bold uppercase tracking-widest text-grayScale-400">
Account
</p>
<div className="space-y-2.5">
<div className="flex items-center justify-between text-sm">
<span className="text-grayScale-400">Role</span>
<Badge
className={cn(
"text-[10px] font-semibold",
profile.role === "ADMIN"
? "bg-brand-500/10 text-brand-600 border border-brand-500/20"
: "bg-grayScale-50 text-grayScale-600 border border-grayScale-200",
)}
>
{profile.role}
</Badge>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-grayScale-400">Status</span>
<span
className={cn(
"inline-flex items-center gap-1.5 text-xs font-semibold",
profile.status === "ACTIVE" ? "text-mint-600" : "text-destructive",
)}
>
<span
className={cn(
"h-1.5 w-1.5 rounded-full",
profile.status === "ACTIVE" ? "bg-mint-500" : "bg-destructive",
)}
/>
{profile.status}
</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-grayScale-400">Email</span>
<span className="flex items-center gap-1">
<span className="max-w-[130px] truncate text-xs text-grayScale-600">
{profile.email}
</span>
<VerifiedIcon verified={profile.email_verified} />
</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-grayScale-400">Phone</span>
<span className="flex items-center gap-1">
<span className="max-w-[110px] truncate text-xs text-grayScale-600">
{profile.phone_number || "—"}
</span>
<VerifiedIcon verified={profile.phone_verified} />
</span>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
<DialogFooter className="mt-2">
<Button
type="button"
variant="outline"
className="border-[#d9bddb] text-[#6f2aa8]"
onClick={cancelEditing}
disabled={saving}
>
Cancel
</Button>
<Button
type="button"
className="bg-[#6f2aa8] text-white hover:bg-[#5e2390]"
onClick={handleSave}
disabled={saving}
>
{saving ? <SpinnerIcon className="mr-1 h-4 w-4" /> : null}
{saving ? "Saving..." : "Save changes"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@ -6,7 +6,6 @@ import {
Globe,
KeyRound,
Languages,
Loader2,
Lock,
Moon,
Palette,
@ -21,6 +20,7 @@ import { Button } from "../components/ui/button";
import { Select } from "../components/ui/select";
import { Separator } from "../components/ui/separator";
import { cn } from "../lib/utils";
import { SpinnerIcon } from "../components/ui/spinner-icon";
import { getMyProfile, updateProfile } from "../api/users.api";
import type { UserProfileData } from "../types/user.types";
import { toast } from "sonner";
@ -212,7 +212,7 @@ function ProfileTab({ profile }: { profile: UserProfileData }) {
<div className="flex justify-end">
<Button onClick={handleSave} disabled={saving} className="min-w-[140px]">
{saving ? (
<Loader2 className="h-4 w-4 animate-spin" />
<SpinnerIcon className="h-4 w-4" />
) : (
<Save className="h-4 w-4" />
)}
@ -298,7 +298,7 @@ function SecurityTab() {
<div className="flex justify-end">
<Button onClick={handleChangePassword} disabled={saving} className="min-w-[160px]">
{saving ? (
<Loader2 className="h-4 w-4 animate-spin" />
<SpinnerIcon className="h-4 w-4" />
) : (
<Lock className="h-4 w-4" />
)}

34
src/pages/TermsPage.tsx Normal file
View File

@ -0,0 +1,34 @@
import { InfoLayout } from "./legal/InfoLayout"
export function TermsPage() {
return (
<InfoLayout
title="Terms and Conditions"
subtitle="These terms define acceptable use of Yimaru Academy services, administration interfaces, and related learning operations."
>
<section className="space-y-3 rounded-xl border border-[#ece4f7] bg-gradient-to-br from-white to-[#fcf8ff] p-5 shadow-sm">
<h2 className="text-lg font-semibold text-grayScale-700">1. Acceptable Use</h2>
<p className="text-sm leading-relaxed text-grayScale-600">
The platform may only be used for legitimate education and administration workflows. Users
must not misuse access, attempt unauthorized data access, or disrupt service availability.
</p>
</section>
<section className="space-y-3 rounded-xl border border-[#ece4f7] bg-gradient-to-br from-white to-[#fcf8ff] p-5 shadow-sm">
<h2 className="text-lg font-semibold text-grayScale-700">2. Account Responsibility</h2>
<p className="text-sm leading-relaxed text-grayScale-600">
Account owners are responsible for protecting credentials and all actions performed through
their account. Shared accounts are discouraged unless explicitly authorized by policy.
</p>
</section>
<section className="space-y-3 rounded-xl border border-[#ece4f7] bg-gradient-to-br from-white to-[#fcf8ff] p-5 shadow-sm">
<h2 className="text-lg font-semibold text-grayScale-700">3. Service and Policy Updates</h2>
<p className="text-sm leading-relaxed text-grayScale-600">
Yimaru Academy may update features, controls, and policies to improve reliability and
security. Continued platform use indicates acceptance of updated terms.
</p>
</section>
</InfoLayout>
)
}

View File

@ -5,6 +5,7 @@ import { Eye, EyeOff } from "lucide-react";
import { BrandLogo } from "../../components/brand/BrandLogo";
import { Button } from "../../components/ui/button";
import { Input } from "../../components/ui/input";
import { SpinnerIcon } from "../../components/ui/spinner-icon";
import { login, loginWithGoogle } from "../../api/auth.api";
@ -282,25 +283,7 @@ export function LoginPage() {
className="group mb-6 flex w-full items-center justify-center gap-3 rounded-xl border border-grayScale-200 bg-white px-4 py-3 text-sm font-medium text-grayScale-600 transition-all duration-200 hover:border-grayScale-300 hover:bg-grayScale-100 hover:shadow-soft focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500/40 disabled:cursor-not-allowed disabled:opacity-60"
>
{googleLoading ? (
<svg
className="h-5 w-5 animate-spin text-grayScale-400"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
<SpinnerIcon className="h-5 w-5" />
) : (
<GoogleIcon className="h-5 w-5 transition-transform duration-200 group-hover:scale-110" />
)}

View File

@ -1,6 +1,6 @@
import { useEffect, useState } from "react"
import { useNavigate } from "react-router-dom"
import { Search, Plus, RefreshCw, Edit2, ToggleLeft, ToggleRight } from "lucide-react"
import { Search, Plus, RefreshCw, Edit2, ToggleLeft, ToggleRight, BookOpen, ChevronDown, ChevronLeft, ChevronRight } from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
import { Button } from "../../components/ui/button"
import { Input } from "../../components/ui/input"
@ -15,7 +15,8 @@ import {
} from "../../components/ui/table"
import { Badge } from "../../components/ui/badge"
import { FileUpload } from "../../components/ui/file-upload"
import { getCourseCategories, getCoursesByCategory, createCourse, updateCourseStatus, updateCourse } from "../../api/courses.api"
import { getCourseCategories, getCoursesByCategory, createCourse, updateCourseStatus, updateCourse, updateCourseThumbnail } from "../../api/courses.api"
import { uploadImageFile } from "../../api/files.api"
import type { Course, CourseCategory } from "../../types/course.types"
import {
Dialog,
@ -26,6 +27,8 @@ import {
} from "../../components/ui/dialog"
import { Textarea } from "../../components/ui/textarea"
import { toast } from "sonner"
import { cn } from "../../lib/utils"
import { SpinnerIcon } from "../../components/ui/spinner-icon"
type CourseWithCategory = Course & { category_name: string }
@ -53,6 +56,8 @@ export function AllCoursesPage() {
const [editTitle, setEditTitle] = useState("")
const [editDescription, setEditDescription] = useState("")
const [updating, setUpdating] = useState(false)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const fetchAllCourses = async () => {
setLoading(true)
@ -97,6 +102,26 @@ export function AllCoursesPage() {
}
return true
})
const totalCount = filteredCourses.length
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize))
const safePage = Math.min(page, totalPages)
const paginatedCourses = filteredCourses.slice((safePage - 1) * pageSize, safePage * pageSize)
const startEntry = totalCount === 0 ? 0 : (safePage - 1) * pageSize + 1
const endEntry = Math.min(safePage * pageSize, totalCount)
const getPageNumbers = () => {
const pages: (number | string)[] = []
if (totalPages <= 7) {
for (let i = 1; i <= totalPages; i++) pages.push(i)
} else {
pages.push(1, 2, 3)
if (safePage > 4) pages.push("...")
if (safePage > 3 && safePage < totalPages - 2) pages.push(safePage)
if (safePage < totalPages - 3) pages.push("...")
pages.push(totalPages)
}
return pages
}
const handleCreateCourse = async () => {
const effectiveCategoryId = createSubCategoryId || createCategoryId
@ -110,12 +135,27 @@ export function AllCoursesPage() {
setCreating(true)
try {
await createCourse({
const createdRes = await createCourse({
category_id: Number(effectiveCategoryId),
title: createTitle.trim(),
description: createDescription.trim(),
})
const createdIdRaw =
(createdRes.data?.data as any)?.id ??
(createdRes.data?.data as any)?.course?.id ??
(createdRes.data?.data as any)?.data?.id
const createdId = Number(createdIdRaw)
if (createThumbnail && Number.isFinite(createdId)) {
const uploadRes = await uploadImageFile(createThumbnail)
const thumbnailUrl = uploadRes.data?.data?.url?.trim()
if (thumbnailUrl) {
await updateCourseThumbnail(createdId, thumbnailUrl)
}
}
toast.success("Sub-category created", {
description: `"${createTitle.trim()}" has been created.`,
})
@ -191,7 +231,7 @@ export function AllCoursesPage() {
return (
<div className="flex flex-col items-center justify-center py-32">
<div className="rounded-2xl bg-white shadow-sm p-6">
<RefreshCw className="h-10 w-10 animate-spin text-brand-600" />
<SpinnerIcon className="h-10 w-10" />
</div>
<p className="mt-4 text-sm font-medium text-grayScale-400">Loading all sub-categories</p>
</div>
@ -244,14 +284,20 @@ export function AllCoursesPage() {
<Input
placeholder="Search by title, description, or category…"
value={search}
onChange={(e) => setSearch(e.target.value)}
onChange={(e) => {
setSearch(e.target.value)
setPage(1)
}}
className="pl-10 transition-colors focus:border-brand-300 focus:ring-brand-200"
/>
</div>
<div className="flex flex-wrap items-center gap-2">
<Select
value={categoryFilter}
onChange={(e) => setCategoryFilter(e.target.value as typeof categoryFilter)}
onChange={(e) => {
setCategoryFilter(e.target.value as typeof categoryFilter)
setPage(1)
}}
>
<option value="all">All Categories</option>
{categories.map((cat) => (
@ -269,31 +315,21 @@ export function AllCoursesPage() {
{/* Courses Table */}
{filteredCourses.length > 0 ? (
<div className="overflow-x-auto rounded-lg border border-grayScale-200">
<div className="rounded-xl border bg-white">
<Table>
<TableHeader>
<TableRow className="bg-grayScale-100 hover:bg-grayScale-100">
<TableHead className="py-3 text-xs font-semibold uppercase tracking-wider text-grayScale-500">
Course
</TableHead>
<TableHead className="py-3 text-xs font-semibold uppercase tracking-wider text-grayScale-500">
Category
</TableHead>
<TableHead className="hidden py-3 text-xs font-semibold uppercase tracking-wider text-grayScale-500 md:table-cell">
Status
</TableHead>
<TableHead className="py-3 text-right text-xs font-semibold uppercase tracking-wider text-grayScale-500">
Actions
</TableHead>
<TableRow>
<TableHead>Course</TableHead>
<TableHead>Category</TableHead>
<TableHead className="hidden md:table-cell">Status</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredCourses.map((course, index) => (
{paginatedCourses.map((course) => (
<TableRow
key={course.id}
className={`cursor-pointer transition-colors hover:bg-brand-100/30 ${
index % 2 === 0 ? "bg-white" : "bg-grayScale-100/40"
}`}
className="group cursor-pointer"
onClick={() =>
navigate(
`/content/category/${course.category_id}/courses/${course.id}/sub-courses`,
@ -369,6 +405,79 @@ export function AllCoursesPage() {
))}
</TableBody>
</Table>
<div className="flex flex-wrap items-center justify-between gap-3 border-t px-4 py-3 text-sm text-grayScale-500">
<div className="flex items-center gap-2">
<span>Showing</span>
<span className="font-medium text-grayScale-600">
{startEntry}-{endEntry}
</span>
<span>of</span>
<span className="font-medium text-grayScale-600">{totalCount}</span>
<span className="mr-4">entries</span>
<span className="border-l pl-4">Rows per page</span>
<div className="relative">
<select
value={pageSize}
onChange={(e) => {
setPageSize(Number(e.target.value))
setPage(1)
}}
className="h-8 appearance-none rounded-md border bg-white pl-2 pr-7 text-sm font-medium text-grayScale-600 focus:outline-none"
>
{[10, 20, 50].map((size) => (
<option key={size} value={size}>
{size}
</option>
))}
</select>
<ChevronDown className="pointer-events-none absolute right-2 top-1/2 h-3 w-3 -translate-y-1/2 text-grayScale-400" />
</div>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => safePage > 1 && setPage(safePage - 1)}
disabled={safePage === 1}
className={cn(
"flex h-8 w-8 items-center justify-center rounded-md border bg-white text-grayScale-500",
safePage === 1 && "cursor-not-allowed opacity-50",
)}
>
<ChevronLeft className="h-4 w-4" />
</button>
{getPageNumbers().map((n, idx) =>
typeof n === "string" ? (
<span key={`ellipsis-${idx}`} className="px-2 text-grayScale-400">
...
</span>
) : (
<button
key={n}
type="button"
onClick={() => setPage(n)}
className={cn(
"h-8 w-8 rounded-md border text-sm font-medium",
n === safePage
? "border-brand-500 bg-brand-500 text-white"
: "bg-white text-grayScale-600 hover:bg-grayScale-50",
)}
>
{n}
</button>
),
)}
<button
onClick={() => safePage < totalPages && setPage(safePage + 1)}
disabled={safePage === totalPages}
className={cn(
"flex h-8 w-8 items-center justify-center rounded-md border bg-white text-grayScale-500",
safePage === totalPages && "cursor-not-allowed opacity-50",
)}
>
<ChevronRight className="h-4 w-4" />
</button>
</div>
</div>
</div>
) : (
<div className="flex flex-col items-center justify-center rounded-lg border-2 border-dashed border-grayScale-200 py-20 text-center">

View File

@ -1,8 +1,9 @@
import { useEffect, useState } from "react"
import { Link, useParams } from "react-router-dom"
import { BookOpen, Mic, Briefcase, HelpCircle, ArrowLeft, ArrowRight, ChevronRight } from "lucide-react"
import { BookOpen, Mic, Briefcase, HelpCircle, ArrowLeft, ArrowRight, ChevronRight, Search } from "lucide-react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../../components/ui/card"
import { Button } from "../../components/ui/button"
import { Input } from "../../components/ui/input"
import { getCourseCategories } from "../../api/courses.api"
import type { CourseCategory } from "../../types/course.types"
@ -63,6 +64,7 @@ export function ContentOverviewPage() {
const { categoryId } = useParams<{ categoryId: string }>()
const [category, setCategory] = useState<CourseCategory | null>(null)
const [sections, setSections] = useState<ContentSection[]>(() => [...contentSections])
const [searchQuery, setSearchQuery] = useState("")
const [dragKey, setDragKey] = useState<string | null>(null)
const [flowSteps, setFlowSteps] = useState<
{
@ -158,6 +160,13 @@ export function ContentOverviewPage() {
setDragKey(null)
}
const filteredSections = sections.filter((section) => {
const q = searchQuery.trim().toLowerCase()
if (!q) return true
const haystack = `${section.title} ${section.description} ${section.action}`.toLowerCase()
return haystack.includes(q)
})
return (
<div className="space-y-8">
{/* Header & Breadcrumb */}
@ -185,6 +194,15 @@ export function ContentOverviewPage() {
{category?.name ?? "Content Management"}
</h1>
</div>
<div className="relative w-full max-w-md">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-300" />
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search overview sections..."
className="pl-9"
/>
</div>
{/* Gradient Divider */}
<div className="relative">
@ -203,7 +221,7 @@ export function ContentOverviewPage() {
{/* Cards Grid (course builder style draggable sections) */}
<div className="grid gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4">
{sections.map((section) => {
{filteredSections.map((section) => {
const Icon = section.icon
return (
<div
@ -274,6 +292,12 @@ export function ContentOverviewPage() {
)
})}
</div>
{sections.length > 0 && filteredSections.length === 0 && (
<div className="rounded-2xl border border-dashed border-grayScale-200 bg-grayScale-50/50 py-14 text-center">
<p className="text-sm font-semibold text-grayScale-500">No matching sections</p>
<p className="mt-1 text-xs text-grayScale-400">Try a different search term.</p>
</div>
)}
{/* Category flow sequence (if defined) */}
{flowSteps.length > 0 && (
<Card className="shadow-soft">

View File

@ -28,6 +28,7 @@ export function CourseCategoryPage() {
const [parentCategoryId, setParentCategoryId] = useState<number | null>(null)
const [newSubCategoryName, setNewSubCategoryName] = useState("")
const [pendingSubCategories, setPendingSubCategories] = useState<string[]>([])
const [searchQuery, setSearchQuery] = useState("")
const fetchCategories = async () => {
setLoading(true)
@ -47,6 +48,11 @@ export function CourseCategoryPage() {
fetchCategories()
}, [])
const normalizedQuery = searchQuery.trim().toLowerCase()
const filteredCategories = normalizedQuery
? categories.filter((c) => c.name?.toLowerCase().includes(normalizedQuery))
: categories
if (loading) {
return (
<div className="flex flex-col items-center justify-center gap-4 py-24">
@ -89,6 +95,14 @@ export function CourseCategoryPage() {
Browse and manage your course categories below
</p>
</div>
<div className="w-full max-w-sm">
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search categories..."
aria-label="Search categories"
/>
</div>
<Button
className="gap-2 bg-brand-500 text-white hover:bg-brand-600"
size="sm"
@ -116,9 +130,21 @@ export function CourseCategoryPage() {
</p>
</div>
</div>
) : filteredCategories.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-5 rounded-2xl border border-dashed border-grayScale-200 bg-grayScale-50/50 py-24">
<div className="grid h-20 w-20 place-items-center rounded-2xl bg-grayScale-50 shadow-sm">
<FolderOpen className="h-9 w-9 text-grayScale-300" />
</div>
<div className="text-center">
<p className="text-sm font-semibold text-grayScale-500">No matching categories</p>
<p className="mt-1 max-w-xs text-xs leading-relaxed text-grayScale-400">
Try a different search term.
</p>
</div>
</div>
) : (
<div className="grid gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{categories.map((category) => (
{filteredCategories.map((category) => (
<Link
key={category.id}
to={`/content/category/${category.id}/courses`}

View File

@ -4,7 +4,6 @@ import {
ChevronDown,
ChevronRight,
GripVertical,
Loader2,
RefreshCw,
Sparkles,
} from "lucide-react"
@ -53,6 +52,7 @@ import type {
} from "../../types/course.types"
import { cn } from "../../lib/utils"
import { toast } from "sonner"
import { SpinnerIcon } from "../../components/ui/spinner-icon"
type PracticeListItem = LearningPathPractice & { display_order: number }
@ -113,10 +113,10 @@ function SortableChip({
ref={setNodeRef}
style={{ transform: CSS.Transform.toString(transform), transition }}
className={cn(
"flex min-w-[180px] items-center gap-2 rounded-xl border px-3 py-2 shadow-sm",
"flex min-w-[180px] items-center gap-2 rounded-xl border bg-white px-3 py-2 shadow-sm",
active
? "border-brand-500 bg-brand-500/90 text-white"
: "border-grayScale-200 bg-white text-grayScale-700",
? "border-transparent bg-brand-500 shadow-[0_8px_20px_rgba(168,85,247,0.35)]"
: "border-grayScale-200",
isDragging && "opacity-60 ring-2 ring-brand-300",
className,
)}
@ -124,8 +124,10 @@ function SortableChip({
<button
type="button"
className={cn(
"grid h-6 w-6 shrink-0 place-items-center rounded-md text-grayScale-300 hover:bg-grayScale-100 hover:text-grayScale-500",
active && "text-white/80 hover:bg-white/10 hover:text-white",
"grid h-6 w-6 shrink-0 place-items-center rounded-md transition-colors",
active
? "text-white/80 hover:bg-white/15 hover:text-white"
: "text-grayScale-300 hover:bg-grayScale-100 hover:text-grayScale-500",
)}
{...attributes}
{...listeners}
@ -135,7 +137,10 @@ function SortableChip({
<button
type="button"
onClick={onClick}
className="min-w-0 flex-1 truncate text-left text-sm font-semibold"
className={cn(
"min-w-0 flex-1 truncate text-left text-sm font-semibold",
active ? "text-white" : "text-grayScale-700",
)}
>
{label}
</button>
@ -509,7 +514,7 @@ export function CourseFlowBuilderPage() {
if (loading) {
return (
<div className="flex flex-col items-center justify-center py-24">
<RefreshCw className="h-8 w-8 animate-spin text-brand-500" />
<SpinnerIcon className="h-8 w-8" />
<p className="mt-3 text-sm text-grayScale-400">Loading learning tree...</p>
</div>
)
@ -587,7 +592,7 @@ export function CourseFlowBuilderPage() {
</CardTitle>
{savingKey && (
<span className="inline-flex items-center gap-1 text-xs text-brand-500">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
<SpinnerIcon className="h-3.5 w-3.5" />
Saving...
</span>
)}
@ -623,7 +628,7 @@ export function CourseFlowBuilderPage() {
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-grayScale-400">Course sub-categories</p>
{loadingCourses ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-brand-500" />
<SpinnerIcon className="h-5 w-5" />
</div>
) : activeCourses.length === 0 ? (
<p className="rounded-lg border border-dashed border-grayScale-200 px-3 py-6 text-center text-xs text-grayScale-400">
@ -660,7 +665,7 @@ export function CourseFlowBuilderPage() {
<CardTitle className="text-base font-semibold text-grayScale-600">Learning path detail</CardTitle>
{savingKey && (
<span className="inline-flex items-center gap-1 text-xs text-brand-500">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
<SpinnerIcon className="h-3.5 w-3.5" />
Saving...
</span>
)}
@ -673,7 +678,7 @@ export function CourseFlowBuilderPage() {
</p>
) : loadingPath ? (
<div className="flex items-center justify-center py-10">
<Loader2 className="h-6 w-6 animate-spin text-brand-500" />
<SpinnerIcon className="h-6 w-6" />
</div>
) : !learningPath || learningPath.sub_courses.length === 0 ? (
<p className="rounded-lg border border-dashed border-grayScale-200 px-3 py-10 text-center text-xs text-grayScale-400">
@ -779,7 +784,7 @@ export function CourseFlowBuilderPage() {
</p>
{loadingPracticesBySubCourse[subCourse.id] ? (
<div className="flex items-center gap-2 py-6 text-xs text-grayScale-400">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
<SpinnerIcon className="h-3.5 w-3.5" />
Loading sets...
</div>
) : (

View File

@ -1,6 +1,6 @@
import { useEffect, useState } from "react"
import { useEffect, useMemo, useState } from "react"
import { Link, useParams, useNavigate } from "react-router-dom"
import { Plus, ArrowLeft, ToggleLeft, ToggleRight, X, Trash2, Edit, AlertCircle, Star, MessageSquare } from "lucide-react"
import { Plus, ArrowLeft, ToggleLeft, ToggleRight, X, Trash2, Edit, AlertCircle, Star, MessageSquare, ChevronDown, ChevronLeft, ChevronRight, Search } from "lucide-react"
import practiceSrc from "../../assets/Practice.svg"
import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg"
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
@ -8,6 +8,7 @@ import alertSrc from "../../assets/Alert.svg"
import { Button } from "../../components/ui/button"
import { Badge } from "../../components/ui/badge"
import { Input } from "../../components/ui/input"
import { FileUpload } from "../../components/ui/file-upload"
import {
Table,
TableBody,
@ -16,13 +17,26 @@ import {
TableHeader,
TableRow,
} from "../../components/ui/table"
import { getCoursesByCategory, getCourseCategories, createCourse, deleteCourse, updateCourseStatus, updateCourse, getRatings } from "../../api/courses.api"
import {
getCoursesByCategory,
getCourseCategories,
createCourse,
deleteCourse,
updateCourseStatus,
updateCourse,
updateCourseThumbnail,
getRatings,
} from "../../api/courses.api"
import { uploadImageFile } from "../../api/files.api"
import type { Course, CourseCategory, Rating } from "../../types/course.types"
import { cn } from "../../lib/utils"
import { SpinnerIcon } from "../../components/ui/spinner-icon"
export function CoursesPage() {
const { categoryId } = useParams<{ categoryId: string }>()
const navigate = useNavigate()
const [courses, setCourses] = useState<Course[]>([])
const [searchQuery, setSearchQuery] = useState("")
const [category, setCategory] = useState<CourseCategory | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
@ -41,12 +55,15 @@ export function CoursesPage() {
const [editTitle, setEditTitle] = useState("")
const [editDescription, setEditDescription] = useState("")
const [editThumbnail, setEditThumbnail] = useState("")
const [editThumbnailFile, setEditThumbnailFile] = useState<File | null>(null)
const [updating, setUpdating] = useState(false)
const [updateError, setUpdateError] = useState<string | null>(null)
const [showRatingsModal, setShowRatingsModal] = useState(false)
const [ratingsCourseId, setRatingsCourseId] = useState<number | null>(null)
const [courseRatings, setCourseRatings] = useState<Rating[]>([])
const [courseRatingsLoading, setCourseRatingsLoading] = useState(false)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const fetchCourses = async () => {
if (!categoryId) return
@ -86,6 +103,10 @@ export function CoursesPage() {
fetchData()
}, [categoryId])
useEffect(() => {
setPage(1)
}, [categoryId, searchQuery])
const handleOpenModal = () => {
setTitle("")
setDescription("")
@ -167,6 +188,7 @@ export function CoursesPage() {
setEditTitle(course.title || "")
setEditDescription(course.description || "")
setEditThumbnail(course.thumbnail || "")
setEditThumbnailFile(null)
setUpdateError(null)
setShowEditModal(true)
}
@ -177,6 +199,7 @@ export function CoursesPage() {
setEditTitle("")
setEditDescription("")
setEditThumbnail("")
setEditThumbnailFile(null)
setUpdateError(null)
}
@ -199,9 +222,18 @@ export function CoursesPage() {
await updateCourse(courseToEdit.id, {
title: editTitle.trim(),
description: editDescription.trim(),
thumbnail: editThumbnail.trim() || undefined,
is_active: courseToEdit.is_active,
})
const thumbnailUrl =
editThumbnailFile
? (await uploadImageFile(editThumbnailFile)).data?.data?.url?.trim()
: editThumbnail.trim() || ""
if (thumbnailUrl) {
await updateCourseThumbnail(courseToEdit.id, thumbnailUrl)
}
handleCloseEditModal()
await fetchCourses()
} catch (err: any) {
@ -230,14 +262,19 @@ export function CoursesPage() {
}
}
const filteredCourses = useMemo(() => {
const q = searchQuery.trim().toLowerCase()
if (!q) return courses
return courses.filter((course) => {
const haystack = `${course.title} ${course.description ?? ""} ${course.id}`.toLowerCase()
return haystack.includes(q)
})
}, [courses, searchQuery])
if (loading) {
return (
<div className="flex flex-col items-center justify-center py-32">
<img src={spinnerSrc} alt="" className="h-10 w-10 animate-spin" />
{/* <div className="rounded-2xl bg-white shadow-sm p-6">
<RefreshCw className="h-10 w-10 animate-spin text-brand-600" />
</div>
<p className="mt-4 text-sm font-medium text-grayScale-400">Loading courses...</p> */}
</div>
)
}
@ -253,6 +290,27 @@ export function CoursesPage() {
)
}
const totalCount = filteredCourses.length
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize))
const safePage = Math.min(page, totalPages)
const paginatedCourses = filteredCourses.slice((safePage - 1) * pageSize, safePage * pageSize)
const startEntry = totalCount === 0 ? 0 : (safePage - 1) * pageSize + 1
const endEntry = Math.min(safePage * pageSize, totalCount)
const getPageNumbers = () => {
const pages: (number | string)[] = []
if (totalPages <= 7) {
for (let i = 1; i <= totalPages; i++) pages.push(i)
} else {
pages.push(1, 2, 3)
if (safePage > 4) pages.push("...")
if (safePage > 3 && safePage < totalPages - 2) pages.push(safePage)
if (safePage < totalPages - 3) pages.push("...")
pages.push(totalPages)
}
return pages
}
return (
<div className="space-y-6">
{/* Header */}
@ -284,9 +342,20 @@ export function CoursesPage() {
{/* Course table or empty state */}
<Card className="shadow-soft">
<CardHeader className="border-b border-grayScale-200 pb-3">
<CardTitle className="text-base font-semibold text-grayScale-600">
Sub-category Management
</CardTitle>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<CardTitle className="text-base font-semibold text-grayScale-600">
Sub-category Management
</CardTitle>
<div className="relative w-full sm:max-w-xs">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-300" />
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search sub-categories..."
className="pl-9"
/>
</div>
</div>
</CardHeader>
<CardContent className="pt-4">
{courses.length === 0 ? (
@ -305,29 +374,28 @@ export function CoursesPage() {
Add your first sub-category
</Button>
</div>
) : filteredCourses.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-lg border-2 border-dashed border-grayScale-200 py-16 text-center">
<p className="text-base font-semibold text-grayScale-600">No matching sub-categories</p>
<p className="mt-1.5 text-sm text-grayScale-400">
Try a different search term.
</p>
</div>
) : (
<div className="overflow-x-auto rounded-lg border border-grayScale-200">
<div className="rounded-xl border bg-white">
<Table>
<TableHeader>
<TableRow className="bg-grayScale-100 hover:bg-grayScale-100">
<TableHead className="py-3 text-xs font-semibold uppercase tracking-wider text-grayScale-500">
Sub-category
</TableHead>
<TableHead className="hidden py-3 text-xs font-semibold uppercase tracking-wider text-grayScale-500 md:table-cell">
Status
</TableHead>
<TableHead className="py-3 text-right text-xs font-semibold uppercase tracking-wider text-grayScale-500">
Actions
</TableHead>
<TableRow>
<TableHead>Sub-category</TableHead>
<TableHead className="hidden md:table-cell">Status</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{courses.map((course, index) => (
{paginatedCourses.map((course) => (
<TableRow
key={course.id}
className={`cursor-pointer transition-colors hover:bg-brand-100/30 ${
index % 2 === 0 ? "bg-white" : "bg-grayScale-100/40"
}`}
className="group cursor-pointer"
onClick={() => handleCourseClick(course.id)}
>
<TableCell className="max-w-md py-3.5">
@ -405,6 +473,79 @@ export function CoursesPage() {
))}
</TableBody>
</Table>
<div className="flex flex-wrap items-center justify-between gap-3 border-t px-4 py-3 text-sm text-grayScale-500">
<div className="flex items-center gap-2">
<span>Showing</span>
<span className="font-medium text-grayScale-600">
{startEntry}-{endEntry}
</span>
<span>of</span>
<span className="font-medium text-grayScale-600">{totalCount}</span>
<span className="mr-4">entries</span>
<span className="border-l pl-4">Rows per page</span>
<div className="relative">
<select
value={pageSize}
onChange={(e) => {
setPageSize(Number(e.target.value))
setPage(1)
}}
className="h-8 appearance-none rounded-md border bg-white pl-2 pr-7 text-sm font-medium text-grayScale-600 focus:outline-none"
>
{[10, 20, 50].map((size) => (
<option key={size} value={size}>
{size}
</option>
))}
</select>
<ChevronDown className="pointer-events-none absolute right-2 top-1/2 h-3 w-3 -translate-y-1/2 text-grayScale-400" />
</div>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => safePage > 1 && setPage(safePage - 1)}
disabled={safePage === 1}
className={cn(
"flex h-8 w-8 items-center justify-center rounded-md border bg-white text-grayScale-500",
safePage === 1 && "cursor-not-allowed opacity-50",
)}
>
<ChevronLeft className="h-4 w-4" />
</button>
{getPageNumbers().map((n, idx) =>
typeof n === "string" ? (
<span key={`ellipsis-${idx}`} className="px-2 text-grayScale-400">
...
</span>
) : (
<button
key={n}
type="button"
onClick={() => setPage(n)}
className={cn(
"h-8 w-8 rounded-md border text-sm font-medium",
n === safePage
? "border-brand-500 bg-brand-500 text-white"
: "bg-white text-grayScale-600 hover:bg-grayScale-50",
)}
>
{n}
</button>
),
)}
<button
onClick={() => safePage < totalPages && setPage(safePage + 1)}
disabled={safePage === totalPages}
className={cn(
"flex h-8 w-8 items-center justify-center rounded-md border bg-white text-grayScale-500",
safePage === totalPages && "cursor-not-allowed opacity-50",
)}
>
<ChevronRight className="h-4 w-4" />
</button>
</div>
</div>
</div>
)}
</CardContent>
@ -544,14 +685,23 @@ export function CoursesPage() {
htmlFor="edit-course-thumbnail"
className="mb-2 block text-sm font-medium text-grayScale-600"
>
Thumbnail URL
Thumbnail
</label>
<Input
id="edit-course-thumbnail"
placeholder="Enter thumbnail URL (e.g., https://example.com/image.jpg)"
value={editThumbnail}
onChange={(e) => setEditThumbnail(e.target.value)}
/>
<div className="space-y-2">
<FileUpload
accept="image/*"
onFileSelect={(file) => setEditThumbnailFile(file)}
label="Upload thumbnail"
description="JPEG, PNG, WEBP"
className="min-h-[90px] rounded-lg border-2 border-dashed border-grayScale-300 transition-colors hover:border-brand-400 hover:bg-brand-50/30"
/>
<Input
id="edit-course-thumbnail"
placeholder="Or paste thumbnail URL (https://...)"
value={editThumbnail}
onChange={(e) => setEditThumbnail(e.target.value)}
/>
</div>
</div>
</div>
@ -592,7 +742,7 @@ export function CoursesPage() {
<div className="max-h-[70vh] overflow-y-auto px-6 py-6">
{courseRatingsLoading ? (
<div className="flex flex-col items-center justify-center py-16">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-brand-500 border-t-transparent" />
<SpinnerIcon className="h-8 w-8" />
<p className="mt-4 text-sm font-medium text-grayScale-500">Loading ratings</p>
</div>
) : courseRatings.length === 0 ? (

View File

@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from "react"
import { RefreshCw } from "lucide-react"
import { ChevronDown, ChevronLeft, ChevronRight, RefreshCw } from "lucide-react"
import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg"
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
import { Button } from "../../components/ui/button"
import { Input } from "../../components/ui/input"
@ -16,6 +17,8 @@ import { Badge } from "../../components/ui/badge"
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "../../components/ui/dialog"
import { getQuestionSetById, getQuestionSets } from "../../api/courses.api"
import type { QuestionSet, QuestionSetDetail } from "../../types/course.types"
import { cn } from "../../lib/utils"
import { SpinnerIcon } from "../../components/ui/spinner-icon"
const statusColor: Record<string, string> = {
PUBLISHED: "bg-green-100 text-green-700",
@ -33,6 +36,8 @@ export function PracticeDetailsPage() {
const [searchQuery, setSearchQuery] = useState("")
const [statusFilter, setStatusFilter] = useState("all")
const [ownerTypeFilter, setOwnerTypeFilter] = useState("all")
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const fetchPractices = useCallback(async () => {
setLoadingList(true)
@ -89,6 +94,10 @@ export function PracticeDetailsPage() {
}
}, [selectedPracticeId, fetchPracticeDetail])
useEffect(() => {
setPage(1)
}, [searchQuery, statusFilter, ownerTypeFilter])
const filteredPractices = useMemo(() => {
return practices.filter((practice) => {
const matchesSearch =
@ -104,6 +113,28 @@ export function PracticeDetailsPage() {
}, [practices, searchQuery, statusFilter, ownerTypeFilter])
const totalCount = useMemo(() => filteredPractices.length, [filteredPractices])
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize))
const safePage = Math.min(page, totalPages)
const paginatedPractices = useMemo(() => {
const start = (safePage - 1) * pageSize
return filteredPractices.slice(start, start + pageSize)
}, [filteredPractices, safePage, pageSize])
const startEntry = totalCount === 0 ? 0 : (safePage - 1) * pageSize + 1
const endEntry = Math.min(safePage * pageSize, totalCount)
const getPageNumbers = () => {
const pages: (number | string)[] = []
if (totalPages <= 7) {
for (let i = 1; i <= totalPages; i++) pages.push(i)
} else {
pages.push(1, 2, 3)
if (safePage > 4) pages.push("...")
if (safePage > 3 && safePage < totalPages - 2) pages.push(safePage)
if (safePage < totalPages - 3) pages.push("...")
pages.push(totalPages)
}
return pages
}
return (
<div className="space-y-6">
@ -115,7 +146,7 @@ export function PracticeDetailsPage() {
</p>
</div>
<Button variant="outline" onClick={fetchPractices} disabled={loadingList}>
<RefreshCw className={`h-4 w-4 ${loadingList ? "animate-spin" : ""}`} />
{loadingList ? <SpinnerIcon className="h-4 w-4" /> : <RefreshCw className="h-4 w-4" />}
Refresh
</Button>
</div>
@ -153,6 +184,7 @@ export function PracticeDetailsPage() {
setSearchQuery("")
setStatusFilter("all")
setOwnerTypeFilter("all")
setPage(1)
}}
>
Clear
@ -160,38 +192,44 @@ export function PracticeDetailsPage() {
</div>
</div>
{loadingList ? (
<div className="py-16 text-center text-sm text-grayScale-500">Loading practices...</div>
) : filteredPractices.length === 0 ? (
<div className="rounded-lg border-2 border-dashed border-grayScale-200 py-16 text-center text-sm text-grayScale-500">
No practice sets found.
</div>
) : (
<div className="overflow-x-auto rounded-lg border border-grayScale-200">
<Table>
<TableHeader>
<TableRow className="bg-grayScale-100 hover:bg-grayScale-100">
<TableHead className="py-3 text-xs font-semibold uppercase tracking-wider text-grayScale-500">Title</TableHead>
<TableHead className="hidden py-3 text-xs font-semibold uppercase tracking-wider text-grayScale-500 md:table-cell">Owner</TableHead>
<TableHead className="py-3 text-xs font-semibold uppercase tracking-wider text-grayScale-500">Status</TableHead>
<TableHead className="hidden py-3 text-xs font-semibold uppercase tracking-wider text-grayScale-500 md:table-cell">Created</TableHead>
<div className="rounded-xl border bg-white">
<Table>
<TableHeader>
<TableRow>
<TableHead>Title</TableHead>
<TableHead className="hidden md:table-cell">Owner</TableHead>
<TableHead>Status</TableHead>
<TableHead className="hidden md:table-cell">Created</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loadingList ? (
<TableRow>
<TableCell colSpan={4} className="py-12 text-center">
<div className="flex flex-col items-center gap-3">
<img src={spinnerSrc} alt="" className="h-6 w-6 animate-spin" />
<span className="text-sm text-grayScale-400">Loading practices...</span>
</div>
</TableCell>
</TableRow>
</TableHeader>
<TableBody>
{filteredPractices.map((practice, index) => (
) : filteredPractices.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="py-12 text-center text-sm text-grayScale-500">
No practice sets found.
</TableCell>
</TableRow>
) : (
paginatedPractices.map((practice) => (
<TableRow
key={practice.id}
onClick={() => {
setSelectedPracticeId(practice.id)
setDetailOpen(true)
}}
className={`cursor-pointer transition-colors hover:bg-brand-100/30 ${
selectedPracticeId === practice.id
? "bg-brand-100/40"
: index % 2 === 0
? "bg-white"
: "bg-grayScale-100/50"
}`}
className={cn(
"group cursor-pointer",
selectedPracticeId === practice.id && "bg-brand-100/30",
)}
>
<TableCell className="max-w-md py-3.5">
<p className="truncate text-sm font-medium text-grayScale-700">{practice.title}</p>
@ -209,11 +247,84 @@ export function PracticeDetailsPage() {
{practice.created_at}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
))
)}
</TableBody>
</Table>
<div className="flex flex-wrap items-center justify-between gap-3 border-t px-4 py-3 text-sm text-grayScale-500">
<div className="flex items-center gap-2">
<span>Showing</span>
<span className="font-medium text-grayScale-600">
{startEntry}-{endEntry}
</span>
<span>of</span>
<span className="font-medium text-grayScale-600">{totalCount}</span>
<span className="mr-4">entries</span>
<span className="border-l pl-4">Rows per page</span>
<div className="relative">
<select
value={pageSize}
onChange={(e) => {
setPageSize(Number(e.target.value))
setPage(1)
}}
className="h-8 appearance-none rounded-md border bg-white pl-2 pr-7 text-sm font-medium text-grayScale-600 focus:outline-none"
>
{[10, 20, 50].map((size) => (
<option key={size} value={size}>
{size}
</option>
))}
</select>
<ChevronDown className="pointer-events-none absolute right-2 top-1/2 h-3 w-3 -translate-y-1/2 text-grayScale-400" />
</div>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => safePage > 1 && setPage(safePage - 1)}
disabled={safePage === 1}
className={cn(
"flex h-8 w-8 items-center justify-center rounded-md border bg-white text-grayScale-500",
safePage === 1 && "cursor-not-allowed opacity-50",
)}
>
<ChevronLeft className="h-4 w-4" />
</button>
{getPageNumbers().map((n, idx) =>
typeof n === "string" ? (
<span key={`ellipsis-${idx}`} className="px-2 text-grayScale-400">
...
</span>
) : (
<button
key={n}
type="button"
onClick={() => setPage(n)}
className={cn(
"h-8 w-8 rounded-md border text-sm font-medium",
n === safePage
? "border-brand-500 bg-brand-500 text-white"
: "bg-white text-grayScale-600 hover:bg-grayScale-50",
)}
>
{n}
</button>
),
)}
<button
onClick={() => safePage < totalPages && setPage(safePage + 1)}
disabled={safePage === totalPages}
className={cn(
"flex h-8 w-8 items-center justify-center rounded-md border bg-white text-grayScale-500",
safePage === totalPages && "cursor-not-allowed opacity-50",
)}
>
<ChevronRight className="h-4 w-4" />
</button>
</div>
</div>
)}
</div>
</CardContent>
</Card>

View File

@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from "react"
import { Link } from "react-router-dom"
import { Plus, Search, Edit, Trash2, HelpCircle, X } from "lucide-react"
import { Plus, Search, Edit, Trash2, HelpCircle, X, ChevronDown, ChevronLeft, ChevronRight } from "lucide-react"
import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg"
import { Button } from "../../components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
import { Input } from "../../components/ui/input"
@ -17,6 +18,7 @@ import {
import { Badge } from "../../components/ui/badge"
import { deleteQuestion, getQuestionById, getQuestions, updateQuestion } from "../../api/courses.api"
import type { QuestionDetail } from "../../types/course.types"
import { cn } from "../../lib/utils"
type QuestionTypeFilter = "all" | "MCQ" | "TRUE_FALSE" | "SHORT_ANSWER" | "AUDIO"
type DifficultyFilter = "all" | "EASY" | "MEDIUM" | "HARD"
@ -300,8 +302,23 @@ export function QuestionsPage() {
const totalCount = filteredQuestions.length
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize))
const canGoPrev = page > 1
const canGoNext = page < totalPages
const safePage = Math.min(page, totalPages)
const startEntry = totalCount === 0 ? 0 : (safePage - 1) * pageSize + 1
const endEntry = Math.min(safePage * pageSize, totalCount)
const getPageNumbers = () => {
const pages: (number | string)[] = []
if (totalPages <= 7) {
for (let i = 1; i <= totalPages; i++) pages.push(i)
} else {
pages.push(1, 2, 3)
if (safePage > 4) pages.push("...")
if (safePage > 3 && safePage < totalPages - 2) pages.push(safePage)
if (safePage < totalPages - 3) pages.push("...")
pages.push(totalPages)
}
return pages
}
return (
<div className="space-y-8">
@ -388,18 +405,6 @@ export function QuestionsPage() {
<option value="PUBLISHED">Published</option>
<option value="INACTIVE">Inactive</option>
</Select>
<Select
value={String(pageSize)}
onChange={(e) => {
const next = Number(e.target.value)
setPageSize(next)
setPage(1)
}}
>
<option value="10">10 / page</option>
<option value="20">20 / page</option>
<option value="50">50 / page</option>
</Select>
</div>
</div>
@ -408,52 +413,54 @@ export function QuestionsPage() {
Showing {paginatedQuestions.length} of {totalCount} questions
</div>
{/* Questions Table */}
{loading ? (
<div className="flex items-center justify-center rounded-lg border border-grayScale-200 py-16">
<p className="text-sm text-grayScale-500">Loading questions...</p>
</div>
) : filteredQuestions.length > 0 ? (
<div className="overflow-x-auto rounded-lg border border-grayScale-200">
<Table>
<TableHeader>
<TableRow className="bg-grayScale-100 hover:bg-grayScale-100">
<TableHead className="w-10 py-3 text-xs font-semibold uppercase tracking-wider text-grayScale-500">
<input
type="checkbox"
checked={isAllCurrentPageSelected}
onChange={toggleSelectAllCurrentPage}
aria-label="Select all questions on current page"
/>
</TableHead>
<TableHead className="py-3 text-xs font-semibold uppercase tracking-wider text-grayScale-500">
Question
</TableHead>
<TableHead className="py-3 text-xs font-semibold uppercase tracking-wider text-grayScale-500">
Type
</TableHead>
<TableHead className="hidden py-3 text-xs font-semibold uppercase tracking-wider text-grayScale-500 md:table-cell">
Difficulty
</TableHead>
<TableHead className="hidden py-3 text-xs font-semibold uppercase tracking-wider text-grayScale-500 md:table-cell">
Status
</TableHead>
<TableHead className="py-3 text-xs font-semibold uppercase tracking-wider text-grayScale-500">
Points
</TableHead>
<TableHead className="py-3 text-right text-xs font-semibold uppercase tracking-wider text-grayScale-500">
Actions
</TableHead>
<div className="rounded-xl border bg-white">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-10">
<input
type="checkbox"
checked={isAllCurrentPageSelected}
onChange={toggleSelectAllCurrentPage}
aria-label="Select all questions on current page"
/>
</TableHead>
<TableHead>Question</TableHead>
<TableHead>Type</TableHead>
<TableHead className="hidden md:table-cell">Difficulty</TableHead>
<TableHead className="hidden md:table-cell">Status</TableHead>
<TableHead>Points</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={7} className="py-12 text-center">
<div className="flex flex-col items-center gap-3">
<img src={spinnerSrc} alt="" className="h-6 w-6 animate-spin" />
<span className="text-sm text-grayScale-400">Loading questions...</span>
</div>
</TableCell>
</TableRow>
</TableHeader>
<TableBody>
{paginatedQuestions.map((question, index) => (
) : filteredQuestions.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="py-12 text-center">
<div className="flex flex-col items-center gap-3">
<HelpCircle className="h-8 w-8 text-grayScale-200" />
<div>
<p className="text-sm font-medium text-grayScale-500">No questions found</p>
<p className="mt-1 text-xs text-grayScale-400">Try adjusting your filters</p>
</div>
</div>
</TableCell>
</TableRow>
) : (
paginatedQuestions.map((question) => (
<TableRow
key={question.id}
onClick={() => openDetails(question.id)}
className={`cursor-pointer transition-colors hover:bg-brand-100/30 ${
index % 2 === 0 ? "bg-white" : "bg-grayScale-100/50"
}`}
className="group cursor-pointer"
>
<TableCell className="py-3.5">
<input
@ -527,46 +534,83 @@ export function QuestionsPage() {
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
) : (
<div className="flex flex-col items-center justify-center rounded-lg border-2 border-dashed border-grayScale-200 py-20 text-center">
<div className="mb-4 grid h-16 w-16 place-items-center rounded-full bg-gradient-to-br from-grayScale-100 to-grayScale-200">
<HelpCircle className="h-8 w-8 text-grayScale-400" />
</div>
<p className="text-base font-semibold text-grayScale-600">
No questions found
</p>
<p className="mt-1.5 max-w-sm text-sm leading-relaxed text-grayScale-400">
Try adjusting your search or filter criteria to find what you're
looking for.
</p>
</div>
)}
))
)}
</TableBody>
</Table>
<div className="flex flex-col gap-3 border-t border-grayScale-200 pt-4 sm:flex-row sm:items-center sm:justify-between">
<p className="text-xs text-grayScale-500">
Page {page} of {totalPages}
</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
disabled={!canGoPrev}
onClick={() => setPage((prev) => Math.max(1, prev - 1))}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
disabled={!canGoNext}
onClick={() => setPage((prev) => Math.min(totalPages, prev + 1))}
>
Next
</Button>
<div className="flex flex-wrap items-center justify-between gap-3 border-t px-4 py-3 text-sm text-grayScale-500">
<div className="flex items-center gap-2">
<span>Showing</span>
<span className="font-medium text-grayScale-600">
{startEntry}-{endEntry}
</span>
<span>of</span>
<span className="font-medium text-grayScale-600">{totalCount}</span>
<span className="mr-4">entries</span>
<span className="border-l pl-4">Rows per page</span>
<div className="relative">
<select
value={pageSize}
onChange={(e) => {
setPageSize(Number(e.target.value))
setPage(1)
}}
className="h-8 appearance-none rounded-md border bg-white pl-2 pr-7 text-sm font-medium text-grayScale-600 focus:outline-none"
>
{[10, 20, 50].map((size) => (
<option key={size} value={size}>
{size}
</option>
))}
</select>
<ChevronDown className="pointer-events-none absolute right-2 top-1/2 h-3 w-3 -translate-y-1/2 text-grayScale-400" />
</div>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => safePage > 1 && setPage(safePage - 1)}
disabled={safePage === 1}
className={cn(
"flex h-8 w-8 items-center justify-center rounded-md border bg-white text-grayScale-500",
safePage === 1 && "cursor-not-allowed opacity-50",
)}
>
<ChevronLeft className="h-4 w-4" />
</button>
{getPageNumbers().map((n, idx) =>
typeof n === "string" ? (
<span key={`ellipsis-${idx}`} className="px-2 text-grayScale-400">
...
</span>
) : (
<button
key={n}
type="button"
onClick={() => setPage(n)}
className={cn(
"h-8 w-8 rounded-md border text-sm font-medium",
n === safePage
? "border-brand-500 bg-brand-500 text-white"
: "bg-white text-grayScale-600 hover:bg-grayScale-50",
)}
>
{n}
</button>
),
)}
<button
onClick={() => safePage < totalPages && setPage(safePage + 1)}
disabled={safePage === totalPages}
className={cn(
"flex h-8 w-8 items-center justify-center rounded-md border bg-white text-grayScale-500",
safePage === totalPages && "cursor-not-allowed opacity-50",
)}
>
<ChevronRight className="h-4 w-4" />
</button>
</div>
</div>
</div>
</CardContent>
@ -629,11 +673,74 @@ export function QuestionsPage() {
<p className="mt-1 text-sm text-grayScale-700">{detailData.question_text}</p>
</div>
<div className="grid grid-cols-2 gap-3 text-sm">
<p><span className="font-medium">ID:</span> {detailData.id}</p>
<p><span className="font-medium">Type:</span> {typeLabels[detailData.question_type] || detailData.question_type}</p>
<p><span className="font-medium">Difficulty:</span> {detailData.difficulty_level || "—"}</p>
<p><span className="font-medium">Points:</span> {detailData.points ?? 0}</p>
<p><span className="font-medium">Status:</span> {detailData.status || "—"}</p>
<p><span className="font-medium">Created:</span> {detailData.created_at || "—"}</p>
</div>
{detailData.explanation ? (
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-grayScale-500">Explanation</p>
<p className="mt-1 text-sm text-grayScale-700">{detailData.explanation}</p>
</div>
) : null}
{detailData.tips ? (
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-grayScale-500">Tips</p>
<p className="mt-1 text-sm text-grayScale-700">{detailData.tips}</p>
</div>
) : null}
{detailData.audio_correct_answer_text ? (
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-grayScale-500">Audio Correct Answer Text</p>
<p className="mt-1 text-sm text-grayScale-700">{detailData.audio_correct_answer_text}</p>
</div>
) : null}
{detailData.voice_prompt ? (
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-grayScale-500">Voice Prompt</p>
<p className="mt-1 break-all text-xs text-grayScale-500">{detailData.voice_prompt}</p>
<audio controls src={detailData.voice_prompt} className="mt-2 h-10 w-full max-w-md" />
</div>
) : null}
{detailData.sample_answer_voice_prompt ? (
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-grayScale-500">Sample Answer Voice Prompt</p>
<p className="mt-1 break-all text-xs text-grayScale-500">{detailData.sample_answer_voice_prompt}</p>
<audio controls src={detailData.sample_answer_voice_prompt} className="mt-2 h-10 w-full max-w-md" />
</div>
) : null}
{detailData.image_url ? (
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-grayScale-500">Image</p>
<p className="mt-1 break-all text-xs text-grayScale-500">{detailData.image_url}</p>
<img
src={detailData.image_url}
alt="Question reference"
className="mt-2 h-28 w-28 rounded-md border border-grayScale-200 object-cover"
/>
</div>
) : null}
{Array.isArray(detailData.short_answers) && detailData.short_answers.length > 0 ? (
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-grayScale-500">Short Answers</p>
<div className="mt-2 space-y-1 text-sm text-grayScale-700">
{detailData.short_answers.map((answer, index) => {
const value =
typeof answer === "string"
? answer
: (answer as { acceptable_answer?: string }).acceptable_answer || ""
return (
<p key={`${value}-${index}`} className="rounded-md border border-grayScale-200 bg-grayScale-50 px-2 py-1">
{value || "—"}
</p>
)
})}
</div>
</div>
) : null}
{(detailData.options ?? []).length > 0 && (
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-grayScale-500">Options</p>

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
import { useEffect, useState } from "react"
import { Link, useParams, useNavigate } from "react-router-dom"
import { ArrowLeft, Plus, FileText, Layers, Edit, Trash2, X, Video, MoreVertical, Star, ChevronLeft, ChevronRight, MessageSquare, Play, Loader2 } from "lucide-react"
import { ArrowLeft, Plus, FileText, Layers, Edit, Trash2, X, Video, MoreVertical, Star, ChevronLeft, ChevronRight, MessageSquare, Play } from "lucide-react"
import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg"
import { Card } from "../../components/ui/card"
import alertSrc from "../../assets/Alert.svg"
@ -13,14 +13,23 @@ import {
getVideosBySubCourse,
updatePractice,
deleteQuestionSet,
createVimeoVideo,
createCourseVideo,
updateSubCourseVideo,
deleteSubCourseVideo,
getRatings,
getVimeoSample,
} from "../../api/courses.api"
import type { SubCourse, QuestionSet, SubCourseVideo, Rating, VimeoSampleVideo } from "../../types/course.types"
import { Select } from "../../components/ui/select"
import { uploadVideoFile } from "../../api/files.api"
import type {
SubCourse,
QuestionSet,
SubCourseVideo,
Rating,
VimeoSampleVideo,
VideoStatus,
VideoVisibility,
} from "../../types/course.types"
import { SpinnerIcon } from "../../components/ui/spinner-icon"
type TabType = "video" | "practice" | "ratings"
type StatusFilter = "all" | "published" | "draft" | "archived"
@ -74,17 +83,19 @@ export function SubCourseContentPage() {
const [videoTitle, setVideoTitle] = useState("")
const [videoDescription, setVideoDescription] = useState("")
const [videoUrl, setVideoUrl] = useState("")
const [videoFile, setVideoFile] = useState<File | null>(null)
const [videoFileSize, setVideoFileSize] = useState<number>(0)
const [videoDuration, setVideoDuration] = useState<number>(0)
const [videoResolution, setVideoResolution] = useState("1080p")
const [videoVisibility, setVideoVisibility] = useState<VideoVisibility>("PUBLISHED")
const [videoStatus, setVideoStatus] = useState<VideoStatus>("PUBLISHED")
const [videoDisplayOrder, setVideoDisplayOrder] = useState<number>(1)
// Vimeo preview state
const [showPreviewModal, setShowPreviewModal] = useState(false)
const [previewIframe, setPreviewIframe] = useState("")
const [previewVideo, setPreviewVideo] = useState<VimeoSampleVideo | null>(null)
const [previewLoading, setPreviewLoading] = useState(false)
const [sampleVideoId, setSampleVideoId] = useState("")
const [modalPreviewIframe, setModalPreviewIframe] = useState("")
const [modalPreviewLoading, setModalPreviewLoading] = useState(false)
useEffect(() => {
const fetchData = async () => {
@ -234,31 +245,83 @@ export function SubCourseContentPage() {
setVideoTitle("")
setVideoDescription("")
setVideoUrl("")
setVideoFile(null)
setVideoFileSize(0)
setVideoDuration(0)
setVideoResolution("1080p")
setVideoVisibility("PUBLISHED")
setVideoStatus("PUBLISHED")
setVideoDisplayOrder(1)
setSaveError(null)
setShowAddVideoModal(true)
}
const handleVideoFileSelect = (file: File | null) => {
setVideoFile(file)
if (!file) {
setVideoFileSize(0)
setVideoDuration(0)
return
}
setVideoFileSize(file.size)
const video = document.createElement("video")
const objectUrl = URL.createObjectURL(file)
video.preload = "metadata"
video.src = objectUrl
video.onloadedmetadata = () => {
setVideoDuration(Math.max(0, Math.round(video.duration || 0)))
URL.revokeObjectURL(objectUrl)
}
video.onerror = () => {
URL.revokeObjectURL(objectUrl)
}
}
const handleSaveNewVideo = async () => {
if (!subCourseId) return
if (!subCourseId || !videoFile) return
setSaving(true)
setSaveError(null)
try {
await createVimeoVideo({
const uploadRes = await uploadVideoFile(videoFile, {
title: videoTitle.trim(),
description: videoDescription.trim(),
})
// Per backend guide, use embed_url as the video_url reference.
const embedUrl = uploadRes.data?.data?.embed_url?.trim()
const vimeoUrl = uploadRes.data?.data?.url?.trim()
if (!embedUrl) throw new Error("Missing uploaded video embed_url")
// Backend requires: https://player.vimeo.com/video/<id>?h=<hash>
// where <hash> is the last path segment from `url` (e.g. https://vimeo.com/<id>/<hash>)
const hashFromUrl = vimeoUrl ? vimeoUrl.split("/").filter(Boolean).at(-1) : undefined
const finalVideoUrl = hashFromUrl ? `${embedUrl}?h=${hashFromUrl}` : embedUrl
const finalTitle = videoTitle.trim() || videoFile.name
await createCourseVideo({
sub_course_id: Number(subCourseId),
title: videoTitle,
description: videoDescription,
source_url: videoUrl,
file_size: videoFileSize,
title: finalTitle,
description: videoDescription.trim(),
video_url: finalVideoUrl,
duration: videoDuration,
resolution: videoResolution.trim() || undefined,
visibility: videoVisibility,
display_order: Number.isFinite(videoDisplayOrder) ? videoDisplayOrder : undefined,
status: videoStatus,
})
setShowAddVideoModal(false)
setVideoTitle("")
setVideoDescription("")
setVideoUrl("")
setVideoFile(null)
setVideoFileSize(0)
setVideoDuration(0)
setVideoResolution("1080p")
setVideoVisibility("PUBLISHED")
setVideoStatus("PUBLISHED")
setVideoDisplayOrder(1)
await fetchVideos()
} catch (err) {
console.error("Failed to create video:", err)
@ -321,15 +384,26 @@ export function SubCourseContentPage() {
}
}
// Preview a video card via Vimeo sample API
// Preview a video card.
// We prefer embedding directly from `video_url` because Vimeo embeds may require the `h=` hash.
const handlePreviewVideo = async (video: SubCourseVideo) => {
const idMatch = video.video_url?.match(/(\d{5,})/)
const vimeoId = idMatch?.[1] ?? "76979871" // fallback to Big Buck Bunny
setShowPreviewModal(true)
setPreviewLoading(true)
setPreviewIframe("")
setPreviewVideo(null)
try {
const directUrl = video.video_url?.trim()
if (directUrl) {
setPreviewIframe(
`<iframe src="${directUrl}" style="width:100%;height:100%;" frameborder="0" allow="autoplay; fullscreen; picture-in-picture" allowfullscreen></iframe>`,
)
setPreviewVideo(null)
return
}
// Fallback to sample API when a direct URL is unavailable.
const idMatch = video.video_url?.match(/(\d{5,})/)
const vimeoId = idMatch?.[1] ?? "76979871" // fallback to Big Buck Bunny
const res = await getVimeoSample(vimeoId)
setPreviewIframe(res.data.data.iframe)
setPreviewVideo(res.data.data.video)
@ -340,28 +414,6 @@ export function SubCourseContentPage() {
}
}
// Preview inside add/edit modal from a sample vimeo ID picker
const handleModalPreview = async (vimeoId: string) => {
if (!vimeoId) {
setModalPreviewIframe("")
return
}
setModalPreviewLoading(true)
try {
const res = await getVimeoSample(vimeoId)
setModalPreviewIframe(res.data.data.iframe)
// Auto-fill fields from vimeo metadata
const v = res.data.data.video
if (!videoTitle) setVideoTitle(v.name)
if (!videoDescription) setVideoDescription(v.description?.slice(0, 200) ?? "")
if (!videoDuration) setVideoDuration(v.duration)
} catch {
setModalPreviewIframe("")
} finally {
setModalPreviewLoading(false)
}
}
const filteredPractices = practices.filter((practice) => {
if (statusFilter === "all") return true
if (statusFilter === "published") return practice.status === "PUBLISHED"
@ -482,7 +534,7 @@ export function SubCourseContentPage() {
<>
{practicesLoading ? (
<div className="flex flex-col items-center justify-center py-20">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-brand-500 border-t-transparent" />
<SpinnerIcon className="h-8 w-8" />
<p className="mt-4 text-sm font-medium text-grayScale-500">Loading practices</p>
</div>
) : filteredPractices.length === 0 ? (
@ -525,7 +577,7 @@ export function SubCourseContentPage() {
<p className="text-sm leading-relaxed text-grayScale-500 line-clamp-2">{practice.description}</p>
<div className="flex items-center gap-2 flex-wrap">
<Badge className="rounded-full bg-brand-50 text-brand-600 text-[11px] font-medium px-2.5 py-0.5 ring-1 ring-inset ring-brand-200">
<Badge className="rounded-full bg-[#f3e8ff] text-[#6b21a8] text-[11px] font-medium px-2.5 py-0.5 ring-1 ring-inset ring-[#d8b4fe]">
{practice.set_type}
</Badge>
{practice.persona && (
@ -581,7 +633,7 @@ export function SubCourseContentPage() {
<>
{videosLoading ? (
<div className="flex flex-col items-center justify-center py-20">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-brand-500 border-t-transparent" />
<SpinnerIcon className="h-8 w-8" />
<p className="mt-4 text-sm font-medium text-grayScale-500">Loading videos</p>
</div>
) : videos.length === 0 ? (
@ -711,7 +763,7 @@ export function SubCourseContentPage() {
<>
{ratingsLoading ? (
<div className="flex flex-col items-center justify-center py-20">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-brand-500 border-t-transparent" />
<SpinnerIcon className="h-8 w-8" />
<p className="mt-4 text-sm font-medium text-grayScale-500">Loading ratings</p>
</div>
) : ratings.length === 0 ? (
@ -931,44 +983,13 @@ export function SubCourseContentPage() {
<div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-4">
<h2 className="text-lg font-semibold text-grayScale-900">Add Video</h2>
<button
onClick={() => { setShowAddVideoModal(false); setSampleVideoId(""); setModalPreviewIframe("") }}
onClick={() => { setShowAddVideoModal(false); setVideoFile(null) }}
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600"
>
<X className="h-5 w-5" />
</button>
</div>
<div className="max-h-[70vh] space-y-5 overflow-y-auto px-6 py-6">
{/* Sample Vimeo picker */}
<div className="rounded-xl border border-brand-100 bg-brand-50/40 p-4 space-y-3">
<p className="text-xs font-semibold uppercase tracking-wider text-brand-600">
Try a sample Vimeo video
</p>
<div className="flex items-center gap-2">
<Select
className="flex-1 text-sm"
value={sampleVideoId}
onChange={(e) => {
setSampleVideoId(e.target.value)
handleModalPreview(e.target.value)
}}
>
<option value="">Select a sample video</option>
<option value="76979871">Big Buck Bunny</option>
<option value="1084537">Big Buck Bunny (alt)</option>
<option value="253989945">Vimeo Staff Pick</option>
<option value="305727901">Big Buck Bunny (4K)</option>
<option value="148751763">GoPro Footage</option>
</Select>
{modalPreviewLoading && <Loader2 className="h-4 w-4 shrink-0 animate-spin text-brand-500" />}
</div>
{modalPreviewIframe && (
<div
className="aspect-video w-full overflow-hidden rounded-lg"
dangerouslySetInnerHTML={{ __html: modalPreviewIframe }}
/>
)}
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium text-grayScale-700">Title</label>
<Input
@ -988,12 +1009,17 @@ export function SubCourseContentPage() {
/>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium text-grayScale-700">Source URL</label>
<label className="text-sm font-medium text-grayScale-700">Video File</label>
<Input
value={videoUrl}
onChange={(e) => setVideoUrl(e.target.value)}
placeholder="https://example-storage.com/video.mp4"
type="file"
accept="video/*"
onChange={(e) => handleVideoFileSelect(e.target.files?.[0] ?? null)}
/>
{videoFile && (
<p className="text-xs text-grayScale-500">
Selected: {videoFile.name}
</p>
)}
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
@ -1017,6 +1043,52 @@ export function SubCourseContentPage() {
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<label className="text-sm font-medium text-grayScale-700">Resolution</label>
<Input
value={videoResolution}
onChange={(e) => setVideoResolution(e.target.value)}
placeholder="1080p"
/>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium text-grayScale-700">Display Order</label>
<Input
type="number"
value={videoDisplayOrder}
onChange={(e) => setVideoDisplayOrder(Number(e.target.value))}
min={1}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<label className="text-sm font-medium text-grayScale-700">Visibility</label>
<select
value={videoVisibility}
onChange={(e) => setVideoVisibility(e.target.value)}
className="h-11 w-full rounded-lg border border-grayScale-200 bg-white px-3 text-sm text-grayScale-700 focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-500/20"
>
<option value="PUBLISHED">PUBLISHED</option>
<option value="DRAFT">DRAFT</option>
<option value="PRIVATE">PRIVATE</option>
<option value="UNLISTED">UNLISTED</option>
</select>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium text-grayScale-700">Status</label>
<select
value={videoStatus}
onChange={(e) => setVideoStatus(e.target.value)}
className="h-11 w-full rounded-lg border border-grayScale-200 bg-white px-3 text-sm text-grayScale-700 focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-500/20"
>
<option value="PUBLISHED">PUBLISHED</option>
<option value="DRAFT">DRAFT</option>
<option value="ARCHIVED">ARCHIVED</option>
</select>
</div>
</div>
{saveError && <p className="rounded-lg bg-red-50 px-3 py-2 text-sm text-red-600">{saveError}</p>}
</div>
<div className="flex flex-col-reverse gap-2.5 border-t border-grayScale-100 px-6 py-4 sm:flex-row sm:justify-end sm:gap-3">
@ -1026,9 +1098,9 @@ export function SubCourseContentPage() {
<Button
className="bg-brand-500 shadow-sm hover:bg-brand-600"
onClick={handleSaveNewVideo}
disabled={saving || !videoTitle.trim() || !videoUrl.trim()}
disabled={saving || !videoTitle.trim() || !videoFile}
>
{saving ? "Uploading..." : "Upload to Vimeo"}
{saving ? "Uploading..." : "Upload Video"}
</Button>
</div>
</div>
@ -1149,7 +1221,7 @@ export function SubCourseContentPage() {
<div className="p-6">
{previewLoading ? (
<div className="flex aspect-video items-center justify-center rounded-xl bg-grayScale-50">
<Loader2 className="h-8 w-8 animate-spin text-brand-500" />
<SpinnerIcon className="h-8 w-8" />
</div>
) : previewIframe ? (
<div

View File

@ -11,7 +11,6 @@ import {
Edit,
Link2,
Plus,
Loader2,
LayoutGrid,
GitBranch,
ChevronDown,
@ -43,6 +42,7 @@ import type {
CourseCategory,
SubCoursePrerequisite,
} from "../../types/course.types";
import { SpinnerIcon } from "../../components/ui/spinner-icon";
export function SubCoursesPage() {
const { categoryId, courseId } = useParams<{
@ -403,10 +403,6 @@ export function SubCoursesPage() {
return (
<div className="flex flex-col items-center justify-center py-24">
<img src={spinnerSrc} alt="" className="h-10 w-10 animate-spin" />
{/* <div className="rounded-full bg-white shadow-sm p-4">
<RefreshCw className="h-8 w-8 animate-spin text-brand-600" />
</div>
<p className="mt-4 text-sm font-medium text-grayScale-400">Loading sub-courses...</p> */}
</div>
);
}
@ -831,7 +827,7 @@ export function SubCoursesPage() {
disabled={prereqAdding || !selectedPrereqId}
>
{prereqAdding ? (
<Loader2 className="h-4 w-4 animate-spin" />
<SpinnerIcon className="h-4 w-4" />
) : (
<Plus className="h-4 w-4" />
)}
@ -890,7 +886,7 @@ export function SubCoursesPage() {
className="ml-3 grid h-8 w-8 shrink-0 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-red-50 hover:text-red-500 disabled:opacity-50"
>
{prereqRemoving === prereq.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
<SpinnerIcon className="h-4 w-4" />
) : (
<Trash2 className="h-4 w-4" />
)}

View File

@ -41,6 +41,7 @@ import {
DialogDescription,
} from "../../components/ui/dialog";
import { cn } from "../../lib/utils";
import { SpinnerIcon } from "../../components/ui/spinner-icon";
import {
getIssues,
getIssueById,
@ -365,7 +366,7 @@ export function IssuesPage() {
fetchIssues();
}}
>
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
{loading ? <SpinnerIcon className="h-4 w-4" /> : <RefreshCw className="h-4 w-4" />}
Refresh
</Button>
<Button
@ -500,7 +501,7 @@ export function IssuesPage() {
<TableRow>
<TableCell colSpan={6} className="text-center py-12">
<div className="flex flex-col items-center gap-3">
<RefreshCw className="h-6 w-6 animate-spin text-grayScale-300" />
<SpinnerIcon className="h-6 w-6" />
<span className="text-sm text-grayScale-400">Loading issues...</span>
</div>
</TableCell>
@ -720,7 +721,7 @@ export function IssuesPage() {
{detailLoading ? (
<div className="flex items-center justify-center py-8">
<RefreshCw className="h-6 w-6 animate-spin text-grayScale-300" />
<SpinnerIcon className="h-6 w-6" />
</div>
) : selectedIssue ? (
<div className="space-y-4">
@ -1030,7 +1031,7 @@ export function IssuesPage() {
onClick={handleDeleteConfirm}
>
{deleteLoading ? (
<RefreshCw className="h-3.5 w-3.5 animate-spin" />
<SpinnerIcon className="h-3.5 w-3.5" />
) : (
<Trash2 className="h-3.5 w-3.5" />
)}

View File

@ -0,0 +1,79 @@
import type { ReactNode } from "react"
import { Link, useLocation } from "react-router-dom"
import logoSrc from "../../assets/logo.svg"
interface InfoLayoutProps {
title: string
subtitle: string
children: ReactNode
lastUpdated?: string
}
const footerLinks = [
{ to: "/about", label: "About" },
{ to: "/terms", label: "Terms and Conditions" },
{ to: "/privacy", label: "Privacy Policy" },
{ to: "/account-deletion", label: "Account Deletion" },
]
export function InfoLayout({
title,
subtitle,
children,
lastUpdated = "March 11, 2026",
}: InfoLayoutProps) {
const location = useLocation()
return (
<div className="relative min-h-screen overflow-hidden bg-[#fefcff] px-4 py-10 sm:px-6">
<div className="pointer-events-none absolute -left-24 -top-24 h-72 w-72 rounded-full bg-[#f3e8ff]" />
<div className="pointer-events-none absolute -right-20 top-24 h-64 w-64 rounded-full bg-[#f5ecff]" />
<div className="mx-auto w-full max-w-5xl rounded-2xl border border-[#eadff7] bg-white px-6 py-6 shadow-[0_20px_60px_rgba(83,33,120,0.08)] sm:px-10 sm:py-8">
<header>
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<Link to="/about" className="inline-flex items-center gap-3">
<span className="inline-flex rounded-xl bg-brand-500 p-2 shadow-sm ring-1 ring-brand-400/40">
<img src={logoSrc} alt="Yimaru Academy" className="h-8 w-auto" />
</span>
<p className="text-xl font-semibold text-grayScale-700">Yimaru Academy</p>
</Link>
<nav className="flex flex-wrap items-center text-sm font-medium">
{footerLinks.map((item, index) => (
<div key={item.to} className="flex items-center">
<Link
to={item.to}
className={`px-3 transition-colors ${
location.pathname === item.to
? "text-[#8f56b5]"
: "text-grayScale-600 hover:text-[#8f56b5]"
}`}
>
{item.label}
</Link>
{index < footerLinks.length - 1 && (
<span className="h-5 w-px bg-[#e0d8eb]" aria-hidden="true" />
)}
</div>
))}
</nav>
</div>
<div className="mt-8">
<h1 className="text-4xl font-bold tracking-tight text-grayScale-700">{title}</h1>
<p className="mt-4 text-sm text-grayScale-500">
<span className="font-semibold text-grayScale-600">Last Updated:</span> {lastUpdated}
</p>
<p className="mt-3 max-w-3xl text-sm leading-relaxed text-grayScale-500">{subtitle}</p>
</div>
<div className="mt-6 h-px bg-[#e8e1f2]" />
</header>
<main className="space-y-6 pt-6">{children}</main>
<footer className="mt-8 flex justify-center text-xs text-grayScale-400">
© Yimaru Academy. All rights reserved.
</footer>
</div>
</div>
)
}

View File

@ -14,7 +14,6 @@ import {
BookOpen,
Video,
ShieldAlert,
Loader2,
MailOpen,
Mail,
CheckCheck,
@ -51,6 +50,7 @@ import {
} from "../../components/ui/dropdown-menu"
import { FileUpload } from "../../components/ui/file-upload"
import { cn } from "../../lib/utils"
import { SpinnerIcon } from "../../components/ui/spinner-icon"
import { useNavigate } from "react-router-dom"
import {
getNotifications,
@ -223,7 +223,7 @@ function NotificationItem({
title={notification.is_read ? "Mark as unread" : "Mark as read"}
>
{toggling ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
<SpinnerIcon className="h-3.5 w-3.5" />
) : notification.is_read ? (
<Mail className="h-3.5 w-3.5" />
) : (
@ -497,6 +497,22 @@ export function NotificationsPage() {
const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE))
const currentPage = Math.floor(offset / PAGE_SIZE) + 1
const startEntry = totalCount === 0 ? 0 : offset + 1
const endEntry = Math.min(offset + PAGE_SIZE, totalCount)
const getPageNumbers = () => {
const pages: (number | string)[] = []
if (totalPages <= 7) {
for (let i = 1; i <= totalPages; i++) pages.push(i)
} else {
pages.push(1, 2, 3)
if (currentPage > 4) pages.push("...")
if (currentPage > 3 && currentPage < totalPages - 2) pages.push(currentPage)
if (currentPage < totalPages - 3) pages.push("...")
pages.push(totalPages)
}
return pages
}
const filteredNotifications = notifications.filter((n) => {
if (channelFilter !== "all" && n.delivery_channel !== channelFilter) return false
@ -559,7 +575,7 @@ export function NotificationsPage() {
onClick={handleMarkAllRead}
>
{bulkLoading ? (
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
<SpinnerIcon className="mr-2 h-3.5 w-3.5" />
) : (
<CheckCheck className="mr-2 h-3.5 w-3.5" />
)}
@ -573,7 +589,7 @@ export function NotificationsPage() {
onClick={handleMarkAllUnread}
>
{bulkLoading ? (
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
<SpinnerIcon className="mr-2 h-3.5 w-3.5" />
) : (
<MailX className="mr-2 h-3.5 w-3.5" />
)}
@ -635,7 +651,7 @@ export function NotificationsPage() {
{/* Loading */}
{loading && (
<div className="flex items-center justify-center py-20">
<Loader2 className="h-6 w-6 animate-spin text-brand-500" />
<SpinnerIcon className="h-6 w-6" />
</div>
)}
@ -709,51 +725,83 @@ export function NotificationsPage() {
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<span className="text-xs font-medium text-grayScale-500">Channel</span>
<Select
value={channelFilter}
onChange={(e) => setChannelFilter(e.target.value as typeof channelFilter)}
className="h-8 w-[130px] text-xs"
>
<option value="all">All</option>
<option value="push">Push</option>
<option value="sms">SMS</option>
</Select>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
className="h-8 w-[130px] justify-between rounded-lg border-grayScale-200 px-2.5 text-xs font-normal text-grayScale-600"
>
<span className="truncate">{channelFilter === "all" ? "All" : channelFilter.toUpperCase()}</span>
<ChevronDown className="ml-2 h-3.5 w-3.5 text-grayScale-400" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-[130px]">
<DropdownMenuRadioGroup
value={channelFilter}
onValueChange={(value) => setChannelFilter(value as typeof channelFilter)}
>
<DropdownMenuRadioItem value="all">All</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="push">Push</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="sms">SMS</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="flex items-center gap-2">
<span className="text-xs font-medium text-grayScale-500">Type</span>
<Select
value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value)}
className="h-8 w-[150px] text-xs"
>
<option value="all">All types</option>
{Array.from(new Set(notifications.map((n) => n.type))).map((t) => (
<option key={t} value={t}>
{formatTypeLabel(t)}
</option>
))}
</Select>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
className="h-8 w-[150px] justify-between rounded-lg border-grayScale-200 px-2.5 text-xs font-normal text-grayScale-600"
>
<span className="truncate">
{typeFilter === "all" ? "All types" : formatTypeLabel(typeFilter)}
</span>
<ChevronDown className="ml-2 h-3.5 w-3.5 text-grayScale-400" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-[220px]">
<DropdownMenuRadioGroup value={typeFilter} onValueChange={setTypeFilter}>
<DropdownMenuRadioItem value="all">All types</DropdownMenuRadioItem>
{Array.from(new Set(notifications.map((n) => n.type))).map((t) => (
<DropdownMenuRadioItem key={t} value={t}>
{formatTypeLabel(t)}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="flex items-center gap-2">
<span className="text-xs font-medium text-grayScale-500">Level</span>
<Select
value={levelFilter}
onChange={(e) => setLevelFilter(e.target.value)}
className="h-8 w-[130px] text-xs"
>
<option value="all">All levels</option>
{Array.from(new Set(notifications.map((n) => n.level))).map((lvl) => (
<option key={lvl} value={lvl}>
{lvl}
</option>
))}
</Select>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
className="h-8 w-[130px] justify-between rounded-lg border-grayScale-200 px-2.5 text-xs font-normal text-grayScale-600"
>
<span className="truncate">{levelFilter === "all" ? "All levels" : levelFilter}</span>
<ChevronDown className="ml-2 h-3.5 w-3.5 text-grayScale-400" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-[150px]">
<DropdownMenuRadioGroup value={levelFilter} onValueChange={setLevelFilter}>
<DropdownMenuRadioItem value="all">All levels</DropdownMenuRadioItem>
{Array.from(new Set(notifications.map((n) => n.level))).map((lvl) => (
<DropdownMenuRadioItem key={lvl} value={lvl}>
{lvl}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</CardContent>
</Card>
<Card className="shadow-none">
<Card className="overflow-hidden rounded-xl border bg-white shadow-none">
<CardContent className="p-0">
<Table>
<TableHeader>
@ -770,8 +818,14 @@ export function NotificationsPage() {
<TableBody>
{filteredNotifications.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="py-10 text-center text-sm text-grayScale-400">
No notifications match your filters.
<TableCell colSpan={7} className="py-12 text-center">
<div className="flex flex-col items-center gap-3">
<BellOff className="h-8 w-8 text-grayScale-200" />
<div>
<p className="text-sm font-medium text-grayScale-500">No notifications match your filters</p>
<p className="mt-1 text-xs text-grayScale-400">Try adjusting your filters</p>
</div>
</div>
</TableCell>
</TableRow>
) : (
@ -851,7 +905,7 @@ export function NotificationsPage() {
title={n.is_read ? "Mark as unread" : "Mark as read"}
>
{isToggling ? (
<Loader2 className="h-3.5 w-3.5 animate-spin text-grayScale-400" />
<SpinnerIcon className="h-3.5 w-3.5" />
) : n.is_read ? (
<Mail className="h-3.5 w-3.5 text-grayScale-400" />
) : (
@ -875,37 +929,72 @@ export function NotificationsPage() {
</TableBody>
</Table>
</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 flex-wrap items-center justify-between gap-3 border-t px-4 py-3 text-sm text-grayScale-500">
<div className="flex items-center gap-2">
<span>Showing</span>
<span className="font-medium text-grayScale-600">
{startEntry}-{endEntry}
</span>
<span>of</span>
<span className="font-medium text-grayScale-600">{totalCount}</span>
<span className="mr-4">entries</span>
<span className="border-l pl-4">Rows per page</span>
<div className="relative">
<select
value={PAGE_SIZE}
disabled
className="h-8 appearance-none rounded-md border bg-white pl-2 pr-7 text-sm font-medium text-grayScale-600 focus:outline-none"
>
<option value={PAGE_SIZE}>{PAGE_SIZE}</option>
</select>
<ChevronDown className="pointer-events-none absolute right-2 top-1/2 h-3 w-3 -translate-y-1/2 text-grayScale-400" />
</div>
</div>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="sm"
<button
onClick={() => currentPage > 1 && setOffset(Math.max(0, offset - PAGE_SIZE))}
disabled={currentPage <= 1}
onClick={() => setOffset(Math.max(0, offset - PAGE_SIZE))}
className={cn(
"flex h-8 w-8 items-center justify-center rounded-md border bg-white text-grayScale-500",
currentPage <= 1 && "cursor-not-allowed opacity-50",
)}
>
<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"
</button>
{getPageNumbers().map((n, idx) =>
typeof n === "string" ? (
<span key={`ellipsis-${idx}`} className="px-2 text-grayScale-400">
...
</span>
) : (
<button
key={n}
type="button"
onClick={() => setOffset((n - 1) * PAGE_SIZE)}
className={cn(
"h-8 w-8 rounded-md border text-sm font-medium",
n === currentPage
? "border-brand-500 bg-brand-500 text-white"
: "bg-white text-grayScale-600 hover:bg-grayScale-50",
)}
>
{n}
</button>
),
)}
<button
onClick={() => currentPage < totalPages && setOffset(offset + PAGE_SIZE)}
disabled={currentPage >= totalPages}
onClick={() => setOffset(offset + PAGE_SIZE)}
className={cn(
"flex h-8 w-8 items-center justify-center rounded-md border bg-white text-grayScale-500",
currentPage >= totalPages && "cursor-not-allowed opacity-50",
)}
>
<ChevronRight className="h-4 w-4" />
</Button>
</button>
</div>
</div>
)}
</Card>
</>
)}
@ -1436,7 +1525,7 @@ export function NotificationsPage() {
<Button type="submit" size="sm" disabled={bulkSending || !bulkMessage.trim()}>
{bulkSending ? (
<>
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
<SpinnerIcon className="mr-2 h-3.5 w-3.5" />
Sending
</>
) : (

View File

@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from "react"
import { ArrowLeft, Loader2, Search, X, Check } from "lucide-react"
import { ArrowLeft, Search, X, Check } from "lucide-react"
import { useNavigate } from "react-router-dom"
import { Button } from "../../components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
@ -10,6 +10,7 @@ import { createRole, setRolePermissions, getAllPermissions } from "../../api/rba
import type { RolePermission } from "../../types/rbac.types"
import { cn } from "../../lib/utils"
import { toast } from "sonner"
import { SpinnerIcon } from "../../components/ui/spinner-icon"
export function AddRolePage() {
const navigate = useNavigate()
@ -186,7 +187,7 @@ export function AddRolePage() {
disabled={saving || !roleName.trim()}
className="w-full bg-brand-500 hover:bg-brand-600"
>
{saving && <Loader2 className="h-4 w-4 animate-spin" />}
{saving && <SpinnerIcon className="h-4 w-4" />}
{saving ? "Creating…" : "Create Role"}
</Button>
</CardContent>
@ -246,7 +247,7 @@ export function AddRolePage() {
{/* Loading */}
{permLoading && (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-brand-500" />
<SpinnerIcon className="h-6 w-6" />
</div>
)}

View File

@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from "react"
import { useNavigate } from "react-router-dom"
import {
Plus, Search, Shield, ShieldCheck, ChevronLeft, ChevronRight,
Loader2, AlertCircle, Eye, X, Pencil, Check,
AlertCircle, Eye, X, Pencil, Check,
} from "lucide-react"
import { Button } from "../../components/ui/button"
import { Card, CardContent } from "../../components/ui/card"
@ -16,6 +16,7 @@ import { getRoles, getRoleDetail, getAllPermissions, setRolePermissions, updateR
import type { Role, RoleDetail, RolePermission } from "../../types/rbac.types"
import { cn } from "../../lib/utils"
import { toast } from "sonner"
import { SpinnerIcon } from "../../components/ui/spinner-icon"
export function RolesListPage() {
const navigate = useNavigate()
@ -277,7 +278,7 @@ export function RolesListPage() {
{/* Loading */}
{loading && (
<div className="flex items-center justify-center py-20">
<Loader2 className="h-8 w-8 animate-spin text-brand-500" />
<SpinnerIcon className="h-8 w-8" />
</div>
)}
@ -473,7 +474,7 @@ export function RolesListPage() {
onClick={handleSaveRole}
disabled={savingRole || !editName.trim()}
>
{savingRole && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
{savingRole && <SpinnerIcon className="h-3.5 w-3.5" />}
{savingRole ? "Saving…" : "Save"}
</Button>
</div>
@ -484,7 +485,7 @@ export function RolesListPage() {
{detailLoading && (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-brand-500" />
<SpinnerIcon className="h-6 w-6" />
</div>
)}
@ -580,7 +581,7 @@ export function RolesListPage() {
{permLoading && (
<div className="flex items-center justify-center py-10">
<Loader2 className="h-6 w-6 animate-spin text-brand-500" />
<SpinnerIcon className="h-6 w-6" />
</div>
)}
@ -677,7 +678,7 @@ export function RolesListPage() {
onClick={handleSavePermissions}
disabled={savingPermissions}
>
{savingPermissions && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
{savingPermissions && <SpinnerIcon className="h-3.5 w-3.5" />}
{savingPermissions ? "Saving…" : "Save Permissions"}
</Button>
</div>

View File

@ -1,10 +1,9 @@
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import {
Search,
Plus,
ChevronDown,
SlidersHorizontal,
ChevronLeft,
ChevronRight,
X,
@ -80,44 +79,85 @@ function formatRoleLabel(role: string): string {
.join(" ");
}
function normalizeFilterValue(value: string): string {
return value.trim().toLowerCase().replace(/\s+/g, "_");
}
export function TeamManagementPage() {
const navigate = useNavigate();
const [members, setMembers] = useState<TeamMember[]>([]);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(5);
const [total, setTotal] = useState(0);
const [search, setSearch] = useState("");
const [roleFilter, setRoleFilter] = useState("");
const [statusFilter, setStatusFilter] = useState("");
const [toggledStatuses, setToggledStatuses] = useState<Record<number, boolean>>({});
const [confirmDialog, setConfirmDialog] = useState<{ id: number; name: string; newStatus: string } | null>(null);
const [updating, setUpdating] = useState(false);
const [loading, setLoading] = useState(false);
useEffect(() => {
const fetchMembers = async () => {
setLoading(true);
try {
const res = await getTeamMembers(page, pageSize);
const data = res.data.data;
setMembers(data);
setTotal(res.data.metadata.total);
const batchSize = 100;
const firstRes = await getTeamMembers(1, batchSize);
const firstBatch = firstRes.data.data ?? [];
const totalPages = firstRes.data.metadata?.total_pages ?? 1;
let allMembers = firstBatch;
if (totalPages > 1) {
const restResponses = await Promise.all(
Array.from({ length: totalPages - 1 }, (_, idx) => getTeamMembers(idx + 2, batchSize)),
);
const restBatches = restResponses.flatMap((res) => res.data.data ?? []);
allMembers = [...firstBatch, ...restBatches];
}
setMembers(allMembers);
const initialStatuses: Record<number, boolean> = {};
data.forEach((m) => {
allMembers.forEach((m) => {
initialStatuses[m.id] = m.status === "active";
});
setToggledStatuses((prev) => ({ ...prev, ...initialStatuses }));
} catch (error) {
console.error("Failed to fetch team members:", error);
setMembers([]);
setTotal(0);
} finally {
setLoading(false);
}
};
fetchMembers();
}, [page, pageSize]);
}, []);
const filteredMembers = useMemo(() => {
return members.filter((member) => {
const q = search.trim().toLowerCase();
const matchesSearch =
!q ||
`${member.first_name} ${member.last_name}`.toLowerCase().includes(q) ||
member.email.toLowerCase().includes(q);
const roleValue = normalizeFilterValue(member.team_role || "");
const statusValue = normalizeFilterValue(member.status || "");
const matchesRole = !roleFilter || roleValue === normalizeFilterValue(roleFilter);
const matchesStatus = !statusFilter || statusValue === normalizeFilterValue(statusFilter);
return matchesSearch && matchesRole && matchesStatus;
});
}, [members, search, roleFilter, statusFilter]);
useEffect(() => {
setPage(1);
}, [search, roleFilter, statusFilter, pageSize]);
const total = filteredMembers.length;
const pageCount = Math.max(1, Math.ceil(total / pageSize));
const safePage = Math.min(page, pageCount);
const startEntry = total === 0 ? 0 : (safePage - 1) * pageSize + 1;
const endEntry = Math.min(safePage * pageSize, total);
const paginatedMembers = useMemo(() => {
const start = (safePage - 1) * pageSize;
return filteredMembers.slice(start, start + pageSize);
}, [filteredMembers, safePage, pageSize]);
const handlePrev = () => safePage > 1 && setPage(safePage - 1);
const handleNext = () => safePage < pageCount && setPage(safePage + 1);
@ -152,6 +192,9 @@ export function TeamManagementPage() {
setToggledStatuses((prev) => ({ ...prev, [id]: newStatus === "active" }));
try {
await updateTeamMemberStatus(id, newStatus);
setMembers((prev) =>
prev.map((member) => (member.id === id ? { ...member, status: newStatus } : member)),
);
toast.success(
`${name || "Team member"} ${newStatus === "active" ? "activated" : "deactivated"} successfully`,
);
@ -230,13 +273,9 @@ export function TeamManagementPage() {
<ChevronDown className="absolute right-2 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-400 pointer-events-none" />
</div>
<Button variant="outline" className="shrink-0">
<SlidersHorizontal className="h-4 w-4" />
More Filters
</Button>
</div>
<div className="rounded-lg border bg-white">
<div className="rounded-xl border bg-white">
<Table>
<TableHeader>
<TableRow>
@ -250,21 +289,30 @@ export function TeamManagementPage() {
</TableHeader>
<TableBody>
{members.length === 0 ? (
{loading ? (
<TableRow>
<TableCell colSpan={6} className="text-center text-grayScale-400">
No team members found
<TableCell colSpan={6} className="py-12 text-center text-sm text-grayScale-400">
Loading team members...
</TableCell>
</TableRow>
) : filteredMembers.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="py-12 text-center">
<div className="flex flex-col items-center gap-2">
<p className="text-sm font-medium text-grayScale-500">No team members found</p>
<p className="text-xs text-grayScale-400">Try adjusting your filters</p>
</div>
</TableCell>
</TableRow>
) : (
members.map((member) => {
paginatedMembers.map((member) => {
const initials = `${member.first_name?.[0] ?? ""}${member.last_name?.[0] ?? ""}`.toUpperCase();
const isActive = toggledStatuses[member.id] ?? false;
return (
<TableRow
key={member.id}
className="cursor-pointer hover:bg-grayScale-50"
className="group cursor-pointer"
onClick={() => navigate(`/team/${member.id}`)}
>
<TableCell>
@ -348,7 +396,14 @@ export function TeamManagementPage() {
<div className="flex flex-wrap items-center justify-between gap-3 border-t px-4 py-3 text-sm text-grayScale-500">
<div className="flex items-center gap-2">
<span>Row Per Page</span>
<span>Showing</span>
<span className="font-medium text-grayScale-600">
{startEntry}-{endEntry}
</span>
<span>of</span>
<span className="font-medium text-grayScale-600">{total}</span>
<span className="mr-4">entries</span>
<span className="border-l pl-4">Rows per page</span>
<div className="relative">
<select
value={pageSize}
@ -366,7 +421,6 @@ export function TeamManagementPage() {
</select>
<ChevronDown className="absolute right-2 top-1/2 h-3 w-3 -translate-y-1/2 text-grayScale-400 pointer-events-none" />
</div>
<span>Entries</span>
</div>
<div className="flex items-center gap-1">

View File

@ -38,6 +38,7 @@ import {
import { cn } from "../../lib/utils";
import { getActivityLogs, getActivityLogById } from "../../api/activity-logs.api";
import type { ActivityLog, ActivityLogFilters } from "../../types/activity-log.types";
import { SpinnerIcon } from "../../components/ui/spinner-icon";
// ── Action type configuration ──────────────────────────────────────
const ACTION_TYPES = [
@ -250,7 +251,7 @@ export function UserLogPage() {
fetchLogs();
}}
>
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
{loading ? <SpinnerIcon className="h-4 w-4" /> : <RefreshCw className="h-4 w-4" />}
Refresh
</Button>
</div>
@ -566,7 +567,7 @@ export function UserLogPage() {
{detailLoading ? (
<div className="flex items-center justify-center py-8">
<RefreshCw className="h-6 w-6 animate-spin text-grayScale-300" />
<SpinnerIcon className="h-6 w-6" />
</div>
) : selectedLog ? (
<div className="space-y-4">

View File

@ -0,0 +1,642 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { CalendarDays, ChevronDown, ChevronLeft, ChevronRight, Search, SlidersHorizontal } from "lucide-react"
import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg"
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
import { Input } from "../../components/ui/input"
import { Badge } from "../../components/ui/badge"
import { Button } from "../../components/ui/button"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "../../components/ui/table"
import { getDeletionRequests } from "../../api/users.api"
import { getRoles } from "../../api/rbac.api"
import { cn } from "../../lib/utils"
import type {
DeletionRequest,
DeletionState,
DeletionUserStatus,
GetDeletionRequestsParams,
} from "../../types/user.types"
import type { Role } from "../../types/rbac.types"
import { mapDeletionRequestApiItem } from "../../types/user.types"
const stateBadge: Record<string, string> = {
PENDING: "bg-amber-100 text-amber-700",
DUE: "bg-brand-100 text-brand-600",
CANCELLED: "bg-grayScale-200 text-grayScale-600",
}
const DATE_PLACEHOLDER = "DD-MM-YYYY HH:mm"
const STATUS_OPTIONS: DeletionUserStatus[] = ["ACTIVE", "PENDING", "SUSPENDED", "DEACTIVATED"]
const STATE_OPTIONS: DeletionState[] = ["PENDING", "DUE", "CANCELLED"]
type DateTimeFilterInputProps = {
value: string
onChange: (value: string) => void
placeholder: string
}
function DateTimeFilterInput({ value, onChange, placeholder }: DateTimeFilterInputProps) {
return (
<div className="relative">
<CalendarDays className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-300" />
<Input
type="datetime-local"
value={value}
onChange={(e) => onChange(e.target.value)}
onClick={(e) => {
// Opens native date-time picker immediately on supported browsers.
(e.currentTarget as HTMLInputElement).showPicker?.()
}}
aria-label={placeholder}
className="h-11 rounded-xl border-grayScale-200 bg-white pl-10 pr-3 text-sm shadow-sm transition focus-visible:border-brand-400 focus-visible:ring-brand-200 [&::-webkit-calendar-picker-indicator]:cursor-pointer [&::-webkit-calendar-picker-indicator]:opacity-70 hover:[&::-webkit-calendar-picker-indicator]:opacity-100"
/>
</div>
)
}
export function DeletionRequestsPage() {
const [items, setItems] = useState<DeletionRequest[]>([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const [loading, setLoading] = useState(false)
const [query, setQuery] = useState("")
const [role, setRole] = useState("")
const [roleOptions, setRoleOptions] = useState<string[]>([])
const [rolesLoading, setRolesLoading] = useState(false)
const [roleMenuOpen, setRoleMenuOpen] = useState(false)
const [roleSearch, setRoleSearch] = useState("")
const [statusMenuOpen, setStatusMenuOpen] = useState(false)
const [stateMenuOpen, setStateMenuOpen] = useState(false)
const [status, setStatus] = useState<DeletionUserStatus | "">("")
const [state, setState] = useState<DeletionState | "">("")
const [requestedAfter, setRequestedAfter] = useState("")
const [requestedBefore, setRequestedBefore] = useState("")
const [scheduledAfter, setScheduledAfter] = useState("")
const [scheduledBefore, setScheduledBefore] = useState("")
const roleMenuRef = useRef<HTMLDivElement | null>(null)
const statusMenuRef = useRef<HTMLDivElement | null>(null)
const stateMenuRef = useRef<HTMLDivElement | null>(null)
const toRfc3339 = (value: string) => {
if (!value) return undefined
const normalized = value.includes("T") ? value : value.replace(" ", "T")
const date = new Date(normalized)
return Number.isNaN(date.getTime()) ? undefined : date.toISOString()
}
const fetchData = useCallback(async () => {
setLoading(true)
try {
const params: GetDeletionRequestsParams = {
query: query || undefined,
role: role || undefined,
status: status || undefined,
state: state || undefined,
requested_after: toRfc3339(requestedAfter),
requested_before: toRfc3339(requestedBefore),
scheduled_after: toRfc3339(scheduledAfter),
scheduled_before: toRfc3339(scheduledBefore),
page,
page_size: pageSize,
}
const res = await getDeletionRequests(params)
const rows = res.data?.data?.items ?? []
setItems(rows.map(mapDeletionRequestApiItem))
setTotal(res.data?.data?.total ?? 0)
} catch (error) {
console.error("Failed to fetch deletion requests:", error)
setItems([])
setTotal(0)
} finally {
setLoading(false)
}
}, [
query,
role,
status,
state,
requestedAfter,
requestedBefore,
scheduledAfter,
scheduledBefore,
page,
pageSize,
])
useEffect(() => {
fetchData()
}, [fetchData])
useEffect(() => {
const fetchRoles = async () => {
setRolesLoading(true)
try {
const pageSize = 50
const firstRes = await getRoles({ page: 1, page_size: pageSize })
const firstBatch = firstRes.data?.data?.roles ?? []
const total = firstRes.data?.data?.total ?? firstBatch.length
const totalPages = Math.max(1, Math.ceil(total / pageSize))
let allRoles: Role[] = firstBatch
if (totalPages > 1) {
const requests: Array<ReturnType<typeof getRoles>> = []
for (let currentPage = 2; currentPage <= totalPages; currentPage += 1) {
requests.push(getRoles({ page: currentPage, page_size: pageSize }))
}
const remaining = await Promise.all(requests)
allRoles = [...firstBatch, ...remaining.flatMap((r) => r.data?.data?.roles ?? [])]
}
const uniqueNames = Array.from(
new Set(
allRoles
.map((r) => r.name?.trim())
.filter((name): name is string => Boolean(name)),
),
).sort((a, b) => a.localeCompare(b))
setRoleOptions(uniqueNames)
} catch (error) {
console.error("Failed to fetch role options:", error)
setRoleOptions([])
} finally {
setRolesLoading(false)
}
}
fetchRoles()
}, [])
useEffect(() => {
if (!roleMenuOpen && !statusMenuOpen && !stateMenuOpen) return
const handleOutsideClick = (event: MouseEvent) => {
const target = event.target as Node
if (roleMenuRef.current && !roleMenuRef.current.contains(target)) {
setRoleMenuOpen(false)
}
if (statusMenuRef.current && !statusMenuRef.current.contains(target)) {
setStatusMenuOpen(false)
}
if (stateMenuRef.current && !stateMenuRef.current.contains(target)) {
setStateMenuOpen(false)
}
}
document.addEventListener("mousedown", handleOutsideClick)
return () => {
document.removeEventListener("mousedown", handleOutsideClick)
}
}, [roleMenuOpen, statusMenuOpen, stateMenuOpen])
const filteredRoleOptions = useMemo(() => {
const q = roleSearch.trim().toLowerCase()
if (!q) return roleOptions
return roleOptions.filter((option) => option.toLowerCase().includes(q))
}, [roleOptions, roleSearch])
const totalPages = useMemo(() => Math.max(1, Math.ceil(total / pageSize)), [total, pageSize])
const safePage = Math.min(page, totalPages)
const startEntry = total === 0 ? 0 : (safePage - 1) * pageSize + 1
const endEntry = Math.min(safePage * pageSize, total)
const getPageNumbers = () => {
const pages: (number | string)[] = []
if (totalPages <= 7) {
for (let i = 1; i <= totalPages; i++) pages.push(i)
} else {
pages.push(1, 2, 3)
if (safePage > 4) pages.push("...")
if (safePage > 3 && safePage < totalPages - 2) pages.push(safePage)
if (safePage < totalPages - 3) pages.push("...")
pages.push(totalPages)
}
return pages
}
const resetFilters = () => {
setQuery("")
setRole("")
setStatus("")
setState("")
setRequestedAfter("")
setRequestedBefore("")
setScheduledAfter("")
setScheduledBefore("")
setRoleSearch("")
setRoleMenuOpen(false)
setStatusMenuOpen(false)
setStateMenuOpen(false)
setPage(1)
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-grayScale-600">Deletion Requests</h1>
<p className="mt-1 text-sm text-grayScale-400">
Review and monitor account deletion requests from users.
</p>
</div>
<Card className="overflow-visible rounded-2xl border border-brand-100/70 bg-white shadow-soft">
<CardHeader className="border-b border-brand-100/70 bg-gradient-to-r from-brand-100/50 via-white to-brand-100/25 pb-4">
<CardTitle className="flex items-center gap-2 text-base font-semibold text-grayScale-700">
<SlidersHorizontal className="h-4 w-4 text-brand-600" />
Filters
</CardTitle>
</CardHeader>
<CardContent className="space-y-5 bg-gradient-to-b from-white to-brand-100/10 pt-5">
<div className="grid grid-cols-1 gap-3 lg:grid-cols-4">
<div className="relative lg:col-span-2">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-300" />
<Input
value={query}
onChange={(e) => {
setQuery(e.target.value)
setPage(1)
}}
placeholder="Search name, email, phone..."
className="h-11 rounded-xl border-grayScale-200 bg-white pl-10 shadow-sm transition focus-visible:border-brand-400 focus-visible:ring-brand-200"
/>
</div>
<div className="relative" ref={roleMenuRef}>
<button
type="button"
className="flex h-11 w-full items-center justify-between rounded-xl border border-grayScale-200 bg-white px-3 text-left text-sm text-grayScale-600 shadow-sm transition hover:bg-grayScale-50 focus:outline-none focus-visible:border-brand-400 focus-visible:ring-2 focus-visible:ring-brand-200"
onClick={() => {
setRoleMenuOpen((prev) => !prev)
setStatusMenuOpen(false)
setStateMenuOpen(false)
if (!roleMenuOpen) setRoleSearch("")
}}
>
<span>{rolesLoading ? "Loading roles..." : role || "Role"}</span>
<ChevronDown className="h-4 w-4 text-grayScale-400" />
</button>
{roleMenuOpen ? (
<div className="absolute z-30 mt-1 w-full rounded-xl border border-grayScale-200 bg-white p-2 shadow-lg">
<div className="relative mb-2">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-grayScale-300" />
<Input
value={roleSearch}
onChange={(e) => setRoleSearch(e.target.value)}
placeholder="Search role..."
className="h-9 rounded-lg border-grayScale-200 pl-8 text-sm"
/>
</div>
<div className="max-h-56 space-y-1 overflow-auto">
<button
type="button"
className={cn(
"w-full rounded-md px-2 py-2 text-left text-sm transition",
role === ""
? "bg-brand-100/50 text-brand-700"
: "text-grayScale-600 hover:bg-grayScale-100",
)}
onClick={() => {
setRole("")
setPage(1)
setRoleMenuOpen(false)
}}
>
All Roles
</button>
{filteredRoleOptions.map((roleName) => (
<button
key={roleName}
type="button"
className={cn(
"w-full rounded-md px-2 py-2 text-left text-sm transition",
role === roleName
? "bg-brand-100/50 text-brand-700"
: "text-grayScale-600 hover:bg-grayScale-100",
)}
onClick={() => {
setRole(roleName)
setPage(1)
setRoleMenuOpen(false)
}}
>
{roleName}
</button>
))}
{!rolesLoading && filteredRoleOptions.length === 0 ? (
<p className="px-2 py-1.5 text-sm text-grayScale-400">No roles found</p>
) : null}
</div>
</div>
) : null}
</div>
<div className="relative" ref={statusMenuRef}>
<button
type="button"
className="flex h-11 w-full items-center justify-between rounded-xl border border-grayScale-200 bg-white px-3 text-left text-sm text-grayScale-600 shadow-sm transition hover:bg-grayScale-50 focus:outline-none focus-visible:border-brand-400 focus-visible:ring-2 focus-visible:ring-brand-200"
onClick={() => {
setStatusMenuOpen((prev) => !prev)
setStateMenuOpen(false)
setRoleMenuOpen(false)
}}
>
<span>{status || "All Statuses"}</span>
<ChevronDown className="h-4 w-4 text-grayScale-400" />
</button>
{statusMenuOpen ? (
<div className="absolute z-30 mt-1 w-full rounded-xl border border-grayScale-200 bg-white p-1.5 shadow-lg">
<button
type="button"
className={cn(
"w-full rounded-lg px-2.5 py-2 text-left text-sm transition",
status === ""
? "bg-brand-100/50 text-brand-700"
: "text-grayScale-600 hover:bg-grayScale-100",
)}
onClick={() => {
setStatus("")
setPage(1)
setStatusMenuOpen(false)
}}
>
All Statuses
</button>
{STATUS_OPTIONS.map((statusOption) => (
<button
key={statusOption}
type="button"
className={cn(
"w-full rounded-lg px-2.5 py-2 text-left text-sm transition",
status === statusOption
? "bg-brand-100/50 text-brand-700"
: "text-grayScale-600 hover:bg-grayScale-100",
)}
onClick={() => {
setStatus(statusOption)
setPage(1)
setStatusMenuOpen(false)
}}
>
{statusOption}
</button>
))}
</div>
) : null}
</div>
</div>
<div className="grid grid-cols-1 gap-3 lg:grid-cols-4">
<div className="relative" ref={stateMenuRef}>
<button
type="button"
className="flex h-11 w-full items-center justify-between rounded-xl border border-grayScale-200 bg-white px-3 text-left text-sm text-grayScale-600 shadow-sm transition hover:bg-grayScale-50 focus:outline-none focus-visible:border-brand-400 focus-visible:ring-2 focus-visible:ring-brand-200"
onClick={() => {
setStateMenuOpen((prev) => !prev)
setStatusMenuOpen(false)
setRoleMenuOpen(false)
}}
>
<span>{state || "All States"}</span>
<ChevronDown className="h-4 w-4 text-grayScale-400" />
</button>
{stateMenuOpen ? (
<div className="absolute z-30 mt-1 w-full rounded-xl border border-grayScale-200 bg-white p-1.5 shadow-lg">
<button
type="button"
className={cn(
"w-full rounded-lg px-2.5 py-2 text-left text-sm transition",
state === ""
? "bg-brand-100/50 text-brand-700"
: "text-grayScale-600 hover:bg-grayScale-100",
)}
onClick={() => {
setState("")
setPage(1)
setStateMenuOpen(false)
}}
>
All States
</button>
{STATE_OPTIONS.map((stateOption) => (
<button
key={stateOption}
type="button"
className={cn(
"w-full rounded-lg px-2.5 py-2 text-left text-sm transition",
state === stateOption
? "bg-brand-100/50 text-brand-700"
: "text-grayScale-600 hover:bg-grayScale-100",
)}
onClick={() => {
setState(stateOption)
setPage(1)
setStateMenuOpen(false)
}}
>
{stateOption}
</button>
))}
</div>
) : null}
</div>
<DateTimeFilterInput
value={requestedAfter}
onChange={(value) => {
setRequestedAfter(value)
setPage(1)
}}
placeholder={`Requested after (${DATE_PLACEHOLDER})`}
/>
<DateTimeFilterInput
value={requestedBefore}
onChange={(value) => {
setRequestedBefore(value)
setPage(1)
}}
placeholder={`Requested before (${DATE_PLACEHOLDER})`}
/>
<DateTimeFilterInput
value={scheduledAfter}
onChange={(value) => {
setScheduledAfter(value)
setPage(1)
}}
placeholder={`Scheduled after (${DATE_PLACEHOLDER})`}
/>
</div>
<div className="grid grid-cols-1 gap-3 lg:grid-cols-4">
<DateTimeFilterInput
value={scheduledBefore}
onChange={(value) => {
setScheduledBefore(value)
setPage(1)
}}
placeholder={`Scheduled before (${DATE_PLACEHOLDER})`}
/>
<div className="flex justify-end lg:col-start-4 lg:col-span-1">
<Button
variant="outline"
className="h-11 rounded-xl border-brand-200 px-5 text-brand-700 hover:bg-brand-100/40"
onClick={resetFilters}
>
Clear Filters
</Button>
</div>
</div>
</CardContent>
</Card>
<Card className="shadow-soft">
<CardHeader className="border-b border-grayScale-200 pb-4">
<CardTitle className="text-base font-semibold text-grayScale-600">
Requests ({total})
</CardTitle>
</CardHeader>
<CardContent className="pt-5">
<div className="rounded-xl border bg-white">
<Table>
<TableHeader>
<TableRow>
<TableHead>User</TableHead>
<TableHead>Role / Status</TableHead>
<TableHead className="hidden md:table-cell">Requested At</TableHead>
<TableHead className="hidden md:table-cell">Scheduled At</TableHead>
<TableHead>Deletion State</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={5} className="py-12 text-center">
<div className="flex flex-col items-center gap-3">
<img src={spinnerSrc} alt="" className="h-6 w-6 animate-spin" />
<span className="text-sm text-grayScale-400">Loading requests...</span>
</div>
</TableCell>
</TableRow>
) : items.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="py-12 text-center">
<div className="flex flex-col items-center gap-3">
<Search className="h-8 w-8 text-grayScale-200" />
<div>
<p className="text-sm font-medium text-grayScale-500">No deletion requests found</p>
<p className="mt-1 text-xs text-grayScale-400">Try adjusting your filters</p>
</div>
</div>
</TableCell>
</TableRow>
) : (
items.map((item, index) => (
<TableRow key={`${item.user_id}-${index}`} className="group">
<TableCell className="py-3.5">
<p className="text-sm font-medium text-grayScale-700">
{item.first_name} {item.last_name}
</p>
<p className="text-xs text-grayScale-500">{item.email}</p>
<p className="text-xs text-grayScale-500">{item.phone_number || "—"}</p>
</TableCell>
<TableCell className="py-3.5">
<p className="text-sm text-grayScale-700">{item.role || "—"}</p>
<p className="text-xs text-grayScale-500">{item.status || "—"}</p>
</TableCell>
<TableCell className="hidden py-3.5 text-sm text-grayScale-600 md:table-cell">
{item.deletion_requested_at || "—"}
</TableCell>
<TableCell className="hidden py-3.5 text-sm text-grayScale-600 md:table-cell">
{item.deletion_scheduled_at || "—"}
</TableCell>
<TableCell className="py-3.5">
<Badge className={stateBadge[item.deletion_state] || "bg-grayScale-200 text-grayScale-600"}>
{item.deletion_state || "—"}
</Badge>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
<div className="flex flex-wrap items-center justify-between gap-3 border-t px-4 py-3 text-sm text-grayScale-500">
<div className="flex items-center gap-2">
<span>Showing</span>
<span className="font-medium text-grayScale-600">
{startEntry}-{endEntry}
</span>
<span>of</span>
<span className="font-medium text-grayScale-600">{total}</span>
<span className="mr-4">entries</span>
<span className="border-l pl-4">Rows per page</span>
<div className="relative">
<select
value={pageSize}
onChange={(e) => {
setPageSize(Number(e.target.value))
setPage(1)
}}
className="h-8 appearance-none rounded-md border bg-white pl-2 pr-7 text-sm font-medium text-grayScale-600 focus:outline-none"
>
{[10, 20, 50, 100].map((size) => (
<option key={size} value={size}>
{size}
</option>
))}
</select>
<ChevronDown className="pointer-events-none absolute right-2 top-1/2 h-3 w-3 -translate-y-1/2 text-grayScale-400" />
</div>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => safePage > 1 && setPage(safePage - 1)}
disabled={safePage === 1}
className={cn(
"flex h-8 w-8 items-center justify-center rounded-md border bg-white text-grayScale-500",
safePage === 1 && "cursor-not-allowed opacity-50",
)}
>
<ChevronLeft className="h-4 w-4" />
</button>
{getPageNumbers().map((n, idx) =>
typeof n === "string" ? (
<span key={`ellipsis-${idx}`} className="px-2 text-grayScale-400">
...
</span>
) : (
<button
key={n}
type="button"
onClick={() => setPage(n)}
className={cn(
"h-8 w-8 rounded-md border text-sm font-medium",
n === safePage
? "border-brand-500 bg-brand-500 text-white"
: "bg-white text-grayScale-600 hover:bg-grayScale-50",
)}
>
{n}
</button>
),
)}
<button
onClick={() => safePage < totalPages && setPage(safePage + 1)}
disabled={safePage === totalPages}
className={cn(
"flex h-8 w-8 items-center justify-center rounded-md border bg-white text-grayScale-500",
safePage === totalPages && "cursor-not-allowed opacity-50",
)}
>
<ChevronRight className="h-4 w-4" />
</button>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
)
}

View File

@ -1,180 +0,0 @@
import { useState } from "react"
import { ArrowLeft, FileText, Mail, Phone, Shield, User } from "lucide-react"
import { useNavigate } from "react-router-dom"
import { toast } from "sonner"
import { Button } from "../../components/ui/button"
import { Card } from "../../components/ui/card"
import { Input } from "../../components/ui/input"
import { Textarea } from "../../components/ui/textarea"
import { Select } from "../../components/ui/select"
import { createUser } from "../../api/users.api"
export function RegisterUserPage() {
const navigate = useNavigate()
const [firstName, setFirstName] = useState("")
const [lastName, setLastName] = useState("")
const [email, setEmail] = useState("")
const [phone, setPhone] = useState("")
const [role, setRole] = useState("")
const [notes, setNotes] = useState("")
const [submitting, setSubmitting] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!firstName.trim() || !lastName.trim() || !email.trim() || !phone.trim() || !role) {
toast.error("Missing required fields", {
description: "Please fill in first name, last name, email, phone, and role.",
})
return
}
setSubmitting(true)
try {
await createUser({
first_name: firstName.trim(),
last_name: lastName.trim(),
email: email.trim(),
phone_number: phone.trim(),
role: role.toUpperCase(),
notes: notes.trim() || undefined,
})
toast.success("User registered", {
description: `${firstName} ${lastName} has been created successfully.`,
})
navigate("/users")
} catch (error: any) {
const message =
error?.response?.data?.message ||
"Failed to register user. Please check the details and try again."
toast.error("Registration failed", {
description: message,
})
} finally {
setSubmitting(false)
}
}
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" onClick={() => navigate("/users")} className="h-8 w-8">
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-2xl font-bold text-grayScale-600">Register New User</h1>
<p className="text-sm text-grayScale-400">Add a new user to the system</p>
</div>
</div>
<Card className="mx-auto max-w-2xl p-6">
<form className="space-y-5" onSubmit={handleSubmit}>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label className="mb-2 flex items-center gap-1.5 text-sm font-medium text-grayScale-600">
<User className="h-4 w-4" />
First Name
</label>
<Input
placeholder="Enter first name"
required
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
/>
</div>
<div>
<label className="mb-2 flex items-center gap-1.5 text-sm font-medium text-grayScale-600">
<User className="h-4 w-4" />
Last Name
</label>
<Input
placeholder="Enter last name"
required
value={lastName}
onChange={(e) => setLastName(e.target.value)}
/>
</div>
</div>
<div>
<label className="mb-2 flex items-center gap-1.5 text-sm font-medium text-grayScale-600">
<Mail className="h-4 w-4" />
Email
</label>
<Input
type="email"
placeholder="Enter email address"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<div>
<label className="mb-2 flex items-center gap-1.5 text-sm font-medium text-grayScale-600">
<Phone className="h-4 w-4" />
Phone
</label>
<Input
type="tel"
placeholder="Enter phone number"
required
value={phone}
onChange={(e) => setPhone(e.target.value)}
/>
</div>
<div>
<label className="mb-2 flex items-center gap-1.5 text-sm font-medium text-grayScale-600">
<Shield className="h-4 w-4" />
Role
</label>
<Select
required
value={role}
onChange={(e) => setRole(e.target.value)}
>
<option value="">Select role</option>
<option value="ADMIN">Admin</option>
<option value="USER">User</option>
</Select>
</div>
<div>
<label className="mb-2 flex items-center gap-1.5 text-sm font-medium text-grayScale-600">
<FileText className="h-4 w-4" />
Notes
</label>
<Textarea
placeholder="Enter any additional notes"
rows={3}
value={notes}
onChange={(e) => setNotes(e.target.value)}
/>
</div>
<div className="flex flex-col-reverse gap-2 pt-2 sm:flex-row sm:justify-end">
<Button
type="button"
variant="outline"
className="w-full sm:w-auto"
onClick={() => navigate("/users")}
disabled={submitting}
>
Cancel
</Button>
<Button
type="submit"
className="w-full bg-brand-500 hover:bg-brand-600 sm:w-auto"
disabled={submitting}
>
{submitting ? "Registering..." : "Register User"}
</Button>
</div>
</form>
</Card>
</div>
)
}

View File

@ -39,6 +39,7 @@ import {
TableRow,
} from "../../components/ui/table";
import { Select } from "../../components/ui/select";
import { SpinnerIcon } from "../../components/ui/spinner-icon";
import type { LearnerCourseProgressItem, LearnerCourseProgressSummary } from "../../types/progress.types";
import type { Course } from "../../types/course.types";
@ -475,7 +476,7 @@ export function UserDetailPage() {
{!progressError && loadingProgress && (
<div className="flex items-center gap-2 rounded-lg border border-grayScale-200 bg-grayScale-100 px-3 py-2 text-xs text-grayScale-500">
<RefreshCw className="h-3.5 w-3.5 animate-spin" />
<SpinnerIcon className="h-3.5 w-3.5" />
Loading learner progress...
</div>
)}
@ -487,10 +488,10 @@ export function UserDetailPage() {
)}
{!progressError && !loadingProgress && progressItems.length > 0 && (
<div className="overflow-x-auto rounded-lg border border-grayScale-200">
<div className="overflow-x-auto rounded-xl border bg-white">
<Table>
<TableHeader>
<TableRow className="bg-grayScale-100/70">
<TableRow>
<TableHead>Course</TableHead>
<TableHead>Level</TableHead>
<TableHead>Status</TableHead>

View File

@ -2,15 +2,15 @@ import { useEffect, useState } from "react"
import { Link } from "react-router-dom"
import {
Users,
UserPlus,
UserX,
UserCheck,
TrendingUp,
ArrowRight,
List,
UsersRound,
Loader2,
} from "lucide-react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../../components/ui/card"
import { SpinnerIcon } from "../../components/ui/spinner-icon"
import { getDashboard } from "../../api/analytics.api"
import type { DashboardUsers } from "../../types/analytics.types"
@ -57,7 +57,7 @@ export function UserManagementDashboard() {
<div className="min-w-0">
<p className="text-sm font-medium text-white/80">Total Users</p>
<p className="text-2xl font-bold text-white">
{statsLoading ? <Loader2 className="h-5 w-5 animate-spin text-white" /> : stats ? formatNum(stats.total_users) : "—"}
{statsLoading ? <SpinnerIcon className="h-5 w-5" /> : stats ? formatNum(stats.total_users) : "—"}
</p>
</div>
</CardContent>
@ -72,7 +72,7 @@ export function UserManagementDashboard() {
<p className="text-sm font-medium text-white/80">Active Users</p>
<p className="text-2xl font-bold text-white">
{statsLoading ? (
<Loader2 className="h-5 w-5 animate-spin text-white" />
<SpinnerIcon className="h-5 w-5" />
) : activeUsers !== null ? (
formatNum(activeUsers)
) : (
@ -92,7 +92,7 @@ export function UserManagementDashboard() {
<p className="text-sm font-medium text-white/80">New This Month</p>
<p className="text-2xl font-bold text-white">
{statsLoading ? (
<Loader2 className="h-5 w-5 animate-spin text-white" />
<SpinnerIcon className="h-5 w-5" />
) : stats ? (
formatNum(stats.new_month)
) : (
@ -108,22 +108,22 @@ export function UserManagementDashboard() {
<div>
<h2 className="mb-4 text-lg font-semibold text-grayScale-600">Quick Actions</h2>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Link to="/users/register" className="group">
<Link to="/users/deletion-requests" className="group">
<Card className="h-full border border-grayScale-100 shadow-sm transition-all duration-200 group-hover:border-brand-200 group-hover:shadow-md">
<CardHeader className="pb-3">
<div className="mb-3 grid h-11 w-11 place-items-center rounded-lg bg-brand-100 text-brand-600 transition-colors group-hover:bg-brand-500 group-hover:text-white">
<UserPlus className="h-5 w-5" />
<UserX className="h-5 w-5" />
</div>
<CardTitle className="text-base font-semibold text-grayScale-600">
Register User
Deletion Requests
</CardTitle>
<CardDescription className="text-sm text-grayScale-400">
Add new users to the system with role assignment.
Review account deletion requests and user deletion states.
</CardDescription>
</CardHeader>
<CardContent>
<span className="inline-flex items-center gap-1.5 text-sm font-medium text-brand-500 transition-colors group-hover:text-brand-600">
Get started
View requests
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
</span>
</CardContent>

View File

@ -1,4 +1,4 @@
import { ChevronDown, ChevronLeft, ChevronRight, Search, Users, X } from "lucide-react"
import { ChevronDown, ChevronLeft, ChevronRight, Search, UserCheck, Users, X } from "lucide-react"
import { useEffect, useState } from "react"
import { useNavigate } from "react-router-dom"
import { Input } from "../../components/ui/input"
@ -36,9 +36,11 @@ export function UsersListPage() {
} | null>(null)
const [roleFilter, setRoleFilter] = useState("")
const [statusFilter, setStatusFilter] = useState("")
const [loading, setLoading] = useState(false)
useEffect(() => {
const fetchUsers = async () => {
setLoading(true)
try {
const res = await getUsers(
page,
@ -62,6 +64,8 @@ export function UsersListPage() {
console.error("Failed to fetch users:", error)
setUsers([])
setTotal(0)
} finally {
setLoading(false)
}
}
@ -93,6 +97,9 @@ export function UsersListPage() {
}
const allSelected = users.length > 0 && selectedIds.size === users.length
const startEntry = total === 0 ? 0 : (safePage - 1) * pageSize + 1
const endEntry = Math.min(safePage * pageSize, total)
const activeUsersOnPage = users.filter((u) => (u.status || "").toUpperCase() === "ACTIVE").length
const getPageNumbers = () => {
const pages: (number | string)[] = []
@ -169,6 +176,37 @@ export function UsersListPage() {
<p className="text-sm text-grayScale-400">View and manage all registered users.</p>
</div>
{/* Stats cards (match UserLogPage approach) */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div className="flex items-center gap-4 rounded-xl border bg-white p-4">
<div className="grid h-10 w-10 place-items-center rounded-lg bg-brand-100 text-brand-600">
<Users className="h-5 w-5" />
</div>
<div>
<p className="text-2xl font-bold text-grayScale-600">{total}</p>
<p className="text-xs text-grayScale-400">Total Users</p>
</div>
</div>
<div className="flex items-center gap-4 rounded-xl border bg-white p-4">
<div className="grid h-10 w-10 place-items-center rounded-lg bg-emerald-100 text-emerald-600">
<UserCheck className="h-5 w-5" />
</div>
<div>
<p className="text-2xl font-bold text-grayScale-600">{activeUsersOnPage}</p>
<p className="text-xs text-grayScale-400">Active In Current Page</p>
</div>
</div>
<div className="flex items-center gap-4 rounded-xl border bg-white p-4">
<div className="grid h-10 w-10 place-items-center rounded-lg bg-amber-100 text-amber-600">
<Search className="h-5 w-5" />
</div>
<div>
<p className="text-2xl font-bold text-grayScale-600">{users.length}</p>
<p className="text-xs text-grayScale-400">Showing Results</p>
</div>
</div>
</div>
<div className="bg-white rounded-xl border">
{/* Search & Filters */}
<div className="p-4 border-b">
@ -238,7 +276,13 @@ export function UsersListPage() {
</TableHeader>
<TableBody>
{users.length === 0 ? (
{loading ? (
<TableRow>
<TableCell colSpan={7} className="py-12 text-center">
<p className="text-sm text-grayScale-400">Loading users...</p>
</TableCell>
</TableRow>
) : users.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="py-16 text-center">
<div className="flex flex-col items-center gap-3">
@ -259,7 +303,7 @@ export function UsersListPage() {
return (
<TableRow
key={u.id}
className="cursor-pointer hover:bg-grayScale-50"
className="group cursor-pointer"
onClick={() => handleRowClick(u.id)}
>
<TableCell onClick={(e) => e.stopPropagation()}>
@ -318,9 +362,16 @@ export function UsersListPage() {
</Table>
{/* Pagination */}
<div className="flex flex-col items-center gap-3 border-t px-4 py-3 text-sm text-grayScale-500 sm:flex-row sm:justify-between">
<div className="flex flex-wrap items-center justify-between gap-3 border-t px-4 py-3 text-sm text-grayScale-500">
<div className="flex items-center gap-2">
<span>Row Per Page</span>
<span>Showing</span>
<span className="font-medium text-grayScale-600">
{startEntry}-{endEntry}
</span>
<span>of</span>
<span className="font-medium text-grayScale-600">{total}</span>
<span className="mr-4">entries</span>
<span className="border-l pl-4">Rows per page</span>
<div className="relative">
<select
value={pageSize}
@ -338,7 +389,6 @@ export function UsersListPage() {
</select>
<ChevronDown className="absolute right-2 top-1/2 h-3 w-3 -translate-y-1/2 text-grayScale-400 pointer-events-none" />
</div>
<span>Entries</span>
</div>
<div className="flex items-center gap-1">

View File

@ -260,6 +260,21 @@ export interface CreateSubCourseVideoRequest {
video_url: string
}
export type VideoVisibility = "PUBLISHED" | "DRAFT" | "PRIVATE" | "UNLISTED" | string
export type VideoStatus = "PUBLISHED" | "DRAFT" | "ARCHIVED" | string
export interface CreateCourseVideoRequest {
sub_course_id: number
title: string
description: string
video_url: string
duration: number
resolution?: string
visibility?: VideoVisibility
display_order?: number
status?: VideoStatus
}
export interface CreateVimeoVideoRequest {
sub_course_id: number
title: string
@ -484,6 +499,7 @@ export interface CreateQuestionRequest {
tips?: string
explanation?: string
status?: string
image_url?: string
options?: QuestionOption[]
voice_prompt?: string
sample_answer_voice_prompt?: string
@ -518,6 +534,7 @@ export interface QuestionDetail {
short_answers?: string[] | QuestionShortAnswer[]
tips?: string | null
explanation?: string | null
image_url?: string | null
voice_prompt?: string | null
sample_answer_voice_prompt?: string | null
audio_correct_answer_text?: string | null

View File

@ -30,6 +30,22 @@ export interface CreateTeamMemberRequest {
bio?: string
}
export interface UpdateTeamMemberRequest {
bio?: string
department?: string
emergency_contact?: string
employment_type?: string
first_name?: string
hire_date?: string
job_title?: string
last_name?: string
permissions?: string[]
phone_number?: string
profile_picture_url?: string
team_role?: string
work_phone?: string
}
export interface TeamMembersMetadata {
total: number
total_pages: number

View File

@ -146,3 +146,85 @@ export interface UpdateProfileRequest {
profile_picture_url?: string
preferred_language?: string
}
export type DeletionUserStatus = "ACTIVE" | "PENDING" | "SUSPENDED" | "DEACTIVATED"
export type DeletionState = "PENDING" | "DUE" | "CANCELLED"
export interface DeletionRequestApiItem {
UserID?: number
FirstName?: string
LastName?: string
Email?: string
PhoneNumber?: string
Role?: string
Status?: DeletionUserStatus | string
DeletionRequestedAt?: string | null
DeletionScheduledAt?: string | null
DeletionCancelledAt?: string | null
DeletionState?: DeletionState | string
user_id?: number
first_name?: string
last_name?: string
email?: string
phone_number?: string
role?: string
status?: DeletionUserStatus | string
deletion_requested_at?: string | null
deletion_scheduled_at?: string | null
deletion_cancelled_at?: string | null
deletion_state?: DeletionState | string
}
export interface DeletionRequest {
user_id: number
first_name: string
last_name: string
email: string
phone_number: string
role: string
status: string
deletion_requested_at: string | null
deletion_scheduled_at: string | null
deletion_cancelled_at: string | null
deletion_state: string
}
export interface GetDeletionRequestsParams {
query?: string
role?: string
status?: DeletionUserStatus
state?: DeletionState
requested_before?: string
requested_after?: string
scheduled_before?: string
scheduled_after?: string
page?: number
page_size?: number
}
export interface GetDeletionRequestsResponse {
status: string
message: string
data: {
items: DeletionRequestApiItem[]
total: number
page: number
page_size: number
}
timestamp: string
}
export const mapDeletionRequestApiItem = (item: DeletionRequestApiItem): DeletionRequest => ({
user_id: item.user_id ?? item.UserID ?? 0,
first_name: item.first_name ?? item.FirstName ?? "",
last_name: item.last_name ?? item.LastName ?? "",
email: item.email ?? item.Email ?? "",
phone_number: item.phone_number ?? item.PhoneNumber ?? "",
role: item.role ?? item.Role ?? "",
status: item.status ?? item.Status ?? "",
deletion_requested_at: item.deletion_requested_at ?? item.DeletionRequestedAt ?? null,
deletion_scheduled_at: item.deletion_scheduled_at ?? item.DeletionScheduledAt ?? null,
deletion_cancelled_at: item.deletion_cancelled_at ?? item.DeletionCancelledAt ?? null,
deletion_state: item.deletion_state ?? item.DeletionState ?? "",
})