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

16 KiB

Code Templates - Copy & Paste Starting Points

File 1: hooks/useAdiloPlayer.js

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

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

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

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

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

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