1
1
import cn from "classnames" ;
2
2
import dayjs from "dayjs" ;
3
- import React , { useCallback , useMemo } from "react" ;
3
+ import React , { useEffect , useMemo , useRef , useState } from "react" ;
4
4
import { Link , useNavigate } from "react-router-dom" ;
5
5
6
6
import { ProfileInfo , SubscriptionQuota } from "@/components/Navbar" ;
7
7
import { useSaveLogs } from "@/hooks/logger" ;
8
- import Crown from "@litespace/assets/Crown" ;
9
8
import { useSubscription } from "@litespace/headless/context/subscription" ;
10
9
import { useUser } from "@litespace/headless/context/user" ;
10
+ import { useFindLessons } from "@litespace/headless/lessons" ;
11
11
import { IUser } from "@litespace/types" ;
12
12
import { Button } from "@litespace/ui/Button" ;
13
13
import { useFormatMessage } from "@litespace/ui/hooks/intl" ;
14
14
import { Tooltip } from "@litespace/ui/Tooltip" ;
15
15
import { Typography } from "@litespace/ui/Typography" ;
16
16
import { isTutorRole } from "@litespace/utils" ;
17
17
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 ;
18
22
19
23
const Navbar : React . FC = ( ) => {
20
24
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" >
22
26
< div
23
27
className = { cn ( "flex justify-between gap-8 items-center py-6 px-4" , {
24
28
"max-w-screen-3xl mx-auto" : location . pathname !== Web . Chat ,
@@ -40,45 +44,144 @@ const Subscription: React.FC = () => {
40
44
const { user } = useUser ( ) ;
41
45
const { info, remainingWeeklyMinutes, loading } = useSubscription ( ) ;
42
46
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 ] ) ;
43
75
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" ;
48
119
49
120
if ( loading || ! user || isTutorRole ( user . role ) ) return null ;
50
121
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" ;
52
130
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 }
58
143
>
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 >
67
151
) ;
152
+ }
68
153
69
154
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
+ </ >
82
185
) ;
83
186
} ;
84
187
@@ -88,14 +191,14 @@ const User: React.FC = () => {
88
191
const navigate = useNavigate ( ) ;
89
192
const { save : saveLogs } = useSaveLogs ( ) ;
90
193
91
- const navToSettings = useCallback ( ( ) => {
194
+ const navToSettings = ( ) => {
92
195
if ( ! user ) return ;
93
196
navigate (
94
197
user . role === IUser . Role . Student
95
198
? Web . StudentSettings
96
199
: Web . TutorProfileSettings
97
200
) ;
98
- } , [ user , navigate ] ) ;
201
+ } ;
99
202
100
203
if ( ! user )
101
204
return (
@@ -131,6 +234,8 @@ const User: React.FC = () => {
131
234
onDoubleClick = { async ( ) => {
132
235
saveLogs ( ) ;
133
236
} }
237
+ aria-label = { user . name ?? "" }
238
+ title = { user . name ?? "" }
134
239
>
135
240
< ProfileInfo
136
241
imageUrl = { user . image }
0 commit comments