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
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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!**