Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apps/mobile/locales/ar-eg.json
Original file line number Diff line number Diff line change
Expand Up @@ -790,7 +790,7 @@
"student-dashboard.past-lessons.loading": "برجاء الانتظار... جاري تحميل الدروس السابقة!",
"student-dashboard.past-lessons.error": "عذرًا، حدث خطأ أثناء تحميل الدروس السابقة، برجاء المحاولة مرة أخرى",
"tour.sdashboard/1.title": "👋 مرحبًا بك في رحلتك لتعلّم اللغة!",
"tour.sdashboard/1.description": "من خلال صفحة المدرّسين، تستطيع ان تستعرض كل المعلمين وتقوم بـ حجز جلسة مباشرة مع أيّ معلم منهم.كل ما عليك هو ان تضغط على المدرّس المناسب لك، وتستعرض ملفه الشخصي، مواعيده المتاحة، وتبدأ الحجز في الحال!",
"tour.sdashboard/1.description": "من خلال صفحة المدرّسين، تستطيع ان تستعرض كل المعلمين وتقوم بـ حجز جلسة مباشرة مع أيّ معلم منهم.كل ما عليك هو ان تضغط على المدرّس المناسب لك، وتستعرض ملفه الشخصي، مواعيده المتاحة، وتبدأ الحجز في الحال!",
"tour.sdashboard/2.title": "هل وجدت المعلم المناسب لك؟",
"tour.sdashboard/2.description": "اضغط على 'احجز الآن' لكي تبدأ أول جلسة مع المعلم.",
"tour.sdashboard/3.title": "الفيديو التعريفي للمعلم",
Expand Down Expand Up @@ -896,10 +896,10 @@
"plan.instead-of": "بدلا من {value}",
"plan.price": "{value} جنيه مصري",
"plan.subscribe-now": "اشترك الآن",
"navbar.subscription.subscribe-now": "اشترك الآن",
"navbar.subscription.personal-quota": "الباقة الشخصية",
"navbar.subscription.quota-consumption": "تم استهلاك {value}",
"navbar.subscription.rest-of-quota": "متبقي {value} من هذا الأسبوع",
"navbar.subscription.rest-of-quota-trial": "متبقي {value} من الفترة التجريبية",
"navbar.subscription.tooltip": "رصيد دقائقك يتجدد تلقائيًا كل صباح يوم {day}.",
"navbar.main": "الرئيسية",
"navbar.register": "التسجيل",
Expand Down
46 changes: 46 additions & 0 deletions apps/web/src/components/Layout/LessonCountdown.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { Typography } from "@litespace/ui/Typography";
import React from "react";

type Props = {
label: string;
seconds: number | null;
};

const LessonCountdown: React.FC<Props> = ({ label, seconds }) => {
if (seconds === null) return null;

const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;

return (
<div className="flex flex-col items-center gap-1 mr-6 ml-auto w-[203px] h-[42px] text-center">
<Typography
tag="span"
className="text-tiny font-semibold text-natural-600"
>
{label}
</Typography>

<div className="flex items-center gap-3">
<Typography tag="span" className="text-base font-bold text-brand-500">
{secs}
</Typography>
<Typography tag="span" className="text-base font-bold text-brand-500">
:
</Typography>
<Typography tag="span" className="text-base font-bold text-brand-500">
{minutes}
</Typography>
<Typography tag="span" className="text-base font-bold text-brand-500">
:
</Typography>
<Typography tag="span" className="text-base font-bold text-brand-500">
{hours}
</Typography>
</div>
</div>
);
};

export default LessonCountdown;
176 changes: 141 additions & 35 deletions apps/web/src/components/Layout/Navbar.tsx
Original file line number Diff line number Diff line change
@@ -1,24 +1,28 @@
import cn from "classnames";
import dayjs from "dayjs";
import React, { useCallback, useMemo } from "react";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { Link, useNavigate } from "react-router-dom";

import { ProfileInfo, SubscriptionQuota } from "@/components/Navbar";
import { useSaveLogs } from "@/hooks/logger";
import Crown from "@litespace/assets/Crown";
import { useSubscription } from "@litespace/headless/context/subscription";
import { useUser } from "@litespace/headless/context/user";
import { useFindLessons } from "@litespace/headless/lessons";
import { IUser } from "@litespace/types";
import { Button } from "@litespace/ui/Button";
import { useFormatMessage } from "@litespace/ui/hooks/intl";
import { Tooltip } from "@litespace/ui/Tooltip";
import { Typography } from "@litespace/ui/Typography";
import { isTutorRole } from "@litespace/utils";
import { Web } from "@litespace/utils/routes";
import { router } from "@/lib/routes";
import LessonCountdown from "@/components/Layout/LessonCountdown";

const LESSON_WINDOW_SECONDS = 4 * 60 * 60;

const Navbar: React.FC = () => {
return (
<div className="shadow-app-navbar shadow lg:shadow-app-navbar-mobile w-full z-navbar bg-natural-50">
<div className="shadow-app-navbar lg:shadow-app-navbar-mobile w-full z-navbar bg-natural-50">
<div
className={cn("flex justify-between gap-8 items-center py-6 px-4", {
"max-w-screen-3xl mx-auto": location.pathname !== Web.Chat,
Expand All @@ -40,45 +44,145 @@ const Subscription: React.FC = () => {
const { user } = useUser();
const { info, remainingWeeklyMinutes, loading } = useSubscription();
const intl = useFormatMessage();
const now = useRef(dayjs().toISOString());
const lessons = useFindLessons({
canceled: false,
users: user ? [user.id] : [],
after: now.current,
userOnly: true,
size: 3,
});
const [remainingSeconds, setRemainingSeconds] = useState<number | null>(null);
const [, refreshLiveLesson] = useState(0);
const liveLesson = useMemo(() => {
const items = lessons.query.data?.list;
if (!items?.length) return null;
const present = dayjs();
return (
items.find(({ lesson }) => {
const start = dayjs(lesson.start);
const end = start.add(lesson.duration, "minute");
return !start.isAfter(present) && end.isAfter(present);
}) || null
);
}, [lessons.query.data?.list]);
const liveTutor = useMemo(() => {
if (!liveLesson) return null;
return liveLesson.members.find(
(member) => member.role !== IUser.Role.Student
);
}, [liveLesson]);

const ended = useMemo(
() => !!info && dayjs(info.end).isBefore(dayjs()),
[info]
);
useEffect(() => {
if (!liveLesson) return;
const id = window.setInterval(
() => refreshLiveLesson((value) => value + 1),
30_000
);
return () => window.clearInterval(id);
}, [liveLesson]);

useEffect(() => {
const firstLesson = lessons.query.data?.list?.[0];
if (!firstLesson?.lesson?.start) {
setRemainingSeconds(null);
return;
}

const target = dayjs(firstLesson.lesson.start);
const sync = () => {
const diff = target.diff(dayjs(), "second");
if (diff > 0 && diff <= LESSON_WINDOW_SECONDS) {
setRemainingSeconds(diff);
return true;
}
setRemainingSeconds(null);
return false;
};

if (!sync()) return;

const id = window.setInterval(() => {
if (!sync()) {
window.clearInterval(id);
}
}, 1000);

return () => window.clearInterval(id);
}, [lessons.query.data?.list]);

const ended = !!info && dayjs(info.end).isBefore(dayjs());
const noMinutesLeft = remainingWeeklyMinutes <= 0;
const showSubscribeCTA =
remainingSeconds === null && (noMinutesLeft || ended);
const ctaButtonClass =
"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";

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

if ((info?.id === -1 && remainingWeeklyMinutes === 0) || ended)
if (liveLesson && liveTutor) {
const durationSeconds = liveLesson.lesson.duration * 60;
const halfPassed =
durationSeconds > 0 &&
dayjs().diff(liveLesson.lesson.start, "second") >= durationSeconds / 2;
const liveLessonMessageKey = halfPassed
? "navbar.lesson.ongoing.after-half"
: "navbar.lesson.ongoing.message";
return (
<Link to={Web.Plans} tabIndex={-1}>
<Button
size="large"
htmlType="button"
endIcon={<Crown className="[&>*]:stroke-natural-50" />}
<div className="flex items-center gap-4">
<Typography
tag="p"
className="text-base font-medium text-right text-natural-700"
>
{intl(liveLessonMessageKey, {
tutorName: liveTutor.name,
})}
</Typography>
<Link
to={router.web({ route: Web.Lesson, id: liveLesson.lesson.id })}
tabIndex={-1}
>
<Typography
tag="span"
className="text-natural-50 text-body font-bold"
>
{intl("navbar.subscription.subscribe-now")}
</Typography>
</Button>
</Link>
<Button size="large" className={ctaButtonClass}>
<span className="text-base font-medium leading-6">
{intl("navbar.lesson.join-now")}
</span>
</Button>
</Link>
</div>
);
}

return (
<Tooltip
content={intl("navbar.subscription.tooltip", {
day: dayjs(info?.start).format("dddd"),
})}
>
<div>
<SubscriptionQuota
remainingMinutes={remainingWeeklyMinutes}
weeklyMinutes={info?.weeklyMinutes || 0}
/>
</div>
</Tooltip>
<>
<LessonCountdown
label={intl("navbar.lesson.starts-in")}
seconds={remainingSeconds}
/>
{showSubscribeCTA && (
<Link to={Web.Plans} tabIndex={-1}>
<Button size="large" htmlType="button" className={ctaButtonClass}>
<span className="text-base font-medium leading-6">
{intl("navbar.subscription.minutes-depleted")}
</span>
</Button>
</Link>
)}
{remainingSeconds === null && !showSubscribeCTA && (
<Tooltip
content={intl("navbar.subscription.tooltip", {
day: dayjs(info?.start).format("dddd"),
})}
>
<div>
<SubscriptionQuota
remainingMinutes={remainingWeeklyMinutes}
weeklyMinutes={info?.weeklyMinutes || 0}
period={info?.period}
/>
</div>
</Tooltip>
)}
</>
);
};

Expand All @@ -88,14 +192,14 @@ const User: React.FC = () => {
const navigate = useNavigate();
const { save: saveLogs } = useSaveLogs();

const navToSettings = useCallback(() => {
const navToSettings = () => {
if (!user) return;
navigate(
user.role === IUser.Role.Student
? Web.StudentSettings
: Web.TutorProfileSettings
);
}, [user, navigate]);
};

if (!user)
return (
Expand Down Expand Up @@ -131,6 +235,8 @@ const User: React.FC = () => {
onDoubleClick={async () => {
saveLogs();
}}
aria-label={user.name ?? ""}
title={user.name ?? ""}
>
<ProfileInfo
imageUrl={user.image}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@ import { formatMinutes, formatPercentage } from "@litespace/ui/utils";
import React, { useMemo } from "react";
import Info from "@litespace/assets/Info";
import cn from "classnames";
import { IPlan } from "@litespace/types";

const SubscriptionQouta: React.FC<{
weeklyMinutes: number;
remainingMinutes: number;
}> = ({ weeklyMinutes, remainingMinutes }) => {
period?: IPlan.Period;
}> = ({ weeklyMinutes, remainingMinutes, period }) => {
const intl = useFormatMessage();

const remaining = useMemo(() => {
Expand Down Expand Up @@ -59,9 +61,14 @@ const SubscriptionQouta: React.FC<{
tag="span"
className="text-natural-950 text-tiny font-semibold text-right"
>
{intl("navbar.subscription.rest-of-quota", {
value: formatMinutes(remainingMinutes),
})}
{intl(
period === IPlan.Period.FreeTrial
? "navbar.subscription.rest-of-quota-trial"
: "navbar.subscription.rest-of-quota",
{
value: formatMinutes(remainingMinutes),
}
)}
</Typography>
</div>
);
Expand Down
8 changes: 3 additions & 5 deletions apps/web/src/components/Session/InSession.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React, { useEffect, useState } from "react";
import { SessionChat } from "@/components/Session/SessionChat";
import { useNavigate, useSearchParams } from "react-router-dom";
import { useSearchParams } from "react-router-dom";
import Controllers, { Controller } from "@/components/Session/Controllers";
import CallMembers from "@/components/Session/CallMembers";
import { useMediaCall } from "@/hooks/mediaCall";
Expand All @@ -15,11 +15,10 @@ import { ISession, IUser, Wss } from "@litespace/types";
import { useSocket } from "@litespace/headless/socket";
import { useCreateReport } from "@litespace/headless/report";
import { useUser } from "@litespace/headless/context/user";
import { useReportLesson } from "@litespace/headless/lessons";
import dayjs from "@/lib/dayjs";
import { useOnError } from "@/hooks/error";
import { useToast } from "@litespace/ui/Toast";
import { useReportLesson } from "@litespace/headless/lessons";
import { Web } from "@litespace/utils/routes";
import { ConfirmationDialog } from "@litespace/ui/ConfirmationDialog";
import CallIncoming from "@litespace/assets/CallIncoming";
import { MIN_LESSON_DURATION } from "@litespace/utils";
Expand Down Expand Up @@ -47,7 +46,6 @@ const InSession: React.FC<{
const call = useMediaCall();
const intl = useFormatMessage();
const toast = useToast();
const navigate = useNavigate();
const { user } = useUser();
const { socket, reconnect } = useSocket();

Expand Down Expand Up @@ -150,7 +148,7 @@ const InSession: React.FC<{
});

const reportLesson = useReportLesson({
onSuccess: () => navigate(Web.UpcomingLessons),
onSuccess: () => {},
onError,
});

Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/components/UpcomingLessons/Content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ export const Content: React.FC<{
queryClient.invalidateQueries({
queryKey: [QueryKey.FindInfiniteLessons],
});
queryClient.invalidateQueries({
queryKey: [QueryKey.FindLessons],
});
}, [toast, intl, queryClient]);

const onCancelError = useCallback(
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/pages/LessonsSchedule.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ const LessonsSchedule: React.FC = () => {
toast.success({ title: intl("cancel-lesson.success") });
setLessonId(null);
invalidate([QueryKey.FindInfiniteLessons]);
invalidate([QueryKey.FindLessons]);
}, [toast, intl, invalidate]);

const onCancelError = useCallback(
Expand Down
Loading