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>
This commit is contained in:
128
src/hooks/useVideoProgress.ts
Normal file
128
src/hooks/useVideoProgress.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
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
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user