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>
This commit is contained in:
dwindown
2026-01-01 23:54:32 +07:00
parent 41f7b797e7
commit 60baf32f73
29 changed files with 3694 additions and 35048 deletions

File diff suppressed because one or more lines are too long

View File

@@ -1,196 +0,0 @@
# E-Course Curriculum: Cara Jual Jasa via Online
## How to Sell Services Online
**Source Video**: Live Zoom - Diskusi Cara Jual Jasa via Online
**Duration**: 2 hours 30 minutes
**Speaker**: Dwindi Ramadhana
**Language**: Indonesian
---
## Course Overview
This comprehensive course teaches you how to successfully sell services online, with a focus on web development and plugin installation services. Learn from real-world examples, case studies, and practical strategies for building a sustainable service business.
---
## Course Structure
### Chapter 1: Pengenalan & Temukan Pasar [00:00:00 - 00:15:00]
- [00:04:40 - 00:08:20] Pembukaan & Setup Diskusi
- Pengenalan topik cara jual jasa via online
- Format diskusi kasual dan interaktif
- Ruang lingkup: jasa pembuatan website & instalasi plugin
- [00:08:20 - 00:15:00] Cara Menemukan Market Anda
- Pentingnya bergabung dengan komunitas (Telegram, grup Facebook, forum)
- Kisah Abdurrahman bin Auf mencari pasar terlebih dahulu
- Mengamati pola komunitas dan masalah umum
- Jangan takut untuk bergabung dan mengamati
### Chapter 2: Discovery Masalah & Eksplorasi [00:15:00 - 00:30:00]
- [00:15:00 - 00:21:30] Identifikasi Masalah yang Sering Muncul
- Menemukan masalah yang sering disebutkan di komunitas
- Berpindah dari komunitas ke chat pribadi (Japri)
- Membangun jejaring melalui manfaat timbal balik
- Fase eksplorasi: trial and error di situs demo
- [00:21:30 - 00:30:00] Dasar Personal Branding
- Melakukan hal-hal bermanfaat untuk orang lain
- Pamerkan pengetahuan Anda (jangan disembunyikan)
- Memahami funnel AIDA (Awareness, Interest, Desire, Action)
- Cold market (grup) vs Warm market (chat pribadi) vs Hot market (klien)
### Chapter 3: Akuisisi & Manajemen Klien [00:30:00 - 00:45:00]
- [00:30:00 - 00:35:00] Psikologi Klien
- Aturan Klien: Semua orang ingin mengeluh
- Semua orang berpikir masalah mereka adalah yang terpenting
- Semua orang ingin didengar
- Empat kunci: Simak, Rekap, Offer, Deal
- [00:35:00 - 00:45:00] Strategi Harga
- Fokus pada effort Anda, bukan budget klien
- Studi kasus: Penetapan harga Elementor Pro
- Memahami nilai vs harga komoditas
- Contoh: Menjual lisensi Elementor Pro dengan harga Rp1.000/tahun
### Chapter 4: Deliver Layanan & Relasi Klien [00:45:00 - 01:00:00]
- [00:45:00 - 00:50:00] Prioritas Pelayanan Klien
- Prioritas 1: Respon cepat (jadilah responsif)
- Prioritas 2: Selesaikan proyek
- Prioritas 3: Eksplorasi hal baru
- Pentingnya keberadaan dan ketersediaan
- [00:50:00 - 01:00:00] Keberlanjutan Bisnis
- Mengapa harga rendah bermasalah (undercutting)
- Dua risiko utama: persaingan dan komoditisasi skill
- Transisi dari layanan dasar ke solusi spesialis
### Chapter 5: Sesi Tanya Jawab - Bagian 1 [01:00:00 - 01:15:00]
- [01:00:00 - 01:07:00] Implementasi Funnel
- Cara menerapkan AIDA dalam praktik
- Personal branding melalui partisipasi komunitas
- Menghadapi keberadaan komersial vs membantu di komunitas
- [01:07:00 - 01:15:00] Strategi Pemasaran
- Pemasaran afiliasi (model komisi 30%)
- Perbandingan pemasaran organik vs berbayar
- Contoh nyata keberhasilan afiliasi
### Chapter 6: Harga Lanjutan & Negosiasi [01:15:00 - 01:30:00]
- [01:15:00 - 01:22:00] Pendekatan Berorientasi Solusi
- Fokus pada solusi, bukan hanya menjual
- Studi kasus: Kustomisasi JetEngine
- Kapan merekomendasikan alternatif vs jasa Anda
- [01:22:00 - 01:30:00] Psikologi Harga
- Hindari harga terperinci (contoh 8 poin vs 4 poin)
- Memahami keberatan klien
- Jelas tentang ruang lingkup sebelum memberi harga
### Chapter 7: Manajemen Proyek & Kontrak [01:30:00 - 01:45:00]
- [01:30:00 - 01:37:00] Kejujuran dalam Relasi Klien
- Bersikap langsung tentang apa yang mungkin
- Contoh: Masalah kompatibilitas tema
- Menghindari janji yang berlebihan
- [01:37:00 - 01:45:00] Esensial Kontrak
- Kapan menggunakan kontrak (proyek besar)
- Format kontrak sederhana (1-2 halaman)
- Syarat dan ketentuan yang jelas
- Kebijakan uang muka
### Chapter 8: Pengembangan Diri & Etos Kerja [01:45:00 - 02:00:00]
- [01:45:00 - 01:53:00] Membangun Skill Anda
- Mindset workaholic vs kerja berkelanjutan
- Belajar dari rasa ingin tahu (contoh PSP)
- Eksplorasi dan pembelajaran terus-menerus
- [01:53:00 - 02:00:00] Alur Kerja Manajemen Proyek
- Strategi alokasi waktu
- Mengganggu saat menangani proyek
- Menyeimbangkan banyak klien
### Chapter 9: Sesi Tanya Jawab - Bagian 2 [02:00:00 - 02:15:00]
- [02:00:00 - 02:07:00] Lisensi Plugin & Etika
- Plugin GPL dari vendor (Envato, dll)
- Risiko plugin nulled/bajakan
- Mengunduh dari sumber resmi
- Edukasi klien tentang lisensi
- [02:07:00 - 02:15:00] Funnel AIDA Lebih Dalam
- Bagaimana eksplorasi memenuhi funnel
- Menciptakan kesadaran melalui kebermanfaatan
- Memindahkan prospek melalui tahapan
### Chapter 10: Tanya Jawab Final & Alat [02:15:00 - 02:30:18]
- [02:15:00 - 02:22:00] Pertanyaan Umum Layanan
- Etika pemasaran (hindari spam)
- Follow-up tanpa mengganggu
- Perjalanan belajar teknis
- [02:22:00 - 02:30:00] Alat yang Direkomendasikan
- Lingkungan pengembangan lokal (LocalWP)
- Rekomendasi hosting
- Sumber belajar
- Penutup dan perpisahan
---
## Key Takeaways
### Prinsip Utama
1. **Komunitas Dulu**: Bergabunglah dengan komunitas sebelum mencoba menjual apa pun
2. **Fokus pada Masalah**: Identifikasi masalah yang sering disebutkan
3. **Eksplorasi & Belajar**: Gunakan situs demo untuk trial and error
4. **Bangun Kepercayaan**: Bagikan pengetahuan dengan gratis, jangan sembunyikan
5. **Pahami Psikologi**: Klien ingin didengar dan dipahami
6. **Harga Berbasis Nilai**: Harga berdasarkan effort dan nilai Anda, bukan budget klien
7. **Jadilah Responsif**: Waktu respon cepat sangat krusial
8. **Berorientasi Solusi**: Fokus pada memecahkan masalah, bukan hanya menjual jasa
9. **Kejujuran Menang**: Bersikap langsung tentang keterbatasan dan kemungkinan
10. **Pembelajaran Terus-menerus**: Selalu eksplorasi alat dan teknik baru
### Studi Kasus Utama
- Penjualan Elementor Pro (100+ domain)
- Negosiasi harga dengan klien
- Membangun personal branding di komunitas
- Transisi dari freelancer ke pemilik produk
---
## Sumber Tambahan
### Tools yang Disebutkan
- LocalWP (local development)
- Elementor Pro
- JetEngine
- Sejoli Shortcode
### Komunitas yang Direkomendasikan
- Grup WordPress Indonesia
- Grup Facebook seputar web development
- Forum Telegram untuk diskusi teknis
---
## Catatan untuk Kursus
Kurikulum ini dirancang untuk memecah video 2.5 jam menjadi pelajaran yang mudah dicerna. Setiap chapter berfokus pada topik tertentu dengan durasi 15 menit, ideal untuk pembelajaran online. Materi mencakup contoh nyata, studi kasus, dan strategi praktis yang dapat langsung diterapkan.
---
**Dokumen ini dibuat berdasarkan transcript video asli**
**Total Durasi Video**: 2 jam 30 menit 18 detik
**Jumlah Chapter**: 10
**Jumlah Pelajaran**: 20

372
adilo-ai-agent-quick-ref.md Normal file
View File

@@ -0,0 +1,372 @@
# Adilo Video Player - Quick AI Agent Reference
## For Your Windsurf/IDE AI Agent
Copy this into your `.codebase` instructions or share with AI agent:
---
## Project: LearnHub - Adilo M3U8 Video Player with Custom Chapters
### Problem Statement
Build a React video player that:
- Streams video from Adilo using M3U8 (HLS) direct URL
- Displays custom chapter navigation
- Allows click-to-jump to chapters
- Tracks user progress
- Saves completion data to Supabase
### Tech Stack
- **React 18+** (Hooks, Context)
- **HLS.js** - for M3U8 streaming
- **Supabase** - for progress tracking
- **HTML5 Video API** - native controls
- **CSS Modules** - styling
---
## Quick Command Reference
### Install Dependencies
```bash
npm install hls.js @supabase/supabase-js
```
### Project Structure to Create
```
src/
├── components/
│ ├── AdiloVideoPlayer.jsx # Main component
│ ├── ChapterNavigation.jsx # Chapter sidebar
│ └── ProgressBar.jsx # Progress indicator
├── hooks/
│ ├── useAdiloPlayer.js # HLS streaming logic
│ └── useChapterTracking.js # Chapter tracking
├── services/
│ ├── adiloService.js # Adilo API calls
│ └── progressService.js # Supabase progress
├── styles/
│ └── AdiloVideoPlayer.module.css
└── types/
└── video.types.js
```
---
## Implementation Phases (In Order)
### ⭐ PHASE 1: useAdiloPlayer Hook
**Goal**: Get HLS.js working with M3U8 URL
**What to build:**
- React hook that initializes HLS.js instance
- Return: videoRef, isReady, isPlaying, currentTime, duration
- Handle browser compatibility (Safari vs HLS.js)
- Clean up HLS instance on unmount
- Emit callbacks: onTimeUpdate, onEnded, onError
**Test with:**
```javascript
const { videoRef, currentTime, isReady } = useAdiloPlayer({
m3u8Url: "https://adilo.bigcommand.com/m3u8/...",
autoplay: false,
onTimeUpdate: (time) => console.log(time)
});
```
---
### ⭐ PHASE 2: useChapterTracking Hook
**Goal**: Determine which chapter is currently active
**What to build:**
- React hook that tracks active chapter
- Input: chapters array, currentTime
- Return: activeChapter, activeChapterId, chapterProgress
- Detect chapter transitions
- Calculate progress percentage
**Chapter data structure:**
```javascript
{
id: "ch1",
startTime: 0,
endTime: 120,
title: "Introduction",
description: "Welcome to the course"
}
```
**Test with:**
```javascript
const { activeChapter, chapterProgress } = useChapterTracking({
chapters: [...],
currentTime: 45
});
// activeChapter should be chapter with startTime ≤ 45 < endTime
```
---
### ⭐ PHASE 3: AdiloVideoPlayer Component
**Goal**: Main player combining both hooks
**What to build:**
- Component that uses both hooks
- Renders: <video> element + video controls
- Props: m3u8Url, videoId, chapters, autoplay, showChapters
- Methods: jumpToChapter(), play(), pause()
- Callbacks: onChapterChange, onVideoComplete, onProgressUpdate
**Usage example:**
```jsx
<AdiloVideoPlayer
m3u8Url="https://adilo.bigcommand.com/m3u8/..."
chapters={[{id: "1", startTime: 0, endTime: 120, title: "Intro"}]}
onVideoComplete={() => markComplete()}
onChapterChange={(ch) => console.log(ch.title)}
/>
```
---
### ⭐ PHASE 4: ChapterNavigation Component
**Goal**: Display chapters user can click to jump
**What to build:**
- Sidebar/timeline showing all chapters
- Highlight current active chapter
- Show time for each chapter
- Click handler to jump to chapter
- Progress bar for each chapter
**Props:**
- chapters: Chapter[]
- activeChapterId: string
- currentTime: number
- onChapterClick: (startTime: number) => void
- completedChapters: string[]
---
### ⭐ PHASE 5: Supabase Integration
**Goal**: Save video progress to database
**Database schema needed:**
```sql
CREATE TABLE video_progress (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL,
video_id uuid NOT NULL,
last_position int,
completed_chapters text[],
watched_percentage int,
is_completed boolean DEFAULT false,
completed_at timestamp,
created_at timestamp DEFAULT now(),
updated_at timestamp DEFAULT now(),
UNIQUE(user_id, video_id)
);
```
**Functions to implement:**
- `saveProgress(userId, videoId, currentTime, completedChapters)`
- `getLastPosition(userId, videoId)` - resume from last position
- `markVideoComplete(userId, videoId)`
- `getVideoAnalytics(userId, videoId)`
---
### ⭐ PHASE 6: Styling
**Goal**: Make it look good and responsive
**Key CSS classes needed:**
- `.adilo-player` - main container
- `.video-container` - video wrapper
- `.chapters-sidebar` - chapter list
- `.chapter-item` - individual chapter
- `.chapter-item.active` - highlight active
- `.progress-bar` - progress visualization
**Responsive breakpoints:**
- Desktop: sidebar on right
- Tablet: sidebar below video
- Mobile: horizontal timeline under video
---
## Key Implementation Details
### HLS.js Initialization Pattern
```javascript
if (Hls.isSupported()) {
const hls = new Hls();
hls.loadSource(m3u8Url);
hls.attachMedia(videoElement);
hls.on(Hls.Events.MANIFEST_PARSED, () => {
videoElement.play();
});
} else if (videoElement.canPlayType('application/vnd.apple.mpegurl')) {
// Safari native HLS support
videoElement.src = m3u8Url;
}
```
### Chapter Jump Implementation
```javascript
const jumpToChapter = (startTime) => {
if (videoRef.current) {
videoRef.current.currentTime = startTime;
videoRef.current.play();
}
};
```
### Track Current Chapter Pattern
```javascript
const current = chapters.find(
ch => currentTime >= ch.startTime && currentTime < ch.endTime
);
setActiveChapter(current?.id);
```
### Debounce Progress Saves
```javascript
// Save progress every 5 seconds, not on every timeupdate
const saveProgressDebounced = debounce(
(userId, videoId, time) => saveProgress(userId, videoId, time),
5000
);
```
---
## Common Tasks for AI Agent
When asking your AI agent to implement:
### Task: "Create useAdiloPlayer hook"
**Should generate:**
- Import HLS from 'hls.js'
- useRef for video element
- useEffect to initialize HLS
- useCallback for event handlers
- Clean up logic in return
### Task: "Add chapter jump functionality"
**Should implement:**
- Button click handler
- Call jumpToChapter(startTime)
- Update videoRef.current.currentTime
- Play the video
### Task: "Save progress to Supabase"
**Should implement:**
- Create/update row in video_progress table
- Include: user_id, video_id, last_position, completed_chapters
- Handle conflicts (UPSERT)
- Error handling
### Task: "Make chapters responsive"
**Should implement:**
- CSS Grid for desktop (sidebar)
- Flex column for mobile
- Media query at 768px breakpoint
- Adjust spacing and font sizes
---
## Testing Checklist
### Unit Tests
- [ ] useAdiloPlayer hook returns correct refs/values
- [ ] useChapterTracking calculates active chapter correctly
- [ ] jumpToChapter updates video.currentTime
- [ ] Progress saves to Supabase
### Integration Tests
- [ ] Video plays when component mounts
- [ ] Chapter changes highlight in UI
- [ ] Clicking chapter jumps player
- [ ] Progress saves on interval
- [ ] Completion triggers callback
### Browser Tests
- [ ] Works on Chrome/Edge (HLS.js)
- [ ] Works on Firefox (HLS.js)
- [ ] Works on Safari (native HLS)
- [ ] Works on mobile browsers
### Edge Cases
- [ ] Bad M3U8 URL shows error
- [ ] Network interruption handled
- [ ] Video paused mid-chapter
- [ ] Page refresh preserves position
---
## Environment Variables Needed
```
VITE_SUPABASE_URL=your_supabase_url
VITE_SUPABASE_ANON_KEY=your_supabase_key
```
---
## Debugging Tips
### HLS.js not loading?
- Check M3U8 URL is correct from Adilo
- Verify CORS headers from Adilo
- Check browser console for HLS.js errors
- Try `hls.on(Hls.Events.ERROR, console.error)`
### Chapter not highlighting?
- Add console.log(currentTime, chapters) to track values
- Verify chapter startTime/endTime are correct
- Check activeChapter state is updating
### Progress not saving?
- Verify Supabase connection works
- Check user_id and video_id are defined
- Add error logs to saveProgress function
- Check database table schema matches
---
## Performance Optimization Tips
1. **Memoize chapters list** to prevent re-renders
```javascript
const chapters = useMemo(() => chaptersData, [chaptersData]);
```
2. **Debounce timeupdate events** (fires 60x per second!)
```javascript
const updateChapter = debounce(() => {...}, 100);
video.addEventListener('timeupdate', updateChapter);
```
3. **Lazy load chapter images/thumbnails**
```javascript
<img loading="lazy" src={chapter.thumbnail} />
```
4. **Use React.memo for ChapterNavigation**
```javascript
export default React.memo(ChapterNavigation);
```
---
## Resources
- **HLS.js Docs**: https://github.com/video-dev/hls.js/wiki
- **HTML5 Video API**: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/video
- **Supabase JS Client**: https://supabase.com/docs/reference/javascript/introduction
---
**Status**: Ready to implement! 🚀
Start with PHASE 1 (useAdiloPlayer hook), then PHASE 2-6 in order.

View File

@@ -0,0 +1,702 @@
# 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!**

557
adilo-player-impl-plan.md Normal file
View File

@@ -0,0 +1,557 @@
# Adilo Custom Video Player with Chapter System - Implementation Plan
## Project Overview
Build a React video player component that uses Adilo's M3U8 streaming URL with custom chapter navigation system for LearnHub LMS.
---
## Architecture
### Components Structure
```
VideoLesson/
├── AdiloVideoPlayer.jsx (Main player component)
├── ChapterNavigation.jsx (Chapter sidebar/timeline)
├── VideoControls.jsx (Custom controls - optional)
└── hooks/
├── useAdiloPlayer.js (HLS player logic)
└── useChapterTracking.js (Chapter progress tracking)
```
### Data Flow
```
Adilo M3U8 URL
HLS.js (streaming)
HTML5 <video> element
currentTime tracking
Chapter UI sync
Supabase (save progress)
```
---
## Step-by-Step Implementation
### PHASE 1: Dependencies Setup
**Install required packages:**
```bash
npm install hls.js
npm install @supabase/supabase-js # For progress tracking
```
**No additional UI library needed** - use native HTML5 video + your own CSS for chapters.
---
### PHASE 2: Core Hook - useAdiloPlayer
**File: `hooks/useAdiloPlayer.js`**
**Purpose:** Handle HLS streaming with HLS.js library
**Responsibilities:**
- Initialize HLS instance
- Load M3U8 URL
- Handle browser compatibility (Safari native HLS vs HLS.js)
- Expose video element ref for external control
- Emit events (play, pause, ended, timeupdate)
**Function signature:**
```javascript
const {
videoRef,
isReady,
isPlaying,
currentTime,
duration,
error
} = useAdiloPlayer({
m3u8Url: string,
autoplay: boolean,
onTimeUpdate: (time: number) => void,
onEnded: () => void,
onError: (error) => void
})
```
**Key features:**
- Auto-dispose HLS instance on unmount
- Handle loading states
- Error boundary for failed streams
- Track play/pause states
---
### PHASE 3: Core Hook - useChapterTracking
**File: `hooks/useChapterTracking.js`**
**Purpose:** Track which chapter user is currently viewing
**Responsibilities:**
- Determine active chapter from currentTime
- Calculate chapter progress percentage
- Detect chapter transitions
- Export chapter completion data
**Function signature:**
```javascript
const {
activeChapter,
activeChapterId,
chapterProgress, // 0-100%
completedChapters,
chapterTimeline // for progress bar
} = useChapterTracking({
chapters: Chapter[],
currentTime: number,
onChapterChange: (chapter) => void
})
```
**Chapter object structure:**
```javascript
{
id: string,
startTime: number, // in seconds
endTime: number, // in seconds
title: string,
description?: string, // optional
thumbnail?: string // optional
}
```
---
### PHASE 4: Main Component - AdiloVideoPlayer
**File: `components/AdiloVideoPlayer.jsx`**
**Purpose:** Main video player component that combines HLS streaming + chapter tracking
**Props:**
```javascript
{
m3u8Url: string, // From Adilo dashboard
videoId: string, // For database tracking
chapters: Chapter[], // Your chapter data
autoplay: boolean, // Default: false
showChapters: boolean, // Default: true
onVideoComplete: (data) => void, // Callback when video ends
onChapterChange: (chapter) => void,
onProgressUpdate: (progress) => void
}
```
**Component structure:**
```jsx
<div className="adilo-player">
{/* Video container */}
<div className="video-container">
<video
ref={videoRef}
controls
controlsList="nodownload"
/>
{/* Loading indicator */}
{!isReady && <LoadingSpinner />}
</div>
{/* Chapter Navigation */}
{showChapters && (
<ChapterNavigation
chapters={chapters}
activeChapterId={activeChapterId}
currentTime={currentTime}
onChapterClick={jumpToChapter}
completedChapters={completedChapters}
/>
)}
{/* Progress bar (optional) */}
<ProgressBar
chapters={chapters}
currentTime={currentTime}
/>
</div>
```
**Key methods:**
- `jumpToChapter(startTime)` - Seek to chapter
- `play()` / `pause()` - Control playback
- `getCurrentProgress()` - Get session progress
---
### PHASE 5: Chapter Navigation Component
**File: `components/ChapterNavigation.jsx`**
**Purpose:** Display chapters as sidebar/timeline with click-to-jump
**Layout options:**
1. **Sidebar** - Vertical list on side (desktop)
2. **Horizontal** - Timeline below video (mobile)
3. **Collapsible** - Toggle on mobile
**Features:**
- Show current/upcoming chapters
- Highlight active chapter
- Show time remaining for current chapter
- Progress indicators
- Drag-to-seek on timeline (optional)
**Chapter item structure:**
```jsx
<div className="chapter-item">
<div className="chapter-time">{formatTime(startTime)}</div>
<div className="chapter-title">{title}</div>
<div className="chapter-progress">{progressBar}</div>
<button onClick={() => jumpToChapter(startTime)}>
Jump to Chapter
</button>
</div>
```
---
### PHASE 6: Supabase Integration (Optional)
**File: `services/progressService.js`**
**Purpose:** Save video progress to database
**Database table structure:**
```sql
CREATE TABLE video_progress (
id uuid PRIMARY KEY,
user_id uuid NOT NULL,
video_id uuid NOT NULL,
last_position int, -- seconds
completed_chapters text[], -- array of chapter IDs
watched_percentage int, -- 0-100
is_completed boolean,
completed_at timestamp,
created_at timestamp,
updated_at timestamp,
UNIQUE(user_id, video_id)
)
```
**Functions to implement:**
```javascript
// Save current progress
saveProgress(userId, videoId, currentTime, completedChapters)
// Resume from last position
getLastPosition(userId, videoId)
// Mark video as complete
markVideoComplete(userId, videoId)
// Get completion analytics
getVideoAnalytics(userId, videoId)
```
---
### PHASE 7: Styling
**File: `styles/AdiloVideoPlayer.module.css` or your preferred CSS approach**
**Key styles needed:**
```css
/* Video container */
.video-container {
position: relative;
width: 100%;
aspect-ratio: 16/9;
background: #000;
}
video {
width: 100%;
height: 100%;
}
/* Chapter sidebar */
.chapters-sidebar {
background: #f5f5f5;
padding: 16px;
max-height: 400px;
overflow-y: auto;
}
.chapter-item {
padding: 12px;
border-radius: 8px;
cursor: pointer;
transition: background 0.2s;
}
.chapter-item.active {
background: #e0f2fe;
border-left: 4px solid #0ea5e9;
}
.chapter-time {
font-weight: 600;
color: #333;
}
.chapter-title {
font-size: 14px;
color: #666;
margin-top: 4px;
}
/* Progress bar */
.progress-bar {
display: flex;
gap: 2px;
height: 4px;
background: #e5e5e5;
border-radius: 2px;
}
.progress-segment {
background: #0ea5e9;
flex: 1;
border-radius: 1px;
}
.progress-segment.completed {
background: #10b981;
}
/* Responsive */
@media (max-width: 768px) {
.chapters-sidebar {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
}
```
---
## Implementation Checklist
### Setup Phase
- [ ] Install dependencies (hls.js, @supabase/supabase-js)
- [ ] Set up folder structure
- [ ] Configure Supabase client (if using progress tracking)
### Core Development
- [ ] Implement `useAdiloPlayer` hook
- [ ] Implement `useChapterTracking` hook
- [ ] Create `AdiloVideoPlayer` component
- [ ] Create `ChapterNavigation` component
- [ ] Add styling/CSS
### Integration
- [ ] Implement Supabase progress service
- [ ] Add error handling & loading states
- [ ] Add accessibility features (ARIA labels, keyboard navigation)
- [ ] Test HLS streaming on different browsers
### Testing
- [ ] Test video playback (Chrome, Firefox, Safari, mobile)
- [ ] Test chapter navigation
- [ ] Test progress saving to Supabase
- [ ] Test responsive design
- [ ] Test error scenarios (bad M3U8 URL, network issues)
### Optional Enhancements
- [ ] Add playback speed control
- [ ] Add quality selector (if HLS variants available)
- [ ] Add full-screen mode
- [ ] Add picture-in-picture
- [ ] Add watch history
- [ ] Add completion badges
---
## Code Example Template
### Main Usage in LearnHub
```jsx
import AdiloVideoPlayer from '@/components/AdiloVideoPlayer';
function LessonPage({ lessonId }) {
const [lesson, setLesson] = useState(null);
const [userProgress, setUserProgress] = useState(null);
const user = useAuth().user;
useEffect(() => {
// Fetch lesson with chapters
fetchLessonData(lessonId).then(setLesson);
// Get user's last progress
getLastPosition(user.id, lessonId).then(setUserProgress);
}, [lessonId]);
const handleChapterChange = (chapter) => {
console.log(`Chapter changed: ${chapter.title}`);
};
const handleVideoComplete = (data) => {
// Mark as complete in Supabase
markVideoComplete(user.id, lessonId);
// Show completion message
toast.success('Lesson completed! 🎉');
};
const handleProgressUpdate = (progress) => {
// Save progress every 10 seconds
if (progress.currentTime % 10 === 0) {
saveProgress(user.id, lessonId, progress.currentTime, progress.completedChapters);
}
};
if (!lesson) return <LoadingPage />;
return (
<div className="lesson-container">
<h1>{lesson.title}</h1>
<AdiloVideoPlayer
m3u8Url={lesson.m3u8Url}
videoId={lesson.id}
chapters={lesson.chapters}
autoplay={false}
showChapters={true}
onChapterChange={handleChapterChange}
onVideoComplete={handleVideoComplete}
onProgressUpdate={handleProgressUpdate}
/>
<div className="lesson-content">
<h2>Lesson Details</h2>
<p>{lesson.description}</p>
</div>
</div>
);
}
export default LessonPage;
```
---
## Important Notes
### Video URL Storage
Store the M3U8 URL in your Supabase `videos` or `lessons` table:
```sql
CREATE TABLE lessons (
id uuid PRIMARY KEY,
title text,
description text,
m3u8_url text, -- Store the Adilo M3U8 URL here
chapters jsonb, -- Store chapters as JSON array
created_at timestamp
)
```
### Security Considerations
-**Don't expose M3U8 URL in frontend code** - fetch from backend
-**Validate M3U8 URLs** - only allow Adilo domains
-**Use CORS headers** - ensure Adilo allows cross-origin requests
-**Log access** - track who watches which videos
### Browser Compatibility
-**Chrome/Edge**: HLS.js library
-**Firefox**: HLS.js library
-**Safari**: Native HLS support (no library needed)
-**Mobile browsers**: Auto-detects capability
### Performance Tips
- 🚀 Lazy load chapter data
- 🚀 Debounce progress updates
- 🚀 Memoize chapter calculations
- 🚀 Use video preload="metadata"
---
## Common Pitfalls to Avoid
1. **❌ Don't forget to dispose HLS instance** - Memory leak
- ✅ Do: Clean up in useEffect return
2. **❌ Don't update state on every timeupdate** - Performance issue
- ✅ Do: Debounce or throttle updates
3. **❌ Don't hardcode M3U8 URLs in component** - Security issue
- ✅ Do: Fetch from backend API
4. **❌ Don't assume HLS.js works everywhere** - Safari native support exists
- ✅ Do: Check `Hls.isSupported()` and fallback
5. **❌ Don't forget CORS headers** - Cross-origin requests fail
- ✅ Do: Verify Adilo allows your domain
---
## Testing Commands
```bash
# Install dev dependencies
npm install --save-dev @testing-library/react @testing-library/jest-dom
# Run tests
npm test
# Build for production
npm run build
# Check bundle size
npm run analyze
```
---
## Next Steps
1. **Get M3U8 URL** from Adilo dashboard ✅ (You found it!)
2. **Store URL** in your Supabase lessons table
3. **Create the hooks** (start with `useAdiloPlayer`)
4. **Build the component** (AdiloVideoPlayer)
5. **Integrate chapters** (ChapterNavigation)
6. **Add progress tracking** (Supabase integration)
7. **Style and polish** (CSS/responsive design)
8. **Test thoroughly** (all browsers, mobile, edge cases)
---
## Files to Create
```
src/
├── components/
│ ├── AdiloVideoPlayer.jsx
│ ├── ChapterNavigation.jsx
│ └── ProgressBar.jsx
├── hooks/
│ ├── useAdiloPlayer.js
│ └── useChapterTracking.js
├── services/
│ ├── progressService.js
│ └── adiloService.js
├── styles/
│ └── AdiloVideoPlayer.module.css
└── types/
└── video.types.js
```
---
**Ready to implement? Start with Phase 1 & 2 (setup + useAdiloPlayer hook). Let me know if you need help with any specific phase!**

View File

@@ -1,179 +0,0 @@
#!/usr/bin/env python3
"""
Analyze video transcript to identify topics and create chapter divisions.
"""
import json
import re
from datetime import timedelta
def seconds_to_timestamp(seconds):
"""Convert seconds to readable timestamp."""
total_seconds = int(float(seconds))
hours, remainder = divmod(total_seconds, 3600)
minutes, seconds = divmod(remainder, 60)
return f"{hours:02d}:{minutes:02d}:{seconds:02d}"
def load_transcript(file_path):
"""Load JSON transcript file."""
with open(file_path, 'r', encoding='utf-8') as f:
data = json.load(f)
return data
def extract_segments(data):
"""Extract transcript segments with timestamps."""
segments = []
for track in data[0]['tracks']:
if 'transcript' in track:
for item in track['transcript']:
start = float(item.get('start', 0))
dur = float(item.get('dur', 0))
text = item.get('text', '').strip()
if text and text != '\n':
segments.append({
'start': start,
'end': start + dur,
'text': text
})
# Sort by start time
segments.sort(key=lambda x: x['start'])
return segments
def extract_keywords(text):
"""Extract key topics from text."""
keywords = {
'Market & Community': ['market', 'pasar', 'grup', 'komunitas', 'telegram', 'facebook', 'forum'],
'Problem Finding': ['masalah', 'problem', 'kesulitan', 'permasalahan', 'error', 'bermasalah'],
'Exploration': ['explor', 'coba', 'trial', 'nyoba', 'eksplor', 'explore'],
'Personal Branding': ['branding', 'personal branding', 'show off', 'image', 'eksistensi'],
'AIDA/Funnel': ['aida', 'awareness', 'interest', 'desire', 'action', 'funel', 'funnel'],
'Trust': ['trust', 'percaya', 'kepercayaan'],
'Clients': ['klien', 'client', 'pelanggan', 'customer'],
'Pricing': ['harga', 'price', 'bayar', 'budget', 'rp', 'juta', 'ribu', 'dibayar'],
'Negotiation': ['tawar', 'negosiasi', 'deal'],
'Services': ['jasa', 'service', 'website', 'plugin', 'elementor', 'instal'],
'Cold/Warm/Hot Market': ['cold market', 'warm market', 'hot market', 'dingin', 'hangat'],
'Network': ['network', 'jaringan', 'koneksi', 'hubungan'],
'Sharing': ['sharing', 'share', 'bagi'],
'Products': ['produk', 'product', 'template'],
'Japri': ['japri', 'private', 'chat pribadi'],
}
found = []
text_lower = text.lower()
for topic, kw_list in keywords.items():
count = sum(1 for kw in kw_list if kw.lower() in text_lower)
if count > 0:
found.append((topic, count))
return sorted(found, key=lambda x: x[1], reverse=True)
def analyze_video():
"""Analyze the video transcript."""
file_path = "/Users/dwindown/CascadeProjects/MeetDwindiCom/access-hub/Live Zoom - Diskusi Cara Jual Jasa via Online.json"
print("="*80)
print("VIDEO TRANSCRIPT ANALYSIS")
print("Cara Jual Jasa via Online (How to Sell Services Online)")
print("="*80)
print()
data = load_transcript(file_path)
segments = extract_segments(data)
print(f"Total segments: {len(segments)}")
if not segments:
print("No segments found!")
return
total_duration = segments[-1]['end']
print(f"Total duration: {seconds_to_timestamp(total_duration)} ({total_duration/60:.1f} minutes)\n")
# Create time-based groups every 5 minutes
print("="*80)
print("CONTENT BREAKDOWN BY 5-MINUTE INTERVALS")
print("="*80)
print()
window = 300 # 5 minutes
current_time = 0
section_num = 1
while current_time < total_duration:
window_end = min(current_time + window, total_duration)
window_segments = [s for s in segments
if current_time <= s['start'] < window_end]
if window_segments:
# Combine text
combined_text = ' '.join([s['text'] for s in window_segments])
# Extract keywords
keywords = extract_keywords(combined_text)
print(f"Section {section_num}: {seconds_to_timestamp(current_time)} - {seconds_to_timestamp(window_end)}")
print("-" * 80)
# Show first 400 characters as preview
preview = combined_text[:400]
print(f"Content: {preview}...")
print()
if keywords:
print("Key topics detected:")
for topic, count in keywords[:7]:
print(f"{topic}: {count} mentions")
else:
print("Key topics: (transition/break section)")
print()
print()
section_num += 1
current_time = window_end
# Now create suggested chapters based on content analysis
print("\n")
print("="*80)
print("SUGGESTED CHAPTER STRUCTURE")
print("="*80)
print()
# Create larger 15-minute groups for chapter suggestions
chapter_window = 900 # 15 minutes
current_time = 0
chapter_num = 1
while current_time < total_duration:
chapter_end = min(current_time + chapter_window, total_duration)
chapter_segments = [s for s in segments
if current_time <= s['start'] < chapter_end]
if chapter_segments:
combined_text = ' '.join([s['text'] for s in chapter_segments])
keywords = extract_keywords(combined_text)
# Get top 3 keywords for chapter title
main_topics = [kw[0] for kw in keywords[:3]]
print(f"Chapter {chapter_num}: {seconds_to_timestamp(current_time)} - {seconds_to_timestamp(chapter_end)}")
print(f"Main topics: {', '.join(main_topics)}")
# Show first 300 chars
preview = combined_text[:300].replace('\n', ' ')
print(f"Preview: {preview}...")
print()
print()
chapter_num += 1
current_time = chapter_end
if __name__ == "__main__":
analyze_video()

View File

@@ -1,167 +0,0 @@
#!/usr/bin/env python3
"""
Analyze video transcript to identify topics and create chapter divisions.
"""
import json
import re
from datetime import timedelta
def seconds_to_timestamp(seconds):
"""Convert seconds to readable timestamp."""
td = timedelta(seconds=float(seconds))
hours, remainder = divmod(td.seconds, 3600)
minutes, seconds = divmod(remainder, 60)
return f"{hours:02d}:{minutes:02d}:{seconds:02d}"
def load_transcript(file_path):
"""Load JSON transcript file."""
with open(file_path, 'r', encoding='utf-8') as f:
data = json.load(f)
return data
def extract_text_with_timestamps(data):
"""Extract text segments with timestamps."""
segments = []
for entry in data:
if 'events' in entry:
for event in entry['events']:
if 'segs' in event:
for seg in event['segs']:
if 'utf8' in seg:
segments.append({
'start': float(event.get('tStartMs', 0)) / 1000,
'text': seg['utf8']
})
return segments
def clean_text(text):
"""Clean transcript text."""
# Remove extra whitespace
text = ' '.join(text.split())
return text
def identify_keywords(text):
"""Identify important keywords in Indonesian business context."""
keywords = {
'market': ['market', 'pasar', 'grup', 'komunitas', 'community'],
'problem': ['masalah', 'problem', 'kesulitan', 'error', 'gagal'],
'branding': ['branding', 'personal branding', 'image', 'citra'],
'funnel': ['funel', 'funnel', 'awareness', 'desire', 'action'],
'client': ['klien', 'client', 'pelanggan', 'customer'],
'price': ['harga', 'price', 'bayar', 'paid', 'invoice'],
'negotiation': ['tawar', 'negosiasi', 'deal'],
'service': ['jasa', 'service', 'website', 'plugin'],
'exploration': ['explore', 'eksplor', 'coba', 'trial'],
'network': ['network', 'jaringan', 'koneksi'],
'sharing': ['sharing', 'share', 'bagi'],
'product': ['produk', 'product', 'template', 'plugin'],
'trust': ['trust', 'percaya', 'kepercayaan'],
'sales': ['jual', 'sales', 'closing'],
}
return keywords
def analyze_structure():
"""Analyze the transcript structure."""
file_path = "/Users/dwindown/CascadeProjects/MeetDwindiCom/access-hub/Live Zoom - Diskusi Cara Jual Jasa via Online.json"
print("Loading transcript...")
data = load_transcript(file_path)
print("Extracting segments...")
segments = extract_text_with_timestamps(data)
print(f"\nTotal segments: {len(segments)}")
# Get total duration
if segments:
total_duration = segments[-1]['start']
print(f"Total duration: {seconds_to_timestamp(total_duration)} ({total_duration/60:.1f} minutes)")
# Sample segments at different intervals
print("\n=== SAMPLING SEGMENTS AT KEY INTERVALS ===\n")
sample_points = [0, 300, 600, 900, 1200, 1500, 1800, 2100, 2400, 2700, 3000,
3300, 3600, 3900, 4200, 4500, 4800, 5100, 5400, 5700, 6000,
6300, 6600, 6900, 7200, 7500, 7800, 8100, 8400, 8700, 9000]
for i, target_time in enumerate(sample_points):
# Find closest segment
closest = None
min_diff = float('inf')
for seg in segments:
diff = abs(seg['start'] - target_time)
if diff < min_diff:
min_diff = diff
closest = seg
if closest and min_diff < 60: # Within 1 minute
text = clean_text(closest['text'])
if len(text) > 50: # Only meaningful segments
print(f"[{seconds_to_timestamp(closest['start'])}]")
print(f"{text[:200]}...")
print()
# Look for transition phrases
print("\n=== LOOKING FOR TRANSITION PHRASES ===\n")
transition_phrases = [
'oke',
'jadi',
'nah',
'kemudian',
'selanjutnya',
'setelah itu',
'sekarang',
'selanjutnya',
'lanjut',
'terus',
'setelah',
'nah sekarang',
'oke jadi',
'jadi sekarang',
'nah kalau',
]
# Look for sections mentioning main topics
print("\n=== SEARCHING FOR TOPIC MENTIONS ===\n")
topic_keywords = {
'Market/Community': ['market', 'pasar', 'grup', 'komunitas', 'community', 'telegram', 'facebook'],
'Problem Finding': ['masalah', 'problem', 'kesulitan', 'permasalahan'],
'Personal Branding': ['branding', 'personal branding', 'show off'],
'AIDA Funnel': ['aida', 'awareness', 'interest', 'desire', 'action', 'funel', 'funnel'],
'Getting Clients': ['klien', 'client', 'calon klien'],
'Pricing/Payment': ['harga', 'bayar', 'budget', 'invoice', 'price'],
'Negotiation': ['tawar', 'negosiasi', 'deal'],
'Trust Building': ['trust', 'percaya', 'kepercayaan'],
'Services/Products': ['jasa', 'service', 'produk', 'elementor', 'plugin', 'website'],
'Cold/Warm/Hot Market': ['cold market', 'warm market', 'hot market'],
}
# Find segments grouped by time periods
print("\n=== CONTENT BY TIME PERIODS (every 10 minutes) ===\n")
period = 600 # 10 minutes in seconds
current_period = 0
while current_period < total_duration:
period_end = current_period + period
period_segments = [s for s in segments if current_period <= s['start'] < period_end]
if period_segments:
# Combine text from this period
period_text = ' '.join([clean_text(s['text']) for s in period_segments[:20]]) # First 20 segments
period_text = period_text[:500] # First 500 chars
print(f"\n{'='*70}")
print(f"PERIOD: {seconds_to_timestamp(current_period)} - {seconds_to_timestamp(period_end)}")
print(f"{'='*70}")
print(period_text)
print()
current_period = period_end
if __name__ == "__main__":
analyze_structure()

View File

@@ -1,162 +0,0 @@
#!/usr/bin/env python3
"""
Analyze video transcript to identify topics and create chapter divisions.
"""
import json
import re
from datetime import timedelta
def seconds_to_timestamp(seconds):
"""Convert seconds to readable timestamp."""
td = timedelta(seconds=float(seconds))
total_seconds = int(td.total_seconds())
hours, remainder = divmod(total_seconds, 3600)
minutes, seconds = divmod(remainder, 60)
return f"{hours:02d}:{minutes:02d}:{seconds:02d}"
def load_transcript(file_path):
"""Load JSON transcript file."""
with open(file_path, 'r', encoding='utf-8') as f:
data = json.load(f)
return data
def extract_transcript_segments(data):
"""Extract all transcript segments with timestamps."""
segments = []
# The structure has a 'tracks' key
if 'tracks' in data[0]:
for track in data[0]['tracks']:
if track['kind'] == 'asr': # Automatic Speech Recognition
for event in track['events']:
start_time = event.get('tStartMs', 0) / 1000
duration = event.get('dDurationMs', 0) / 1000
# Extract text from segments
text_parts = []
if 'segs' in event:
for seg in event['segs']:
if 'utf8' in seg:
text_parts.append(seg['utf8'])
text = ' '.join(text_parts)
if text.strip():
segments.append({
'start': start_time,
'end': start_time + duration,
'text': text
})
return segments
def group_by_time_window(segments, window_seconds=600):
"""Group segments into time windows for analysis."""
groups = []
current_time = 0
while current_time < segments[-1]['end']:
window_end = current_time + window_seconds
window_segments = [s for s in segments
if current_time <= s['start'] < window_end]
if window_segments:
combined_text = ' '.join([s['text'] for s in window_segments])
groups.append({
'start': current_time,
'end': window_end,
'segments': window_segments,
'text': combined_text
})
current_time = window_end
return groups
def extract_keywords(text):
"""Extract key topics from text."""
keywords = {
'Market & Community': ['market', 'pasar', 'grup', 'komunitas', 'telegram', 'facebook', 'forum'],
'Problem Finding': ['masalah', 'problem', 'kesulitan', 'permasalahan', 'error'],
'Exploration': ['explor', 'coba', 'trial', 'nyoba', 'eksplor'],
'Personal Branding': ['branding', 'personal branding', 'show off', 'image'],
'AIDA/Funnel': ['aida', 'awareness', 'interest', 'desire', 'action', 'funel', 'funnel'],
'Trust': ['trust', 'percaya', 'kepercayaan'],
'Clients': ['klien', 'client', 'pelanggan', 'customer'],
'Pricing': ['harga', 'price', 'bayar', 'budget', 'rp', 'juta', 'ribu'],
'Negotiation': ['tawar', 'negosiasi', 'deal'],
'Services': ['jasa', 'service', 'website', 'plugin', 'elementor', 'instal'],
'Cold/Warm/Hot Market': ['cold', 'warm', 'hot', 'dingin', 'hangat'],
'Network': ['network', 'jaringan', 'koneksi', 'hubungan'],
'Sharing': ['sharing', 'share', 'bagi'],
'Products': ['produk', 'product', 'template'],
}
found = []
text_lower = text.lower()
for topic, kw_list in keywords.items():
count = sum(1 for kw in kw_list if kw.lower() in text_lower)
if count > 0:
found.append((topic, count))
return sorted(found, key=lambda x: x[1], reverse=True)
def identify_main_topics():
"""Identify main topics throughout the video."""
file_path = "/Users/dwindown/CascadeProjects/MeetDwindiCom/access-hub/Live Zoom - Diskusi Cara Jual Jasa via Online.json"
print("Loading transcript...")
data = load_transcript(file_path)
print("Extracting segments...")
segments = extract_transcript_segments(data)
print(f"Total segments: {len(segments)}")
if not segments:
print("No segments found!")
return
total_duration = segments[-1]['end']
print(f"Total duration: {seconds_to_timestamp(total_duration)} ({total_duration/60:.1f} minutes)")
print("\n" + "="*80)
print("ANALYZING CONTENT IN 10-MINUTE INTERVALS")
print("="*80 + "\n")
# Group by 10-minute windows
groups = group_by_time_window(segments, window_seconds=600)
for i, group in enumerate(groups, 1):
print(f"\n{'='*80}")
print(f"SECTION {i}: {seconds_to_timestamp(group['start'])} - {seconds_to_timestamp(group['end'])}")
print(f"{'='*80}")
# Get first 500 chars for preview
preview = group['text'][:500]
print(f"\nContent Preview:\n{preview}...")
# Extract keywords
keywords = extract_keywords(group['text'])
if keywords:
print(f"\nMain Topics:")
for topic, count in keywords[:5]:
print(f" - {topic}: {count} mentions")
print("\n" + "="*80)
print("DETAILED BREAKDOWN (5-minute intervals for first hour)")
print("="*80 + "\n")
# More detailed for first hour
detailed_groups = group_by_time_window(segments[:int(len(segments)*0.4)], window_seconds=300)
for i, group in enumerate(detailed_groups, 1):
print(f"\n--- {seconds_to_timestamp(group['start'])} - {seconds_to_timestamp(group['end'])} ---")
# Get text summary
text_summary = group['text'][:300]
print(f"{text_summary}...")
if __name__ == "__main__":
identify_main_topics()

236
package-lock.json generated
View File

@@ -49,12 +49,15 @@
"@tiptap/extension-text-align": "^3.14.0", "@tiptap/extension-text-align": "^3.14.0",
"@tiptap/react": "^3.13.0", "@tiptap/react": "^3.13.0",
"@tiptap/starter-kit": "^3.13.0", "@tiptap/starter-kit": "^3.13.0",
"@types/hls.js": "^0.13.3",
"@types/video.js": "^7.3.58",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"date-fns": "^3.6.0", "date-fns": "^3.6.0",
"dompurify": "^3.3.1", "dompurify": "^3.3.1",
"embla-carousel-react": "^8.6.0", "embla-carousel-react": "^8.6.0",
"hls.js": "^1.6.15",
"input-otp": "^1.4.2", "input-otp": "^1.4.2",
"lowlight": "^3.3.0", "lowlight": "^3.3.0",
"lucide-react": "^0.462.0", "lucide-react": "^0.462.0",
@@ -74,6 +77,7 @@
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"tiptap-extension-resize-image": "^1.3.2", "tiptap-extension-resize-image": "^1.3.2",
"vaul": "^0.9.9", "vaul": "^0.9.9",
"video.js": "^8.23.4",
"zod": "^3.25.76" "zod": "^3.25.76"
}, },
"devDependencies": { "devDependencies": {
@@ -3502,6 +3506,12 @@
"@types/unist": "*" "@types/unist": "*"
} }
}, },
"node_modules/@types/hls.js": {
"version": "0.13.3",
"resolved": "https://registry.npmjs.org/@types/hls.js/-/hls.js-0.13.3.tgz",
"integrity": "sha512-Po8ZPCsAcPPuf5OODPEkb6cdWJ/w4BdX1veP7IIOc2WG0x1SW4GEQ1+FHKN1AMG2AePJfNUceJbh5PKtP92yRQ==",
"license": "MIT"
},
"node_modules/@types/json-schema": { "node_modules/@types/json-schema": {
"version": "7.0.15", "version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -3590,6 +3600,12 @@
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/video.js": {
"version": "7.3.58",
"resolved": "https://registry.npmjs.org/@types/video.js/-/video.js-7.3.58.tgz",
"integrity": "sha512-1CQjuSrgbv1/dhmcfQ83eVyYbvGyqhTvb2Opxr0QCV+iJ4J6/J+XWQ3Om59WiwCd1MN3rDUHasx5XRrpUtewYQ==",
"license": "MIT"
},
"node_modules/@types/ws": { "node_modules/@types/ws": {
"version": "8.18.1", "version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
@@ -3857,6 +3873,54 @@
"url": "https://opencollective.com/typescript-eslint" "url": "https://opencollective.com/typescript-eslint"
} }
}, },
"node_modules/@videojs/http-streaming": {
"version": "3.17.2",
"resolved": "https://registry.npmjs.org/@videojs/http-streaming/-/http-streaming-3.17.2.tgz",
"integrity": "sha512-VBQ3W4wnKnVKb/limLdtSD2rAd5cmHN70xoMf4OmuDd0t2kfJX04G+sfw6u2j8oOm2BXYM9E1f4acHruqKnM1g==",
"license": "Apache-2.0",
"dependencies": {
"@babel/runtime": "^7.12.5",
"@videojs/vhs-utils": "^4.1.1",
"aes-decrypter": "^4.0.2",
"global": "^4.4.0",
"m3u8-parser": "^7.2.0",
"mpd-parser": "^1.3.1",
"mux.js": "7.1.0",
"video.js": "^7 || ^8"
},
"engines": {
"node": ">=8",
"npm": ">=5"
},
"peerDependencies": {
"video.js": "^8.19.0"
}
},
"node_modules/@videojs/vhs-utils": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/@videojs/vhs-utils/-/vhs-utils-4.1.1.tgz",
"integrity": "sha512-5iLX6sR2ownbv4Mtejw6Ax+naosGvoT9kY+gcuHzANyUZZ+4NpeNdKMUhb6ag0acYej1Y7cmr/F2+4PrggMiVA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.12.5",
"global": "^4.4.0"
},
"engines": {
"node": ">=8",
"npm": ">=5"
}
},
"node_modules/@videojs/xhr": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@videojs/xhr/-/xhr-2.7.0.tgz",
"integrity": "sha512-giab+EVRanChIupZK7gXjHy90y3nncA2phIOyG3Ne5fvpiMJzvqYwiTOnEVW2S4CoYcuKJkomat7bMXA/UoUZQ==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.5.5",
"global": "~4.4.0",
"is-function": "^1.0.1"
}
},
"node_modules/@vitejs/plugin-react-swc": { "node_modules/@vitejs/plugin-react-swc": {
"version": "3.11.0", "version": "3.11.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-3.11.0.tgz", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-3.11.0.tgz",
@@ -3871,6 +3935,15 @@
"vite": "^4 || ^5 || ^6 || ^7" "vite": "^4 || ^5 || ^6 || ^7"
} }
}, },
"node_modules/@xmldom/xmldom": {
"version": "0.8.11",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz",
"integrity": "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/acorn": { "node_modules/acorn": {
"version": "8.15.0", "version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
@@ -3894,6 +3967,18 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
} }
}, },
"node_modules/aes-decrypter": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/aes-decrypter/-/aes-decrypter-4.0.2.tgz",
"integrity": "sha512-lc+/9s6iJvuaRe5qDlMTpCFjnwpkeOXp8qP3oiZ5jsj1MRg+SBVUmmICrhxHvc8OELSmc+fEyyxAuppY6hrWzw==",
"license": "Apache-2.0",
"dependencies": {
"@babel/runtime": "^7.12.5",
"@videojs/vhs-utils": "^4.1.1",
"global": "^4.4.0",
"pkcs7": "^1.0.4"
}
},
"node_modules/ajv": { "node_modules/ajv": {
"version": "6.12.6", "version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
@@ -4522,6 +4607,11 @@
"csstype": "^3.0.2" "csstype": "^3.0.2"
} }
}, },
"node_modules/dom-walk": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/dom-walk/-/dom-walk-0.1.2.tgz",
"integrity": "sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w=="
},
"node_modules/dompurify": { "node_modules/dompurify": {
"version": "3.3.1", "version": "3.3.1",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz",
@@ -5083,6 +5173,16 @@
"url": "https://github.com/sponsors/isaacs" "url": "https://github.com/sponsors/isaacs"
} }
}, },
"node_modules/global": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/global/-/global-4.4.0.tgz",
"integrity": "sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w==",
"license": "MIT",
"dependencies": {
"min-document": "^2.19.0",
"process": "^0.11.10"
}
},
"node_modules/globals": { "node_modules/globals": {
"version": "15.15.0", "version": "15.15.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz", "resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz",
@@ -5134,6 +5234,12 @@
"node": ">=12.0.0" "node": ">=12.0.0"
} }
}, },
"node_modules/hls.js": {
"version": "1.6.15",
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.15.tgz",
"integrity": "sha512-E3a5VwgXimGHwpRGV+WxRTKeSp2DW5DI5MWv34ulL3t5UNmyJWCQ1KmLEHbYzcfThfXG8amBL+fCYPneGHC4VA==",
"license": "Apache-2.0"
},
"node_modules/iceberg-js": { "node_modules/iceberg-js": {
"version": "0.8.1", "version": "0.8.1",
"resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz", "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz",
@@ -5244,6 +5350,12 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/is-function": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/is-function/-/is-function-1.0.2.tgz",
"integrity": "sha512-lw7DUp0aWXYg+CBCN+JKkcE0Q2RayZnSvnZBlwgxHBQhqt5pZNVy4Ri7H9GmmXkdu7LUthszM+Tor1u/2iBcpQ==",
"license": "MIT"
},
"node_modules/is-glob": { "node_modules/is-glob": {
"version": "4.0.3", "version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
@@ -5926,6 +6038,17 @@
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc"
} }
}, },
"node_modules/m3u8-parser": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/m3u8-parser/-/m3u8-parser-7.2.0.tgz",
"integrity": "sha512-CRatFqpjVtMiMaKXxNvuI3I++vUumIXVVT/JpCpdU/FynV/ceVw1qpPyyBNindL+JlPMSesx+WX1QJaZEJSaMQ==",
"license": "Apache-2.0",
"dependencies": {
"@babel/runtime": "^7.12.5",
"@videojs/vhs-utils": "^4.1.1",
"global": "^4.4.0"
}
},
"node_modules/markdown-it": { "node_modules/markdown-it": {
"version": "14.1.0", "version": "14.1.0",
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz", "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz",
@@ -5971,6 +6094,15 @@
"node": ">=8.6" "node": ">=8.6"
} }
}, },
"node_modules/min-document": {
"version": "2.19.2",
"resolved": "https://registry.npmjs.org/min-document/-/min-document-2.19.2.tgz",
"integrity": "sha512-8S5I8db/uZN8r9HSLFVWPdJCvYOejMcEC82VIzNUc6Zkklf/d1gg2psfE79/vyhWOj4+J8MtwmoOz3TmvaGu5A==",
"license": "MIT",
"dependencies": {
"dom-walk": "^0.1.0"
}
},
"node_modules/minimatch": { "node_modules/minimatch": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
@@ -5993,6 +6125,21 @@
"node": ">=16 || 14 >=14.17" "node": ">=16 || 14 >=14.17"
} }
}, },
"node_modules/mpd-parser": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/mpd-parser/-/mpd-parser-1.3.1.tgz",
"integrity": "sha512-1FuyEWI5k2HcmhS1HkKnUAQV7yFPfXPht2DnRRGtoiiAAW+ESTbtEXIDpRkwdU+XyrQuwrIym7UkoPKsZ0SyFw==",
"license": "Apache-2.0",
"dependencies": {
"@babel/runtime": "^7.12.5",
"@videojs/vhs-utils": "^4.0.0",
"@xmldom/xmldom": "^0.8.3",
"global": "^4.4.0"
},
"bin": {
"mpd-to-m3u8-json": "bin/parse.js"
}
},
"node_modules/ms": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -6000,6 +6147,23 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/mux.js": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/mux.js/-/mux.js-7.1.0.tgz",
"integrity": "sha512-NTxawK/BBELJrYsZThEulyUMDVlLizKdxyAsMuzoCD1eFj97BVaA8D/CvKsKu6FOLYkFojN5CbM9h++ZTZtknA==",
"license": "Apache-2.0",
"dependencies": {
"@babel/runtime": "^7.11.2",
"global": "^4.4.0"
},
"bin": {
"muxjs-transmux": "bin/transmux.js"
},
"engines": {
"node": ">=8",
"npm": ">=5"
}
},
"node_modules/mz": { "node_modules/mz": {
"version": "2.7.0", "version": "2.7.0",
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
@@ -6242,6 +6406,18 @@
"node": ">= 6" "node": ">= 6"
} }
}, },
"node_modules/pkcs7": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/pkcs7/-/pkcs7-1.0.4.tgz",
"integrity": "sha512-afRERtHn54AlwaF2/+LFszyAANTCggGilmcmILUzEjvs3XgFZT+xE6+QWQcAGmu4xajy+Xtj7acLOPdx5/eXWQ==",
"license": "Apache-2.0",
"dependencies": {
"@babel/runtime": "^7.5.5"
},
"bin": {
"pkcs7": "bin/cli.js"
}
},
"node_modules/plyr": { "node_modules/plyr": {
"version": "3.8.3", "version": "3.8.3",
"resolved": "https://registry.npmjs.org/plyr/-/plyr-3.8.3.tgz", "resolved": "https://registry.npmjs.org/plyr/-/plyr-3.8.3.tgz",
@@ -6424,6 +6600,15 @@
"node": ">= 0.8.0" "node": ">= 0.8.0"
} }
}, },
"node_modules/process": {
"version": "0.11.10",
"resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
"integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==",
"license": "MIT",
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/prop-types": { "node_modules/prop-types": {
"version": "15.8.1", "version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
@@ -7629,6 +7814,57 @@
"d3-timer": "^3.0.1" "d3-timer": "^3.0.1"
} }
}, },
"node_modules/video.js": {
"version": "8.23.4",
"resolved": "https://registry.npmjs.org/video.js/-/video.js-8.23.4.tgz",
"integrity": "sha512-qI0VTlYmKzEqRsz1Nppdfcaww4RSxZAq77z2oNSl3cNg2h6do5C8Ffl0KqWQ1OpD8desWXsCrde7tKJ9gGTEyQ==",
"license": "Apache-2.0",
"dependencies": {
"@babel/runtime": "^7.12.5",
"@videojs/http-streaming": "^3.17.2",
"@videojs/vhs-utils": "^4.1.1",
"@videojs/xhr": "2.7.0",
"aes-decrypter": "^4.0.2",
"global": "4.4.0",
"m3u8-parser": "^7.2.0",
"mpd-parser": "^1.3.1",
"mux.js": "^7.0.1",
"videojs-contrib-quality-levels": "4.1.0",
"videojs-font": "4.2.0",
"videojs-vtt.js": "0.15.5"
}
},
"node_modules/videojs-contrib-quality-levels": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/videojs-contrib-quality-levels/-/videojs-contrib-quality-levels-4.1.0.tgz",
"integrity": "sha512-TfrXJJg1Bv4t6TOCMEVMwF/CoS8iENYsWNKip8zfhB5kTcegiFYezEA0eHAJPU64ZC8NQbxQgOwAsYU8VXbOWA==",
"license": "Apache-2.0",
"dependencies": {
"global": "^4.4.0"
},
"engines": {
"node": ">=16",
"npm": ">=8"
},
"peerDependencies": {
"video.js": "^8"
}
},
"node_modules/videojs-font": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/videojs-font/-/videojs-font-4.2.0.tgz",
"integrity": "sha512-YPq+wiKoGy2/M7ccjmlvwi58z2xsykkkfNMyIg4xb7EZQQNwB71hcSsB3o75CqQV7/y5lXkXhI/rsGAS7jfEmQ==",
"license": "Apache-2.0"
},
"node_modules/videojs-vtt.js": {
"version": "0.15.5",
"resolved": "https://registry.npmjs.org/videojs-vtt.js/-/videojs-vtt.js-0.15.5.tgz",
"integrity": "sha512-yZbBxvA7QMYn15Lr/ZfhhLPrNpI/RmCSCqgIff57GC2gIrV5YfyzLfLyZMj0NnZSAz8syB4N0nHXpZg9MyrMOQ==",
"license": "Apache-2.0",
"dependencies": {
"global": "^4.3.1"
}
},
"node_modules/vite": { "node_modules/vite": {
"version": "5.4.19", "version": "5.4.19",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.19.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.19.tgz",

View File

@@ -52,12 +52,15 @@
"@tiptap/extension-text-align": "^3.14.0", "@tiptap/extension-text-align": "^3.14.0",
"@tiptap/react": "^3.13.0", "@tiptap/react": "^3.13.0",
"@tiptap/starter-kit": "^3.13.0", "@tiptap/starter-kit": "^3.13.0",
"@types/hls.js": "^0.13.3",
"@types/video.js": "^7.3.58",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"date-fns": "^3.6.0", "date-fns": "^3.6.0",
"dompurify": "^3.3.1", "dompurify": "^3.3.1",
"embla-carousel-react": "^8.6.0", "embla-carousel-react": "^8.6.0",
"hls.js": "^1.6.15",
"input-otp": "^1.4.2", "input-otp": "^1.4.2",
"lowlight": "^3.3.0", "lowlight": "^3.3.0",
"lucide-react": "^0.462.0", "lucide-react": "^0.462.0",
@@ -77,6 +80,7 @@
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"tiptap-extension-resize-image": "^1.3.2", "tiptap-extension-resize-image": "^1.3.2",
"vaul": "^0.9.9", "vaul": "^0.9.9",
"video.js": "^8.23.4",
"zod": "^3.25.76" "zod": "^3.25.76"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -17,7 +17,6 @@ interface TimelineChaptersProps {
export function TimelineChapters({ export function TimelineChapters({
chapters, chapters,
isYouTube = true,
onChapterClick, onChapterClick,
currentTime = 0, currentTime = 0,
accentColor = '#f97316', accentColor = '#f97316',
@@ -64,14 +63,10 @@ export function TimelineChapters({
return ( return (
<button <button
key={index} key={index}
onClick={() => isYouTube && onChapterClick && onChapterClick(chapter.time)} onClick={() => onChapterClick && onChapterClick(chapter.time)}
disabled={!isYouTube}
className={` className={`
w-full flex items-center gap-3 p-3 rounded-lg transition-all text-left w-full flex items-center gap-3 p-3 rounded-lg transition-all text-left
${isYouTube hover:bg-muted cursor-pointer
? 'hover:bg-muted cursor-pointer'
: 'cursor-default'
}
${active ${active
? `bg-primary/10 border-l-4` ? `bg-primary/10 border-l-4`
: 'border-l-4 border-transparent' : 'border-l-4 border-transparent'
@@ -82,7 +77,7 @@ export function TimelineChapters({
? { borderColor: accentColor, backgroundColor: `${accentColor}10` } ? { borderColor: accentColor, backgroundColor: `${accentColor}10` }
: undefined : undefined
} }
title={isYouTube ? `Klik untuk lompat ke ${formatTime(chapter.time)}` : undefined} title={`Klik untuk lompat ke ${formatTime(chapter.time)}`}
> >
{/* Timestamp */} {/* Timestamp */}
<div className={` <div className={`
@@ -111,12 +106,6 @@ export function TimelineChapters({
); );
})} })}
</div> </div>
{!isYouTube && (
<p className="text-xs text-muted-foreground mt-3 italic">
Timeline hanya tersedia untuk video YouTube
</p>
)}
</div> </div>
</Card> </Card>
); );

View File

@@ -1,6 +1,10 @@
import { useEffect, useRef, useState, forwardRef, useImperativeHandle } from 'react'; import { useEffect, useRef, useState, forwardRef, useImperativeHandle, useCallback } from 'react';
import { Plyr } from 'plyr-react'; import { Plyr } from 'plyr-react';
import 'plyr/dist/plyr.css'; 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 { interface VideoChapter {
time: number; // Time in seconds time: number; // Time in seconds
@@ -8,13 +12,18 @@ interface VideoChapter {
} }
interface VideoPlayerWithChaptersProps { interface VideoPlayerWithChaptersProps {
videoUrl: string; videoUrl?: string;
embedCode?: string | null; embedCode?: string | null;
m3u8Url?: string;
mp4Url?: string;
videoHost?: 'youtube' | 'adilo' | 'unknown';
chapters?: VideoChapter[]; chapters?: VideoChapter[];
accentColor?: string; accentColor?: string;
onChapterChange?: (chapter: VideoChapter) => void; onChapterChange?: (chapter: VideoChapter) => void;
onTimeUpdate?: (time: number) => void; onTimeUpdate?: (time: number) => void;
className?: string; className?: string;
videoId?: string; // For progress tracking
videoType?: 'lesson' | 'webinar'; // For progress tracking
} }
export interface VideoPlayerRef { export interface VideoPlayerRef {
@@ -25,22 +34,87 @@ export interface VideoPlayerRef {
export const VideoPlayerWithChapters = forwardRef<VideoPlayerRef, VideoPlayerWithChaptersProps>(({ export const VideoPlayerWithChapters = forwardRef<VideoPlayerRef, VideoPlayerWithChaptersProps>(({
videoUrl, videoUrl,
embedCode, embedCode,
m3u8Url,
mp4Url,
videoHost = 'unknown',
chapters = [], chapters = [],
accentColor, accentColor,
onChapterChange, onChapterChange,
onTimeUpdate, onTimeUpdate,
className = '', className = '',
videoId,
videoType,
}, ref) => { }, ref) => {
const plyrRef = useRef<any>(null); const plyrRef = useRef<any>(null);
const currentChapterIndexRef = useRef<number>(-1);
const [currentChapterIndex, setCurrentChapterIndex] = useState<number>(-1); const [currentChapterIndex, setCurrentChapterIndex] = useState<number>(-1);
const [currentTime, setCurrentTime] = useState(0); const [currentTime, setCurrentTime] = useState(0);
const [playerInstance, setPlayerInstance] = useState<any>(null); const [playerInstance, setPlayerInstance] = useState<any>(null);
const [showResumePrompt, setShowResumePrompt] = useState(false);
const [resumeTime, setResumeTime] = useState(0);
const saveProgressTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Detect if this is a YouTube URL // Determine if using Adilo (M3U8) or YouTube
const isYouTube = videoUrl && ( const isAdilo = videoHost === 'adilo' || m3u8Url;
videoUrl.includes('youtube.com') || const isYouTube = videoHost === 'youtube' || (videoUrl && (videoUrl.includes('youtube.com') || videoUrl.includes('youtu.be')));
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 // Get YouTube video ID
const getYouTubeId = (url: string): string | null => { const getYouTubeId = (url: string): string | null => {
@@ -109,23 +183,15 @@ export const VideoPlayerWithChapters = forwardRef<VideoPlayerRef, VideoPlayerWit
onTimeUpdate(time); onTimeUpdate(time);
} }
saveProgressDebounced(time);
// Find current chapter // Find current chapter
if (chapters.length > 0) { const index = findCurrentChapter(time);
let index = chapters.findIndex((chapter, i) => { if (index !== currentChapterIndexRef.current) {
const nextChapter = chapters[i + 1]; currentChapterIndexRef.current = index;
return time >= chapter.time && (!nextChapter || time < nextChapter.time); setCurrentChapterIndex(index);
}); if (index >= 0 && onChapterChange) {
onChapterChange(chapters[index]);
// If before first chapter, no active chapter
if (index === -1 && time < chapters[0].time) {
index = -1;
}
if (index !== currentChapterIndex) {
setCurrentChapterIndex(index);
if (index >= 0 && onChapterChange) {
onChapterChange(chapters[index]);
}
} }
} }
}); });
@@ -139,22 +205,15 @@ export const VideoPlayerWithChapters = forwardRef<VideoPlayerRef, VideoPlayerWit
onTimeUpdate(time); onTimeUpdate(time);
} }
saveProgressDebounced(time);
// Find current chapter // Find current chapter
if (chapters.length > 0) { const index = findCurrentChapter(time);
let index = chapters.findIndex((chapter, i) => { if (index !== currentChapterIndexRef.current) {
const nextChapter = chapters[i + 1]; currentChapterIndexRef.current = index;
return time >= chapter.time && (!nextChapter || time < nextChapter.time); setCurrentChapterIndex(index);
}); if (index >= 0 && onChapterChange) {
onChapterChange(chapters[index]);
if (index === -1 && time < chapters[0].time) {
index = -1;
}
if (index !== currentChapterIndex) {
setCurrentChapterIndex(index);
if (index >= 0 && onChapterChange) {
onChapterChange(chapters[index]);
}
} }
} }
}, 500); }, 500);
@@ -166,11 +225,24 @@ export const VideoPlayerWithChapters = forwardRef<VideoPlayerRef, VideoPlayerWit
}, 100); }, 100);
return () => clearInterval(checkPlayer); return () => clearInterval(checkPlayer);
}, [isYouTube, chapters, currentChapterIndex, onChapterChange, onTimeUpdate]); }, [isYouTube, findCurrentChapter, onChapterChange, onTimeUpdate, chapters, saveProgressDebounced]);
// Jump to specific time using Plyr API // Jump to specific time using Plyr API or Adilo player
const jumpToTime = (time: number) => { const jumpToTime = (time: number) => {
if (playerInstance) { 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.currentTime = time;
playerInstance.play(); playerInstance.play();
} }
@@ -180,14 +252,204 @@ export const VideoPlayerWithChapters = forwardRef<VideoPlayerRef, VideoPlayerWit
return currentTime; 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 // Expose methods via ref
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
jumpToTime, jumpToTime,
getCurrentTime, 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) { if (useEmbed) {
// Custom embed (Adilo, Vimeo, etc.) // Custom embed (Vimeo, etc. - not Adilo anymore)
return ( return (
<div <div
className={`aspect-video rounded-lg overflow-hidden ${className}`} className={`aspect-video rounded-lg overflow-hidden ${className}`}
@@ -248,8 +510,16 @@ export const VideoPlayerWithChapters = forwardRef<VideoPlayerRef, VideoPlayerWit
'current-time', 'current-time',
'mute', 'mute',
'volume', 'volume',
'captions',
'settings',
'pip',
'airplay',
'fullscreen', 'fullscreen',
], ],
speed: {
selected: 1,
options: [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2],
},
youtube: { youtube: {
noCookie: true, noCookie: true,
rel: 0, rel: 0,
@@ -261,6 +531,10 @@ export const VideoPlayerWithChapters = forwardRef<VideoPlayerRef, VideoPlayerWit
fs: 0, fs: 0,
}, },
hideControls: false, hideControls: false,
keyboardShortcuts: {
focused: true,
global: true,
},
}} }}
/> />
</div> </div>

View File

@@ -139,7 +139,7 @@ export function ChaptersEditor({ chapters, onChange, className = '' }: ChaptersE
<div className="text-xs text-muted-foreground space-y-1 pt-2 border-t"> <div className="text-xs text-muted-foreground space-y-1 pt-2 border-t">
<p>💡 <strong>Format:</strong> Enter time as MM:SS or HH:MM:SS (e.g., 5:30 or 1:23:34)</p> <p>💡 <strong>Format:</strong> Enter time as MM:SS or HH:MM:SS (e.g., 5:30 or 1:23:34)</p>
<p>📌 <strong>Note:</strong> Chapters only work with YouTube videos. Embed codes show static timeline.</p> <p>📌 <strong>Note:</strong> Chapters work with both YouTube and Adilo videos.</p>
<p> <strong>Tip:</strong> Chapters are automatically sorted by time when displayed.</p> <p> <strong>Tip:</strong> Chapters are automatically sorted by time when displayed.</p>
</div> </div>
</CardContent> </CardContent>

View File

@@ -6,6 +6,7 @@ import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { toast } from '@/hooks/use-toast'; import { toast } from '@/hooks/use-toast';
import { Plus, Pencil, Trash2, ChevronUp, ChevronDown, GripVertical } from 'lucide-react'; import { Plus, Pencil, Trash2, ChevronUp, ChevronDown, GripVertical } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@@ -29,6 +30,11 @@ interface Lesson {
title: string; title: string;
content: string | null; content: string | null;
video_url: string | null; video_url: string | null;
youtube_url: string | null;
embed_code: string | null;
m3u8_url?: string | null;
mp4_url?: string | null;
video_host?: 'youtube' | 'adilo' | 'unknown';
position: number; position: number;
release_at: string | null; release_at: string | null;
chapters?: VideoChapter[]; chapters?: VideoChapter[];
@@ -54,6 +60,11 @@ export function CurriculumEditor({ productId }: CurriculumEditorProps) {
title: '', title: '',
content: '', content: '',
video_url: '', video_url: '',
youtube_url: '',
embed_code: '',
m3u8_url: '',
mp4_url: '',
video_host: 'youtube' as 'youtube' | 'adilo' | 'unknown',
release_at: '', release_at: '',
chapters: [] as VideoChapter[], chapters: [] as VideoChapter[],
}); });
@@ -73,7 +84,7 @@ export function CurriculumEditor({ productId }: CurriculumEditorProps) {
.order('position'), .order('position'),
supabase supabase
.from('bootcamp_lessons') .from('bootcamp_lessons')
.select('*') .select('id, module_id, title, content, video_url, youtube_url, embed_code, m3u8_url, mp4_url, video_host, position, release_at, chapters')
.order('position'), .order('position'),
]); ]);
@@ -177,6 +188,11 @@ export function CurriculumEditor({ productId }: CurriculumEditorProps) {
title: '', title: '',
content: '', content: '',
video_url: '', video_url: '',
youtube_url: '',
embed_code: '',
m3u8_url: '',
mp4_url: '',
video_host: 'youtube',
release_at: '', release_at: '',
}); });
setLessonDialogOpen(true); setLessonDialogOpen(true);
@@ -189,6 +205,11 @@ export function CurriculumEditor({ productId }: CurriculumEditorProps) {
title: lesson.title, title: lesson.title,
content: lesson.content || '', content: lesson.content || '',
video_url: lesson.video_url || '', video_url: lesson.video_url || '',
youtube_url: lesson.youtube_url || '',
embed_code: lesson.embed_code || '',
m3u8_url: lesson.m3u8_url || '',
mp4_url: lesson.mp4_url || '',
video_host: lesson.video_host || 'youtube',
release_at: lesson.release_at ? lesson.release_at.split('T')[0] : '', release_at: lesson.release_at ? lesson.release_at.split('T')[0] : '',
chapters: lesson.chapters || [], chapters: lesson.chapters || [],
}); });
@@ -206,6 +227,11 @@ export function CurriculumEditor({ productId }: CurriculumEditorProps) {
title: lessonForm.title, title: lessonForm.title,
content: lessonForm.content || null, content: lessonForm.content || null,
video_url: lessonForm.video_url || null, video_url: lessonForm.video_url || null,
youtube_url: lessonForm.youtube_url || null,
embed_code: lessonForm.embed_code || null,
m3u8_url: lessonForm.m3u8_url || null,
mp4_url: lessonForm.mp4_url || null,
video_host: lessonForm.video_host || 'youtube',
release_at: lessonForm.release_at ? new Date(lessonForm.release_at).toISOString() : null, release_at: lessonForm.release_at ? new Date(lessonForm.release_at).toISOString() : null,
chapters: lessonForm.chapters || [], chapters: lessonForm.chapters || [],
}; };
@@ -443,15 +469,70 @@ export function CurriculumEditor({ productId }: CurriculumEditorProps) {
className="border-2" className="border-2"
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Video URL</Label> <Label>Video Host</Label>
<Input <Select
value={lessonForm.video_url} value={lessonForm.video_host}
onChange={(e) => setLessonForm({ ...lessonForm, video_url: e.target.value })} onValueChange={(value: 'youtube' | 'adilo') => setLessonForm({ ...lessonForm, video_host: value })}
placeholder="https://youtube.com/... or https://vimeo.com/..." >
className="border-2" <SelectTrigger className="border-2">
/> <SelectValue placeholder="Select video host" />
</SelectTrigger>
<SelectContent>
<SelectItem value="youtube">YouTube</SelectItem>
<SelectItem value="adilo">Adilo (M3U8)</SelectItem>
</SelectContent>
</Select>
</div> </div>
{/* YouTube URL */}
{lessonForm.video_host === 'youtube' && (
<div className="space-y-2">
<Label>YouTube URL</Label>
<Input
value={lessonForm.video_url}
onChange={(e) => setLessonForm({ ...lessonForm, video_url: e.target.value })}
placeholder="https://www.youtube.com/watch?v=..."
className="border-2"
/>
<p className="text-sm text-muted-foreground">
Paste YouTube URL here
</p>
</div>
)}
{/* Adilo URLs */}
{lessonForm.video_host === 'adilo' && (
<div className="space-y-4 p-4 bg-muted border-2 border-border rounded-lg">
<div className="space-y-2">
<Label>M3U8 URL (Primary)</Label>
<Input
value={lessonForm.m3u8_url}
onChange={(e) => setLessonForm({ ...lessonForm, m3u8_url: e.target.value })}
placeholder="https://adilo.bigcommand.com/m3u8/..."
className="border-2 font-mono text-sm"
/>
<p className="text-sm text-muted-foreground">
HLS streaming URL from Adilo
</p>
</div>
<div className="space-y-2">
<Label>MP4 URL (Optional Fallback)</Label>
<Input
value={lessonForm.mp4_url}
onChange={(e) => setLessonForm({ ...lessonForm, mp4_url: e.target.value })}
placeholder="https://adilo.bigcommand.com/videos/..."
className="border-2 font-mono text-sm"
/>
<p className="text-sm text-muted-foreground">
Direct MP4 file for legacy browsers (optional)
</p>
</div>
</div>
)}
<ChaptersEditor <ChaptersEditor
chapters={lessonForm.chapters || []} chapters={lessonForm.chapters || []}
onChange={(chapters) => setLessonForm({ ...lessonForm, chapters })} onChange={(chapters) => setLessonForm({ ...lessonForm, chapters })}

View File

@@ -15,6 +15,12 @@ interface ReviewModalProps {
orderId?: string | null; orderId?: string | null;
type: 'consulting' | 'bootcamp' | 'webinar' | 'general'; type: 'consulting' | 'bootcamp' | 'webinar' | 'general';
contextLabel?: string; contextLabel?: string;
existingReview?: {
id: string;
rating: number;
title?: string;
body?: string;
};
onSuccess?: () => void; onSuccess?: () => void;
} }
@@ -26,6 +32,7 @@ export function ReviewModal({
orderId, orderId,
type, type,
contextLabel, contextLabel,
existingReview,
onSuccess, onSuccess,
}: ReviewModalProps) { }: ReviewModalProps) {
const [rating, setRating] = useState(0); const [rating, setRating] = useState(0);
@@ -34,6 +41,20 @@ export function ReviewModal({
const [body, setBody] = useState(''); const [body, setBody] = useState('');
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
// Pre-populate form when existingReview is provided or modal opens with existing data
useEffect(() => {
if (existingReview) {
setRating(existingReview.rating);
setTitle(existingReview.title || '');
setBody(existingReview.body || '');
} else {
// Reset form for new review
setRating(0);
setTitle('');
setBody('');
}
}, [existingReview, open]);
const handleSubmit = async () => { const handleSubmit = async () => {
if (rating === 0) { if (rating === 0) {
toast({ title: 'Error', description: 'Pilih rating terlebih dahulu', variant: 'destructive' }); toast({ title: 'Error', description: 'Pilih rating terlebih dahulu', variant: 'destructive' });
@@ -45,22 +66,46 @@ export function ReviewModal({
} }
setSubmitting(true); setSubmitting(true);
const { error } = await supabase.from('reviews').insert({
user_id: userId, let error;
product_id: productId || null,
order_id: orderId || null, if (existingReview) {
type, // Update existing review
rating, const result = await supabase
title: title.trim(), .from('reviews')
body: body.trim() || null, .update({
is_approved: false, rating,
}); title: title.trim(),
body: body.trim() || null,
is_approved: false, // Reset approval status on edit
})
.eq('id', existingReview.id);
error = result.error;
} else {
// Insert new review
const result = await supabase.from('reviews').insert({
user_id: userId,
product_id: productId || null,
order_id: orderId || null,
type,
rating,
title: title.trim(),
body: body.trim() || null,
is_approved: false,
});
error = result.error;
}
if (error) { if (error) {
console.error('Review submit error:', error); console.error('Review submit error:', error);
toast({ title: 'Error', description: 'Gagal mengirim ulasan', variant: 'destructive' }); toast({ title: 'Error', description: 'Gagal mengirim ulasan', variant: 'destructive' });
} else { } else {
toast({ title: 'Berhasil', description: 'Terima kasih! Ulasan Anda akan ditinjau oleh admin.' }); toast({
title: 'Berhasil',
description: existingReview
? 'Ulasan Anda diperbarui dan akan ditinjau ulang oleh admin.'
: 'Terima kasih! Ulasan Anda akan ditinjau oleh admin.'
});
// Reset form // Reset form
setRating(0); setRating(0);
setTitle(''); setTitle('');
@@ -81,7 +126,7 @@ export function ReviewModal({
<Dialog open={open} onOpenChange={handleClose}> <Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-md"> <DialogContent className="sm:max-w-md">
<DialogHeader> <DialogHeader>
<DialogTitle>Beri Ulasan</DialogTitle> <DialogTitle>{existingReview ? 'Edit Ulasan' : 'Beri Ulasan'}</DialogTitle>
{contextLabel && ( {contextLabel && (
<DialogDescription>{contextLabel}</DialogDescription> <DialogDescription>{contextLabel}</DialogDescription>
)} )}
@@ -140,7 +185,7 @@ export function ReviewModal({
Batal Batal
</Button> </Button>
<Button onClick={handleSubmit} disabled={submitting}> <Button onClick={handleSubmit} disabled={submitting}>
{submitting ? 'Mengirim...' : 'Kirim Ulasan'} {submitting ? 'Menyimpan...' : (existingReview ? 'Simpan Perubahan' : 'Kirim Ulasan')}
</Button> </Button>
</div> </div>
</DialogContent> </DialogContent>

352
src/hooks/useAdiloPlayer.ts Normal file
View File

@@ -0,0 +1,352 @@
import { useRef, useEffect, useState, useCallback } from 'react';
import Hls from 'hls.js';
import videojs from 'video.js';
import 'video.js/dist/video-js.css';
interface UseAdiloPlayerProps {
m3u8Url?: string;
mp4Url?: string;
autoplay?: boolean;
onTimeUpdate?: (time: number) => void;
onDuration?: (duration: number) => void;
onEnded?: () => void;
onError?: (error: any) => void;
accentColor?: string;
}
export const useAdiloPlayer = ({
m3u8Url,
mp4Url,
autoplay = false,
onTimeUpdate,
onDuration,
onEnded,
onError,
accentColor,
}: UseAdiloPlayerProps) => {
const videoRef = useRef<HTMLVideoElement>(null);
const videoJsRef = useRef<any>(null);
const hlsRef = useRef<Hls | null>(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<any>(null);
// Use refs to store stable callback references
const callbacksRef = useRef({
onTimeUpdate,
onDuration,
onEnded,
onError,
});
// Update callbacks ref when props change
useEffect(() => {
callbacksRef.current = {
onTimeUpdate,
onDuration,
onEnded,
onError,
};
}, [onTimeUpdate, onDuration, onEnded, onError]);
useEffect(() => {
const video = videoRef.current;
if (!video || (!m3u8Url && !mp4Url)) return;
// Clean up previous HLS instance
if (hlsRef.current) {
hlsRef.current.destroy();
hlsRef.current = null;
}
setError(null);
// Try M3U8 with HLS.js first
if (m3u8Url) {
if (Hls.isSupported()) {
const hls = new Hls({
enableWorker: true,
lowLatencyMode: true,
xhrSetup: (xhr, url) => {
// Allow CORS for HLS requests
xhr.withCredentials = false;
},
});
hlsRef.current = hls;
hls.loadSource(m3u8Url);
hls.attachMedia(video);
hls.on(Hls.Events.MANIFEST_PARSED, (event, data) => {
console.log('✅ HLS manifest parsed:', data.levels.length, 'quality levels');
// Don't set ready yet - wait for first fragment to load
});
hls.on(Hls.Events.FRAG_PARSED, () => {
console.log('✅ First segment loaded, video ready');
setIsReady(true);
// Log video element state
console.log('📹 Video element state:', {
readyState: video.readyState,
videoWidth: video.videoWidth,
videoHeight: video.videoHeight,
duration: video.duration,
paused: video.paused,
});
if (autoplay) {
video.play().catch((err) => {
console.error('Autoplay failed:', err);
callbacksRef.current.onError?.(err);
});
}
});
hls.on(Hls.Events.ERROR, (event, data) => {
if (data.fatal) {
console.error('❌ HLS error:', data.type, data.details);
switch (data.type) {
case Hls.ErrorTypes.NETWORK_ERROR:
console.log('🔄 Recovering from network error...');
hls.startLoad();
break;
case Hls.ErrorTypes.MEDIA_ERROR:
console.log('🔄 Recovering from media error...');
hls.recoverMediaError();
break;
default:
console.error('💥 Fatal error, destroying HLS instance');
hls.destroy();
// Fallback to MP4
if (mp4Url) {
console.log('📹 Falling back to MP4');
video.src = mp4Url;
} else {
setError(data);
callbacksRef.current.onError?.(data);
}
break;
}
}
});
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
// Safari native HLS support
video.src = m3u8Url;
video.addEventListener('loadedmetadata', () => {
setIsReady(true);
if (autoplay) {
video.play().catch((err) => {
console.error('Autoplay failed:', err);
callbacksRef.current.onError?.(err);
});
}
});
} else {
// No HLS support, fallback to MP4
if (mp4Url) {
video.src = mp4Url;
} else {
setError(new Error('No supported video format'));
callbacksRef.current.onError?.(new Error('No supported video format'));
}
}
} else if (mp4Url) {
// Direct MP4 playback
video.src = mp4Url;
video.addEventListener('loadedmetadata', () => {
setIsReady(true);
if (autoplay) {
video.play().catch((err) => {
console.error('Autoplay failed:', err);
callbacksRef.current.onError?.(err);
});
}
});
}
// Time update handler
const handleTimeUpdate = () => {
const time = video.currentTime;
setCurrentTime(time);
callbacksRef.current.onTimeUpdate?.(time);
};
// Duration handler
const handleDurationChange = () => {
const dur = video.duration;
if (dur && !isNaN(dur)) {
setDuration(dur);
callbacksRef.current.onDuration?.(dur);
}
};
// Play/pause handlers
const handlePlay = () => setIsPlaying(true);
const handlePause = () => setIsPlaying(false);
const handleEnded = () => {
setIsPlaying(false);
callbacksRef.current.onEnded?.();
};
video.addEventListener('timeupdate', handleTimeUpdate);
video.addEventListener('durationchange', handleDurationChange);
video.addEventListener('play', handlePlay);
video.addEventListener('pause', handlePause);
video.addEventListener('ended', handleEnded);
// Initialize Video.js after HLS.js has set up the video
// Wait for video to be ready before initializing Video.js
const initializeVideoJs = () => {
if (!videoRef.current || videoJsRef.current) return;
// Initialize Video.js with the video element
const player = videojs(videoRef.current, {
controls: true,
autoplay: false,
preload: 'auto',
fluid: false,
fill: true,
responsive: false,
html5: {
vhs: {
overrideNative: true,
},
nativeVideoTracks: false,
nativeAudioTracks: false,
nativeTextTracks: false,
},
playbackRates: [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2],
controlBar: {
volumePanel: {
inline: false,
},
},
});
videoJsRef.current = player;
// Apply custom accent color if provided
if (accentColor) {
const styleId = 'videojs-custom-theme';
let styleElement = document.getElementById(styleId) as HTMLStyleElement;
if (!styleElement) {
styleElement = document.createElement('style');
styleElement.id = styleId;
document.head.appendChild(styleElement);
}
styleElement.textContent = `
.video-js .vjs-play-progress,
.video-js .vjs-volume-level {
background-color: ${accentColor} !important;
}
.video-js .vjs-control-bar,
.video-js .vjs-big-play-button {
background-color: rgba(0, 0, 0, 0.7);
}
.video-js .vjs-slider {
background-color: rgba(255, 255, 255, 0.3);
}
`;
}
console.log('✅ Video.js initialized successfully');
};
// Initialize Video.js after a short delay to ensure HLS.js is ready
const initTimeout = setTimeout(() => {
initializeVideoJs();
}, 100);
return () => {
clearTimeout(initTimeout);
video.removeEventListener('timeupdate', handleTimeUpdate);
video.removeEventListener('durationchange', handleDurationChange);
video.removeEventListener('play', handlePlay);
video.removeEventListener('pause', handlePause);
video.removeEventListener('ended', handleEnded);
if (videoJsRef.current) {
videoJsRef.current.dispose();
videoJsRef.current = null;
}
if (hlsRef.current) {
hlsRef.current.destroy();
hlsRef.current = null;
}
};
}, [m3u8Url, mp4Url, autoplay, accentColor]);
// Jump to specific time
const jumpToTime = useCallback((time: number) => {
const video = videoRef.current;
if (video && isReady) {
const wasPlaying = !video.paused;
// Wait for video to be seekable if needed
if (video.seekable.length > 0) {
video.currentTime = time;
// Only attempt to play if video was already playing
if (wasPlaying) {
video.play().catch((err) => {
// Ignore AbortError from rapid play() calls
if (err.name !== 'AbortError') {
console.error('Jump failed:', err);
}
});
}
} else {
// Video not seekable yet, wait for it to be ready
console.log('⏳ Video not seekable yet, waiting...');
const onSeekable = () => {
video.currentTime = time;
if (wasPlaying) {
video.play().catch((err) => {
if (err.name !== 'AbortError') {
console.error('Jump failed:', err);
}
});
}
video.removeEventListener('canplay', onSeekable);
};
video.addEventListener('canplay', onSeekable, { once: true });
}
}
}, [isReady]);
// Play control
const play = useCallback(() => {
const video = videoRef.current;
if (video && isReady) {
video.play().catch((err) => {
console.error('Play failed:', err);
});
}
}, [isReady]);
// Pause control
const pause = useCallback(() => {
const video = videoRef.current;
if (video) {
video.pause();
}
}, []);
return {
videoRef,
isReady,
isPlaying,
currentTime,
duration,
error,
jumpToTime,
play,
pause,
};
};

View File

@@ -0,0 +1,128 @@
import { useEffect, useRef, useState, useCallback } from 'react';
import { supabase } from '@/integrations/supabase/client';
import { useAuth } from './useAuth';
interface UseVideoProgressOptions {
videoId: string;
videoType: 'lesson' | 'webinar';
duration?: number;
onSaveInterval?: number; // seconds, default 5
}
interface VideoProgress {
last_position: number;
total_duration?: number;
completed: boolean;
last_watched_at: string;
}
export const useVideoProgress = ({
videoId,
videoType,
duration,
onSaveInterval = 5,
}: UseVideoProgressOptions) => {
const { user } = useAuth();
const [progress, setProgress] = useState<VideoProgress | null>(null);
const [loading, setLoading] = useState(true);
const lastSavedPosition = useRef<number>(0);
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const userRef = useRef(user);
const videoIdRef = useRef(videoId);
const videoTypeRef = useRef(videoType);
const durationRef = useRef(duration);
// Update refs when props change
useEffect(() => {
userRef.current = user;
videoIdRef.current = videoId;
videoTypeRef.current = videoType;
durationRef.current = duration;
}, [user, videoId, videoType, duration]);
// Load existing progress
useEffect(() => {
if (!user || !videoId) {
setLoading(false);
return;
}
const loadProgress = async () => {
const { data, error } = await supabase
.from('video_progress')
.select('*')
.eq('user_id', user.id)
.eq('video_id', videoId)
.eq('video_type', videoType)
.maybeSingle();
if (error) {
console.error('Error loading video progress:', error);
} else if (data) {
setProgress(data);
lastSavedPosition.current = data.last_position;
}
setLoading(false);
};
loadProgress();
}, [user, videoId, videoType]);
// Save progress directly (not debounced for reliability)
const saveProgress = useCallback(async (position: number) => {
const currentUser = userRef.current;
const currentVideoId = videoIdRef.current;
const currentVideoType = videoTypeRef.current;
const currentDuration = durationRef.current;
if (!currentUser || !currentVideoId) return;
// Don't save if position hasn't changed significantly (less than 1 second)
if (Math.abs(position - lastSavedPosition.current) < 1) return;
const completed = currentDuration ? position / currentDuration >= 0.95 : false;
const { error } = await supabase
.from('video_progress')
.upsert(
{
user_id: currentUser.id,
video_id: currentVideoId,
video_type: currentVideoType,
last_position: position,
total_duration: currentDuration,
completed,
},
{
onConflict: 'user_id,video_id,video_type',
}
);
if (error) {
console.error('Error saving video progress:', error);
} else {
lastSavedPosition.current = position;
}
}, []); // Empty deps - uses refs internally
// Save on unmount
useEffect(() => {
return () => {
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
}
// Save final position
if (lastSavedPosition.current > 0) {
saveProgress(lastSavedPosition.current);
}
};
}, [saveProgress]);
return {
progress,
loading,
saveProgress, // Return the direct save function
hasProgress: progress !== null && progress.last_position > 5, // Only show if more than 5 seconds watched
};
};

43
src/lib/adiloHelper.ts Normal file
View File

@@ -0,0 +1,43 @@
/**
* Extract M3U8 and MP4 URLs from Adilo embed code
*/
export const extractAdiloUrls = (embedCode: string): { m3u8Url?: string; mp4Url?: string } => {
const m3u8Match = embedCode.match(/(https:\/\/[^"'\s]+\.m3u8[^"'\s]*)/);
const mp4Match = embedCode.match(/(https:\/\/[^"'\s]+\.mp4[^"'\s]*)/);
return {
m3u8Url: m3u8Match?.[1],
mp4Url: mp4Match?.[1],
};
};
/**
* Generate Adilo embed code from URLs
*/
export const generateAdiloEmbed = (m3u8Url: string, videoId: string): string => {
return `<iframe src="https://adilo.bigcommand.com/embed/${videoId}" allowfullscreen></iframe>`;
};
/**
* Check if a URL is an Adilo URL
*/
export const isAdiloUrl = (url: string): boolean => {
return url.includes('adilo.bigcommand.com') || url.includes('.m3u8');
};
/**
* Check if a URL is a YouTube URL
*/
export const isYouTubeUrl = (url: string): boolean => {
return url.includes('youtube.com') || url.includes('youtu.be');
};
/**
* Get video host type from URL
*/
export const getVideoHostType = (url?: string | null): 'youtube' | 'adilo' | 'unknown' => {
if (!url) return 'unknown';
if (isYouTubeUrl(url)) return 'youtube';
if (isAdiloUrl(url)) return 'adilo';
return 'unknown';
};

View File

@@ -25,7 +25,6 @@ interface Product {
id: string; id: string;
title: string; title: string;
slug: string; slug: string;
video_source?: string;
} }
interface Module { interface Module {
@@ -42,6 +41,9 @@ interface Lesson {
video_url: string | null; video_url: string | null;
youtube_url: string | null; youtube_url: string | null;
embed_code: string | null; embed_code: string | null;
m3u8_url?: string | null;
mp4_url?: string | null;
video_host?: 'youtube' | 'adilo' | 'unknown';
duration_seconds: number | null; duration_seconds: number | null;
position: number; position: number;
release_at: string | null; release_at: string | null;
@@ -91,7 +93,7 @@ export default function Bootcamp() {
const checkAccessAndFetch = async () => { const checkAccessAndFetch = async () => {
const { data: productData, error: productError } = await supabase const { data: productData, error: productError } = await supabase
.from('products') .from('products')
.select('id, title, slug, video_source') .select('id, title, slug')
.eq('slug', slug) .eq('slug', slug)
.eq('type', 'bootcamp') .eq('type', 'bootcamp')
.maybeSingle(); .maybeSingle();
@@ -140,6 +142,9 @@ export default function Bootcamp() {
video_url, video_url,
youtube_url, youtube_url,
embed_code, embed_code,
m3u8_url,
mp4_url,
video_host,
duration_seconds, duration_seconds,
position, position,
release_at, release_at,
@@ -283,12 +288,39 @@ export default function Bootcamp() {
}; };
const VideoPlayer = ({ lesson }: { lesson: Lesson }) => { const VideoPlayer = ({ lesson }: { lesson: Lesson }) => {
const activeSource = product?.video_source || 'youtube';
const hasChapters = lesson.chapters && lesson.chapters.length > 0; const hasChapters = lesson.chapters && lesson.chapters.length > 0;
// Get video based on product's active source // Get video based on lesson's video_host (prioritize Adilo)
const getVideoSource = () => { const getVideoSource = () => {
if (activeSource === 'youtube') { // If video_host is explicitly set, use it. Otherwise auto-detect with Adilo priority.
const lessonVideoHost = lesson.video_host || (
lesson.m3u8_url ? 'adilo' :
lesson.video_url?.includes('adilo.bigcommand.com') ? 'adilo' :
lesson.youtube_url || lesson.video_url?.includes('youtube.com') || lesson.video_url?.includes('youtu.be') ? 'youtube' :
'unknown'
);
if (lessonVideoHost === 'adilo') {
// Adilo M3U8 streaming
if (lesson.m3u8_url && lesson.m3u8_url.trim()) {
return {
type: 'adilo',
m3u8Url: lesson.m3u8_url,
mp4Url: lesson.mp4_url || undefined,
videoHost: 'adilo'
};
} else if (lesson.mp4_url && lesson.mp4_url.trim()) {
// Fallback to MP4 only
return {
type: 'adilo',
mp4Url: lesson.mp4_url,
videoHost: 'adilo'
};
}
}
// YouTube or fallback
if (lessonVideoHost === 'youtube') {
if (lesson.youtube_url && lesson.youtube_url.trim()) { if (lesson.youtube_url && lesson.youtube_url.trim()) {
return { return {
type: 'youtube', type: 'youtube',
@@ -302,32 +334,14 @@ export default function Bootcamp() {
url: lesson.video_url, url: lesson.video_url,
embedUrl: getYouTubeEmbedUrl(lesson.video_url) embedUrl: getYouTubeEmbedUrl(lesson.video_url)
}; };
} else {
// Fallback to embed if YouTube not available
return lesson.embed_code && lesson.embed_code.trim() ? {
type: 'embed',
html: lesson.embed_code
} : null;
}
} else {
if (lesson.embed_code && lesson.embed_code.trim()) {
return {
type: 'embed',
html: lesson.embed_code
};
} else {
// Fallback to YouTube if embed not available
return lesson.youtube_url && lesson.youtube_url.trim() ? {
type: 'youtube',
url: lesson.youtube_url,
embedUrl: getYouTubeEmbedUrl(lesson.youtube_url)
} : lesson.video_url && lesson.video_url.trim() ? {
type: 'youtube',
url: lesson.video_url,
embedUrl: getYouTubeEmbedUrl(lesson.video_url)
} : null;
} }
} }
// Final fallback: try embed code
return lesson.embed_code && lesson.embed_code.trim() ? {
type: 'embed',
html: lesson.embed_code
} : null;
}; };
const video = getVideoSource(); const video = getVideoSource();
@@ -355,7 +369,6 @@ export default function Bootcamp() {
<div className="mt-4"> <div className="mt-4">
<TimelineChapters <TimelineChapters
chapters={lesson.chapters} chapters={lesson.chapters}
isYouTube={false}
currentTime={currentTime} currentTime={currentTime}
accentColor={accentColor} accentColor={accentColor}
/> />
@@ -365,19 +378,24 @@ export default function Bootcamp() {
); );
} }
// YouTube with chapters support // Adilo or YouTube with chapters support
const isYouTube = video.type === 'youtube'; const isYouTube = video.type === 'youtube';
const isAdilo = video.type === 'adilo';
return ( return (
<div className={hasChapters ? "grid grid-cols-1 lg:grid-cols-3 gap-6 mb-6" : "mb-6"}> <div className={hasChapters ? "grid grid-cols-1 lg:grid-cols-3 gap-6 mb-6" : "mb-6"}>
<div className={hasChapters ? "lg:col-span-2" : ""}> <div className={hasChapters ? "lg:col-span-2" : ""}>
<VideoPlayerWithChapters <VideoPlayerWithChapters
ref={playerRef} ref={playerRef}
videoUrl={video.url} videoUrl={isYouTube ? video.url : undefined}
embedCode={lesson.embed_code} m3u8Url={isAdilo ? video.m3u8Url : undefined}
mp4Url={isAdilo ? video.mp4Url : undefined}
videoHost={isAdilo ? 'adilo' : isYouTube ? 'youtube' : 'unknown'}
chapters={lesson.chapters} chapters={lesson.chapters}
accentColor={accentColor} accentColor={accentColor}
onTimeUpdate={setCurrentTime} onTimeUpdate={setCurrentTime}
videoId={lesson.id}
videoType="lesson"
/> />
</div> </div>
@@ -385,7 +403,6 @@ export default function Bootcamp() {
<div className="lg:col-span-1"> <div className="lg:col-span-1">
<TimelineChapters <TimelineChapters
chapters={lesson.chapters} chapters={lesson.chapters}
isYouTube={isYouTube}
onChapterClick={(time) => { onChapterClick={(time) => {
if (playerRef.current) { if (playerRef.current) {
playerRef.current.jumpToTime(time); playerRef.current.jumpToTime(time);
@@ -704,6 +721,12 @@ export default function Bootcamp() {
productId={product.id} productId={product.id}
type="bootcamp" type="bootcamp"
contextLabel={product.title} contextLabel={product.title}
existingReview={userReview ? {
id: userReview.id,
rating: userReview.rating,
title: userReview.title,
body: userReview.body,
} : undefined}
onSuccess={() => { onSuccess={() => {
// Refresh review data // Refresh review data
const refreshReview = async () => { const refreshReview = async () => {

View File

@@ -26,8 +26,12 @@ interface Product {
sale_price: number | null; sale_price: number | null;
meeting_link: string | null; meeting_link: string | null;
recording_url: string | null; recording_url: string | null;
m3u8_url: string | null;
mp4_url: string | null;
video_host: 'youtube' | 'adilo' | 'unknown' | null;
event_start: string | null; event_start: string | null;
duration_minutes: number | null; duration_minutes: number | null;
chapters?: { time: number; title: string; }[];
created_at: string; created_at: string;
} }
@@ -43,6 +47,7 @@ interface Lesson {
title: string; title: string;
duration_seconds: number | null; duration_seconds: number | null;
position: number; position: number;
chapters?: { time: number; title: string; }[];
} }
interface UserReview { interface UserReview {
@@ -116,7 +121,8 @@ export default function ProductDetail() {
id, id,
title, title,
duration_seconds, duration_seconds,
position position,
chapters
) )
`) `)
.eq('product_id', product.id) .eq('product_id', product.id)
@@ -215,6 +221,43 @@ export default function ProductDetail() {
const isInCart = product ? items.some(item => item.id === product.id) : false; const isInCart = product ? items.some(item => item.id === product.id) : false;
const formatChapterTime = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${String(secs).padStart(2, '0')}`;
};
const renderWebinarChapters = () => {
if (product?.type !== 'webinar' || !product.chapters || product.chapters.length === 0) return null;
return (
<Card className="border-2 border-border mb-6">
<CardContent className="pt-6">
<h3 className="text-xl font-bold mb-4">Daftar isi Webinar</h3>
<div className="space-y-3">
{product.chapters.map((chapter, index) => (
<div
key={index}
className="flex items-center gap-3 p-3 rounded-lg hover:bg-accent transition-colors cursor-pointer group"
onClick={() => product && navigate(`/webinar/${product.slug}`)}
>
<div className="flex-shrink-0 w-12 text-center">
<span className="text-sm font-mono text-muted-foreground group-hover:text-primary">
{formatChapterTime(chapter.time)}
</span>
</div>
<div className="flex-1">
<p className="text-sm font-medium">{chapter.title}</p>
</div>
<Play className="w-4 h-4 text-muted-foreground group-hover:text-primary flex-shrink-0" />
</div>
))}
</div>
</CardContent>
</Card>
);
};
const getVideoEmbed = (url: string) => { const getVideoEmbed = (url: string) => {
const youtubeMatch = url.match(/(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/)([^&\s]+)/); const youtubeMatch = url.match(/(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/)([^&\s]+)/);
if (youtubeMatch) return `https://www.youtube.com/embed/${youtubeMatch[1]}`; if (youtubeMatch) return `https://www.youtube.com/embed/${youtubeMatch[1]}`;
@@ -268,20 +311,25 @@ export default function ProductDetail() {
if (product.recording_url) { if (product.recording_url) {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="aspect-video bg-muted rounded-none overflow-hidden border-2 border-border"> <Card className="border-2 border-primary/20 bg-primary/5">
<iframe <CardContent className="pt-6">
src={getVideoEmbed(product.recording_url)} <div className="flex items-start gap-4">
className="w-full h-full" <div className="rounded-full bg-primary/10 p-3">
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" <Play className="w-6 h-6 text-primary" />
allowFullScreen </div>
/> <div className="flex-1">
</div> <h3 className="font-semibold text-lg mb-1">Rekaman webinar tersedia</h3>
<Button asChild variant="outline" className="border-2"> <p className="text-sm text-muted-foreground mb-4">
<a href={product.recording_url} target="_blank" rel="noopener noreferrer"> Akses rekaman webinar kapan saja. Pelajari materi sesuai kecepatan Anda.
<Video className="w-4 h-4 mr-2" /> </p>
Tonton Rekaman <Button onClick={() => navigate(`/webinar/${product.slug}`)} size="lg">
</a> <Video className="w-4 h-4 mr-2" />
</Button> Tonton Sekarang
</Button>
</div>
</div>
</CardContent>
</Card>
</div> </div>
); );
} }
@@ -352,15 +400,36 @@ export default function ProductDetail() {
</div> </div>
</CollapsibleTrigger> </CollapsibleTrigger>
<CollapsibleContent> <CollapsibleContent>
<div className="border-l-2 border-border ml-4 pl-4 py-2 space-y-2"> <div className="border-l-2 border-border ml-4 pl-4 py-2 space-y-3">
{module.lessons.map((lesson) => ( {module.lessons.map((lesson) => (
<div key={lesson.id} className="flex items-center justify-between py-1 text-sm"> <div key={lesson.id} className="space-y-2">
<div className="flex items-center gap-2"> {/* Lesson header */}
<Play className="w-3 h-3 text-muted-foreground" /> <div className="flex items-center justify-between py-1 text-sm">
<span>{lesson.title}</span> <div className="flex items-center gap-2">
<Play className="w-3 h-3 text-muted-foreground" />
<span className="font-medium">{lesson.title}</span>
</div>
{lesson.duration_seconds && (
<span className="text-muted-foreground">{formatDuration(lesson.duration_seconds)}</span>
)}
</div> </div>
{lesson.duration_seconds && (
<span className="text-muted-foreground">{formatDuration(lesson.duration_seconds)}</span> {/* Lesson chapters (if any) */}
{lesson.chapters && lesson.chapters.length > 0 && (
<div className="ml-5 space-y-1">
{lesson.chapters.map((chapter, chapterIndex) => (
<div
key={chapterIndex}
className="flex items-center gap-2 py-1 px-2 text-xs text-muted-foreground hover:bg-accent/50 rounded transition-colors cursor-pointer group"
onClick={() => product && navigate(`/bootcamp/${product.slug}`)}
>
<span className="font-mono w-10 text-center group-hover:text-primary">
{formatChapterTime(chapter.time)}
</span>
<span className="flex-1 group-hover:text-foreground">{chapter.title}</span>
</div>
))}
</div>
)} )}
</div> </div>
))} ))}
@@ -378,6 +447,39 @@ export default function ProductDetail() {
<AppLayout> <AppLayout>
<div className="container mx-auto px-4 py-8"> <div className="container mx-auto px-4 py-8">
<div className="max-w-4xl mx-auto"> <div className="max-w-4xl mx-auto">
{/* Ownership Banner - shown at top for purchased users */}
{hasAccess && (
<div className="bg-green-50 dark:bg-green-950 border-2 border-green-200 dark:border-green-800 rounded-lg p-4 mb-6">
<div className="flex items-center justify-between gap-4 flex-wrap">
<div className="flex items-center gap-3">
<CheckCircle className="w-6 h-6 text-green-600 dark:text-green-400 flex-shrink-0" />
<div>
<p className="font-semibold text-green-900 dark:text-green-100">
Anda memiliki akses ke produk ini
</p>
<p className="text-sm text-green-700 dark:text-green-300">
{product.type === 'webinar' && 'Selamat menonton rekaman webinar!'}
{product.type === 'bootcamp' && 'Mulai belajar sekarang!'}
{product.type === 'consulting' && 'Jadwalkan sesi konsultasi Anda.'}
</p>
</div>
</div>
<Button
onClick={() => {
if (product.type === 'webinar') {
navigate(`/webinar/${product.slug}`);
} else if (product.type === 'bootcamp') {
navigate(`/bootcamp/${product.slug}`);
}
}}
className="bg-green-600 hover:bg-green-700 text-white shadow-sm"
>
Tonton Sekarang
</Button>
</div>
</div>
)}
<div className="flex flex-col md:flex-row md:items-start justify-between gap-4 mb-6"> <div className="flex flex-col md:flex-row md:items-start justify-between gap-4 mb-6">
<div> <div>
<h1 className="text-4xl font-bold mb-2">{product.title}</h1> <h1 className="text-4xl font-bold mb-2">{product.title}</h1>
@@ -392,7 +494,6 @@ export default function ProductDetail() {
{product.type === 'webinar' && !product.recording_url && product.event_start && new Date(product.event_start) <= new Date() && ( {product.type === 'webinar' && !product.recording_url && product.event_start && new Date(product.event_start) <= new Date() && (
<Badge className="bg-muted text-primary">Telah Lewat</Badge> <Badge className="bg-muted text-primary">Telah Lewat</Badge>
)} )}
{hasAccess && <Badge className="bg-primary text-primary-foreground">Anda memiliki akses</Badge>}
</div> </div>
</div> </div>
<div className="text-right"> <div className="text-right">
@@ -432,6 +533,8 @@ export default function ProductDetail() {
</Card> </Card>
)} )}
{renderWebinarChapters()}
<div className="flex gap-4 flex-wrap"> <div className="flex gap-4 flex-wrap">
{renderActionButtons()} {renderActionButtons()}
</div> </div>

View File

@@ -1,15 +1,18 @@
import { useEffect, useState, useRef } from 'react'; import { useEffect, useState, useRef, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router-dom'; import { useParams, useNavigate } from 'react-router-dom';
import { supabase } from '@/integrations/supabase/client'; import { supabase } from '@/integrations/supabase/client';
import { useAuth } from '@/hooks/useAuth'; import { useAuth } from '@/hooks/useAuth';
import { useVideoProgress } from '@/hooks/useVideoProgress';
import { AppLayout } from '@/components/AppLayout'; import { AppLayout } from '@/components/AppLayout';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { toast } from '@/hooks/use-toast'; import { toast } from '@/hooks/use-toast';
import { ChevronLeft, Play } from 'lucide-react'; import { ChevronLeft, Play, Star, Clock, CheckCircle } from 'lucide-react';
import { VideoPlayerWithChapters, VideoPlayerRef } from '@/components/VideoPlayerWithChapters'; import { VideoPlayerWithChapters, VideoPlayerRef } from '@/components/VideoPlayerWithChapters';
import { TimelineChapters } from '@/components/TimelineChapters'; import { TimelineChapters } from '@/components/TimelineChapters';
import { ReviewModal } from '@/components/reviews/ReviewModal';
import { Badge } from '@/components/ui/badge';
interface VideoChapter { interface VideoChapter {
time: number; time: number;
@@ -21,10 +24,22 @@ interface Product {
title: string; title: string;
slug: string; slug: string;
recording_url: string | null; recording_url: string | null;
m3u8_url?: string | null;
mp4_url?: string | null;
video_host?: 'youtube' | 'adilo' | 'unknown';
description: string | null; description: string | null;
chapters?: VideoChapter[]; chapters?: VideoChapter[];
} }
interface UserReview {
id: string;
rating: number;
title?: string;
body?: string;
is_approved: boolean;
created_at: string;
}
export default function WebinarRecording() { export default function WebinarRecording() {
const { slug } = useParams<{ slug: string }>(); const { slug } = useParams<{ slug: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -34,6 +49,8 @@ export default function WebinarRecording() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [currentTime, setCurrentTime] = useState(0); const [currentTime, setCurrentTime] = useState(0);
const [accentColor, setAccentColor] = useState<string>(''); const [accentColor, setAccentColor] = useState<string>('');
const [userReview, setUserReview] = useState<UserReview | null>(null);
const [reviewModalOpen, setReviewModalOpen] = useState(false);
const playerRef = useRef<VideoPlayerRef>(null); const playerRef = useRef<VideoPlayerRef>(null);
useEffect(() => { useEffect(() => {
@@ -47,7 +64,7 @@ export default function WebinarRecording() {
const checkAccessAndFetch = async () => { const checkAccessAndFetch = async () => {
const { data: productData, error: productError } = await supabase const { data: productData, error: productError } = await supabase
.from('products') .from('products')
.select('id, title, slug, recording_url, description, chapters') .select('id, title, slug, recording_url, m3u8_url, mp4_url, video_host, description, chapters')
.eq('slug', slug) .eq('slug', slug)
.eq('type', 'webinar') .eq('type', 'webinar')
.maybeSingle(); .maybeSingle();
@@ -103,23 +120,60 @@ export default function WebinarRecording() {
} }
setLoading(false); setLoading(false);
// Check if user has already reviewed this webinar
checkUserReview();
}; };
const isYouTube = product?.recording_url && ( const checkUserReview = async () => {
product.recording_url.includes('youtube.com') || if (!product || !user) return;
product.recording_url.includes('youtu.be')
const { data } = await supabase
.from('reviews')
.select('id, rating, title, body, is_approved, created_at')
.eq('user_id', user.id)
.eq('product_id', product.id)
.order('created_at', { ascending: false })
.limit(1);
if (data && data.length > 0) {
setUserReview(data[0] as UserReview);
} else {
setUserReview(null);
}
};
// Check if user has submitted a review (regardless of approval status)
const hasSubmittedReview = userReview !== null;
// Determine video host (prioritize Adilo over YouTube)
const detectedVideoHost = product?.video_host || (
product?.m3u8_url ? 'adilo' :
product?.recording_url?.includes('adilo.bigcommand.com') ? 'adilo' :
product?.recording_url?.includes('youtube.com') || product?.recording_url?.includes('youtu.be')
? 'youtube'
: 'unknown'
); );
const handleChapterClick = (time: number) => { const handleChapterClick = useCallback((time: number) => {
// VideoPlayerWithChapters will handle the jump // VideoPlayerWithChapters will handle the jump
if (playerRef.current && playerRef.current.jumpToTime) { if (playerRef.current && playerRef.current.jumpToTime) {
playerRef.current.jumpToTime(time); playerRef.current.jumpToTime(time);
} }
}; }, []);
const handleTimeUpdate = (time: number) => { const handleTimeUpdate = useCallback((time: number) => {
setCurrentTime(time); setCurrentTime(time);
}; }, []);
// Fetch progress data for review trigger
const { progress, hasProgress: hasWatchProgress } = useVideoProgress({
videoId: product?.id || '',
videoType: 'webinar',
});
// Show review prompt if user has watched more than 5 seconds (any engagement)
const shouldShowReviewPrompt = hasWatchProgress;
if (authLoading || loading) { if (authLoading || loading) {
return ( return (
@@ -138,69 +192,177 @@ export default function WebinarRecording() {
return ( return (
<AppLayout> <AppLayout>
<div className="container mx-auto px-4 py-8"> <div className="container mx-auto px-4 py-8 max-w-5xl">
<Button variant="ghost" onClick={() => navigate('/dashboard')} className="mb-6"> <Button variant="ghost" onClick={() => navigate('/dashboard')} className="mb-6">
<ChevronLeft className="w-4 h-4 mr-2" /> <ChevronLeft className="w-4 h-4 mr-2" />
Kembali ke Dashboard Kembali ke Dashboard
</Button> </Button>
<Card className="border-2 border-border"> <h1 className="text-3xl md:text-4xl font-bold mb-6">{product.title}</h1>
<CardHeader>
<CardTitle className="text-2xl md:text-3xl">{product.title}</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{/* Video Player with Chapters */}
<div className={hasChapters ? "grid grid-cols-1 lg:grid-cols-3 gap-6" : ""}>
<div className={hasChapters ? "lg:col-span-2" : ""}>
{product.recording_url && (
<VideoPlayerWithChapters
ref={playerRef}
videoUrl={product.recording_url}
chapters={product.chapters}
accentColor={accentColor}
onTimeUpdate={handleTimeUpdate}
/>
)}
</div>
{/* Timeline Chapters */} {/* Video Player */}
{hasChapters && ( <div className="mb-6">
<div className="lg:col-span-1"> {(product.recording_url || product.m3u8_url) && (
<TimelineChapters <VideoPlayerWithChapters
chapters={product.chapters} ref={playerRef}
isYouTube={isYouTube} videoUrl={product.recording_url || undefined}
onChapterClick={handleChapterClick} m3u8Url={product.m3u8_url || undefined}
currentTime={currentTime} mp4Url={product.mp4_url || undefined}
accentColor={accentColor} videoHost={detectedVideoHost}
/> chapters={product.chapters}
</div> accentColor={accentColor}
)} onTimeUpdate={handleTimeUpdate}
</div> videoId={product.id}
videoType="webinar"
/>
)}
</div>
{/* Description */} {/* Timeline Chapters - video track for navigation */}
{product.description && ( {hasChapters && (
<div className="mb-6">
<TimelineChapters
chapters={product.chapters}
onChapterClick={handleChapterClick}
currentTime={currentTime}
accentColor={accentColor}
/>
</div>
)}
{/* Description */}
{product.description && (
<Card className="border-2 border-border mb-6">
<CardContent className="pt-6">
<div className="prose max-w-none"> <div className="prose max-w-none">
<div dangerouslySetInnerHTML={{ __html: product.description }} /> <div dangerouslySetInnerHTML={{ __html: product.description }} />
</div> </div>
)} </CardContent>
</Card>
)}
{/* Instructions */} {/* Instructions */}
<Card className="bg-muted border-2 border-border"> <Card className="bg-muted border-2 border-border mb-6">
<CardContent className="pt-6"> <CardContent className="pt-6">
<h3 className="font-semibold mb-2 flex items-center gap-2"> <h3 className="font-semibold mb-2 flex items-center gap-2">
<Play className="w-5 h-5" /> <Play className="w-5 h-5" />
Panduan Menonton Panduan Menonton
</h3> </h3>
<ul className="text-sm text-muted-foreground space-y-1 list-disc list-inside"> <ul className="text-sm text-muted-foreground space-y-1 list-disc list-inside">
<li>Gunakan tombol fullscreen di pojok kanan bawah video untuk tampilan terbaik</li> <li>Gunakan tombol fullscreen di pojok kanan bawah video untuk tampilan terbaik</li>
<li>Anda dapat memutar ulang video kapan saja</li> <li>Anda dapat memutar ulang video kapan saja</li>
<li>Pastikan koneksi internet stabil untuk pengalaman menonton yang lancar</li> <li>Pastikan koneksi internet stabil untuk pengalaman menonton yang lancar</li>
</ul> </ul>
</CardContent>
</Card>
</CardContent> </CardContent>
</Card> </Card>
{/* Review Section - Show after any engagement, but only if user hasn't submitted a review yet */}
{shouldShowReviewPrompt && !hasSubmittedReview && (
<Card className="border-2 border-primary/20 bg-primary/5 mb-6">
<CardContent className="pt-6">
<div className="flex items-start gap-4">
<div className="rounded-full bg-primary/10 p-3">
<Star className="w-6 h-6 text-primary fill-primary" />
</div>
<div className="flex-1">
<h3 className="font-semibold text-lg mb-2">Bagaimana webinar ini?</h3>
<p className="text-sm text-muted-foreground mb-4">
Berikan ulasan Anda untuk membantu peserta lain memilih webinar yang tepat.
</p>
<Button onClick={() => setReviewModalOpen(true)}>
<Star className="w-4 h-4 mr-2" />
Beri ulasan
</Button>
</div>
</div>
</CardContent>
</Card>
)}
{/* User's Existing Review */}
{userReview && (
<Card className="border-2 border-border mb-6">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<CheckCircle className={`w-5 h-5 ${userReview.is_approved ? 'text-green-600' : 'text-yellow-600'}`} />
Ulasan Anda{!userReview.is_approved && ' (Menunggu Persetujuan)'}
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center gap-2 mb-3">
{[1, 2, 3, 4, 5].map((star) => (
<Star
key={star}
className={`w-5 h-5 ${
star <= userReview.rating
? 'text-yellow-500 fill-yellow-500'
: 'text-gray-300'
}`}
/>
))}
<Badge variant="secondary" className="ml-2">
{new Date(userReview.created_at).toLocaleDateString('id-ID', {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</Badge>
{!userReview.is_approved && (
<Badge className="bg-yellow-100 text-yellow-800 border-yellow-300">
Menunggu persetujuan admin
</Badge>
)}
</div>
{userReview.title && (
<h4 className="font-semibold text-lg mb-2">{userReview.title}</h4>
)}
{userReview.body && (
<p className="text-muted-foreground">{userReview.body}</p>
)}
{!userReview.is_approved && (
<p className="text-sm text-muted-foreground mt-2 italic">
Ulasan Anda sedang ditinjau oleh admin dan akan segera ditampilkan setelah disetujui.
</p>
)}
<Button
variant="outline"
size="sm"
className="mt-4"
onClick={() => setReviewModalOpen(true)}
>
Edit ulasan
</Button>
</CardContent>
</Card>
)}
</div> </div>
{/* Review Modal */}
{product && user && (
<ReviewModal
open={reviewModalOpen}
onOpenChange={setReviewModalOpen}
userId={user.id}
productId={product.id}
type="webinar"
contextLabel={product.title}
existingReview={userReview ? {
id: userReview.id,
rating: userReview.rating,
title: userReview.title,
body: userReview.body,
} : undefined}
onSuccess={() => {
checkUserReview();
toast({
title: 'Terima kasih!',
description: userReview
? 'Ulasan Anda berhasil diperbarui.'
: 'Ulasan Anda berhasil disimpan.',
});
}}
/>
)}
</AppLayout> </AppLayout>
); );
} }

View File

@@ -36,12 +36,14 @@ interface Product {
content: string; content: string;
meeting_link: string | null; meeting_link: string | null;
recording_url: string | null; recording_url: string | null;
m3u8_url?: string | null;
mp4_url?: string | null;
video_host?: 'youtube' | 'adilo' | 'unknown';
event_start: string | null; event_start: string | null;
duration_minutes: number | null; duration_minutes: number | null;
price: number; price: number;
sale_price: number | null; sale_price: number | null;
is_active: boolean; is_active: boolean;
video_source?: string;
chapters?: VideoChapter[]; chapters?: VideoChapter[];
} }
@@ -53,12 +55,14 @@ const emptyProduct = {
content: '', content: '',
meeting_link: '', meeting_link: '',
recording_url: '', recording_url: '',
m3u8_url: '',
mp4_url: '',
video_host: 'youtube' as 'youtube' | 'adilo' | 'unknown',
event_start: null as string | null, event_start: null as string | null,
duration_minutes: null as number | null, duration_minutes: null as number | null,
price: 0, price: 0,
sale_price: null as number | null, sale_price: null as number | null,
is_active: true, is_active: true,
video_source: 'youtube' as string,
chapters: [] as VideoChapter[], chapters: [] as VideoChapter[],
}; };
@@ -84,7 +88,10 @@ export default function AdminProducts() {
}, [user, isAdmin, authLoading]); }, [user, isAdmin, authLoading]);
const fetchProducts = async () => { const fetchProducts = async () => {
const { data, error } = await supabase.from('products').select('*').order('created_at', { ascending: false }); const { data, error } = await supabase
.from('products')
.select('id, title, slug, type, description, meeting_link, recording_url, m3u8_url, mp4_url, video_host, event_start, duration_minutes, price, sale_price, is_active, chapters')
.order('created_at', { ascending: false });
if (!error && data) setProducts(data); if (!error && data) setProducts(data);
setLoading(false); setLoading(false);
}; };
@@ -121,12 +128,14 @@ export default function AdminProducts() {
content: product.content || '', content: product.content || '',
meeting_link: product.meeting_link || '', meeting_link: product.meeting_link || '',
recording_url: product.recording_url || '', recording_url: product.recording_url || '',
m3u8_url: product.m3u8_url || '',
mp4_url: product.mp4_url || '',
video_host: product.video_host || 'youtube',
event_start: product.event_start ? product.event_start.slice(0, 16) : null, event_start: product.event_start ? product.event_start.slice(0, 16) : null,
duration_minutes: product.duration_minutes, duration_minutes: product.duration_minutes,
price: product.price, price: product.price,
sale_price: product.sale_price, sale_price: product.sale_price,
is_active: product.is_active, is_active: product.is_active,
video_source: product.video_source || 'youtube',
chapters: product.chapters || [], chapters: product.chapters || [],
}); });
setDialogOpen(true); setDialogOpen(true);
@@ -152,12 +161,14 @@ export default function AdminProducts() {
content: form.content, content: form.content,
meeting_link: form.meeting_link || null, meeting_link: form.meeting_link || null,
recording_url: form.recording_url || null, recording_url: form.recording_url || null,
m3u8_url: form.m3u8_url || null,
mp4_url: form.mp4_url || null,
video_host: form.video_host || 'youtube',
event_start: form.event_start || null, event_start: form.event_start || null,
duration_minutes: form.duration_minutes || null, duration_minutes: form.duration_minutes || null,
price: form.price, price: form.price,
sale_price: form.sale_price || null, sale_price: form.sale_price || null,
is_active: form.is_active, is_active: form.is_active,
video_source: form.video_source || 'youtube',
chapters: form.chapters || [], chapters: form.chapters || [],
}; };
@@ -461,18 +472,73 @@ export default function AdminProducts() {
<Label>Konten</Label> <Label>Konten</Label>
<RichTextEditor content={form.content} onChange={(v) => setForm({ ...form, content: v })} /> <RichTextEditor content={form.content} onChange={(v) => setForm({ ...form, content: v })} />
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Meeting Link</Label>
<Input value={form.meeting_link} onChange={(e) => setForm({ ...form, meeting_link: e.target.value })} placeholder="https://meet.google.com/..." className="border-2" />
</div>
<div className="space-y-2">
<Label>Recording URL</Label>
<Input value={form.recording_url} onChange={(e) => setForm({ ...form, recording_url: e.target.value })} placeholder="https://youtube.com/..." className="border-2" />
</div>
</div>
{form.type === 'webinar' && ( {form.type === 'webinar' && (
<> <>
<div className="space-y-2">
<Label>Meeting Link</Label>
<Input value={form.meeting_link} onChange={(e) => setForm({ ...form, meeting_link: e.target.value })} placeholder="https://meet.google.com/..." className="border-2" />
</div>
<div className="space-y-2">
<Label>Video Host</Label>
<Select value={form.video_host} onValueChange={(value: 'youtube' | 'adilo') => setForm({ ...form, video_host: value })}>
<SelectTrigger className="border-2">
<SelectValue placeholder="Select video host" />
</SelectTrigger>
<SelectContent>
<SelectItem value="youtube">YouTube</SelectItem>
<SelectItem value="adilo">Adilo (M3U8)</SelectItem>
</SelectContent>
</Select>
</div>
{/* YouTube URL */}
{form.video_host === 'youtube' && (
<div className="space-y-2">
<Label>YouTube Recording URL</Label>
<Input
value={form.recording_url}
onChange={(e) => setForm({ ...form, recording_url: e.target.value })}
placeholder="https://www.youtube.com/watch?v=..."
className="border-2"
/>
<p className="text-sm text-muted-foreground">
Paste YouTube URL here
</p>
</div>
)}
{/* Adilo URLs */}
{form.video_host === 'adilo' && (
<div className="space-y-4 p-4 bg-muted border-2 border-border rounded-lg">
<div className="space-y-2">
<Label>M3U8 URL (Primary)</Label>
<Input
value={form.m3u8_url}
onChange={(e) => setForm({ ...form, m3u8_url: e.target.value })}
placeholder="https://adilo.bigcommand.com/m3u8/..."
className="border-2 font-mono text-sm"
/>
<p className="text-sm text-muted-foreground">
HLS streaming URL from Adilo
</p>
</div>
<div className="space-y-2">
<Label>MP4 URL (Optional Fallback)</Label>
<Input
value={form.mp4_url}
onChange={(e) => setForm({ ...form, mp4_url: e.target.value })}
placeholder="https://adilo.bigcommand.com/videos/..."
className="border-2 font-mono text-sm"
/>
<p className="text-sm text-muted-foreground">
Direct MP4 file for legacy browsers (optional)
</p>
</div>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<Label>Tanggal & Waktu Webinar</Label> <Label>Tanggal & Waktu Webinar</Label>
@@ -523,10 +589,10 @@ export default function AdminProducts() {
<RadioGroupItem value="embed" id="embed" /> <RadioGroupItem value="embed" id="embed" />
<div className="flex-1"> <div className="flex-1">
<Label htmlFor="embed" className="font-medium cursor-pointer"> <Label htmlFor="embed" className="font-medium cursor-pointer">
Custom Embed (Backup) Adilo (Backup)
</Label> </Label>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Use custom embed codes (Adilo, Vimeo, etc.) for all lessons Use Adilo M3U8/MP4 URLs for all lessons
</p> </p>
</div> </div>
</div> </div>
@@ -535,7 +601,7 @@ export default function AdminProducts() {
<Alert> <Alert>
<Info className="h-4 w-4" /> <Info className="h-4 w-4" />
<AlertDescription> <AlertDescription>
This setting affects ALL lessons in this bootcamp. Configure both YouTube URLs and embed codes for each lesson in the curriculum editor. Use this toggle to switch between sources instantly. This setting affects ALL lessons in this bootcamp. Configure video URLs for each lesson in the curriculum editor. Use this toggle to switch between YouTube and Adilo sources instantly.
</AlertDescription> </AlertDescription>
</Alert> </Alert>
</div> </div>

View File

@@ -10,6 +10,8 @@ import { Plus, Pencil, Trash2, ChevronUp, ChevronDown, GripVertical, ArrowLeft,
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { RichTextEditor } from '@/components/RichTextEditor'; import { RichTextEditor } from '@/components/RichTextEditor';
import { AppLayout } from '@/components/AppLayout'; import { AppLayout } from '@/components/AppLayout';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { ChaptersEditor } from '@/components/admin/ChaptersEditor';
interface Module { interface Module {
id: string; id: string;
@@ -17,6 +19,11 @@ interface Module {
position: number; position: number;
} }
interface VideoChapter {
time: number;
title: string;
}
interface Lesson { interface Lesson {
id: string; id: string;
module_id: string; module_id: string;
@@ -25,8 +32,12 @@ interface Lesson {
video_url: string | null; video_url: string | null;
youtube_url: string | null; youtube_url: string | null;
embed_code: string | null; embed_code: string | null;
m3u8_url?: string | null;
mp4_url?: string | null;
video_host?: 'youtube' | 'adilo' | 'unknown';
position: number; position: number;
release_at: string | null; release_at: string | null;
chapters?: VideoChapter[];
} }
export default function ProductCurriculum() { export default function ProductCurriculum() {
@@ -52,7 +63,11 @@ export default function ProductCurriculum() {
video_url: '', video_url: '',
youtube_url: '', youtube_url: '',
embed_code: '', embed_code: '',
m3u8_url: '',
mp4_url: '',
video_host: 'youtube' as 'youtube' | 'adilo' | 'unknown',
release_at: '', release_at: '',
chapters: [] as VideoChapter[],
}); });
useEffect(() => { useEffect(() => {
@@ -67,7 +82,7 @@ export default function ProductCurriculum() {
const [productRes, modulesRes, lessonsRes] = await Promise.all([ const [productRes, modulesRes, lessonsRes] = await Promise.all([
supabase.from('products').select('id, title, slug').eq('id', id).single(), supabase.from('products').select('id, title, slug').eq('id', id).single(),
supabase.from('bootcamp_modules').select('*').eq('product_id', id).order('position'), supabase.from('bootcamp_modules').select('*').eq('product_id', id).order('position'),
supabase.from('bootcamp_lessons').select('*').order('position'), supabase.from('bootcamp_lessons').select('id, module_id, title, content, video_url, youtube_url, embed_code, m3u8_url, mp4_url, video_host, position, release_at, chapters').order('position'),
]); ]);
if (productRes.data) { if (productRes.data) {
@@ -176,7 +191,11 @@ export default function ProductCurriculum() {
video_url: '', video_url: '',
youtube_url: '', youtube_url: '',
embed_code: '', embed_code: '',
m3u8_url: '',
mp4_url: '',
video_host: 'youtube',
release_at: '', release_at: '',
chapters: [],
}); });
setSelectedModuleId(moduleId); setSelectedModuleId(moduleId);
setSelectedLessonId('new'); setSelectedLessonId('new');
@@ -191,7 +210,11 @@ export default function ProductCurriculum() {
video_url: lesson.video_url || '', video_url: lesson.video_url || '',
youtube_url: lesson.youtube_url || '', youtube_url: lesson.youtube_url || '',
embed_code: lesson.embed_code || '', embed_code: lesson.embed_code || '',
m3u8_url: lesson.m3u8_url || '',
mp4_url: lesson.mp4_url || '',
video_host: lesson.video_host || 'youtube',
release_at: lesson.release_at ? lesson.release_at.split('T')[0] : '', release_at: lesson.release_at ? lesson.release_at.split('T')[0] : '',
chapters: lesson.chapters || [],
}); });
setSelectedModuleId(lesson.module_id); setSelectedModuleId(lesson.module_id);
setSelectedLessonId(lesson.id); setSelectedLessonId(lesson.id);
@@ -212,7 +235,11 @@ export default function ProductCurriculum() {
video_url: lessonForm.video_url || null, video_url: lessonForm.video_url || null,
youtube_url: lessonForm.youtube_url || null, youtube_url: lessonForm.youtube_url || null,
embed_code: lessonForm.embed_code || null, embed_code: lessonForm.embed_code || null,
m3u8_url: lessonForm.m3u8_url || null,
mp4_url: lessonForm.mp4_url || null,
video_host: lessonForm.video_host || 'youtube',
release_at: lessonForm.release_at ? new Date(lessonForm.release_at).toISOString() : null, release_at: lessonForm.release_at ? new Date(lessonForm.release_at).toISOString() : null,
chapters: lessonForm.chapters || [],
}; };
if (editingLesson) { if (editingLesson) {
@@ -543,50 +570,83 @@ export default function ProductCurriculum() {
/> />
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="space-y-2">
<Label>Video Host</Label>
<Select
value={lessonForm.video_host}
onValueChange={(value: 'youtube' | 'adilo') => setLessonForm({ ...lessonForm, video_host: value })}
>
<SelectTrigger className="border-2">
<SelectValue placeholder="Select video host" />
</SelectTrigger>
<SelectContent>
<SelectItem value="youtube">YouTube</SelectItem>
<SelectItem value="adilo">Adilo (M3U8)</SelectItem>
</SelectContent>
</Select>
</div>
{/* YouTube URL */}
{lessonForm.video_host === 'youtube' && (
<div className="space-y-2"> <div className="space-y-2">
<Label>YouTube URL (Primary)</Label> <Label>YouTube URL</Label>
<Input <Input
value={lessonForm.youtube_url} value={lessonForm.video_url}
onChange={(e) => setLessonForm({ ...lessonForm, youtube_url: e.target.value })} onChange={(e) => setLessonForm({ ...lessonForm, video_url: e.target.value })}
placeholder="https://www.youtube.com/watch?v=..." placeholder="https://www.youtube.com/watch?v=..."
className="border-2" className="border-2"
/> />
{lessonForm.youtube_url && ( <p className="text-sm text-muted-foreground">
<p className="text-xs text-green-600"> YouTube configured</p> Paste YouTube URL here
)} </p>
</div> </div>
)}
<div className="space-y-2"> {/* Adilo URLs */}
<Label>Release Date (optional)</Label> {lessonForm.video_host === 'adilo' && (
<Input <div className="space-y-4 p-4 bg-muted border-2 border-border rounded-lg">
type="date" <div className="space-y-2">
value={lessonForm.release_at} <Label>M3U8 URL (Primary)</Label>
onChange={(e) => setLessonForm({ ...lessonForm, release_at: e.target.value })} <Input
className="border-2" value={lessonForm.m3u8_url}
/> onChange={(e) => setLessonForm({ ...lessonForm, m3u8_url: e.target.value })}
placeholder="https://adilo.bigcommand.com/m3u8/..."
className="border-2 font-mono text-sm"
/>
<p className="text-sm text-muted-foreground">
HLS streaming URL from Adilo
</p>
</div>
<div className="space-y-2">
<Label>MP4 URL (Optional Fallback)</Label>
<Input
value={lessonForm.mp4_url}
onChange={(e) => setLessonForm({ ...lessonForm, mp4_url: e.target.value })}
placeholder="https://adilo.bigcommand.com/videos/..."
className="border-2 font-mono text-sm"
/>
<p className="text-sm text-muted-foreground">
Direct MP4 file for legacy browsers (optional)
</p>
</div>
</div> </div>
</div> )}
<div className="space-y-2"> <div className="space-y-2">
<Label>Embed Code (Backup)</Label> <Label>Release Date (optional)</Label>
<textarea <Input
value={lessonForm.embed_code} type="date"
onChange={(e) => setLessonForm({ ...lessonForm, embed_code: e.target.value })} value={lessonForm.release_at}
placeholder="<iframe>...</iframe>" onChange={(e) => setLessonForm({ ...lessonForm, release_at: e.target.value })}
rows={4} className="border-2"
className="w-full px-3 py-2 border-2 border-border rounded-md font-mono text-sm"
/> />
{lessonForm.embed_code && (
<p className="text-xs text-green-600"> Embed code configured</p>
)}
</div> </div>
<div className="p-4 bg-muted border-2 border-border rounded-lg"> <ChaptersEditor
<p className="text-sm text-muted-foreground"> chapters={lessonForm.chapters || []}
💡 <strong>Tip:</strong> Configure both YouTube URL and embed code for redundancy. Use product settings to toggle between sources. This setting affects ALL lessons in the bootcamp. onChange={(chapters) => setLessonForm({ ...lessonForm, chapters })}
</p> />
</div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Content</Label> <Label>Content</Label>

View File

@@ -33,8 +33,8 @@ interface ConsultingSession {
start_time: string; start_time: string;
end_time: string; end_time: string;
status: string; status: string;
recording_url: string | null;
topic_category: string | null; topic_category: string | null;
meet_link: string | null;
} }
export default function MemberAccess() { export default function MemberAccess() {
@@ -73,7 +73,7 @@ export default function MemberAccess() {
// Get completed consulting sessions with recordings // Get completed consulting sessions with recordings
supabase supabase
.from('consulting_slots') .from('consulting_slots')
.select('id, date, start_time, end_time, status, recording_url, topic_category') .select('id, date, start_time, end_time, status, topic_category, meet_link')
.eq('user_id', user!.id) .eq('user_id', user!.id)
.eq('status', 'done') .eq('status', 'done')
.order('date', { ascending: false }), .order('date', { ascending: false }),
@@ -298,16 +298,16 @@ export default function MemberAccess() {
<Clock className="w-4 h-4 ml-2" /> <Clock className="w-4 h-4 ml-2" />
<span>{session.start_time.substring(0, 5)} - {session.end_time.substring(0, 5)}</span> <span>{session.start_time.substring(0, 5)} - {session.end_time.substring(0, 5)}</span>
</div> </div>
{session.recording_url ? ( {session.meet_link ? (
<Button asChild className="shadow-sm"> <Button asChild className="shadow-sm" size="sm">
<a href={session.recording_url} target="_blank" rel="noopener noreferrer"> <a href={session.meet_link} target="_blank" rel="noopener noreferrer">
<Video className="w-4 h-4 mr-2" /> <Video className="w-4 h-4 mr-2" />
Tonton Rekaman Rekam Sesi
<ArrowRight className="w-4 h-4 ml-2" /> <ArrowRight className="w-4 h-4 ml-2" />
</a> </a>
</Button> </Button>
) : ( ) : (
<Badge className="bg-muted text-primary">Rekaman segera tersedia</Badge> <Badge className="bg-muted text-primary">Selesai</Badge>
)} )}
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -45,8 +45,8 @@ interface ConsultingSlot {
start_time: string; start_time: string;
end_time: string; end_time: string;
status: string; status: string;
product_id: string | null;
meet_link: string | null; meet_link: string | null;
topic_category?: string | null;
} }
export default function MemberDashboard() { export default function MemberDashboard() {
@@ -144,7 +144,7 @@ export default function MemberDashboard() {
// Fetch confirmed consulting slots for quick access // Fetch confirmed consulting slots for quick access
supabase supabase
.from("consulting_slots") .from("consulting_slots")
.select("id, date, start_time, end_time, status, product_id, meet_link") .select("id, date, start_time, end_time, status, meet_link, topic_category")
.eq("user_id", user!.id) .eq("user_id", user!.id)
.eq("status", "confirmed") .eq("status", "confirmed")
.order("date", { ascending: false }), .order("date", { ascending: false }),
@@ -178,10 +178,9 @@ export default function MemberDashboard() {
switch (item.product.type) { switch (item.product.type) {
case "consulting": { case "consulting": {
// Only show if user has a confirmed upcoming consulting slot for this product // Only show if user has a confirmed upcoming consulting slot
const upcomingSlot = consultingSlots.find( const upcomingSlot = consultingSlots.find(
(slot) => (slot) =>
slot.product_id === item.product.id &&
slot.status === "confirmed" && slot.status === "confirmed" &&
new Date(slot.date) >= new Date(now.setHours(0, 0, 0, 0)) new Date(slot.date) >= new Date(now.setHours(0, 0, 0, 0))
); );
@@ -350,7 +349,7 @@ export default function MemberDashboard() {
</p> </p>
</div> </div>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Badge className={order.payment_status === "paid" ? "bg-brand-accent text-white" : "bg-amber-500 text-white"} rounded-full> <Badge className={order.payment_status === "paid" ? "bg-brand-accent text-white rounded-full" : "bg-amber-500 text-white rounded-full"}>
{order.payment_status === "paid" ? "Lunas" : "Pending"} {order.payment_status === "paid" ? "Lunas" : "Pending"}
</Badge> </Badge>
<span className="font-bold">{formatIDR(order.total_amount)}</span> <span className="font-bold">{formatIDR(order.total_amount)}</span>

View File

@@ -0,0 +1,37 @@
-- Add Adilo video columns to products table (webinars)
ALTER TABLE products
ADD COLUMN IF NOT EXISTS m3u8_url TEXT,
ADD COLUMN IF NOT EXISTS mp4_url TEXT,
ADD COLUMN IF NOT EXISTS video_host TEXT DEFAULT 'youtube',
ADD COLUMN IF NOT EXISTS adilo_video_id TEXT;
-- Add Adilo video columns to bootcamp_lessons table
ALTER TABLE bootcamp_lessons
ADD COLUMN IF NOT EXISTS m3u8_url TEXT,
ADD COLUMN IF NOT EXISTS mp4_url TEXT,
ADD COLUMN IF NOT EXISTS video_host TEXT DEFAULT 'youtube',
ADD COLUMN IF NOT EXISTS adilo_video_id TEXT;
-- Add constraint to ensure valid video hosts
ALTER TABLE products
ADD CONSTRAINT products_video_host_check
CHECK (video_host IN ('youtube', 'adilo'));
ALTER TABLE bootcamp_lessons
ADD CONSTRAINT bootcamp_lessons_video_host_check
CHECK (video_host IN ('youtube', 'adilo'));
-- Create indexes for faster queries
CREATE INDEX IF NOT EXISTS idx_products_video_host ON products(video_host);
CREATE INDEX IF NOT EXISTS idx_bootcamp_lessons_video_host ON bootcamp_lessons(video_host);
-- Comments for documentation
COMMENT ON COLUMN products.m3u8_url IS 'M3U8 streaming URL from Adilo for HLS playback';
COMMENT ON COLUMN products.mp4_url IS 'MP4 fallback URL from Adilo for direct download/legacy browsers';
COMMENT ON COLUMN products.video_host IS 'Video hosting platform: youtube or adilo';
COMMENT ON COLUMN products.adilo_video_id IS 'Adilo video identifier for API reference';
COMMENT ON COLUMN bootcamp_lessons.m3u8_url IS 'M3U8 streaming URL from Adilo for HLS playback';
COMMENT ON COLUMN bootcamp_lessons.mp4_url IS 'MP4 fallback URL from Adilo for direct download/legacy browsers';
COMMENT ON COLUMN bootcamp_lessons.video_host IS 'Video hosting platform: youtube or adilo';
COMMENT ON COLUMN bootcamp_lessons.adilo_video_id IS 'Adilo video identifier for API reference';

View File

@@ -0,0 +1,94 @@
-- Fix consulting_slots table: ensure user_id column exists, backfill from orders, and add RLS policies
-- This fixes the 400 error when members try to fetch their consulting slots
-- Add user_id column if it doesn't exist
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'consulting_slots'
AND column_name = 'user_id'
) THEN
ALTER TABLE consulting_slots
ADD COLUMN user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE;
RAISE NOTICE 'user_id column added to consulting_slots';
ELSE
RAISE NOTICE 'user_id column already exists in consulting_slots';
END IF;
END $$;
-- Backfill user_id from orders for existing records
DO $$
DECLARE
backfill_count INTEGER;
null_count INTEGER;
BEGIN
-- Count NULL user_ids before backfill
SELECT COUNT(*) INTO null_count FROM consulting_slots WHERE user_id IS NULL;
RAISE NOTICE 'Found % consulting_slots with NULL user_id', null_count;
-- Backfill from orders
UPDATE consulting_slots cs
SET user_id = o.user_id
FROM orders o
WHERE cs.order_id = o.id
AND cs.user_id IS NULL;
GET DIAGNOSTICS backfill_count = ROW_COUNT;
RAISE NOTICE 'Backfilled user_id for % consulting_slots from orders', backfill_count;
-- Check remaining NULLs
SELECT COUNT(*) INTO null_count FROM consulting_slots WHERE user_id IS NULL;
RAISE NOTICE 'Remaining consulting_slots with NULL user_id: %', null_count;
END $$;
-- Create index for faster queries
CREATE INDEX IF NOT EXISTS idx_consulting_slots_user_id ON consulting_slots(user_id);
CREATE INDEX IF NOT EXISTS idx_consulting_slots_user_status ON consulting_slots(user_id, status);
-- Enable RLS on consulting_slots (if not already enabled)
ALTER TABLE consulting_slots ENABLE ROW LEVEL SECURITY;
-- Drop ALL existing policies first to avoid conflicts
DROP POLICY IF EXISTS "consulting_slots_select_own" ON consulting_slots;
DROP POLICY IF EXISTS "consulting_slots_insert_own" ON consulting_slots;
DROP POLICY IF EXISTS "consulting_slots_update_own" ON consulting_slots;
DROP POLICY IF EXISTS "consulting_slots_select_all" ON consulting_slots;
DROP POLICY IF EXISTS "consulting_slots_service_role" ON consulting_slots;
-- Create RLS policies for consulting_slots
-- Policy for users to see their own slots
CREATE POLICY "consulting_slots_select_own"
ON consulting_slots
FOR SELECT
TO authenticated
USING (auth.uid() = user_id);
-- Policy for users to insert their own slots
CREATE POLICY "consulting_slots_insert_own"
ON consulting_slots
FOR INSERT
TO authenticated
WITH CHECK (auth.uid() = user_id);
-- Policy for users to update their own slots
CREATE POLICY "consulting_slots_update_own"
ON consulting_slots
FOR UPDATE
TO authenticated
USING (auth.uid() = user_id)
WITH CHECK (auth.uid() = user_id);
-- Policy for service role (admins) to access all slots
CREATE POLICY "consulting_slots_service_role"
ON consulting_slots
FOR ALL
TO service_role
USING (true)
WITH CHECK (true);
-- Grant permissions
GRANT USAGE ON SCHEMA public TO service_role;
GRANT ALL ON consulting_slots TO service_role;
GRANT SELECT, INSERT, UPDATE, DELETE ON consulting_slots TO authenticated;

View File

@@ -0,0 +1,50 @@
-- Clean up ALL consulting_slots RLS policies and recreate with simple working policies
-- This fixes the 400 error caused by conflicting policies using has_role() function
-- Drop ALL existing policies (including the problematic ones with has_role)
DROP POLICY IF EXISTS "Users see own slots" ON consulting_slots;
DROP POLICY IF EXISTS "Admin manage slots" ON consulting_slots;
DROP POLICY IF EXISTS "Users create own slots" ON consulting_slots;
DROP POLICY IF EXISTS "consulting_slots_select_own" ON consulting_slots;
DROP POLICY IF EXISTS "consulting_slots_insert_own" ON consulting_slots;
DROP POLICY IF EXISTS "consulting_slots_update_own" ON consulting_slots;
DROP POLICY IF EXISTS "consulting_slots_select_all" ON consulting_slots;
DROP POLICY IF EXISTS "consulting_slots_service_role" ON consulting_slots;
-- Create simple, working policies
-- Users can see their own consulting slots
CREATE POLICY "Users can view own consulting slots"
ON consulting_slots
FOR SELECT
TO authenticated
USING (auth.uid() = user_id);
-- Users can insert their own consulting slots
CREATE POLICY "Users can insert own consulting slots"
ON consulting_slots
FOR INSERT
TO authenticated
WITH CHECK (auth.uid() = user_id);
-- Users can update their own consulting slots
CREATE POLICY "Users can update own consulting slots"
ON consulting_slots
FOR UPDATE
TO authenticated
USING (auth.uid() = user_id)
WITH CHECK (auth.uid() = user_id);
-- Users can delete their own consulting slots
CREATE POLICY "Users can delete own consulting slots"
ON consulting_slots
FOR DELETE
TO authenticated
USING (auth.uid() = user_id);
-- Service role (for edge functions and admin operations) can do everything
CREATE POLICY "Service role full access"
ON consulting_slots
FOR ALL
TO service_role
USING (true)
WITH CHECK (true);

View File

@@ -0,0 +1,59 @@
-- Video progress tracking table
-- Stores user's playback position for lessons and webinars
CREATE TABLE IF NOT EXISTS video_progress (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
video_id TEXT NOT NULL, -- lesson_id or webinar/product_id
video_type TEXT NOT NULL CHECK (video_type IN ('lesson', 'webinar')),
last_position DECIMAL(10,2) NOT NULL DEFAULT 0, -- seconds
total_duration DECIMAL(10,2), -- total video duration in seconds
completed BOOLEAN DEFAULT FALSE,
last_watched_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
UNIQUE(user_id, video_id, video_type)
);
-- Indexes for fast queries
CREATE INDEX IF NOT EXISTS idx_video_progress_user ON video_progress(user_id);
CREATE INDEX IF NOT EXISTS idx_video_progress_user_type ON video_progress(user_id, video_type);
CREATE INDEX IF NOT EXISTS idx_video_progress_completed ON video_progress(user_id, completed) WHERE completed = TRUE;
-- Enable RLS
ALTER TABLE video_progress ENABLE ROW LEVEL SECURITY;
-- Drop existing policies if any
DROP POLICY IF EXISTS "Users manage own progress" ON video_progress;
DROP POLICY IF EXISTS "Service role full access" ON video_progress;
-- Users can manage their own progress
CREATE POLICY "Users manage own progress"
ON video_progress
FOR ALL
TO authenticated
USING (auth.uid() = user_id)
WITH CHECK (auth.uid() = user_id);
-- Service role has full access
CREATE POLICY "Service role full access"
ON video_progress
FOR ALL
TO service_role
USING (true)
WITH CHECK (true);
-- Function to update updated_at timestamp
CREATE OR REPLACE FUNCTION update_video_progress_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Trigger to auto-update updated_at
DROP TRIGGER IF EXISTS update_video_progress_updated_at ON video_progress;
CREATE TRIGGER update_video_progress_updated_at
BEFORE UPDATE ON video_progress
FOR EACH ROW
EXECUTE FUNCTION update_video_progress_updated_at();