Files
meet-hub/adilo-code-templates-starter.md
dwindown 60baf32f73 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>
2026-01-01 23:54:32 +07:00

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!**