Skip to content

Commit 113e46e

Browse files
committed
update(web): redesign studnet navbar
1 parent aeb1e81 commit 113e46e

File tree

10 files changed

+1095
-259
lines changed

10 files changed

+1095
-259
lines changed

apps/mobile/locales/ar-eg.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -790,7 +790,7 @@
790790
"student-dashboard.past-lessons.loading": "برجاء الانتظار... جاري تحميل الدروس السابقة!",
791791
"student-dashboard.past-lessons.error": "عذرًا، حدث خطأ أثناء تحميل الدروس السابقة، برجاء المحاولة مرة أخرى",
792792
"tour.sdashboard/1.title": "👋 مرحبًا بك في رحلتك لتعلّم اللغة!",
793-
"tour.sdashboard/1.description": "من خلال صفحة المدرّسين، تستطيع ان تستعرض كل المعلمين وتقوم بـ حجز جلسة مباشرة مع أيّ معلم منهم.كل ما عليك هو ان تضغط على المدرّس المناسب لك، وتستعرض ملفه الشخصي، مواعيده المتاحة، وتبدأ الحجز في الحال!",
793+
"tour.sdashboard/1.description": "من خلال صفحة المدرّسين، تستطيع ان تستعرض كل المعلمين وتقوم بـ حجز جلسة مباشرة مع أيّ معلم منهم.كل ما عليك هو ان تضغط على المدرّس المناسب لك، وتستعرض ملفه الشخصي، مواعيده المتاحة، وتبدأ الحجز في الحال!",
794794
"tour.sdashboard/2.title": "هل وجدت المعلم المناسب لك؟",
795795
"tour.sdashboard/2.description": "اضغط على 'احجز الآن' لكي تبدأ أول جلسة مع المعلم.",
796796
"tour.sdashboard/3.title": "الفيديو التعريفي للمعلم",
@@ -900,6 +900,7 @@
900900
"navbar.subscription.personal-quota": "الباقة الشخصية",
901901
"navbar.subscription.quota-consumption": "تم استهلاك {value}",
902902
"navbar.subscription.rest-of-quota": "متبقي {value} من هذا الأسبوع",
903+
"navbar.subscription.rest-of-quota-trial": "متبقي {value} من الفترة التجريبية",
903904
"navbar.subscription.tooltip": "رصيد دقائقك يتجدد تلقائيًا كل صباح يوم {day}.",
904905
"navbar.main": "الرئيسية",
905906
"navbar.register": "التسجيل",
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { Typography } from "@litespace/ui/Typography";
2+
import React from "react";
3+
4+
type Props = {
5+
label: string;
6+
seconds: number | null;
7+
};
8+
9+
const LessonCountdown: React.FC<Props> = ({ label, seconds }) => {
10+
if (seconds === null) return null;
11+
12+
const hours = Math.floor(seconds / 3600);
13+
const minutes = Math.floor((seconds % 3600) / 60);
14+
const secs = seconds % 60;
15+
16+
return (
17+
<div className="flex flex-col items-center gap-1 mr-6 ml-auto w-[203px] h-[42px] text-center">
18+
<Typography
19+
tag="span"
20+
className="text-tiny font-semibold text-natural-600"
21+
>
22+
{label}
23+
</Typography>
24+
25+
<div className="flex items-center gap-3">
26+
<Typography tag="span" className="text-base font-bold text-brand-500">
27+
{secs}
28+
</Typography>
29+
<Typography tag="span" className="text-base font-bold text-brand-500">
30+
:
31+
</Typography>
32+
<Typography tag="span" className="text-base font-bold text-brand-500">
33+
{minutes}
34+
</Typography>
35+
<Typography tag="span" className="text-base font-bold text-brand-500">
36+
:
37+
</Typography>
38+
<Typography tag="span" className="text-base font-bold text-brand-500">
39+
{hours}
40+
</Typography>
41+
</div>
42+
</div>
43+
);
44+
};
45+
46+
export default LessonCountdown;

apps/web/src/components/Layout/Navbar.tsx

Lines changed: 140 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,28 @@
11
import cn from "classnames";
22
import dayjs from "dayjs";
3-
import React, { useCallback, useMemo } from "react";
3+
import React, { useEffect, useMemo, useRef, useState } from "react";
44
import { Link, useNavigate } from "react-router-dom";
55

66
import { ProfileInfo, SubscriptionQuota } from "@/components/Navbar";
77
import { useSaveLogs } from "@/hooks/logger";
8-
import Crown from "@litespace/assets/Crown";
98
import { useSubscription } from "@litespace/headless/context/subscription";
109
import { useUser } from "@litespace/headless/context/user";
10+
import { useFindLessons } from "@litespace/headless/lessons";
1111
import { IUser } from "@litespace/types";
1212
import { Button } from "@litespace/ui/Button";
1313
import { useFormatMessage } from "@litespace/ui/hooks/intl";
1414
import { Tooltip } from "@litespace/ui/Tooltip";
1515
import { Typography } from "@litespace/ui/Typography";
1616
import { isTutorRole } from "@litespace/utils";
1717
import { Web } from "@litespace/utils/routes";
18+
import { router } from "@/lib/routes";
19+
import LessonCountdown from "./LessonCountdown";
20+
21+
const LESSON_WINDOW_SECONDS = 4 * 60 * 60;
1822

1923
const Navbar: React.FC = () => {
2024
return (
21-
<div className="shadow-app-navbar shadow lg:shadow-app-navbar-mobile w-full z-navbar bg-natural-50">
25+
<div className="shadow-app-navbar lg:shadow-app-navbar-mobile w-full z-navbar bg-natural-50">
2226
<div
2327
className={cn("flex justify-between gap-8 items-center py-6 px-4", {
2428
"max-w-screen-3xl mx-auto": location.pathname !== Web.Chat,
@@ -40,45 +44,144 @@ const Subscription: React.FC = () => {
4044
const { user } = useUser();
4145
const { info, remainingWeeklyMinutes, loading } = useSubscription();
4246
const intl = useFormatMessage();
47+
const now = useRef(dayjs().toISOString());
48+
const lessons = useFindLessons({
49+
canceled: false,
50+
users: user ? [user.id] : [],
51+
after: now.current,
52+
userOnly: true,
53+
size: 3,
54+
});
55+
const [remainingSeconds, setRemainingSeconds] = useState<number | null>(null);
56+
const [, refreshLiveLesson] = useState(0);
57+
const liveLesson = useMemo(() => {
58+
const items = lessons.query.data?.list;
59+
if (!items?.length) return null;
60+
const present = dayjs();
61+
return (
62+
items.find(({ lesson }) => {
63+
const start = dayjs(lesson.start);
64+
const end = start.add(lesson.duration, "minute");
65+
return !start.isAfter(present) && end.isAfter(present);
66+
}) || null
67+
);
68+
}, [lessons.query.data?.list, remainingSeconds]);
69+
const liveTutor = useMemo(() => {
70+
if (!liveLesson) return null;
71+
return liveLesson.members.find(
72+
(member) => member.role !== IUser.Role.Student
73+
);
74+
}, [liveLesson]);
4375

44-
const ended = useMemo(
45-
() => !!info && dayjs(info.end).isBefore(dayjs()),
46-
[info]
47-
);
76+
useEffect(() => {
77+
if (!liveLesson) return;
78+
const id = window.setInterval(
79+
() => refreshLiveLesson((value) => value + 1),
80+
30_000
81+
);
82+
return () => window.clearInterval(id);
83+
}, [liveLesson?.lesson.id]);
84+
85+
useEffect(() => {
86+
if (!lessons.query.data?.list?.[0]?.lesson?.start) {
87+
setRemainingSeconds(null);
88+
return;
89+
}
90+
91+
const target = dayjs(lessons.query.data?.list?.[0]?.lesson?.start);
92+
const sync = () => {
93+
const diff = target.diff(dayjs(), "second");
94+
if (diff > 0 && diff <= LESSON_WINDOW_SECONDS) {
95+
setRemainingSeconds(diff);
96+
return true;
97+
}
98+
setRemainingSeconds(null);
99+
return false;
100+
};
101+
102+
if (!sync()) return;
103+
104+
const id = window.setInterval(() => {
105+
if (!sync()) {
106+
window.clearInterval(id);
107+
}
108+
}, 1000);
109+
110+
return () => window.clearInterval(id);
111+
}, [lessons.query.data?.list?.[0]?.lesson?.start]);
112+
113+
const ended = !!info && dayjs(info.end).isBefore(dayjs());
114+
const noMinutesLeft = remainingWeeklyMinutes <= 0;
115+
const showSubscribeCTA =
116+
remainingSeconds === null && (noMinutesLeft || ended);
117+
const ctaButtonClass =
118+
"flex h-10 items-center justify-center gap-2 rounded-lg border border-brand-950/20 bg-brand-500 text-natural-0 px-4 hover:bg-brand-600 focus-visible:bg-brand-600 active:bg-brand-600";
48119

49120
if (loading || !user || isTutorRole(user.role)) return null;
50121

51-
if ((info?.id === -1 && remainingWeeklyMinutes === 0) || ended)
122+
if (liveLesson && liveTutor) {
123+
const durationSeconds = liveLesson.lesson.duration * 60;
124+
const halfPassed =
125+
durationSeconds > 0 &&
126+
dayjs().diff(liveLesson.lesson.start, "second") >= durationSeconds / 2;
127+
const liveLessonMessageKey = halfPassed
128+
? "navbar.lesson.ongoing.after-half"
129+
: "navbar.lesson.ongoing.message";
52130
return (
53-
<Link to={Web.Plans} tabIndex={-1}>
54-
<Button
55-
size="large"
56-
htmlType="button"
57-
endIcon={<Crown className="[&>*]:stroke-natural-50" />}
131+
<div className="flex items-center gap-4">
132+
<Typography
133+
tag="p"
134+
className="text-base font-medium text-right text-natural-700"
135+
>
136+
{intl(liveLessonMessageKey, {
137+
tutorName: liveTutor.name,
138+
})}
139+
</Typography>
140+
<Link
141+
to={router.web({ route: Web.Lesson, id: liveLesson.lesson.id })}
142+
tabIndex={-1}
58143
>
59-
<Typography
60-
tag="span"
61-
className="text-natural-50 text-body font-bold"
62-
>
63-
{intl("navbar.subscription.subscribe-now")}
64-
</Typography>
65-
</Button>
66-
</Link>
144+
<Button size="large" className={ctaButtonClass}>
145+
<span className="text-base font-medium leading-6">
146+
{intl("navbar.lesson.join-now")}
147+
</span>
148+
</Button>
149+
</Link>
150+
</div>
67151
);
152+
}
68153

69154
return (
70-
<Tooltip
71-
content={intl("navbar.subscription.tooltip", {
72-
day: dayjs(info?.start).format("dddd"),
73-
})}
74-
>
75-
<div>
76-
<SubscriptionQuota
77-
remainingMinutes={remainingWeeklyMinutes}
78-
weeklyMinutes={info?.weeklyMinutes || 0}
79-
/>
80-
</div>
81-
</Tooltip>
155+
<>
156+
<LessonCountdown
157+
label={intl("navbar.lesson.starts-in")}
158+
seconds={remainingSeconds}
159+
/>
160+
{showSubscribeCTA && (
161+
<Link to={Web.Plans} tabIndex={-1}>
162+
<Button size="large" htmlType="button" className={ctaButtonClass}>
163+
<span className="text-base font-medium leading-6">
164+
{intl("navbar.subscription.minutes-depleted")}
165+
</span>
166+
</Button>
167+
</Link>
168+
)}
169+
{remainingSeconds === null && !showSubscribeCTA && (
170+
<Tooltip
171+
content={intl("navbar.subscription.tooltip", {
172+
day: dayjs(info?.start).format("dddd"),
173+
})}
174+
>
175+
<div>
176+
<SubscriptionQuota
177+
remainingMinutes={remainingWeeklyMinutes}
178+
weeklyMinutes={info?.weeklyMinutes || 0}
179+
period={info?.period}
180+
/>
181+
</div>
182+
</Tooltip>
183+
)}
184+
</>
82185
);
83186
};
84187

@@ -88,14 +191,14 @@ const User: React.FC = () => {
88191
const navigate = useNavigate();
89192
const { save: saveLogs } = useSaveLogs();
90193

91-
const navToSettings = useCallback(() => {
194+
const navToSettings = () => {
92195
if (!user) return;
93196
navigate(
94197
user.role === IUser.Role.Student
95198
? Web.StudentSettings
96199
: Web.TutorProfileSettings
97200
);
98-
}, [user, navigate]);
201+
};
99202

100203
if (!user)
101204
return (
@@ -131,6 +234,8 @@ const User: React.FC = () => {
131234
onDoubleClick={async () => {
132235
saveLogs();
133236
}}
237+
aria-label={user.name ?? ""}
238+
title={user.name ?? ""}
134239
>
135240
<ProfileInfo
136241
imageUrl={user.image}

apps/web/src/components/Navbar/SubscriptionQuota/SubscriptionQouta.tsx

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,13 @@ import { formatMinutes, formatPercentage } from "@litespace/ui/utils";
44
import React, { useMemo } from "react";
55
import Info from "@litespace/assets/Info";
66
import cn from "classnames";
7+
import { IPlan } from "@litespace/types";
78

89
const SubscriptionQouta: React.FC<{
910
weeklyMinutes: number;
1011
remainingMinutes: number;
11-
}> = ({ weeklyMinutes, remainingMinutes }) => {
12+
period?: IPlan.Period;
13+
}> = ({ weeklyMinutes, remainingMinutes, period }) => {
1214
const intl = useFormatMessage();
1315

1416
const remaining = useMemo(() => {
@@ -59,9 +61,14 @@ const SubscriptionQouta: React.FC<{
5961
tag="span"
6062
className="text-natural-950 text-tiny font-semibold text-right"
6163
>
62-
{intl("navbar.subscription.rest-of-quota", {
63-
value: formatMinutes(remainingMinutes),
64-
})}
64+
{intl(
65+
period === IPlan.Period.FreeTrial
66+
? "navbar.subscription.rest-of-quota-trial"
67+
: "navbar.subscription.rest-of-quota",
68+
{
69+
value: formatMinutes(remainingMinutes),
70+
}
71+
)}
6572
</Typography>
6673
</div>
6774
);

apps/web/src/components/Session/InSession.tsx

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,11 @@ import { useUser } from "@litespace/headless/context/user";
1818
import dayjs from "@/lib/dayjs";
1919
import { useOnError } from "@/hooks/error";
2020
import { useToast } from "@litespace/ui/Toast";
21-
import { useReportLesson } from "@litespace/headless/lessons";
2221
import { Web } from "@litespace/utils/routes";
2322
import { ConfirmationDialog } from "@litespace/ui/ConfirmationDialog";
2423
import CallIncoming from "@litespace/assets/CallIncoming";
24+
import { useQueryClient } from "@tanstack/react-query";
25+
import { QueryKey } from "@litespace/headless/constants";
2526
import { MIN_LESSON_DURATION } from "@litespace/utils";
2627

2728
const InSession: React.FC<{
@@ -50,6 +51,7 @@ const InSession: React.FC<{
5051
const navigate = useNavigate();
5152
const { user } = useUser();
5253
const { socket, reconnect } = useSocket();
54+
const queryClient = useQueryClient();
5355

5456
const [chat, setChat] = useState(false);
5557
const [_, setParams] = useSearchParams();
@@ -149,8 +151,16 @@ const InSession: React.FC<{
149151
onError,
150152
});
151153

152-
const reportLesson = useReportLesson({
153-
onSuccess: () => navigate(Web.UpcomingLessons),
154+
const cancelLesson = useCancelLesson({
155+
onSuccess: () => {
156+
queryClient.invalidateQueries({
157+
queryKey: [QueryKey.FindInfiniteLessons],
158+
});
159+
queryClient.invalidateQueries({
160+
queryKey: [QueryKey.FindLessons],
161+
});
162+
navigate(Web.UpcomingLessons);
163+
},
154164
onError,
155165
});
156166

apps/web/src/components/UpcomingLessons/Content.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,9 @@ export const Content: React.FC<{
5252
queryClient.invalidateQueries({
5353
queryKey: [QueryKey.FindInfiniteLessons],
5454
});
55+
queryClient.invalidateQueries({
56+
queryKey: [QueryKey.FindLessons],
57+
});
5558
}, [toast, intl, queryClient]);
5659

5760
const onCancelError = useCallback(

apps/web/src/pages/LessonsSchedule.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ const LessonsSchedule: React.FC = () => {
8484
toast.success({ title: intl("cancel-lesson.success") });
8585
setLessonId(null);
8686
invalidate([QueryKey.FindInfiniteLessons]);
87+
invalidate([QueryKey.FindLessons]);
8788
}, [toast, intl, invalidate]);
8889

8990
const onCancelError = useCallback(

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,5 +118,5 @@
118118
"sharp"
119119
]
120120
},
121-
"packageManager": "pnpm@10.16.1+sha512.0e155aa2629db8672b49e8475da6226aa4bdea85fdcdfdc15350874946d4f3c91faaf64cbdc4a5d1ab8002f473d5c3fcedcd197989cf0390f9badd3c04678706"
121+
"packageManager": "pnpm@10.18.0"
122122
}

0 commit comments

Comments
 (0)