diff --git a/.env b/.env index 0855a36..4359c69 100644 --- a/.env +++ b/.env @@ -1,3 +1,3 @@ -# VITE_API_BASE_URL=https://api.yimaru.yaltopia.com/api/v1 -VITE_API_BASE_URL=http://localhost:8432/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= diff --git a/src/api/courses.api.ts b/src/api/courses.api.ts index e81a848..3e14ec3 100644 --- a/src/api/courses.api.ts +++ b/src/api/courses.api.ts @@ -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}`) diff --git a/src/api/files.api.ts b/src/api/files.api.ts new file mode 100644 index 0000000..5397182 --- /dev/null +++ b/src/api/files.api.ts @@ -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("/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("/files/url", { + params: { key }, + }) + diff --git a/src/api/http.ts b/src/api/http.ts index 6e9d62f..5ba1228 100644 --- a/src/api/http.ts +++ b/src/api/http.ts @@ -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; diff --git a/src/api/team.api.ts b/src/api/team.api.ts index 6780890..bfd5d4b 100644 --- a/src/api/team.api.ts +++ b/src/api/team.api.ts @@ -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("/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) diff --git a/src/api/users.api.ts b/src/api/users.api.ts index e270d57..9d191dd 100644 --- a/src/api/users.api.ts +++ b/src/api/users.api.ts @@ -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("/users/summary"); + +export const getDeletionRequests = (params: GetDeletionRequestsParams) => + http.get("/admin/users/deletion-requests", { params }); + +export const updateUserProfilePicture = (id: number, profilePictureUrl: string) => + http.post(`/user/${id}/profile-picture`, { + profile_picture_url: profilePictureUrl, + }); diff --git a/src/app/AppRoutes.tsx b/src/app/AppRoutes.tsx index 09cf9a8..f4468e0 100644 --- a/src/app/AppRoutes.tsx +++ b/src/app/AppRoutes.tsx @@ -22,7 +22,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" @@ -40,6 +40,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 ( @@ -47,13 +51,17 @@ export function AppRoutes() { } /> } /> } /> + } /> + } /> + } /> + } /> }> } /> } /> }> } /> } /> - } /> + } /> } /> } /> diff --git a/src/components/topbar/NotificationDropdown.tsx b/src/components/topbar/NotificationDropdown.tsx index 59da1f8..2bb83ad 100644 --- a/src/components/topbar/NotificationDropdown.tsx +++ b/src/components/topbar/NotificationDropdown.tsx @@ -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" @@ -213,7 +213,7 @@ export function NotificationDropdown() {
{loading ? (
- +
) : notifications.length === 0 ? (
diff --git a/src/components/ui/dropdown-menu.tsx b/src/components/ui/dropdown-menu.tsx index c88dc14..426eab1 100644 --- a/src/components/ui/dropdown-menu.tsx +++ b/src/components/ui/dropdown-menu.tsx @@ -18,7 +18,7 @@ export const DropdownMenuSubTrigger = React.forwardRef< (({ className, inset, ...props }, ref) => ( )) diff --git a/src/components/ui/select.tsx b/src/components/ui/select.tsx index feb83e8..278e514 100644 --- a/src/components/ui/select.tsx +++ b/src/components/ui/select.tsx @@ -10,7 +10,7 @@ export const Select = React.forwardRef(
updateField("first_name", e.target.value)} - placeholder="First name" - /> - updateField("last_name", e.target.value)} - placeholder="Last name" - /> - - #{profile.id} - +
+
- ) : ( -
-

- {fullName} -

- {profile.nick_name && ( - - @{profile.nick_name} - - )} - - #{profile.id} - -
- )} + - {/* Badges row */} -
- - - {profile.role} - - - - {profile.status} - - - - Joined {formatDate(profile.created_at)} - -
+
+
+ + +
+

Summary

+ +
+
+
{profile.job_title || "Role-focused work item"}
+
{profile.team_role || "Team responsibility"}
+
{profile.email}
+
{profile.phone_number || "No phone number"}
+
+
+
+ + + +

Employment

+
+

{profile.department || "Department not set"}

+

Employment type: {profile.employment_type || "—"}

+
+
+
+ + + +

More about me

+
+ Status {profile.status} + Email {profile.email_verified ? "verified" : "not verified"} + Joined {formatDate(profile.created_at)} + Last login {formatDateTime(profile.last_login)} +
+
+
+ + + +
+

First Name

+

{profile.first_name}

+
+
+

Last Name

+

{profile.last_name}

+
+
+

Department

+

{profile.department || "—"}

+
+
+

Team Role

+

{profile.team_role || "—"}

+
+
+

Job Title

+

{profile.job_title || "—"}

+
+
+

Hire Date

+

{formatDate(profile.hire_date) || "—"}

+
+
+

Phone Number

+

{profile.phone_number || "—"}

+
+
+
+
+
+ !saving && setEditing(open)}> + + + Edit profile + - {/* ─── Detail Cards Grid ─── */} -
- {/* ── Contact & Personal ── */} - -
- -
- {/* Contact */} -
-

- Contact -

-
- } - /> - } - /> - - updateField("region", e.target.value)} - placeholder="Region" - /> - updateField("country", e.target.value)} - placeholder="Country" - /> -
- } - /> - updateField("preferred_language", e.target.value)} - > - - - - - - - } - /> -
-
- - {/* Personal */} -
-

- Personal -

-
- updateField("birth_day", e.target.value)} - /> - } - /> - updateField("gender", e.target.value)} - > - - - - - - } - /> - updateField("age_group", e.target.value)} - > - - - - - - - - - } - /> - updateField("occupation", e.target.value)} - placeholder="Occupation" - /> - } - /> - updateField("education_level", e.target.value)} - placeholder="Education level" - /> - } - /> -
+
+
+

First Name

+ updateField("first_name", e.target.value)} /> +
+
+

Last Name

+ updateField("last_name", e.target.value)} /> +
+
+

Phone Number

+ updateField("phone_number", e.target.value)} /> +
+
+

Profile Picture

+
+ + updateField("profile_picture_url", e.target.value)} + placeholder="Or paste image URL (https://...)" + />
- - - - {/* ── Right Sidebar ── */} -
- {/* Profile Completion */} - -
- - -
-

Profile Completion

-

- {completionPct === 100 ? "All set!" : "Complete your profile for the best experience."} -

-
-
- - - {/* Activity */} - -
- -

- Activity -

-
-
- -
-
-

Last Login

-

{formatDateTime(profile.last_login)}

-
-
-
-
- -
-
-

Account Created

-

{formatDateTime(profile.created_at)}

-
-
-
- - - {/* Quick Account Info */} - -
- -

- Account -

-
-
- Role - - {profile.role} - -
-
- Status - - - {profile.status} - -
-
- Email - - - {profile.email} - - - -
-
- Phone - - - {profile.phone_number || "—"} - - - -
-
-
- -
-
- - {/* ─── Learning & Goals Card ─── */} - -
-
-

- Learning & Preferences -

-
- -
-
- updateField("learning_goal", e.target.value)} - placeholder="Your learning goal" - /> - } - /> -
-
- updateField("language_goal", e.target.value)} - placeholder="Language goal" - /> - } - /> -
-
- updateField("language_challange", e.target.value)} - placeholder="Language challenge" - /> - } - /> -
-
- updateField("favoutite_topic", e.target.value)} - placeholder="Favourite topic" - /> - } +
+

Bio

+