Skip to content

Commit

Permalink
feat(ui): implemented full student dashboard
Browse files Browse the repository at this point in the history
  • Loading branch information
mostafakamar2308 committed Dec 19, 2024
1 parent 3e44f66 commit 9ae3cde
Show file tree
Hide file tree
Showing 30 changed files with 732 additions and 144 deletions.
19 changes: 19 additions & 0 deletions apps/nova/src/components/Common/Loader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { Typography } from "@litespace/luna/Typography";
import React from "react";

export const Loader: React.FC<{
text: string;
}> = ({ text }) => {
return (
<div className="h-full flex flex-col justify-center items-center gap-10 mt-20">
<div className="w-[80px] h-[80px] animate-spin flex items-center relative rounded-full justify-center bg-[conic-gradient(from_180deg_at_50%_50%,#1D7C4E_0deg,rgba(17,173,207,0)_360deg)]">
<div className="w-[60px] h-[60px] bg-white rounded-full" />
<div className="w-[13px] h-[10px] bg-[rgba(29,124,78,1)] rounded-full absolute bottom-0 left-1/2 -translate-x-1/2" />
</div>

<Typography element="h4" weight="bold" className="text-natural-950">
{text}
</Typography>
</div>
);
};
29 changes: 29 additions & 0 deletions apps/nova/src/components/Common/LoadingError.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import ExclaimationMarkCircle from "@litespace/assets/ExclaimationMarkCircle";
import { Button, ButtonVariant } from "@litespace/luna/Button";
import { useFormatMessage } from "@litespace/luna/hooks/intl";
import { Typography } from "@litespace/luna/Typography";
import { Void } from "@litespace/types";

export const LoadingError: React.FC<{ retry: Void; error: string }> = ({
retry,
error,
}) => {
const intl = useFormatMessage();
return (
<div className="flex flex-col mt-20 gap-10 items-center justify-center">
<div className="p-[6px] bg-destructive-200 rounded-full">
<ExclaimationMarkCircle />
</div>
<Typography
element="h4"
weight="bold"
className="text-natural-950 text-center"
>
{error}
</Typography>
<Button onClick={retry} variant={ButtonVariant.Secondary}>
{intl("global.retry")}
</Button>
</div>
);
};
2 changes: 1 addition & 1 deletion apps/nova/src/components/Layout/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ const Sidebar = () => {
return [
{
label: intl("sidebar.dashboard"),
route: Route.Root,
route: Route.Dashboard,
Icon: Home,
},
{
Expand Down
40 changes: 40 additions & 0 deletions apps/nova/src/components/dashboard/ChatSummaryWrapper.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { Route } from "@/types/routes";
import { useFindUserRooms } from "@litespace/headless/chat";
import { useUser } from "@litespace/headless/context/user";
import { ChatSummary, type ChatSummaryProps } from "@litespace/luna/Chat";
import { orUndefined } from "@litespace/sol/utils";
import { IRoom } from "@litespace/types";
import dayjs from "dayjs";
import { useMemo } from "react";

function organizeRooms(
list: IRoom.FindUserRoomsApiRecord[] | null
): ChatSummaryProps["rooms"] {
return (
list?.map((room) => ({
id: room.roomId,
url: Route.Chat.concat("?room=", room.roomId.toString()),
name: orUndefined(room.otherMember?.name),
image: orUndefined(room.otherMember?.image),
message: room.latestMessage?.text || "TODO",
sentAt: room.latestMessage?.updatedAt || dayjs().toString(), // TODO
read: room.unreadMessagesCount === 0,
})) || []
);
}

export const ChatSummaryWrapper = () => {
const { user } = useUser();
const rooms = useFindUserRooms(user?.id, { size: 4 });
const organizedRooms = useMemo(() => organizeRooms(rooms.list), [rooms.list]);

return (
<ChatSummary
loading={rooms.query.isLoading || rooms.query.isPending}
error={rooms.query.isError}
retry={rooms.query.refetch}
chatsUrl={Route.Chat}
rooms={organizedRooms}
/>
);
};
111 changes: 111 additions & 0 deletions apps/nova/src/components/dashboard/PastLessonsWrapper.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { Route } from "@/types/routes";
import { useUser } from "@litespace/headless/context/user";
import { useInfiniteLessons } from "@litespace/headless/lessons";
import { useFormatMessage } from "@litespace/luna/hooks/intl";
import { PastLessonsTable } from "@litespace/luna/Lessons";
import { Typography } from "@litespace/luna/Typography";
import { ILesson, IUser } from "@litespace/types";
import { useCallback, useMemo, useState } from "react";
import BookLesson from "@/components/Lessons/BookLesson";
import { LoadingError, Loader } from "@litespace/luna/Loading";
import { InView } from "react-intersection-observer";

function organizeLessons(
list: ILesson.FindUserLessonsApiResponse["list"] | null
) {
return (
list?.map((lesson) => {
const teacher = lesson.members.find(
(member) => member.role === IUser.Role.Tutor
);
return {
id: lesson.lesson.id,
start: lesson.lesson.start,
duration: lesson.lesson.duration,
tutor: {
id: teacher?.userId || 0,
name: teacher?.name || null,
imageUrl: teacher?.image || null,
},
};
}) || []
);
}

export const PastLessonsWrapper = () => {
const intl = useFormatMessage();
const [tutor, setTutor] = useState<number | null>(null);

const closeRebookingDialog = useCallback(() => {
setTutor(null);
}, []);

const openRebookingDialog = useCallback((tutorId: number) => {
setTutor(tutorId);
}, []);

const { user } = useUser();
const lessonsQuery = useInfiniteLessons({
users: user ? [user?.id] : [],
userOnly: true,
past: true,
future: false,
size: 8,
});

const lessons = useMemo(
() => organizeLessons(lessonsQuery.list),
[lessonsQuery.list]
);

return (
<div className="grid gap-6 ">
<Typography
element="subtitle-2"
weight="bold"
className="text-natural-950 "
>
{intl("student-dashboard.previous-lessons.title")}
</Typography>

{lessonsQuery.query.isError ? (
<LoadingError
error={intl("student-dashboard.error")}
retry={lessonsQuery.query.refetch}
/>
) : null}

{lessonsQuery.query.isPending || lessonsQuery.query.isLoading ? (
<div className="[&>*]:mt-0">
<Loader text={intl("student-dashboard.loading")} />
</div>
) : null}

{lessons &&
!lessonsQuery.query.isLoading &&
!lessonsQuery.query.isError ? (
<InView
as="div"
onChange={(inView) => {
if (inView && lessonsQuery.query.hasNextPage) lessonsQuery.more();
}}
className="max-h-[500px] overflow-y-auto scrollbar-thin scrollbar-thumb-border-stronger scrollbar-track-surface-300"
>
<PastLessonsTable
tutorsRoute={Route.Tutors}
onRebook={openRebookingDialog}
lessons={lessons}
/>
</InView>
) : null}

{tutor ? (
<BookLesson
close={closeRebookingDialog}
open={!!tutor}
tutorId={tutor}
/>
) : null}
</div>
);
};
47 changes: 47 additions & 0 deletions apps/nova/src/components/dashboard/StudentOverviewWrapper.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { useFormatMessage } from "@litespace/luna/hooks/intl";
import { StudentOverview } from "@litespace/luna/StudentOverview";
import { Typography } from "@litespace/luna/Typography";
import { useFindPublicStudentStats } from "@litespace/headless/student";
import { Loader, LoadingError } from "@litespace/luna/Loading";

export const StudentOverviewWrapper = () => {
const intl = useFormatMessage();

const statsQuery = useFindPublicStudentStats();

return (
<div className="grid gap-6 justify-items-start">
<Typography
element="subtitle-2"
weight="bold"
className="text-natural-950"
>
{intl("student-dashboard.overview.title")}
</Typography>

{statsQuery.isLoading || statsQuery.isPending ? (
<div className="w-full flex items-start justify-center [&>*]:mt-0">
<Loader text={intl("student-dashboard.loading")} />
</div>
) : null}

{statsQuery.isError ? (
<div className="w-full flex items-center justify-center [&>*]:mt-0">
<LoadingError
error={intl("student-dashboard.loading")}
retry={statsQuery.refetch}
/>
</div>
) : null}

{statsQuery.data ? (
<StudentOverview
tutorCount={statsQuery.data.tutorCount}
completedLessonCount={statsQuery.data.completedLessonCount}
totalLearningTime={statsQuery.data.totalLearningTime}
totalLessonCount={statsQuery.data.completedLessonCount}
/>
) : null}
</div>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { Route } from "@/types/routes";
import { useUser } from "@litespace/headless/context/user";
import { useInfiniteLessons } from "@litespace/headless/lessons";
import { UpcomingLessonsSummary } from "@litespace/luna/Lessons";
import { ILesson, IUser } from "@litespace/types";
import { useMemo } from "react";

function organizeUpcomingLessons(
list: ILesson.FindUserLessonsApiResponse["list"] | null
) {
return (
list?.map((item) => ({
start: item.lesson.start,
tutorName:
item.members.find((member) => member.role === IUser.Role.Tutor)?.name ||
null,
url: Route.Call,
})) || []
);
}

export const UpcomingLessonsSummaryWrapper = () => {
const { user } = useUser();

const lessonsQuery = useInfiniteLessons({
users: user ? [user?.id] : [],
userOnly: true,
future: true,
past: false,
size: 4,
});
const lessons = useMemo(
() => organizeUpcomingLessons(lessonsQuery.list),
[lessonsQuery.list]
);
return (
<UpcomingLessonsSummary
loading={lessonsQuery.query.isLoading || lessonsQuery.query.isPending}
error={lessonsQuery.query.isError}
retry={lessonsQuery.query.refetch}
lessons={lessons}
lessonsUrl={Route.UpcomingLessons}
tutorsUrl={Route.Tutors}
/>
);
};
18 changes: 16 additions & 2 deletions apps/nova/src/pages/Dashboard.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,21 @@
import React from "react";

import { ChatSummaryWrapper } from "@/components/dashboard/ChatSummaryWrapper";
import { UpcomingLessonsSummaryWrapper } from "@/components/dashboard/UpcomingLessonsSummaryWrapper";
import { PastLessonsWrapper } from "@/components/dashboard/PastLessonsWrapper";
import { StudentOverviewWrapper } from "@/components/dashboard/StudentOverviewWrapper";
const Dashboard: React.FC = () => {
return <div>Dashboard</div>;
return (
<div className="grid grid-cols-[66%,33%] p-6 gap-6">
<div className="flex flex-col gap-6">
<StudentOverviewWrapper />
<PastLessonsWrapper />
</div>
<div className="flex flex-col gap-6">
<UpcomingLessonsSummaryWrapper />
<ChatSummaryWrapper />
</div>
</div>
);
};

export default Dashboard;
29 changes: 20 additions & 9 deletions packages/assets/assets/people.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions packages/headless/src/constants/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export enum QueryKey {
FindWithdrawalMethods = "find-withdrawal-methods",
FindUserById = "find-user-by-id",
FindStudentStats = "find-student-stats",
FindPublicStudentStats = "find-public-student-stats",
FindTopic = "find-topic",
FindPeerId = "find-peer-id",
FindAsset = "find-asset",
Expand Down
12 changes: 12 additions & 0 deletions packages/headless/src/student.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,15 @@ export function useFindStudentStats(
enabled: !!id,
});
}

export function useFindPublicStudentStats() {
const atlas = useAtlas();
const findStats = useCallback(async () => {
return await atlas.user.findPublicStudentStats();
}, [atlas.user]);

return useQuery({
queryFn: findStats,
queryKey: [QueryKey.FindPublicStudentStats],
});
}
1 change: 1 addition & 0 deletions packages/luna/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
"./SidebarNav": "./dist/components/SidebarNav/index.js",
"./Slider": "./dist/components/Slider/index.js",
"./Stepper": "./dist/components/Stepper/index.js",
"./StudentOverview": "./dist/components/StudentOverview/index.js",
"./Switch": "./dist/components/Switch/index.js",
"./Textarea": "./dist/components/Textarea/index.js",
"./TextEditor": "./dist/components/TextEditor/index.js",
Expand Down
Loading

0 comments on commit 9ae3cde

Please sign in to comment.