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>
571 lines
17 KiB
TypeScript
571 lines
17 KiB
TypeScript
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
|
|
title: string;
|
|
}
|
|
|
|
interface VideoPlayerWithChaptersProps {
|
|
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 {
|
|
jumpToTime: (time: number) => void;
|
|
getCurrentTime: () => number;
|
|
}
|
|
|
|
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);
|
|
|
|
// 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 => {
|
|
const match = url.match(/(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/)([^&\s/]+)/);
|
|
return match ? match[1] : null;
|
|
};
|
|
|
|
// Convert embed code to YouTube URL if possible
|
|
const getYouTubeUrlFromEmbed = (embed: string): string | null => {
|
|
const match = embed.match(/src=["'](?:https?:)?\/\/(?:www\.)?youtube\.com\/embed\/([^"'\s?]*)/);
|
|
return match ? `https://www.youtube.com/watch?v=${match[1]}` : null;
|
|
};
|
|
|
|
// Determine which video source to use
|
|
const effectiveVideoUrl = isYouTube ? videoUrl : (embedCode ? getYouTubeUrlFromEmbed(embedCode) : videoUrl);
|
|
const useEmbed = !isYouTube && embedCode;
|
|
|
|
// Block right-click and dev tools
|
|
useEffect(() => {
|
|
const blockRightClick = (e: MouseEvent | KeyboardEvent) => {
|
|
// Block right-click
|
|
if (e.type === 'contextmenu') {
|
|
e.preventDefault();
|
|
return false;
|
|
}
|
|
|
|
// Block F12, Ctrl+Shift+I, Ctrl+Shift+J, Ctrl+U
|
|
const keyboardEvent = e as KeyboardEvent;
|
|
if (
|
|
keyboardEvent.key === 'F12' ||
|
|
(keyboardEvent.ctrlKey && keyboardEvent.shiftKey && (keyboardEvent.key === 'I' || keyboardEvent.key === 'J')) ||
|
|
(keyboardEvent.ctrlKey && keyboardEvent.key === 'U')
|
|
) {
|
|
e.preventDefault();
|
|
return false;
|
|
}
|
|
};
|
|
|
|
document.addEventListener('contextmenu', blockRightClick);
|
|
document.addEventListener('keydown', blockRightClick);
|
|
|
|
return () => {
|
|
document.removeEventListener('contextmenu', blockRightClick);
|
|
document.removeEventListener('keydown', blockRightClick);
|
|
};
|
|
}, []);
|
|
|
|
// Initialize Plyr and set up time tracking
|
|
useEffect(() => {
|
|
if (!isYouTube) return;
|
|
|
|
// Wait for player to initialize
|
|
const checkPlayer = setInterval(() => {
|
|
const player = plyrRef.current?.plyr;
|
|
if (player) {
|
|
clearInterval(checkPlayer);
|
|
setPlayerInstance(player);
|
|
|
|
// Set up time tracking using Plyr's event API
|
|
if (typeof player.on === 'function') {
|
|
player.on('timeupdate', () => {
|
|
const time = player.currentTime;
|
|
setCurrentTime(time);
|
|
|
|
if (onTimeUpdate) {
|
|
onTimeUpdate(time);
|
|
}
|
|
|
|
saveProgressDebounced(time);
|
|
|
|
// Find current chapter
|
|
const index = findCurrentChapter(time);
|
|
if (index !== currentChapterIndexRef.current) {
|
|
currentChapterIndexRef.current = index;
|
|
setCurrentChapterIndex(index);
|
|
if (index >= 0 && onChapterChange) {
|
|
onChapterChange(chapters[index]);
|
|
}
|
|
}
|
|
});
|
|
} else {
|
|
// Fallback: poll for time updates
|
|
const interval = setInterval(() => {
|
|
const time = player.currentTime;
|
|
setCurrentTime(time);
|
|
|
|
if (onTimeUpdate) {
|
|
onTimeUpdate(time);
|
|
}
|
|
|
|
saveProgressDebounced(time);
|
|
|
|
// Find current chapter
|
|
const index = findCurrentChapter(time);
|
|
if (index !== currentChapterIndexRef.current) {
|
|
currentChapterIndexRef.current = index;
|
|
setCurrentChapterIndex(index);
|
|
if (index >= 0 && onChapterChange) {
|
|
onChapterChange(chapters[index]);
|
|
}
|
|
}
|
|
}, 500);
|
|
|
|
// Store interval ID for cleanup
|
|
return () => clearInterval(interval);
|
|
}
|
|
}
|
|
}, 100);
|
|
|
|
return () => clearInterval(checkPlayer);
|
|
}, [isYouTube, findCurrentChapter, onChapterChange, onTimeUpdate, chapters, saveProgressDebounced]);
|
|
|
|
// Jump to specific time using Plyr API or Adilo player
|
|
const jumpToTime = (time: number) => {
|
|
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();
|
|
}
|
|
};
|
|
|
|
const getCurrentTime = () => {
|
|
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 (Vimeo, etc. - not Adilo anymore)
|
|
return (
|
|
<div
|
|
className={`aspect-video rounded-lg overflow-hidden ${className}`}
|
|
dangerouslySetInnerHTML={{ __html: embedCode }}
|
|
/>
|
|
);
|
|
}
|
|
|
|
const youtubeId = effectiveVideoUrl ? getYouTubeId(effectiveVideoUrl) : null;
|
|
|
|
// Apply custom accent color
|
|
useEffect(() => {
|
|
if (!accentColor || !plyrRef.current) return;
|
|
|
|
const style = document.createElement('style');
|
|
style.textContent = `
|
|
.plyr__control--overlared,
|
|
.plyr__controls .plyr__control.plyr__tab-focus,
|
|
.plyr__controls .plyr__control:hover,
|
|
.plyr__controls .plyr__control[aria-current='true'] {
|
|
background: ${accentColor} !important;
|
|
}
|
|
.plyr__progress__value {
|
|
background: ${accentColor} !important;
|
|
}
|
|
.plyr__volume__value {
|
|
background: ${accentColor} !important;
|
|
}
|
|
`;
|
|
document.head.appendChild(style);
|
|
|
|
return () => {
|
|
document.head.removeChild(style);
|
|
};
|
|
}, [accentColor]);
|
|
|
|
return (
|
|
<div className={`relative ${className}`}>
|
|
{youtubeId && (
|
|
<>
|
|
<div style={{ position: 'relative', pointerEvents: 'auto' }}>
|
|
<Plyr
|
|
ref={plyrRef}
|
|
source={{
|
|
type: 'video',
|
|
sources: [
|
|
{
|
|
src: `https://www.youtube.com/watch?v=${youtubeId}`,
|
|
provider: 'youtube',
|
|
},
|
|
],
|
|
}}
|
|
options={{
|
|
controls: [
|
|
'play-large',
|
|
'play',
|
|
'progress',
|
|
'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,
|
|
showinfo: 0,
|
|
iv_load_policy: 3,
|
|
modestbranding: 1,
|
|
controls: 0,
|
|
disablekb: 1,
|
|
fs: 0,
|
|
},
|
|
hideControls: false,
|
|
keyboardShortcuts: {
|
|
focused: true,
|
|
global: true,
|
|
},
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
<style>{`
|
|
/* Block YouTube UI overlays */
|
|
.plyr__video-wrapper .plyr__video-embed iframe {
|
|
pointer-events: none !important;
|
|
}
|
|
|
|
/* Only allow clicks on Plyr controls */
|
|
.plyr__controls,
|
|
.plyr__control--overlaid {
|
|
pointer-events: auto !important;
|
|
}
|
|
|
|
/* Hide YouTube's native play button that appears behind */
|
|
.plyr__video-wrapper::after {
|
|
content: '';
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
pointer-events: none;
|
|
z-index: 1;
|
|
}
|
|
`}</style>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
});
|