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 "@/components/Layout/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,145 @@ 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 ] ) ;
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 ] ) ;
84
+
85
+ useEffect ( ( ) => {
86
+ const firstLesson = lessons . query . data ?. list ?. [ 0 ] ;
87
+ if ( ! firstLesson ?. lesson ?. start ) {
88
+ setRemainingSeconds ( null ) ;
89
+ return ;
90
+ }
91
+
92
+ const target = dayjs ( firstLesson . lesson . start ) ;
93
+ const sync = ( ) => {
94
+ const diff = target . diff ( dayjs ( ) , "second" ) ;
95
+ if ( diff > 0 && diff <= LESSON_WINDOW_SECONDS ) {
96
+ setRemainingSeconds ( diff ) ;
97
+ return true ;
98
+ }
99
+ setRemainingSeconds ( null ) ;
100
+ return false ;
101
+ } ;
102
+
103
+ if ( ! sync ( ) ) return ;
104
+
105
+ const id = window . setInterval ( ( ) => {
106
+ if ( ! sync ( ) ) {
107
+ window . clearInterval ( id ) ;
108
+ }
109
+ } , 1000 ) ;
110
+
111
+ return ( ) => window . clearInterval ( id ) ;
112
+ } , [ lessons . query . data ?. list ] ) ;
113
+
114
+ const ended = ! ! info && dayjs ( info . end ) . isBefore ( dayjs ( ) ) ;
115
+ const noMinutesLeft = remainingWeeklyMinutes <= 0 ;
116
+ const showSubscribeCTA =
117
+ remainingSeconds === null && ( noMinutesLeft || ended ) ;
118
+ const ctaButtonClass =
119
+ "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
120
49
121
if ( loading || ! user || isTutorRole ( user . role ) ) return null ;
50
122
51
- if ( ( info ?. id === - 1 && remainingWeeklyMinutes === 0 ) || ended )
123
+ if ( liveLesson && liveTutor ) {
124
+ const durationSeconds = liveLesson . lesson . duration * 60 ;
125
+ const halfPassed =
126
+ durationSeconds > 0 &&
127
+ dayjs ( ) . diff ( liveLesson . lesson . start , "second" ) >= durationSeconds / 2 ;
128
+ const liveLessonMessageKey = halfPassed
129
+ ? "navbar.lesson.ongoing.after-half"
130
+ : "navbar.lesson.ongoing.message" ;
52
131
return (
53
- < Link to = { Web . Plans } tabIndex = { - 1 } >
54
- < Button
55
- size = "large"
56
- htmlType = "button"
57
- endIcon = { < Crown className = "[&>*]:stroke-natural-50" /> }
132
+ < div className = "flex items-center gap-4" >
133
+ < Typography
134
+ tag = "p"
135
+ className = "text-base font-medium text-right text-natural-700"
136
+ >
137
+ { intl ( liveLessonMessageKey , {
138
+ tutorName : liveTutor . name ,
139
+ } ) }
140
+ </ Typography >
141
+ < Link
142
+ to = { router . web ( { route : Web . Lesson , id : liveLesson . lesson . id } ) }
143
+ tabIndex = { - 1 }
58
144
>
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 >
145
+ < Button size = "large" className = { ctaButtonClass } >
146
+ < span className = "text-base font-medium leading-6" >
147
+ { intl ( "navbar.lesson.join-now" ) }
148
+ </ span >
149
+ </ Button >
150
+ </ Link >
151
+ </ div >
67
152
) ;
153
+ }
68
154
69
155
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 >
156
+ < >
157
+ < LessonCountdown
158
+ label = { intl ( "navbar.lesson.starts-in" ) }
159
+ seconds = { remainingSeconds }
160
+ />
161
+ { showSubscribeCTA && (
162
+ < Link to = { Web . Plans } tabIndex = { - 1 } >
163
+ < Button size = "large" htmlType = "button" className = { ctaButtonClass } >
164
+ < span className = "text-base font-medium leading-6" >
165
+ { intl ( "navbar.subscription.minutes-depleted" ) }
166
+ </ span >
167
+ </ Button >
168
+ </ Link >
169
+ ) }
170
+ { remainingSeconds === null && ! showSubscribeCTA && (
171
+ < Tooltip
172
+ content = { intl ( "navbar.subscription.tooltip" , {
173
+ day : dayjs ( info ?. start ) . format ( "dddd" ) ,
174
+ } ) }
175
+ >
176
+ < div >
177
+ < SubscriptionQuota
178
+ remainingMinutes = { remainingWeeklyMinutes }
179
+ weeklyMinutes = { info ?. weeklyMinutes || 0 }
180
+ period = { info ?. period }
181
+ />
182
+ </ div >
183
+ </ Tooltip >
184
+ ) }
185
+ </ >
82
186
) ;
83
187
} ;
84
188
@@ -88,14 +192,14 @@ const User: React.FC = () => {
88
192
const navigate = useNavigate ( ) ;
89
193
const { save : saveLogs } = useSaveLogs ( ) ;
90
194
91
- const navToSettings = useCallback ( ( ) => {
195
+ const navToSettings = ( ) => {
92
196
if ( ! user ) return ;
93
197
navigate (
94
198
user . role === IUser . Role . Student
95
199
? Web . StudentSettings
96
200
: Web . TutorProfileSettings
97
201
) ;
98
- } , [ user , navigate ] ) ;
202
+ } ;
99
203
100
204
if ( ! user )
101
205
return (
@@ -131,6 +235,8 @@ const User: React.FC = () => {
131
235
onDoubleClick = { async ( ) => {
132
236
saveLogs ( ) ;
133
237
} }
238
+ aria-label = { user . name ?? "" }
239
+ title = { user . name ?? "" }
134
240
>
135
241
< ProfileInfo
136
242
imageUrl = { user . image }
0 commit comments