Fix video player reloading by moving VideoPlayer component outside main component
- Move VideoPlayer component outside Bootcamp component to prevent re-creation on every render - This prevents useAdiloPlayer and all hooks from re-initializing unnecessarily - Video now plays and jumps correctly without reloading - Component structure now matches WebinarRecording page pattern 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -205,10 +205,28 @@ export default function Bootcamp() {
|
|||||||
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const isLessonCompleted = (lessonId: string) => {
|
// Helper function to get YouTube embed URL
|
||||||
return progress.some(p => p.lesson_id === lessonId);
|
const getYouTubeEmbedUrl = (url: string): string => {
|
||||||
};
|
const match = url.match(/(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/)([^&\s]+)/);
|
||||||
|
return match ? `https://www.youtube.com/embed/${match[1]}` : url;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Move VideoPlayer component outside main component to prevent re-creation on every render
|
||||||
|
const VideoPlayer = ({
|
||||||
|
lesson,
|
||||||
|
playerRef,
|
||||||
|
currentTime,
|
||||||
|
accentColor,
|
||||||
|
setCurrentTime
|
||||||
|
}: {
|
||||||
|
lesson: Lesson;
|
||||||
|
playerRef: React.RefObject<VideoPlayerRef>;
|
||||||
|
currentTime: number;
|
||||||
|
accentColor: string;
|
||||||
|
setCurrentTime: (time: number) => void;
|
||||||
|
}) => {
|
||||||
|
|
||||||
const formatTime = (seconds: number): string => {
|
const formatTime = (seconds: number): string => {
|
||||||
const hours = Math.floor(seconds / 3600);
|
const hours = Math.floor(seconds / 3600);
|
||||||
@@ -221,6 +239,289 @@ export default function Bootcamp() {
|
|||||||
return `${minutes}:${secs.toString().padStart(2, '0')}`;
|
return `${minutes}:${secs.toString().padStart(2, '0')}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const hasChapters = lesson.chapters && lesson.chapters.length > 0;
|
||||||
|
|
||||||
|
// Get video based on lesson's video_host (prioritize Adilo)
|
||||||
|
const getVideoSource = () => {
|
||||||
|
// If video_host is explicitly set, use it. Otherwise auto-detect with Adilo priority.
|
||||||
|
const lessonVideoHost = lesson.video_host || (
|
||||||
|
lesson.m3u8_url ? 'adilo' :
|
||||||
|
lesson.video_url?.includes('adilo.bigcommand.com') ? 'adilo' :
|
||||||
|
lesson.youtube_url || lesson.video_url?.includes('youtube.com') || lesson.video_url?.includes('youtu.be') ? 'youtube' :
|
||||||
|
'unknown'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (lessonVideoHost === 'adilo') {
|
||||||
|
// Adilo M3U8 streaming
|
||||||
|
if (lesson.m3u8_url && lesson.m3u8_url.trim()) {
|
||||||
|
return {
|
||||||
|
type: 'adilo',
|
||||||
|
m3u8Url: lesson.m3u8_url,
|
||||||
|
mp4Url: lesson.mp4_url || undefined,
|
||||||
|
videoHost: 'adilo'
|
||||||
|
};
|
||||||
|
} else if (lesson.mp4_url && lesson.mp4_url.trim()) {
|
||||||
|
// Fallback to MP4 only
|
||||||
|
return {
|
||||||
|
type: 'adilo',
|
||||||
|
mp4Url: lesson.mp4_url,
|
||||||
|
videoHost: 'adilo'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// YouTube or fallback
|
||||||
|
if (lessonVideoHost === 'youtube') {
|
||||||
|
if (lesson.youtube_url && lesson.youtube_url.trim()) {
|
||||||
|
return {
|
||||||
|
type: 'youtube',
|
||||||
|
url: lesson.youtube_url,
|
||||||
|
embedUrl: getYouTubeEmbedUrl(lesson.youtube_url)
|
||||||
|
};
|
||||||
|
} else if (lesson.video_url && lesson.video_url.trim()) {
|
||||||
|
// Fallback to old video_url for backward compatibility
|
||||||
|
return {
|
||||||
|
type: 'youtube',
|
||||||
|
url: lesson.video_url,
|
||||||
|
embedUrl: getYouTubeEmbedUrl(lesson.video_url)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final fallback: try embed code
|
||||||
|
return lesson.embed_code && lesson.embed_code.trim() ? {
|
||||||
|
type: 'embed',
|
||||||
|
html: lesson.embed_code
|
||||||
|
} : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Memoize video source to prevent unnecessary re-renders
|
||||||
|
const video = useMemo(getVideoSource, [lesson.id, lesson.video_host, lesson.m3u8_url, lesson.mp4_url, lesson.youtube_url, lesson.video_url, lesson.embed_code]);
|
||||||
|
|
||||||
|
// Show warning if no video available
|
||||||
|
if (!video) {
|
||||||
|
return (
|
||||||
|
<Card className="border-2 border-destructive bg-destructive/10 mb-6">
|
||||||
|
<CardContent className="py-12 text-center">
|
||||||
|
<p className="text-destructive font-medium">Konten tidak tersedia</p>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">Video belum dikonfigurasi untuk pelajaran ini.</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render based on video type
|
||||||
|
if (video.type === 'embed') {
|
||||||
|
return (
|
||||||
|
<div className="mb-6">
|
||||||
|
<div className="aspect-video bg-muted rounded-none overflow-hidden border-2 border-border">
|
||||||
|
<div dangerouslySetInnerHTML={{ __html: video.html }} />
|
||||||
|
</div>
|
||||||
|
{hasChapters && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<TimelineChapters
|
||||||
|
chapters={lesson.chapters}
|
||||||
|
currentTime={currentTime}
|
||||||
|
accentColor={accentColor}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adilo or YouTube with chapters support
|
||||||
|
const isYouTube = video.type === 'youtube';
|
||||||
|
const isAdilo = video.type === 'adilo';
|
||||||
|
|
||||||
|
// Memoize URL values to ensure they're stable across renders
|
||||||
|
const videoUrl = useMemo(() => (isYouTube ? video.url : undefined), [isYouTube, video.url]);
|
||||||
|
const m3u8Url = useMemo(() => (isAdilo ? video.m3u8Url : undefined), [isAdilo, video.m3u8Url]);
|
||||||
|
const mp4Url = useMemo(() => (isAdilo ? video.mp4Url : undefined), [isAdilo, video.mp4Url]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Video Player - Full Width */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<VideoPlayerWithChapters
|
||||||
|
ref={playerRef}
|
||||||
|
videoUrl={videoUrl}
|
||||||
|
m3u8Url={m3u8Url}
|
||||||
|
mp4Url={mp4Url}
|
||||||
|
videoHost={isAdilo ? 'adilo' : isYouTube ? 'youtube' : 'unknown'}
|
||||||
|
chapters={lesson.chapters}
|
||||||
|
accentColor={accentColor}
|
||||||
|
onTimeUpdate={setCurrentTime}
|
||||||
|
videoId={lesson.id}
|
||||||
|
videoType="lesson"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Timeline Chapters - Below video like WebinarRecording */}
|
||||||
|
{hasChapters && (
|
||||||
|
<div className="mb-6">
|
||||||
|
<TimelineChapters
|
||||||
|
chapters={lesson.chapters}
|
||||||
|
onChapterClick={(time) => {
|
||||||
|
if (playerRef.current) {
|
||||||
|
playerRef.current.jumpToTime(time);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
currentTime={currentTime}
|
||||||
|
accentColor={accentColor}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Bootcamp() {
|
||||||
|
const { slug, lessonId } = useParams<{ slug: string; lessonId?: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { user, loading: authLoading } = useAuth();
|
||||||
|
|
||||||
|
const [product, setProduct] = useState<Product | null>(null);
|
||||||
|
const [modules, setModules] = useState<Module[]>([]);
|
||||||
|
const [progress, setProgress] = useState<Progress[]>([]);
|
||||||
|
const [selectedLesson, setSelectedLesson] = useState<Lesson | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||||
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||||
|
const [userReview, setUserReview] = useState<UserReview | null>(null);
|
||||||
|
const [reviewModalOpen, setReviewModalOpen] = useState(false);
|
||||||
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
|
const [accentColor, setAccentColor] = useState<string>('');
|
||||||
|
const playerRef = useRef<VideoPlayerRef>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!authLoading && !user) {
|
||||||
|
navigate('/auth');
|
||||||
|
} else if (user && slug) {
|
||||||
|
checkAccessAndFetch();
|
||||||
|
}
|
||||||
|
}, [user, authLoading, slug]);
|
||||||
|
|
||||||
|
const checkAccessAndFetch = async () => {
|
||||||
|
const { data: productData, error: productError } = await supabase
|
||||||
|
.from('products')
|
||||||
|
.select('id, title, slug')
|
||||||
|
.eq('slug', slug)
|
||||||
|
.eq('type', 'bootcamp')
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
|
if (productError || !productData) {
|
||||||
|
toast({ title: 'Error', description: 'Bootcamp tidak ditemukan', variant: 'destructive' });
|
||||||
|
navigate('/dashboard');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setProduct(productData);
|
||||||
|
|
||||||
|
// Fetch accent color from settings
|
||||||
|
const { data: settings } = await supabase
|
||||||
|
.from('platform_settings')
|
||||||
|
.select('brand_accent_color')
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (settings?.brand_accent_color) {
|
||||||
|
setAccentColor(settings.brand_accent_color);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: accessData } = await supabase
|
||||||
|
.from('user_access')
|
||||||
|
.select('id')
|
||||||
|
.eq('user_id', user!.id)
|
||||||
|
.eq('product_id', productData.id)
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
|
if (!accessData) {
|
||||||
|
toast({ title: 'Akses ditolak', description: 'Anda tidak memiliki akses ke bootcamp ini', variant: 'destructive' });
|
||||||
|
navigate('/dashboard');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: modulesData } = await supabase
|
||||||
|
.from('bootcamp_modules')
|
||||||
|
.select(`
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
position,
|
||||||
|
bootcamp_lessons (
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
content,
|
||||||
|
video_url,
|
||||||
|
youtube_url,
|
||||||
|
embed_code,
|
||||||
|
m3u8_url,
|
||||||
|
mp4_url,
|
||||||
|
video_host,
|
||||||
|
duration_seconds,
|
||||||
|
position,
|
||||||
|
release_at,
|
||||||
|
chapters
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
.eq('product_id', productData.id)
|
||||||
|
.order('position');
|
||||||
|
|
||||||
|
if (modulesData) {
|
||||||
|
const sortedModules = modulesData.map(m => ({
|
||||||
|
...m,
|
||||||
|
lessons: (m.bootcamp_lessons as Lesson[]).sort((a, b) => a.position - b.position)
|
||||||
|
}));
|
||||||
|
setModules(sortedModules);
|
||||||
|
|
||||||
|
// Select lesson based on URL parameter or default to first lesson
|
||||||
|
const allLessons = sortedModules.flatMap(m => m.lessons);
|
||||||
|
|
||||||
|
if (lessonId) {
|
||||||
|
// Find the lesson by ID from URL
|
||||||
|
const lessonFromUrl = allLessons.find(l => l.id === lessonId);
|
||||||
|
if (lessonFromUrl) {
|
||||||
|
setSelectedLesson(lessonFromUrl);
|
||||||
|
} else if (allLessons.length > 0) {
|
||||||
|
// If lesson not found, default to first lesson
|
||||||
|
setSelectedLesson(allLessons[0]);
|
||||||
|
}
|
||||||
|
} else if (allLessons.length > 0 && sortedModules[0].lessons.length > 0) {
|
||||||
|
// No lessonId in URL, select first lesson
|
||||||
|
setSelectedLesson(sortedModules[0].lessons[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: progressData } = await supabase
|
||||||
|
.from('lesson_progress')
|
||||||
|
.select('lesson_id, completed_at')
|
||||||
|
.eq('user_id', user!.id);
|
||||||
|
|
||||||
|
if (progressData) {
|
||||||
|
setProgress(progressData);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user has already reviewed this bootcamp
|
||||||
|
const { data: reviewData } = await supabase
|
||||||
|
.from('reviews')
|
||||||
|
.select('id, rating, title, body, is_approved, created_at')
|
||||||
|
.eq('user_id', user!.id)
|
||||||
|
.eq('product_id', productData.id)
|
||||||
|
.order('created_at', { ascending: false })
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (reviewData && reviewData.length > 0) {
|
||||||
|
setUserReview(reviewData[0] as UserReview);
|
||||||
|
} else {
|
||||||
|
setUserReview(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isLessonCompleted = (lessonId: string) => {
|
||||||
|
return progress.some(p => p.lesson_id === lessonId);
|
||||||
|
};
|
||||||
|
|
||||||
const handleSelectLesson = (lesson: Lesson) => {
|
const handleSelectLesson = (lesson: Lesson) => {
|
||||||
setSelectedLesson(lesson);
|
setSelectedLesson(lesson);
|
||||||
// Update URL without full page reload
|
// Update URL without full page reload
|
||||||
@@ -245,11 +546,12 @@ export default function Bootcamp() {
|
|||||||
|
|
||||||
const newProgress = [...progress, { lesson_id: selectedLesson.id, completed_at: new Date().toISOString() }];
|
const newProgress = [...progress, { lesson_id: selectedLesson.id, completed_at: new Date().toISOString() }];
|
||||||
setProgress(newProgress);
|
setProgress(newProgress);
|
||||||
|
|
||||||
// Calculate completion percentage for notification
|
// Calculate completion percentage for notification
|
||||||
const completedCount = newProgress.length;
|
const completedCount = newProgress.length;
|
||||||
|
const totalLessons = modules.reduce((sum, m) => sum + m.lessons.length, 0);
|
||||||
const completionPercent = Math.round((completedCount / totalLessons) * 100);
|
const completionPercent = Math.round((completedCount / totalLessons) * 100);
|
||||||
|
|
||||||
// Trigger progress notification at milestones
|
// Trigger progress notification at milestones
|
||||||
if (completionPercent === 25 || completionPercent === 50 || completionPercent === 75 || completionPercent === 100) {
|
if (completionPercent === 25 || completionPercent === 50 || completionPercent === 75 || completionPercent === 100) {
|
||||||
try {
|
try {
|
||||||
@@ -293,144 +595,6 @@ export default function Bootcamp() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getYouTubeEmbedUrl = (url: string): string => {
|
|
||||||
const match = url.match(/(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/)([^&\s]+)/);
|
|
||||||
return match ? `https://www.youtube.com/embed/${match[1]}` : url;
|
|
||||||
};
|
|
||||||
|
|
||||||
const VideoPlayer = ({ lesson }: { lesson: Lesson }) => {
|
|
||||||
const hasChapters = lesson.chapters && lesson.chapters.length > 0;
|
|
||||||
|
|
||||||
// Get video based on lesson's video_host (prioritize Adilo)
|
|
||||||
const getVideoSource = () => {
|
|
||||||
// If video_host is explicitly set, use it. Otherwise auto-detect with Adilo priority.
|
|
||||||
const lessonVideoHost = lesson.video_host || (
|
|
||||||
lesson.m3u8_url ? 'adilo' :
|
|
||||||
lesson.video_url?.includes('adilo.bigcommand.com') ? 'adilo' :
|
|
||||||
lesson.youtube_url || lesson.video_url?.includes('youtube.com') || lesson.video_url?.includes('youtu.be') ? 'youtube' :
|
|
||||||
'unknown'
|
|
||||||
);
|
|
||||||
|
|
||||||
if (lessonVideoHost === 'adilo') {
|
|
||||||
// Adilo M3U8 streaming
|
|
||||||
if (lesson.m3u8_url && lesson.m3u8_url.trim()) {
|
|
||||||
return {
|
|
||||||
type: 'adilo',
|
|
||||||
m3u8Url: lesson.m3u8_url,
|
|
||||||
mp4Url: lesson.mp4_url || undefined,
|
|
||||||
videoHost: 'adilo'
|
|
||||||
};
|
|
||||||
} else if (lesson.mp4_url && lesson.mp4_url.trim()) {
|
|
||||||
// Fallback to MP4 only
|
|
||||||
return {
|
|
||||||
type: 'adilo',
|
|
||||||
mp4Url: lesson.mp4_url,
|
|
||||||
videoHost: 'adilo'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// YouTube or fallback
|
|
||||||
if (lessonVideoHost === 'youtube') {
|
|
||||||
if (lesson.youtube_url && lesson.youtube_url.trim()) {
|
|
||||||
return {
|
|
||||||
type: 'youtube',
|
|
||||||
url: lesson.youtube_url,
|
|
||||||
embedUrl: getYouTubeEmbedUrl(lesson.youtube_url)
|
|
||||||
};
|
|
||||||
} else if (lesson.video_url && lesson.video_url.trim()) {
|
|
||||||
// Fallback to old video_url for backward compatibility
|
|
||||||
return {
|
|
||||||
type: 'youtube',
|
|
||||||
url: lesson.video_url,
|
|
||||||
embedUrl: getYouTubeEmbedUrl(lesson.video_url)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Final fallback: try embed code
|
|
||||||
return lesson.embed_code && lesson.embed_code.trim() ? {
|
|
||||||
type: 'embed',
|
|
||||||
html: lesson.embed_code
|
|
||||||
} : null;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Memoize video source to prevent unnecessary re-renders
|
|
||||||
const video = useMemo(getVideoSource, [lesson.id, lesson.video_host, lesson.m3u8_url, lesson.mp4_url, lesson.youtube_url, lesson.video_url, lesson.embed_code]);
|
|
||||||
|
|
||||||
// Show warning if no video available
|
|
||||||
if (!video) {
|
|
||||||
return (
|
|
||||||
<Card className="border-2 border-destructive bg-destructive/10 mb-6">
|
|
||||||
<CardContent className="py-12 text-center">
|
|
||||||
<p className="text-destructive font-medium">Konten tidak tersedia</p>
|
|
||||||
<p className="text-sm text-muted-foreground mt-1">Video belum dikonfigurasi untuk pelajaran ini.</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render based on video type
|
|
||||||
if (video.type === 'embed') {
|
|
||||||
return (
|
|
||||||
<div className="mb-6">
|
|
||||||
<div className="aspect-video bg-muted rounded-none overflow-hidden border-2 border-border">
|
|
||||||
<div dangerouslySetInnerHTML={{ __html: video.html }} />
|
|
||||||
</div>
|
|
||||||
{hasChapters && (
|
|
||||||
<div className="mt-4">
|
|
||||||
<TimelineChapters
|
|
||||||
chapters={lesson.chapters}
|
|
||||||
currentTime={currentTime}
|
|
||||||
accentColor={accentColor}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Adilo or YouTube with chapters support
|
|
||||||
const isYouTube = video.type === 'youtube';
|
|
||||||
const isAdilo = video.type === 'adilo';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{/* Video Player - Full Width */}
|
|
||||||
<div className="mb-6">
|
|
||||||
<VideoPlayerWithChapters
|
|
||||||
ref={playerRef}
|
|
||||||
videoUrl={isYouTube ? video.url : undefined}
|
|
||||||
m3u8Url={isAdilo ? video.m3u8Url : undefined}
|
|
||||||
mp4Url={isAdilo ? video.mp4Url : undefined}
|
|
||||||
videoHost={isAdilo ? 'adilo' : isYouTube ? 'youtube' : 'unknown'}
|
|
||||||
chapters={lesson.chapters}
|
|
||||||
accentColor={accentColor}
|
|
||||||
onTimeUpdate={setCurrentTime}
|
|
||||||
videoId={lesson.id}
|
|
||||||
videoType="lesson"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Timeline Chapters - Below video like WebinarRecording */}
|
|
||||||
{hasChapters && (
|
|
||||||
<div className="mb-6">
|
|
||||||
<TimelineChapters
|
|
||||||
chapters={lesson.chapters}
|
|
||||||
onChapterClick={(time) => {
|
|
||||||
if (playerRef.current) {
|
|
||||||
playerRef.current.jumpToTime(time);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
currentTime={currentTime}
|
|
||||||
accentColor={accentColor}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const completedCount = progress.length;
|
const completedCount = progress.length;
|
||||||
const totalLessons = modules.reduce((sum, m) => sum + m.lessons.length, 0);
|
const totalLessons = modules.reduce((sum, m) => sum + m.lessons.length, 0);
|
||||||
const isBootcampCompleted = totalLessons > 0 && completedCount >= totalLessons;
|
const isBootcampCompleted = totalLessons > 0 && completedCount >= totalLessons;
|
||||||
|
|||||||
Reference in New Issue
Block a user