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:
@@ -1,6 +1,10 @@
|
||||
import { useEffect, useRef, useState, forwardRef, useImperativeHandle } from 'react';
|
||||
import { useEffect, useRef, useState, forwardRef, useImperativeHandle, useCallback } from 'react';
|
||||
import { Plyr } from 'plyr-react';
|
||||
import 'plyr/dist/plyr.css';
|
||||
import { useAdiloPlayer } from '@/hooks/useAdiloPlayer';
|
||||
import { useVideoProgress } from '@/hooks/useVideoProgress';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { RotateCcw } from 'lucide-react';
|
||||
|
||||
interface VideoChapter {
|
||||
time: number; // Time in seconds
|
||||
@@ -8,13 +12,18 @@ interface VideoChapter {
|
||||
}
|
||||
|
||||
interface VideoPlayerWithChaptersProps {
|
||||
videoUrl: string;
|
||||
videoUrl?: string;
|
||||
embedCode?: string | null;
|
||||
m3u8Url?: string;
|
||||
mp4Url?: string;
|
||||
videoHost?: 'youtube' | 'adilo' | 'unknown';
|
||||
chapters?: VideoChapter[];
|
||||
accentColor?: string;
|
||||
onChapterChange?: (chapter: VideoChapter) => void;
|
||||
onTimeUpdate?: (time: number) => void;
|
||||
className?: string;
|
||||
videoId?: string; // For progress tracking
|
||||
videoType?: 'lesson' | 'webinar'; // For progress tracking
|
||||
}
|
||||
|
||||
export interface VideoPlayerRef {
|
||||
@@ -25,22 +34,87 @@ export interface VideoPlayerRef {
|
||||
export const VideoPlayerWithChapters = forwardRef<VideoPlayerRef, VideoPlayerWithChaptersProps>(({
|
||||
videoUrl,
|
||||
embedCode,
|
||||
m3u8Url,
|
||||
mp4Url,
|
||||
videoHost = 'unknown',
|
||||
chapters = [],
|
||||
accentColor,
|
||||
onChapterChange,
|
||||
onTimeUpdate,
|
||||
className = '',
|
||||
videoId,
|
||||
videoType,
|
||||
}, ref) => {
|
||||
const plyrRef = useRef<any>(null);
|
||||
const currentChapterIndexRef = useRef<number>(-1);
|
||||
const [currentChapterIndex, setCurrentChapterIndex] = useState<number>(-1);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [playerInstance, setPlayerInstance] = useState<any>(null);
|
||||
const [showResumePrompt, setShowResumePrompt] = useState(false);
|
||||
const [resumeTime, setResumeTime] = useState(0);
|
||||
const saveProgressTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Detect if this is a YouTube URL
|
||||
const isYouTube = videoUrl && (
|
||||
videoUrl.includes('youtube.com') ||
|
||||
videoUrl.includes('youtu.be')
|
||||
);
|
||||
// Determine if using Adilo (M3U8) or YouTube
|
||||
const isAdilo = videoHost === 'adilo' || m3u8Url;
|
||||
const isYouTube = videoHost === 'youtube' || (videoUrl && (videoUrl.includes('youtube.com') || videoUrl.includes('youtu.be')));
|
||||
|
||||
// Video progress tracking
|
||||
const { progress, loading: progressLoading, saveProgress: saveProgressDirect, hasProgress } = useVideoProgress({
|
||||
videoId: videoId || '',
|
||||
videoType: videoType || 'lesson',
|
||||
duration: playerInstance?.duration,
|
||||
});
|
||||
|
||||
// Debounced save function (saves every 5 seconds)
|
||||
const saveProgressDebounced = useCallback((time: number) => {
|
||||
if (saveProgressTimeoutRef.current) {
|
||||
clearTimeout(saveProgressTimeoutRef.current);
|
||||
}
|
||||
saveProgressTimeoutRef.current = setTimeout(() => {
|
||||
saveProgressDirect(time);
|
||||
}, 5000);
|
||||
}, [saveProgressDirect]);
|
||||
|
||||
// Stable callback for finding current chapter
|
||||
const findCurrentChapter = useCallback((time: number) => {
|
||||
if (chapters.length === 0) return -1;
|
||||
|
||||
let index = chapters.findIndex((chapter, i) => {
|
||||
const nextChapter = chapters[i + 1];
|
||||
return time >= chapter.time && (!nextChapter || time < nextChapter.time);
|
||||
});
|
||||
|
||||
if (index === -1 && time < chapters[0].time) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return index;
|
||||
}, [chapters]);
|
||||
|
||||
// Stable onTimeUpdate callback for Adilo player
|
||||
const handleAdiloTimeUpdate = useCallback((time: number) => {
|
||||
setCurrentTime(time);
|
||||
onTimeUpdate?.(time);
|
||||
saveProgressDebounced(time);
|
||||
|
||||
// Find and update current chapter for Adilo
|
||||
const index = findCurrentChapter(time);
|
||||
if (index !== currentChapterIndexRef.current) {
|
||||
currentChapterIndexRef.current = index;
|
||||
setCurrentChapterIndex(index);
|
||||
if (index >= 0 && onChapterChange) {
|
||||
onChapterChange(chapters[index]);
|
||||
}
|
||||
}
|
||||
}, [onTimeUpdate, onChapterChange, findCurrentChapter, chapters, saveProgressDebounced]);
|
||||
|
||||
// Adilo player hook
|
||||
const adiloPlayer = useAdiloPlayer({
|
||||
m3u8Url,
|
||||
mp4Url,
|
||||
onTimeUpdate: handleAdiloTimeUpdate,
|
||||
accentColor,
|
||||
});
|
||||
|
||||
// Get YouTube video ID
|
||||
const getYouTubeId = (url: string): string | null => {
|
||||
@@ -109,23 +183,15 @@ export const VideoPlayerWithChapters = forwardRef<VideoPlayerRef, VideoPlayerWit
|
||||
onTimeUpdate(time);
|
||||
}
|
||||
|
||||
saveProgressDebounced(time);
|
||||
|
||||
// Find current chapter
|
||||
if (chapters.length > 0) {
|
||||
let index = chapters.findIndex((chapter, i) => {
|
||||
const nextChapter = chapters[i + 1];
|
||||
return time >= chapter.time && (!nextChapter || time < nextChapter.time);
|
||||
});
|
||||
|
||||
// If before first chapter, no active chapter
|
||||
if (index === -1 && time < chapters[0].time) {
|
||||
index = -1;
|
||||
}
|
||||
|
||||
if (index !== currentChapterIndex) {
|
||||
setCurrentChapterIndex(index);
|
||||
if (index >= 0 && onChapterChange) {
|
||||
onChapterChange(chapters[index]);
|
||||
}
|
||||
const index = findCurrentChapter(time);
|
||||
if (index !== currentChapterIndexRef.current) {
|
||||
currentChapterIndexRef.current = index;
|
||||
setCurrentChapterIndex(index);
|
||||
if (index >= 0 && onChapterChange) {
|
||||
onChapterChange(chapters[index]);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -139,22 +205,15 @@ export const VideoPlayerWithChapters = forwardRef<VideoPlayerRef, VideoPlayerWit
|
||||
onTimeUpdate(time);
|
||||
}
|
||||
|
||||
saveProgressDebounced(time);
|
||||
|
||||
// Find current chapter
|
||||
if (chapters.length > 0) {
|
||||
let index = chapters.findIndex((chapter, i) => {
|
||||
const nextChapter = chapters[i + 1];
|
||||
return time >= chapter.time && (!nextChapter || time < nextChapter.time);
|
||||
});
|
||||
|
||||
if (index === -1 && time < chapters[0].time) {
|
||||
index = -1;
|
||||
}
|
||||
|
||||
if (index !== currentChapterIndex) {
|
||||
setCurrentChapterIndex(index);
|
||||
if (index >= 0 && onChapterChange) {
|
||||
onChapterChange(chapters[index]);
|
||||
}
|
||||
const index = findCurrentChapter(time);
|
||||
if (index !== currentChapterIndexRef.current) {
|
||||
currentChapterIndexRef.current = index;
|
||||
setCurrentChapterIndex(index);
|
||||
if (index >= 0 && onChapterChange) {
|
||||
onChapterChange(chapters[index]);
|
||||
}
|
||||
}
|
||||
}, 500);
|
||||
@@ -166,11 +225,24 @@ export const VideoPlayerWithChapters = forwardRef<VideoPlayerRef, VideoPlayerWit
|
||||
}, 100);
|
||||
|
||||
return () => clearInterval(checkPlayer);
|
||||
}, [isYouTube, chapters, currentChapterIndex, onChapterChange, onTimeUpdate]);
|
||||
}, [isYouTube, findCurrentChapter, onChapterChange, onTimeUpdate, chapters, saveProgressDebounced]);
|
||||
|
||||
// Jump to specific time using Plyr API
|
||||
// Jump to specific time using Plyr API or Adilo player
|
||||
const jumpToTime = (time: number) => {
|
||||
if (playerInstance) {
|
||||
if (isAdilo) {
|
||||
const video = adiloPlayer.videoRef.current;
|
||||
if (video && adiloPlayer.isReady) {
|
||||
video.currentTime = time;
|
||||
const wasPlaying = !video.paused;
|
||||
if (wasPlaying) {
|
||||
video.play().catch((err) => {
|
||||
if (err.name !== 'AbortError') {
|
||||
console.error('Jump failed:', err);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (playerInstance) {
|
||||
playerInstance.currentTime = time;
|
||||
playerInstance.play();
|
||||
}
|
||||
@@ -180,14 +252,204 @@ export const VideoPlayerWithChapters = forwardRef<VideoPlayerRef, VideoPlayerWit
|
||||
return currentTime;
|
||||
};
|
||||
|
||||
// Check for saved progress and show resume prompt
|
||||
useEffect(() => {
|
||||
if (!progressLoading && hasProgress && progress && progress.last_position > 5) {
|
||||
setShowResumePrompt(true);
|
||||
setResumeTime(progress.last_position);
|
||||
}
|
||||
}, [progressLoading, hasProgress, progress]);
|
||||
|
||||
const handleResume = () => {
|
||||
jumpToTime(resumeTime);
|
||||
setShowResumePrompt(false);
|
||||
};
|
||||
|
||||
const handleStartFromBeginning = () => {
|
||||
setShowResumePrompt(false);
|
||||
};
|
||||
|
||||
// Save progress immediately on pause/ended
|
||||
useEffect(() => {
|
||||
if (!adiloPlayer.videoRef.current) return;
|
||||
|
||||
const video = adiloPlayer.videoRef.current;
|
||||
const handlePause = () => {
|
||||
// Save immediately on pause
|
||||
saveProgressDirect(video.currentTime);
|
||||
};
|
||||
|
||||
const handleEnded = () => {
|
||||
// Save immediately on end
|
||||
saveProgressDirect(video.currentTime);
|
||||
};
|
||||
|
||||
video.addEventListener('pause', handlePause);
|
||||
video.addEventListener('ended', handleEnded);
|
||||
|
||||
return () => {
|
||||
video.removeEventListener('pause', handlePause);
|
||||
video.removeEventListener('ended', handleEnded);
|
||||
};
|
||||
}, [adiloPlayer.videoRef, saveProgressDirect]);
|
||||
|
||||
// Keyboard shortcuts
|
||||
useEffect(() => {
|
||||
const handleKeyPress = (e: KeyboardEvent) => {
|
||||
// Ignore if user is typing in an input
|
||||
if (
|
||||
e.target instanceof HTMLInputElement ||
|
||||
e.target instanceof HTMLTextAreaElement ||
|
||||
e.target instanceof HTMLSelectElement ||
|
||||
e.target.isContentEditable
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const player = isAdilo ? adiloPlayer.videoRef.current : playerInstance;
|
||||
if (!player) return;
|
||||
|
||||
// Space: Play/Pause
|
||||
if (e.code === 'Space') {
|
||||
e.preventDefault();
|
||||
if (isAdilo) {
|
||||
const video = player as HTMLVideoElement;
|
||||
video.paused ? video.play() : video.pause();
|
||||
} else {
|
||||
player.playing ? player.pause() : player.play();
|
||||
}
|
||||
}
|
||||
|
||||
// Arrow Left: Back 5 seconds
|
||||
if (e.code === 'ArrowLeft') {
|
||||
e.preventDefault();
|
||||
const currentTime = isAdilo ? (player as HTMLVideoElement).currentTime : player.currentTime;
|
||||
jumpToTime(Math.max(0, currentTime - 5));
|
||||
}
|
||||
|
||||
// Arrow Right: Forward 5 seconds
|
||||
if (e.code === 'ArrowRight') {
|
||||
e.preventDefault();
|
||||
const currentTime = isAdilo ? (player as HTMLVideoElement).currentTime : player.currentTime;
|
||||
const duration = isAdilo ? (player as HTMLVideoElement).duration : player.duration;
|
||||
jumpToTime(Math.min(duration, currentTime + 5));
|
||||
}
|
||||
|
||||
// Arrow Up: Volume up 10%
|
||||
if (e.code === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
if (!isAdilo) {
|
||||
const newVolume = Math.min(1, player.volume + 0.1);
|
||||
player.volume = newVolume;
|
||||
}
|
||||
}
|
||||
|
||||
// Arrow Down: Volume down 10%
|
||||
if (e.code === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
if (!isAdilo) {
|
||||
const newVolume = Math.max(0, player.volume - 0.1);
|
||||
player.volume = newVolume;
|
||||
}
|
||||
}
|
||||
|
||||
// F: Fullscreen
|
||||
if (e.code === 'KeyF') {
|
||||
e.preventDefault();
|
||||
if (isAdilo) {
|
||||
if (document.fullscreenElement) {
|
||||
document.exitFullscreen();
|
||||
} else {
|
||||
(player as HTMLVideoElement).parentElement?.requestFullscreen();
|
||||
}
|
||||
} else {
|
||||
player.fullscreen.toggle();
|
||||
}
|
||||
}
|
||||
|
||||
// M: Mute
|
||||
if (e.code === 'KeyM') {
|
||||
e.preventDefault();
|
||||
if (isAdilo) {
|
||||
(player as HTMLVideoElement).muted = !(player as HTMLVideoElement).muted;
|
||||
} else {
|
||||
player.muted = !player.muted;
|
||||
}
|
||||
}
|
||||
|
||||
// J: Back 10 seconds
|
||||
if (e.code === 'KeyJ') {
|
||||
e.preventDefault();
|
||||
const currentTime = isAdilo ? (player as HTMLVideoElement).currentTime : player.currentTime;
|
||||
jumpToTime(Math.max(0, currentTime - 10));
|
||||
}
|
||||
|
||||
// L: Forward 10 seconds
|
||||
if (e.code === 'KeyL') {
|
||||
e.preventDefault();
|
||||
const currentTime = isAdilo ? (player as HTMLVideoElement).currentTime : player.currentTime;
|
||||
const duration = isAdilo ? (player as HTMLVideoElement).duration : player.duration;
|
||||
jumpToTime(Math.min(duration, currentTime + 10));
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyPress);
|
||||
return () => document.removeEventListener('keydown', handleKeyPress);
|
||||
}, [isAdilo, adiloPlayer.isReady, playerInstance]);
|
||||
|
||||
// Expose methods via ref
|
||||
useImperativeHandle(ref, () => ({
|
||||
jumpToTime,
|
||||
getCurrentTime,
|
||||
}));
|
||||
|
||||
// Adilo M3U8 Player with Video.js
|
||||
if (isAdilo) {
|
||||
return (
|
||||
<div className={`relative ${className}`}>
|
||||
<div className="aspect-video rounded-lg overflow-hidden bg-black vjs-big-play-centered">
|
||||
<video
|
||||
ref={adiloPlayer.videoRef}
|
||||
className="video-js vjs-default-skin vjs-big-play-centered vjs-fill"
|
||||
playsInline
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Resume prompt */}
|
||||
{showResumePrompt && (
|
||||
<div className="absolute inset-0 bg-black/80 flex items-center justify-center z-10 rounded-lg">
|
||||
<div className="text-center space-y-4 p-6">
|
||||
<div className="text-white text-lg font-semibold">
|
||||
Lanjutkan dari posisi terakhir?
|
||||
</div>
|
||||
<div className="text-gray-300 text-sm">
|
||||
{Math.floor(resumeTime / 60)}:{String(Math.floor(resumeTime % 60)).padStart(2, '0')}
|
||||
</div>
|
||||
<div className="flex gap-3 justify-center">
|
||||
<Button
|
||||
onClick={handleResume}
|
||||
className="bg-primary hover:bg-primary/90"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4 mr-2" />
|
||||
Lanjutkan
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleStartFromBeginning}
|
||||
variant="outline"
|
||||
className="bg-white/10 hover:bg-white/20 text-white border-white/20"
|
||||
>
|
||||
Mulai dari awal
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (useEmbed) {
|
||||
// Custom embed (Adilo, Vimeo, etc.)
|
||||
// Custom embed (Vimeo, etc. - not Adilo anymore)
|
||||
return (
|
||||
<div
|
||||
className={`aspect-video rounded-lg overflow-hidden ${className}`}
|
||||
@@ -248,8 +510,16 @@ export const VideoPlayerWithChapters = forwardRef<VideoPlayerRef, VideoPlayerWit
|
||||
'current-time',
|
||||
'mute',
|
||||
'volume',
|
||||
'captions',
|
||||
'settings',
|
||||
'pip',
|
||||
'airplay',
|
||||
'fullscreen',
|
||||
],
|
||||
speed: {
|
||||
selected: 1,
|
||||
options: [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2],
|
||||
},
|
||||
youtube: {
|
||||
noCookie: true,
|
||||
rel: 0,
|
||||
@@ -261,6 +531,10 @@ export const VideoPlayerWithChapters = forwardRef<VideoPlayerRef, VideoPlayerWit
|
||||
fs: 0,
|
||||
},
|
||||
hideControls: false,
|
||||
keyboardShortcuts: {
|
||||
focused: true,
|
||||
global: true,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user