Files
meet-hub/src/hooks/useVideoProgress.ts
dwindown 60baf32f73 Display bootcamp lesson chapters on Product Detail page as marketing content
This commit implements displaying lesson chapters/timeline as marketing content
on the Product Detail page for bootcamp products, helping potential buyers
understand the detailed breakdown of what they'll learn.

## Changes

### Product Detail Page (src/pages/ProductDetail.tsx)
- Updated Lesson interface to include optional chapters property
- Modified fetchCurriculum to fetch chapters along with lessons
- Enhanced renderCurriculumPreview to display chapters as nested content under lessons
- Chapters shown with timestamps and titles, clickable to navigate to bootcamp access page
- Visual hierarchy: Module → Lesson → Chapters with proper indentation and styling

### Review System Fixes
- Fixed review prompt re-appearing after submission (before admin approval)
- Added hasSubmittedReview check to prevent showing prompt when review exists
- Fixed edit review functionality to pre-populate form with existing data
- ReviewModal now handles both INSERT (new) and UPDATE (edit) operations
- Edit resets is_approved to false requiring re-approval

### Video Player Enhancements
- Implemented Adilo/Video.js integration for M3U8/HLS playback
- Added video progress tracking with refs pattern for reliability
- Implemented chapter navigation for both Adilo and YouTube players
- Added keyboard shortcuts (Space, Arrows, F, M, J, L)
- Resume prompt for returning users with saved progress

### Database Migrations
- Added Adilo video support fields (m3u8_url, mp4_url, video_host)
- Created video_progress table for tracking user watch progress
- Fixed consulting slots user_id foreign key
- Added chapters support to products and bootcamp_lessons tables

### Documentation
- Added Adilo implementation plan and quick reference docs
- Cleaned up transcript analysis files

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-01 23:54:32 +07:00

129 lines
3.5 KiB
TypeScript

import { useEffect, useRef, useState, useCallback } from 'react';
import { supabase } from '@/integrations/supabase/client';
import { useAuth } from './useAuth';
interface UseVideoProgressOptions {
videoId: string;
videoType: 'lesson' | 'webinar';
duration?: number;
onSaveInterval?: number; // seconds, default 5
}
interface VideoProgress {
last_position: number;
total_duration?: number;
completed: boolean;
last_watched_at: string;
}
export const useVideoProgress = ({
videoId,
videoType,
duration,
onSaveInterval = 5,
}: UseVideoProgressOptions) => {
const { user } = useAuth();
const [progress, setProgress] = useState<VideoProgress | null>(null);
const [loading, setLoading] = useState(true);
const lastSavedPosition = useRef<number>(0);
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const userRef = useRef(user);
const videoIdRef = useRef(videoId);
const videoTypeRef = useRef(videoType);
const durationRef = useRef(duration);
// Update refs when props change
useEffect(() => {
userRef.current = user;
videoIdRef.current = videoId;
videoTypeRef.current = videoType;
durationRef.current = duration;
}, [user, videoId, videoType, duration]);
// Load existing progress
useEffect(() => {
if (!user || !videoId) {
setLoading(false);
return;
}
const loadProgress = async () => {
const { data, error } = await supabase
.from('video_progress')
.select('*')
.eq('user_id', user.id)
.eq('video_id', videoId)
.eq('video_type', videoType)
.maybeSingle();
if (error) {
console.error('Error loading video progress:', error);
} else if (data) {
setProgress(data);
lastSavedPosition.current = data.last_position;
}
setLoading(false);
};
loadProgress();
}, [user, videoId, videoType]);
// Save progress directly (not debounced for reliability)
const saveProgress = useCallback(async (position: number) => {
const currentUser = userRef.current;
const currentVideoId = videoIdRef.current;
const currentVideoType = videoTypeRef.current;
const currentDuration = durationRef.current;
if (!currentUser || !currentVideoId) return;
// Don't save if position hasn't changed significantly (less than 1 second)
if (Math.abs(position - lastSavedPosition.current) < 1) return;
const completed = currentDuration ? position / currentDuration >= 0.95 : false;
const { error } = await supabase
.from('video_progress')
.upsert(
{
user_id: currentUser.id,
video_id: currentVideoId,
video_type: currentVideoType,
last_position: position,
total_duration: currentDuration,
completed,
},
{
onConflict: 'user_id,video_id,video_type',
}
);
if (error) {
console.error('Error saving video progress:', error);
} else {
lastSavedPosition.current = position;
}
}, []); // Empty deps - uses refs internally
// Save on unmount
useEffect(() => {
return () => {
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
}
// Save final position
if (lastSavedPosition.current > 0) {
saveProgress(lastSavedPosition.current);
}
};
}, [saveProgress]);
return {
progress,
loading,
saveProgress, // Return the direct save function
hasProgress: progress !== null && progress.last_position > 5, // Only show if more than 5 seconds watched
};
};