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:
352
src/hooks/useAdiloPlayer.ts
Normal file
352
src/hooks/useAdiloPlayer.ts
Normal file
@@ -0,0 +1,352 @@
|
||||
import { useRef, useEffect, useState, useCallback } from 'react';
|
||||
import Hls from 'hls.js';
|
||||
import videojs from 'video.js';
|
||||
import 'video.js/dist/video-js.css';
|
||||
|
||||
interface UseAdiloPlayerProps {
|
||||
m3u8Url?: string;
|
||||
mp4Url?: string;
|
||||
autoplay?: boolean;
|
||||
onTimeUpdate?: (time: number) => void;
|
||||
onDuration?: (duration: number) => void;
|
||||
onEnded?: () => void;
|
||||
onError?: (error: any) => void;
|
||||
accentColor?: string;
|
||||
}
|
||||
|
||||
export const useAdiloPlayer = ({
|
||||
m3u8Url,
|
||||
mp4Url,
|
||||
autoplay = false,
|
||||
onTimeUpdate,
|
||||
onDuration,
|
||||
onEnded,
|
||||
onError,
|
||||
accentColor,
|
||||
}: UseAdiloPlayerProps) => {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const videoJsRef = useRef<any>(null);
|
||||
const hlsRef = useRef<Hls | null>(null);
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [error, setError] = useState<any>(null);
|
||||
|
||||
// Use refs to store stable callback references
|
||||
const callbacksRef = useRef({
|
||||
onTimeUpdate,
|
||||
onDuration,
|
||||
onEnded,
|
||||
onError,
|
||||
});
|
||||
|
||||
// Update callbacks ref when props change
|
||||
useEffect(() => {
|
||||
callbacksRef.current = {
|
||||
onTimeUpdate,
|
||||
onDuration,
|
||||
onEnded,
|
||||
onError,
|
||||
};
|
||||
}, [onTimeUpdate, onDuration, onEnded, onError]);
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video || (!m3u8Url && !mp4Url)) return;
|
||||
|
||||
// Clean up previous HLS instance
|
||||
if (hlsRef.current) {
|
||||
hlsRef.current.destroy();
|
||||
hlsRef.current = null;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
|
||||
// Try M3U8 with HLS.js first
|
||||
if (m3u8Url) {
|
||||
if (Hls.isSupported()) {
|
||||
const hls = new Hls({
|
||||
enableWorker: true,
|
||||
lowLatencyMode: true,
|
||||
xhrSetup: (xhr, url) => {
|
||||
// Allow CORS for HLS requests
|
||||
xhr.withCredentials = false;
|
||||
},
|
||||
});
|
||||
|
||||
hlsRef.current = hls;
|
||||
hls.loadSource(m3u8Url);
|
||||
hls.attachMedia(video);
|
||||
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, (event, data) => {
|
||||
console.log('✅ HLS manifest parsed:', data.levels.length, 'quality levels');
|
||||
// Don't set ready yet - wait for first fragment to load
|
||||
});
|
||||
|
||||
hls.on(Hls.Events.FRAG_PARSED, () => {
|
||||
console.log('✅ First segment loaded, video ready');
|
||||
setIsReady(true);
|
||||
|
||||
// Log video element state
|
||||
console.log('📹 Video element state:', {
|
||||
readyState: video.readyState,
|
||||
videoWidth: video.videoWidth,
|
||||
videoHeight: video.videoHeight,
|
||||
duration: video.duration,
|
||||
paused: video.paused,
|
||||
});
|
||||
|
||||
if (autoplay) {
|
||||
video.play().catch((err) => {
|
||||
console.error('Autoplay failed:', err);
|
||||
callbacksRef.current.onError?.(err);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
hls.on(Hls.Events.ERROR, (event, data) => {
|
||||
if (data.fatal) {
|
||||
console.error('❌ HLS error:', data.type, data.details);
|
||||
switch (data.type) {
|
||||
case Hls.ErrorTypes.NETWORK_ERROR:
|
||||
console.log('🔄 Recovering from network error...');
|
||||
hls.startLoad();
|
||||
break;
|
||||
case Hls.ErrorTypes.MEDIA_ERROR:
|
||||
console.log('🔄 Recovering from media error...');
|
||||
hls.recoverMediaError();
|
||||
break;
|
||||
default:
|
||||
console.error('💥 Fatal error, destroying HLS instance');
|
||||
hls.destroy();
|
||||
// Fallback to MP4
|
||||
if (mp4Url) {
|
||||
console.log('📹 Falling back to MP4');
|
||||
video.src = mp4Url;
|
||||
} else {
|
||||
setError(data);
|
||||
callbacksRef.current.onError?.(data);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
// Safari native HLS support
|
||||
video.src = m3u8Url;
|
||||
video.addEventListener('loadedmetadata', () => {
|
||||
setIsReady(true);
|
||||
if (autoplay) {
|
||||
video.play().catch((err) => {
|
||||
console.error('Autoplay failed:', err);
|
||||
callbacksRef.current.onError?.(err);
|
||||
});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// No HLS support, fallback to MP4
|
||||
if (mp4Url) {
|
||||
video.src = mp4Url;
|
||||
} else {
|
||||
setError(new Error('No supported video format'));
|
||||
callbacksRef.current.onError?.(new Error('No supported video format'));
|
||||
}
|
||||
}
|
||||
} else if (mp4Url) {
|
||||
// Direct MP4 playback
|
||||
video.src = mp4Url;
|
||||
video.addEventListener('loadedmetadata', () => {
|
||||
setIsReady(true);
|
||||
if (autoplay) {
|
||||
video.play().catch((err) => {
|
||||
console.error('Autoplay failed:', err);
|
||||
callbacksRef.current.onError?.(err);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Time update handler
|
||||
const handleTimeUpdate = () => {
|
||||
const time = video.currentTime;
|
||||
setCurrentTime(time);
|
||||
callbacksRef.current.onTimeUpdate?.(time);
|
||||
};
|
||||
|
||||
// Duration handler
|
||||
const handleDurationChange = () => {
|
||||
const dur = video.duration;
|
||||
if (dur && !isNaN(dur)) {
|
||||
setDuration(dur);
|
||||
callbacksRef.current.onDuration?.(dur);
|
||||
}
|
||||
};
|
||||
|
||||
// Play/pause handlers
|
||||
const handlePlay = () => setIsPlaying(true);
|
||||
const handlePause = () => setIsPlaying(false);
|
||||
const handleEnded = () => {
|
||||
setIsPlaying(false);
|
||||
callbacksRef.current.onEnded?.();
|
||||
};
|
||||
|
||||
video.addEventListener('timeupdate', handleTimeUpdate);
|
||||
video.addEventListener('durationchange', handleDurationChange);
|
||||
video.addEventListener('play', handlePlay);
|
||||
video.addEventListener('pause', handlePause);
|
||||
video.addEventListener('ended', handleEnded);
|
||||
|
||||
// Initialize Video.js after HLS.js has set up the video
|
||||
// Wait for video to be ready before initializing Video.js
|
||||
const initializeVideoJs = () => {
|
||||
if (!videoRef.current || videoJsRef.current) return;
|
||||
|
||||
// Initialize Video.js with the video element
|
||||
const player = videojs(videoRef.current, {
|
||||
controls: true,
|
||||
autoplay: false,
|
||||
preload: 'auto',
|
||||
fluid: false,
|
||||
fill: true,
|
||||
responsive: false,
|
||||
html5: {
|
||||
vhs: {
|
||||
overrideNative: true,
|
||||
},
|
||||
nativeVideoTracks: false,
|
||||
nativeAudioTracks: false,
|
||||
nativeTextTracks: false,
|
||||
},
|
||||
playbackRates: [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2],
|
||||
controlBar: {
|
||||
volumePanel: {
|
||||
inline: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
videoJsRef.current = player;
|
||||
|
||||
// Apply custom accent color if provided
|
||||
if (accentColor) {
|
||||
const styleId = 'videojs-custom-theme';
|
||||
let styleElement = document.getElementById(styleId) as HTMLStyleElement;
|
||||
|
||||
if (!styleElement) {
|
||||
styleElement = document.createElement('style');
|
||||
styleElement.id = styleId;
|
||||
document.head.appendChild(styleElement);
|
||||
}
|
||||
|
||||
styleElement.textContent = `
|
||||
.video-js .vjs-play-progress,
|
||||
.video-js .vjs-volume-level {
|
||||
background-color: ${accentColor} !important;
|
||||
}
|
||||
.video-js .vjs-control-bar,
|
||||
.video-js .vjs-big-play-button {
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
.video-js .vjs-slider {
|
||||
background-color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
console.log('✅ Video.js initialized successfully');
|
||||
};
|
||||
|
||||
// Initialize Video.js after a short delay to ensure HLS.js is ready
|
||||
const initTimeout = setTimeout(() => {
|
||||
initializeVideoJs();
|
||||
}, 100);
|
||||
|
||||
return () => {
|
||||
clearTimeout(initTimeout);
|
||||
video.removeEventListener('timeupdate', handleTimeUpdate);
|
||||
video.removeEventListener('durationchange', handleDurationChange);
|
||||
video.removeEventListener('play', handlePlay);
|
||||
video.removeEventListener('pause', handlePause);
|
||||
video.removeEventListener('ended', handleEnded);
|
||||
|
||||
if (videoJsRef.current) {
|
||||
videoJsRef.current.dispose();
|
||||
videoJsRef.current = null;
|
||||
}
|
||||
|
||||
if (hlsRef.current) {
|
||||
hlsRef.current.destroy();
|
||||
hlsRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [m3u8Url, mp4Url, autoplay, accentColor]);
|
||||
|
||||
// Jump to specific time
|
||||
const jumpToTime = useCallback((time: number) => {
|
||||
const video = videoRef.current;
|
||||
if (video && isReady) {
|
||||
const wasPlaying = !video.paused;
|
||||
|
||||
// Wait for video to be seekable if needed
|
||||
if (video.seekable.length > 0) {
|
||||
video.currentTime = time;
|
||||
|
||||
// Only attempt to play if video was already playing
|
||||
if (wasPlaying) {
|
||||
video.play().catch((err) => {
|
||||
// Ignore AbortError from rapid play() calls
|
||||
if (err.name !== 'AbortError') {
|
||||
console.error('Jump failed:', err);
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Video not seekable yet, wait for it to be ready
|
||||
console.log('⏳ Video not seekable yet, waiting...');
|
||||
const onSeekable = () => {
|
||||
video.currentTime = time;
|
||||
if (wasPlaying) {
|
||||
video.play().catch((err) => {
|
||||
if (err.name !== 'AbortError') {
|
||||
console.error('Jump failed:', err);
|
||||
}
|
||||
});
|
||||
}
|
||||
video.removeEventListener('canplay', onSeekable);
|
||||
};
|
||||
video.addEventListener('canplay', onSeekable, { once: true });
|
||||
}
|
||||
}
|
||||
}, [isReady]);
|
||||
|
||||
// Play control
|
||||
const play = useCallback(() => {
|
||||
const video = videoRef.current;
|
||||
if (video && isReady) {
|
||||
video.play().catch((err) => {
|
||||
console.error('Play failed:', err);
|
||||
});
|
||||
}
|
||||
}, [isReady]);
|
||||
|
||||
// Pause control
|
||||
const pause = useCallback(() => {
|
||||
const video = videoRef.current;
|
||||
if (video) {
|
||||
video.pause();
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
videoRef,
|
||||
isReady,
|
||||
isPlaying,
|
||||
currentTime,
|
||||
duration,
|
||||
error,
|
||||
jumpToTime,
|
||||
play,
|
||||
pause,
|
||||
};
|
||||
};
|
||||
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