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>
703 lines
16 KiB
Markdown
703 lines
16 KiB
Markdown
# Code Templates - Copy & Paste Starting Points
|
|
|
|
## File 1: hooks/useAdiloPlayer.js
|
|
|
|
```javascript
|
|
import { useRef, useEffect, useState, useCallback } from 'react';
|
|
import Hls from 'hls.js';
|
|
|
|
/**
|
|
* Hook for managing Adilo video playback via HLS.js
|
|
* Handles M3U8 URL streaming with browser compatibility
|
|
*/
|
|
export function useAdiloPlayer({
|
|
m3u8Url,
|
|
autoplay = false,
|
|
onTimeUpdate = () => {},
|
|
onEnded = () => {},
|
|
onError = () => {},
|
|
} = {}) {
|
|
const videoRef = useRef(null);
|
|
const hlsRef = useRef(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(null);
|
|
|
|
// Initialize HLS streaming
|
|
useEffect(() => {
|
|
const video = videoRef.current;
|
|
if (!video || !m3u8Url) return;
|
|
|
|
try {
|
|
// Safari has native HLS support
|
|
if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
|
video.src = m3u8Url;
|
|
setIsReady(true);
|
|
}
|
|
// Other browsers use HLS.js
|
|
else if (Hls.isSupported()) {
|
|
const hls = new Hls({
|
|
autoStartLoad: true,
|
|
startPosition: -1,
|
|
});
|
|
|
|
hls.loadSource(m3u8Url);
|
|
hls.attachMedia(video);
|
|
hlsRef.current = hls;
|
|
|
|
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
|
setIsReady(true);
|
|
if (autoplay) {
|
|
video.play().catch(err => console.error('Autoplay failed:', err));
|
|
}
|
|
});
|
|
|
|
hls.on(Hls.Events.ERROR, (event, data) => {
|
|
console.error('HLS Error:', data);
|
|
setError(data.message || 'HLS streaming error');
|
|
onError(data);
|
|
});
|
|
} else {
|
|
setError('HLS streaming not supported in this browser');
|
|
}
|
|
} catch (err) {
|
|
console.error('Video initialization error:', err);
|
|
setError(err.message);
|
|
onError(err);
|
|
}
|
|
|
|
// Cleanup
|
|
return () => {
|
|
if (hlsRef.current) {
|
|
hlsRef.current.destroy();
|
|
hlsRef.current = null;
|
|
}
|
|
};
|
|
}, [m3u8Url, autoplay, onError]);
|
|
|
|
// Track video events
|
|
useEffect(() => {
|
|
const video = videoRef.current;
|
|
if (!video) return;
|
|
|
|
const handleTimeUpdate = () => {
|
|
setCurrentTime(video.currentTime);
|
|
onTimeUpdate(video.currentTime);
|
|
};
|
|
|
|
const handleLoadedMetadata = () => {
|
|
setDuration(video.duration);
|
|
};
|
|
|
|
const handlePlay = () => setIsPlaying(true);
|
|
const handlePause = () => setIsPlaying(false);
|
|
const handleEnded = () => {
|
|
setIsPlaying(false);
|
|
onEnded();
|
|
};
|
|
|
|
video.addEventListener('timeupdate', handleTimeUpdate);
|
|
video.addEventListener('loadedmetadata', handleLoadedMetadata);
|
|
video.addEventListener('play', handlePlay);
|
|
video.addEventListener('pause', handlePause);
|
|
video.addEventListener('ended', handleEnded);
|
|
|
|
return () => {
|
|
video.removeEventListener('timeupdate', handleTimeUpdate);
|
|
video.removeEventListener('loadedmetadata', handleLoadedMetadata);
|
|
video.removeEventListener('play', handlePlay);
|
|
video.removeEventListener('pause', handlePause);
|
|
video.removeEventListener('ended', handleEnded);
|
|
};
|
|
}, [onTimeUpdate, onEnded]);
|
|
|
|
// Control methods
|
|
const play = useCallback(() => {
|
|
videoRef.current?.play().catch(err => console.error('Play error:', err));
|
|
}, []);
|
|
|
|
const pause = useCallback(() => {
|
|
videoRef.current?.pause();
|
|
}, []);
|
|
|
|
const seek = useCallback((time) => {
|
|
if (videoRef.current) {
|
|
videoRef.current.currentTime = time;
|
|
}
|
|
}, []);
|
|
|
|
return {
|
|
videoRef,
|
|
isReady,
|
|
isPlaying,
|
|
currentTime,
|
|
duration,
|
|
error,
|
|
play,
|
|
pause,
|
|
seek,
|
|
};
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## File 2: hooks/useChapterTracking.js
|
|
|
|
```javascript
|
|
import { useMemo, useEffect, useState, useCallback } from 'react';
|
|
|
|
/**
|
|
* Hook for tracking which chapter is currently active
|
|
* based on video's currentTime
|
|
*/
|
|
export function useChapterTracking({
|
|
chapters = [],
|
|
currentTime = 0,
|
|
onChapterChange = () => {},
|
|
} = {}) {
|
|
const [activeChapterId, setActiveChapterId] = useState(null);
|
|
const [completedChapters, setCompletedChapters] = useState([]);
|
|
|
|
// Find active chapter from currentTime
|
|
const activeChapter = useMemo(() => {
|
|
return chapters.find(
|
|
ch => currentTime >= ch.startTime && currentTime < ch.endTime
|
|
) || null;
|
|
}, [chapters, currentTime]);
|
|
|
|
// Detect chapter changes
|
|
useEffect(() => {
|
|
if (activeChapter?.id !== activeChapterId) {
|
|
setActiveChapterId(activeChapter?.id || null);
|
|
if (activeChapter) {
|
|
onChapterChange(activeChapter);
|
|
}
|
|
}
|
|
}, [activeChapter, activeChapterId, onChapterChange]);
|
|
|
|
// Track completed chapters
|
|
useEffect(() => {
|
|
if (activeChapter?.id && !completedChapters.includes(activeChapter.id)) {
|
|
// Mark chapter as visited (not necessarily completed)
|
|
setCompletedChapters(prev => [...prev, activeChapter.id]);
|
|
}
|
|
}, [activeChapter?.id, completedChapters]);
|
|
|
|
// Calculate current chapter progress
|
|
const chapterProgress = useMemo(() => {
|
|
if (!activeChapter) return 0;
|
|
|
|
const chapterDuration = activeChapter.endTime - activeChapter.startTime;
|
|
const timeInChapter = currentTime - activeChapter.startTime;
|
|
return Math.round((timeInChapter / chapterDuration) * 100);
|
|
}, [activeChapter, currentTime]);
|
|
|
|
// Get overall video progress
|
|
const overallProgress = useMemo(() => {
|
|
if (!chapters.length) return 0;
|
|
const lastChapter = chapters[chapters.length - 1];
|
|
return Math.round((currentTime / lastChapter.endTime) * 100);
|
|
}, [chapters, currentTime]);
|
|
|
|
return {
|
|
activeChapter,
|
|
activeChapterId,
|
|
chapterProgress, // 0-100 within current chapter
|
|
overallProgress, // 0-100 for entire video
|
|
completedChapters, // Array of visited chapter IDs
|
|
isVideoComplete: overallProgress >= 100,
|
|
};
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## File 3: components/AdiloVideoPlayer.jsx
|
|
|
|
```javascript
|
|
import React, { useState, useCallback } from 'react';
|
|
import { useAdiloPlayer } from '@/hooks/useAdiloPlayer';
|
|
import { useChapterTracking } from '@/hooks/useChapterTracking';
|
|
import ChapterNavigation from './ChapterNavigation';
|
|
import styles from './AdiloVideoPlayer.module.css';
|
|
|
|
/**
|
|
* Main Adilo video player component with chapter support
|
|
*/
|
|
export default function AdiloVideoPlayer({
|
|
m3u8Url,
|
|
videoId,
|
|
chapters = [],
|
|
autoplay = false,
|
|
showChapters = true,
|
|
onVideoComplete = () => {},
|
|
onChapterChange = () => {},
|
|
onProgressUpdate = () => {},
|
|
}) {
|
|
const [lastSaveTime, setLastSaveTime] = useState(0);
|
|
|
|
const {
|
|
videoRef,
|
|
isReady,
|
|
isPlaying,
|
|
currentTime,
|
|
duration,
|
|
error,
|
|
play,
|
|
pause,
|
|
seek,
|
|
} = useAdiloPlayer({
|
|
m3u8Url,
|
|
autoplay,
|
|
onTimeUpdate: handleTimeUpdate,
|
|
onEnded: handleVideoEnded,
|
|
onError: (err) => console.error('Player error:', err),
|
|
});
|
|
|
|
const {
|
|
activeChapter,
|
|
activeChapterId,
|
|
chapterProgress,
|
|
overallProgress,
|
|
completedChapters,
|
|
isVideoComplete,
|
|
} = useChapterTracking({
|
|
chapters,
|
|
currentTime,
|
|
onChapterChange,
|
|
});
|
|
|
|
// Save progress periodically (every 5 seconds)
|
|
function handleTimeUpdate(time) {
|
|
const now = Date.now();
|
|
if (now - lastSaveTime > 5000) {
|
|
onProgressUpdate({
|
|
videoId,
|
|
currentTime: time,
|
|
duration,
|
|
progress: overallProgress,
|
|
activeChapterId,
|
|
completedChapters,
|
|
});
|
|
setLastSaveTime(now);
|
|
}
|
|
}
|
|
|
|
function handleVideoEnded() {
|
|
onVideoComplete({
|
|
videoId,
|
|
completedChapters,
|
|
totalWatched: duration,
|
|
});
|
|
}
|
|
|
|
const handleChapterClick = useCallback((startTime) => {
|
|
seek(startTime);
|
|
play();
|
|
}, [seek, play]);
|
|
|
|
return (
|
|
<div className={styles.container}>
|
|
{/* Main Video Player */}
|
|
<div className={styles.playerWrapper}>
|
|
<video
|
|
ref={videoRef}
|
|
className={styles.video}
|
|
controls
|
|
controlsList="nodownload"
|
|
/>
|
|
|
|
{/* Loading Indicator */}
|
|
{!isReady && (
|
|
<div className={styles.loading}>
|
|
<div className={styles.spinner} />
|
|
<p>Loading video...</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Error State */}
|
|
{error && (
|
|
<div className={styles.error}>
|
|
<p>⚠️ Error: {error}</p>
|
|
<p className={styles.errorSmall}>
|
|
Make sure the M3U8 URL is valid and accessible
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Progress Bar */}
|
|
<div className={styles.progressContainer}>
|
|
<div className={styles.progressBar}>
|
|
{chapters.map((chapter, idx) => (
|
|
<div
|
|
key={chapter.id}
|
|
className={`${styles.progressSegment} ${
|
|
completedChapters.includes(chapter.id) ? styles.completed : ''
|
|
}`}
|
|
style={{
|
|
flex: chapter.endTime - chapter.startTime,
|
|
opacity: activeChapterId === chapter.id ? 1 : 0.7,
|
|
}}
|
|
onClick={() => handleChapterClick(chapter.startTime)}
|
|
/>
|
|
))}
|
|
</div>
|
|
<div className={styles.timeInfo}>
|
|
<span>{formatTime(currentTime)}</span>
|
|
<span>{formatTime(duration)}</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Chapter Navigation */}
|
|
{showChapters && (
|
|
<ChapterNavigation
|
|
chapters={chapters}
|
|
activeChapterId={activeChapterId}
|
|
currentTime={currentTime}
|
|
completedChapters={completedChapters}
|
|
onChapterClick={handleChapterClick}
|
|
/>
|
|
)}
|
|
|
|
{/* Status Info */}
|
|
<div className={styles.statusBar}>
|
|
<span>Playing: {activeChapter?.title || 'Video'}</span>
|
|
<span className={styles.progress}>{overallProgress}% watched</span>
|
|
{isVideoComplete && <span className={styles.complete}>✓ Completed</span>}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Utility function
|
|
function formatTime(seconds) {
|
|
if (!seconds || isNaN(seconds)) return '0:00';
|
|
const hours = Math.floor(seconds / 3600);
|
|
const minutes = Math.floor((seconds % 3600) / 60);
|
|
const secs = Math.floor(seconds % 60);
|
|
|
|
if (hours > 0) {
|
|
return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
|
}
|
|
return `${minutes}:${secs.toString().padStart(2, '0')}`;
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## File 4: components/ChapterNavigation.jsx
|
|
|
|
```javascript
|
|
import React from 'react';
|
|
import styles from './ChapterNavigation.module.css';
|
|
|
|
/**
|
|
* Chapter navigation sidebar component
|
|
*/
|
|
export default function ChapterNavigation({
|
|
chapters = [],
|
|
activeChapterId,
|
|
currentTime = 0,
|
|
completedChapters = [],
|
|
onChapterClick = () => {},
|
|
}) {
|
|
return (
|
|
<div className={styles.sidebar}>
|
|
<h3 className={styles.title}>Chapters</h3>
|
|
|
|
<div className={styles.chaptersList}>
|
|
{chapters.map((chapter) => {
|
|
const isActive = chapter.id === activeChapterId;
|
|
const isCompleted = completedChapters.includes(chapter.id);
|
|
const timeRemaining = chapter.endTime - currentTime;
|
|
|
|
return (
|
|
<button
|
|
key={chapter.id}
|
|
className={`${styles.chapterItem} ${
|
|
isActive ? styles.active : ''
|
|
} ${isCompleted ? styles.completed : ''}`}
|
|
onClick={() => onChapterClick(chapter.startTime)}
|
|
title={chapter.description || chapter.title}
|
|
>
|
|
<div className={styles.time}>
|
|
{formatTime(chapter.startTime)}
|
|
</div>
|
|
|
|
<div className={styles.content}>
|
|
<div className={styles.title}>{chapter.title}</div>
|
|
{chapter.description && (
|
|
<p className={styles.description}>{chapter.description}</p>
|
|
)}
|
|
</div>
|
|
|
|
{isCompleted && (
|
|
<span className={styles.badge}>✓</span>
|
|
)}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function formatTime(seconds) {
|
|
const minutes = Math.floor(seconds / 60);
|
|
const secs = Math.floor(seconds % 60);
|
|
return `${minutes}:${secs.toString().padStart(2, '0')}`;
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## File 5: services/progressService.js
|
|
|
|
```javascript
|
|
import { createClient } from '@supabase/supabase-js';
|
|
|
|
const supabase = createClient(
|
|
process.env.VITE_SUPABASE_URL,
|
|
process.env.VITE_SUPABASE_ANON_KEY
|
|
);
|
|
|
|
/**
|
|
* Save video progress to Supabase
|
|
*/
|
|
export async function saveProgress(userId, videoId, currentTime, completedChapters) {
|
|
try {
|
|
const { data, error } = await supabase
|
|
.from('video_progress')
|
|
.upsert({
|
|
user_id: userId,
|
|
video_id: videoId,
|
|
last_position: Math.round(currentTime),
|
|
completed_chapters: completedChapters,
|
|
updated_at: new Date(),
|
|
}, {
|
|
onConflict: 'user_id,video_id'
|
|
});
|
|
|
|
if (error) throw error;
|
|
return data;
|
|
} catch (error) {
|
|
console.error('Error saving progress:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get user's last position for a video
|
|
*/
|
|
export async function getLastPosition(userId, videoId) {
|
|
try {
|
|
const { data, error } = await supabase
|
|
.from('video_progress')
|
|
.select('last_position, completed_chapters')
|
|
.eq('user_id', userId)
|
|
.eq('video_id', videoId)
|
|
.single();
|
|
|
|
if (error && error.code !== 'PGRST116') throw error; // 116 = no rows
|
|
return data || { last_position: 0, completed_chapters: [] };
|
|
} catch (error) {
|
|
console.error('Error fetching progress:', error);
|
|
return { last_position: 0, completed_chapters: [] };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Mark video as completed
|
|
*/
|
|
export async function markVideoComplete(userId, videoId) {
|
|
try {
|
|
const { data, error } = await supabase
|
|
.from('video_progress')
|
|
.update({
|
|
is_completed: true,
|
|
completed_at: new Date(),
|
|
updated_at: new Date(),
|
|
})
|
|
.eq('user_id', userId)
|
|
.eq('video_id', videoId);
|
|
|
|
if (error) throw error;
|
|
return data;
|
|
} catch (error) {
|
|
console.error('Error marking complete:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get video analytics
|
|
*/
|
|
export async function getVideoAnalytics(userId, videoId) {
|
|
try {
|
|
const { data, error } = await supabase
|
|
.from('video_progress')
|
|
.select('*')
|
|
.eq('user_id', userId)
|
|
.eq('video_id', videoId)
|
|
.single();
|
|
|
|
if (error && error.code !== 'PGRST116') throw error;
|
|
return data || null;
|
|
} catch (error) {
|
|
console.error('Error fetching analytics:', error);
|
|
return null;
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## File 6: styles/AdiloVideoPlayer.module.css
|
|
|
|
```css
|
|
.container {
|
|
width: 100%;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 16px;
|
|
background: #f5f5f5;
|
|
border-radius: 8px;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.playerWrapper {
|
|
position: relative;
|
|
width: 100%;
|
|
aspect-ratio: 16/9;
|
|
background: #000;
|
|
}
|
|
|
|
.video {
|
|
width: 100%;
|
|
height: 100%;
|
|
}
|
|
|
|
.loading,
|
|
.error {
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
background: rgba(0, 0, 0, 0.8);
|
|
color: white;
|
|
z-index: 10;
|
|
}
|
|
|
|
.spinner {
|
|
width: 40px;
|
|
height: 40px;
|
|
border: 4px solid rgba(255, 255, 255, 0.3);
|
|
border-top-color: white;
|
|
border-radius: 50%;
|
|
animation: spin 1s linear infinite;
|
|
}
|
|
|
|
@keyframes spin {
|
|
to { transform: rotate(360deg); }
|
|
}
|
|
|
|
.error {
|
|
background: rgba(220, 38, 38, 0.9);
|
|
}
|
|
|
|
.errorSmall {
|
|
font-size: 12px;
|
|
margin-top: 8px;
|
|
opacity: 0.8;
|
|
}
|
|
|
|
.progressContainer {
|
|
padding: 0 16px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
}
|
|
|
|
.progressBar {
|
|
display: flex;
|
|
gap: 2px;
|
|
height: 6px;
|
|
background: #e5e7eb;
|
|
border-radius: 3px;
|
|
cursor: pointer;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.progressSegment {
|
|
flex: 1;
|
|
background: #0ea5e9;
|
|
border-radius: 1px;
|
|
transition: background 0.2s;
|
|
}
|
|
|
|
.progressSegment.completed {
|
|
background: #10b981;
|
|
}
|
|
|
|
.timeInfo {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
font-size: 12px;
|
|
color: #666;
|
|
}
|
|
|
|
.statusBar {
|
|
padding: 8px 16px;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
font-size: 13px;
|
|
color: #666;
|
|
border-top: 1px solid #e5e7eb;
|
|
}
|
|
|
|
.progress {
|
|
font-weight: 600;
|
|
color: #0ea5e9;
|
|
}
|
|
|
|
.complete {
|
|
color: #10b981;
|
|
font-weight: 600;
|
|
}
|
|
|
|
/* Responsive */
|
|
@media (max-width: 768px) {
|
|
.container {
|
|
gap: 12px;
|
|
}
|
|
|
|
.playerWrapper {
|
|
aspect-ratio: 16/9;
|
|
}
|
|
|
|
.progressContainer {
|
|
padding: 0 12px;
|
|
}
|
|
|
|
.statusBar {
|
|
padding: 6px 12px;
|
|
font-size: 12px;
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
**Ready to start? Copy these files into your project and follow the implementation plan!**
|