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:
@@ -17,7 +17,6 @@ interface TimelineChaptersProps {
|
||||
|
||||
export function TimelineChapters({
|
||||
chapters,
|
||||
isYouTube = true,
|
||||
onChapterClick,
|
||||
currentTime = 0,
|
||||
accentColor = '#f97316',
|
||||
@@ -64,14 +63,10 @@ export function TimelineChapters({
|
||||
return (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => isYouTube && onChapterClick && onChapterClick(chapter.time)}
|
||||
disabled={!isYouTube}
|
||||
onClick={() => onChapterClick && onChapterClick(chapter.time)}
|
||||
className={`
|
||||
w-full flex items-center gap-3 p-3 rounded-lg transition-all text-left
|
||||
${isYouTube
|
||||
? 'hover:bg-muted cursor-pointer'
|
||||
: 'cursor-default'
|
||||
}
|
||||
hover:bg-muted cursor-pointer
|
||||
${active
|
||||
? `bg-primary/10 border-l-4`
|
||||
: 'border-l-4 border-transparent'
|
||||
@@ -82,7 +77,7 @@ export function TimelineChapters({
|
||||
? { borderColor: accentColor, backgroundColor: `${accentColor}10` }
|
||||
: undefined
|
||||
}
|
||||
title={isYouTube ? `Klik untuk lompat ke ${formatTime(chapter.time)}` : undefined}
|
||||
title={`Klik untuk lompat ke ${formatTime(chapter.time)}`}
|
||||
>
|
||||
{/* Timestamp */}
|
||||
<div className={`
|
||||
@@ -111,12 +106,6 @@ export function TimelineChapters({
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{!isYouTube && (
|
||||
<p className="text-xs text-muted-foreground mt-3 italic">
|
||||
Timeline hanya tersedia untuk video YouTube
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -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/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 {
|
||||
time: number; // Time in seconds
|
||||
@@ -8,13 +12,18 @@ interface VideoChapter {
|
||||
}
|
||||
|
||||
interface VideoPlayerWithChaptersProps {
|
||||
videoUrl: string;
|
||||
videoUrl?: string;
|
||||
embedCode?: string | null;
|
||||
m3u8Url?: string;
|
||||
mp4Url?: string;
|
||||
videoHost?: 'youtube' | 'adilo' | 'unknown';
|
||||
chapters?: VideoChapter[];
|
||||
accentColor?: string;
|
||||
onChapterChange?: (chapter: VideoChapter) => void;
|
||||
onTimeUpdate?: (time: number) => void;
|
||||
className?: string;
|
||||
videoId?: string; // For progress tracking
|
||||
videoType?: 'lesson' | 'webinar'; // For progress tracking
|
||||
}
|
||||
|
||||
export interface VideoPlayerRef {
|
||||
@@ -25,22 +34,87 @@ export interface VideoPlayerRef {
|
||||
export const VideoPlayerWithChapters = forwardRef<VideoPlayerRef, VideoPlayerWithChaptersProps>(({
|
||||
videoUrl,
|
||||
embedCode,
|
||||
m3u8Url,
|
||||
mp4Url,
|
||||
videoHost = 'unknown',
|
||||
chapters = [],
|
||||
accentColor,
|
||||
onChapterChange,
|
||||
onTimeUpdate,
|
||||
className = '',
|
||||
videoId,
|
||||
videoType,
|
||||
}, ref) => {
|
||||
const plyrRef = useRef<any>(null);
|
||||
const currentChapterIndexRef = useRef<number>(-1);
|
||||
const [currentChapterIndex, setCurrentChapterIndex] = useState<number>(-1);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
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
|
||||
const isYouTube = videoUrl && (
|
||||
videoUrl.includes('youtube.com') ||
|
||||
videoUrl.includes('youtu.be')
|
||||
);
|
||||
// Determine if using Adilo (M3U8) or YouTube
|
||||
const isAdilo = videoHost === 'adilo' || m3u8Url;
|
||||
const isYouTube = videoHost === 'youtube' || (videoUrl && (videoUrl.includes('youtube.com') || 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
|
||||
const getYouTubeId = (url: string): string | null => {
|
||||
@@ -109,23 +183,15 @@ export const VideoPlayerWithChapters = forwardRef<VideoPlayerRef, VideoPlayerWit
|
||||
onTimeUpdate(time);
|
||||
}
|
||||
|
||||
saveProgressDebounced(time);
|
||||
|
||||
// Find current chapter
|
||||
if (chapters.length > 0) {
|
||||
let index = chapters.findIndex((chapter, i) => {
|
||||
const nextChapter = chapters[i + 1];
|
||||
return time >= chapter.time && (!nextChapter || time < nextChapter.time);
|
||||
});
|
||||
|
||||
// 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]);
|
||||
}
|
||||
const index = findCurrentChapter(time);
|
||||
if (index !== currentChapterIndexRef.current) {
|
||||
currentChapterIndexRef.current = index;
|
||||
setCurrentChapterIndex(index);
|
||||
if (index >= 0 && onChapterChange) {
|
||||
onChapterChange(chapters[index]);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -139,22 +205,15 @@ export const VideoPlayerWithChapters = forwardRef<VideoPlayerRef, VideoPlayerWit
|
||||
onTimeUpdate(time);
|
||||
}
|
||||
|
||||
saveProgressDebounced(time);
|
||||
|
||||
// Find current chapter
|
||||
if (chapters.length > 0) {
|
||||
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) {
|
||||
index = -1;
|
||||
}
|
||||
|
||||
if (index !== currentChapterIndex) {
|
||||
setCurrentChapterIndex(index);
|
||||
if (index >= 0 && onChapterChange) {
|
||||
onChapterChange(chapters[index]);
|
||||
}
|
||||
const index = findCurrentChapter(time);
|
||||
if (index !== currentChapterIndexRef.current) {
|
||||
currentChapterIndexRef.current = index;
|
||||
setCurrentChapterIndex(index);
|
||||
if (index >= 0 && onChapterChange) {
|
||||
onChapterChange(chapters[index]);
|
||||
}
|
||||
}
|
||||
}, 500);
|
||||
@@ -166,11 +225,24 @@ export const VideoPlayerWithChapters = forwardRef<VideoPlayerRef, VideoPlayerWit
|
||||
}, 100);
|
||||
|
||||
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) => {
|
||||
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.play();
|
||||
}
|
||||
@@ -180,14 +252,204 @@ export const VideoPlayerWithChapters = forwardRef<VideoPlayerRef, VideoPlayerWit
|
||||
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
|
||||
useImperativeHandle(ref, () => ({
|
||||
jumpToTime,
|
||||
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) {
|
||||
// Custom embed (Adilo, Vimeo, etc.)
|
||||
// Custom embed (Vimeo, etc. - not Adilo anymore)
|
||||
return (
|
||||
<div
|
||||
className={`aspect-video rounded-lg overflow-hidden ${className}`}
|
||||
@@ -248,8 +510,16 @@ export const VideoPlayerWithChapters = forwardRef<VideoPlayerRef, VideoPlayerWit
|
||||
'current-time',
|
||||
'mute',
|
||||
'volume',
|
||||
'captions',
|
||||
'settings',
|
||||
'pip',
|
||||
'airplay',
|
||||
'fullscreen',
|
||||
],
|
||||
speed: {
|
||||
selected: 1,
|
||||
options: [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2],
|
||||
},
|
||||
youtube: {
|
||||
noCookie: true,
|
||||
rel: 0,
|
||||
@@ -261,6 +531,10 @@ export const VideoPlayerWithChapters = forwardRef<VideoPlayerRef, VideoPlayerWit
|
||||
fs: 0,
|
||||
},
|
||||
hideControls: false,
|
||||
keyboardShortcuts: {
|
||||
focused: true,
|
||||
global: true,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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">
|
||||
<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>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
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 { Plus, Pencil, Trash2, ChevronUp, ChevronDown, GripVertical } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -29,6 +30,11 @@ interface Lesson {
|
||||
title: string;
|
||||
content: 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;
|
||||
release_at: string | null;
|
||||
chapters?: VideoChapter[];
|
||||
@@ -54,6 +60,11 @@ export function CurriculumEditor({ productId }: CurriculumEditorProps) {
|
||||
title: '',
|
||||
content: '',
|
||||
video_url: '',
|
||||
youtube_url: '',
|
||||
embed_code: '',
|
||||
m3u8_url: '',
|
||||
mp4_url: '',
|
||||
video_host: 'youtube' as 'youtube' | 'adilo' | 'unknown',
|
||||
release_at: '',
|
||||
chapters: [] as VideoChapter[],
|
||||
});
|
||||
@@ -73,7 +84,7 @@ export function CurriculumEditor({ productId }: CurriculumEditorProps) {
|
||||
.order('position'),
|
||||
supabase
|
||||
.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'),
|
||||
]);
|
||||
|
||||
@@ -177,6 +188,11 @@ export function CurriculumEditor({ productId }: CurriculumEditorProps) {
|
||||
title: '',
|
||||
content: '',
|
||||
video_url: '',
|
||||
youtube_url: '',
|
||||
embed_code: '',
|
||||
m3u8_url: '',
|
||||
mp4_url: '',
|
||||
video_host: 'youtube',
|
||||
release_at: '',
|
||||
});
|
||||
setLessonDialogOpen(true);
|
||||
@@ -189,6 +205,11 @@ export function CurriculumEditor({ productId }: CurriculumEditorProps) {
|
||||
title: lesson.title,
|
||||
content: lesson.content || '',
|
||||
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] : '',
|
||||
chapters: lesson.chapters || [],
|
||||
});
|
||||
@@ -206,6 +227,11 @@ export function CurriculumEditor({ productId }: CurriculumEditorProps) {
|
||||
title: lessonForm.title,
|
||||
content: lessonForm.content || 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,
|
||||
chapters: lessonForm.chapters || [],
|
||||
};
|
||||
@@ -443,15 +469,70 @@ export function CurriculumEditor({ productId }: CurriculumEditorProps) {
|
||||
className="border-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Video URL</Label>
|
||||
<Input
|
||||
value={lessonForm.video_url}
|
||||
onChange={(e) => setLessonForm({ ...lessonForm, video_url: e.target.value })}
|
||||
placeholder="https://youtube.com/... or https://vimeo.com/..."
|
||||
className="border-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">
|
||||
<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
|
||||
chapters={lessonForm.chapters || []}
|
||||
onChange={(chapters) => setLessonForm({ ...lessonForm, chapters })}
|
||||
|
||||
@@ -15,6 +15,12 @@ interface ReviewModalProps {
|
||||
orderId?: string | null;
|
||||
type: 'consulting' | 'bootcamp' | 'webinar' | 'general';
|
||||
contextLabel?: string;
|
||||
existingReview?: {
|
||||
id: string;
|
||||
rating: number;
|
||||
title?: string;
|
||||
body?: string;
|
||||
};
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
@@ -26,6 +32,7 @@ export function ReviewModal({
|
||||
orderId,
|
||||
type,
|
||||
contextLabel,
|
||||
existingReview,
|
||||
onSuccess,
|
||||
}: ReviewModalProps) {
|
||||
const [rating, setRating] = useState(0);
|
||||
@@ -34,6 +41,20 @@ export function ReviewModal({
|
||||
const [body, setBody] = useState('');
|
||||
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 () => {
|
||||
if (rating === 0) {
|
||||
toast({ title: 'Error', description: 'Pilih rating terlebih dahulu', variant: 'destructive' });
|
||||
@@ -45,22 +66,46 @@ export function ReviewModal({
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
const { error } = 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,
|
||||
});
|
||||
|
||||
let error;
|
||||
|
||||
if (existingReview) {
|
||||
// Update existing review
|
||||
const result = await supabase
|
||||
.from('reviews')
|
||||
.update({
|
||||
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) {
|
||||
console.error('Review submit error:', error);
|
||||
toast({ title: 'Error', description: 'Gagal mengirim ulasan', variant: 'destructive' });
|
||||
} 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
|
||||
setRating(0);
|
||||
setTitle('');
|
||||
@@ -81,7 +126,7 @@ export function ReviewModal({
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Beri Ulasan</DialogTitle>
|
||||
<DialogTitle>{existingReview ? 'Edit Ulasan' : 'Beri Ulasan'}</DialogTitle>
|
||||
{contextLabel && (
|
||||
<DialogDescription>{contextLabel}</DialogDescription>
|
||||
)}
|
||||
@@ -140,7 +185,7 @@ export function ReviewModal({
|
||||
Batal
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={submitting}>
|
||||
{submitting ? 'Mengirim...' : 'Kirim Ulasan'}
|
||||
{submitting ? 'Menyimpan...' : (existingReview ? 'Simpan Perubahan' : 'Kirim Ulasan')}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
352
src/hooks/useAdiloPlayer.ts
Normal file
352
src/hooks/useAdiloPlayer.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
128
src/hooks/useVideoProgress.ts
Normal file
128
src/hooks/useVideoProgress.ts
Normal 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
43
src/lib/adiloHelper.ts
Normal 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';
|
||||
};
|
||||
@@ -25,7 +25,6 @@ interface Product {
|
||||
id: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
video_source?: string;
|
||||
}
|
||||
|
||||
interface Module {
|
||||
@@ -42,6 +41,9 @@ interface Lesson {
|
||||
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';
|
||||
duration_seconds: number | null;
|
||||
position: number;
|
||||
release_at: string | null;
|
||||
@@ -91,7 +93,7 @@ export default function Bootcamp() {
|
||||
const checkAccessAndFetch = async () => {
|
||||
const { data: productData, error: productError } = await supabase
|
||||
.from('products')
|
||||
.select('id, title, slug, video_source')
|
||||
.select('id, title, slug')
|
||||
.eq('slug', slug)
|
||||
.eq('type', 'bootcamp')
|
||||
.maybeSingle();
|
||||
@@ -140,6 +142,9 @@ export default function Bootcamp() {
|
||||
video_url,
|
||||
youtube_url,
|
||||
embed_code,
|
||||
m3u8_url,
|
||||
mp4_url,
|
||||
video_host,
|
||||
duration_seconds,
|
||||
position,
|
||||
release_at,
|
||||
@@ -283,12 +288,39 @@ export default function Bootcamp() {
|
||||
};
|
||||
|
||||
const VideoPlayer = ({ lesson }: { lesson: Lesson }) => {
|
||||
const activeSource = product?.video_source || 'youtube';
|
||||
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 = () => {
|
||||
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()) {
|
||||
return {
|
||||
type: 'youtube',
|
||||
@@ -302,32 +334,14 @@ export default function Bootcamp() {
|
||||
url: 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();
|
||||
@@ -355,7 +369,6 @@ export default function Bootcamp() {
|
||||
<div className="mt-4">
|
||||
<TimelineChapters
|
||||
chapters={lesson.chapters}
|
||||
isYouTube={false}
|
||||
currentTime={currentTime}
|
||||
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 isAdilo = video.type === 'adilo';
|
||||
|
||||
return (
|
||||
<div className={hasChapters ? "grid grid-cols-1 lg:grid-cols-3 gap-6 mb-6" : "mb-6"}>
|
||||
<div className={hasChapters ? "lg:col-span-2" : ""}>
|
||||
<VideoPlayerWithChapters
|
||||
ref={playerRef}
|
||||
videoUrl={video.url}
|
||||
embedCode={lesson.embed_code}
|
||||
videoUrl={isYouTube ? video.url : undefined}
|
||||
m3u8Url={isAdilo ? video.m3u8Url : undefined}
|
||||
mp4Url={isAdilo ? video.mp4Url : undefined}
|
||||
videoHost={isAdilo ? 'adilo' : isYouTube ? 'youtube' : 'unknown'}
|
||||
chapters={lesson.chapters}
|
||||
accentColor={accentColor}
|
||||
onTimeUpdate={setCurrentTime}
|
||||
videoId={lesson.id}
|
||||
videoType="lesson"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -385,7 +403,6 @@ export default function Bootcamp() {
|
||||
<div className="lg:col-span-1">
|
||||
<TimelineChapters
|
||||
chapters={lesson.chapters}
|
||||
isYouTube={isYouTube}
|
||||
onChapterClick={(time) => {
|
||||
if (playerRef.current) {
|
||||
playerRef.current.jumpToTime(time);
|
||||
@@ -704,6 +721,12 @@ export default function Bootcamp() {
|
||||
productId={product.id}
|
||||
type="bootcamp"
|
||||
contextLabel={product.title}
|
||||
existingReview={userReview ? {
|
||||
id: userReview.id,
|
||||
rating: userReview.rating,
|
||||
title: userReview.title,
|
||||
body: userReview.body,
|
||||
} : undefined}
|
||||
onSuccess={() => {
|
||||
// Refresh review data
|
||||
const refreshReview = async () => {
|
||||
|
||||
@@ -26,8 +26,12 @@ interface Product {
|
||||
sale_price: number | null;
|
||||
meeting_link: string | null;
|
||||
recording_url: string | null;
|
||||
m3u8_url: string | null;
|
||||
mp4_url: string | null;
|
||||
video_host: 'youtube' | 'adilo' | 'unknown' | null;
|
||||
event_start: string | null;
|
||||
duration_minutes: number | null;
|
||||
chapters?: { time: number; title: string; }[];
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
@@ -43,6 +47,7 @@ interface Lesson {
|
||||
title: string;
|
||||
duration_seconds: number | null;
|
||||
position: number;
|
||||
chapters?: { time: number; title: string; }[];
|
||||
}
|
||||
|
||||
interface UserReview {
|
||||
@@ -105,7 +110,7 @@ export default function ProductDetail() {
|
||||
|
||||
const fetchCurriculum = async () => {
|
||||
if (!product) return;
|
||||
|
||||
|
||||
const { data: modulesData } = await supabase
|
||||
.from('bootcamp_modules')
|
||||
.select(`
|
||||
@@ -116,7 +121,8 @@ export default function ProductDetail() {
|
||||
id,
|
||||
title,
|
||||
duration_seconds,
|
||||
position
|
||||
position,
|
||||
chapters
|
||||
)
|
||||
`)
|
||||
.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 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 youtubeMatch = url.match(/(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/)([^&\s]+)/);
|
||||
if (youtubeMatch) return `https://www.youtube.com/embed/${youtubeMatch[1]}`;
|
||||
@@ -268,20 +311,25 @@ export default function ProductDetail() {
|
||||
if (product.recording_url) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="aspect-video bg-muted rounded-none overflow-hidden border-2 border-border">
|
||||
<iframe
|
||||
src={getVideoEmbed(product.recording_url)}
|
||||
className="w-full h-full"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowFullScreen
|
||||
/>
|
||||
</div>
|
||||
<Button asChild variant="outline" className="border-2">
|
||||
<a href={product.recording_url} target="_blank" rel="noopener noreferrer">
|
||||
<Video className="w-4 h-4 mr-2" />
|
||||
Tonton Rekaman
|
||||
</a>
|
||||
</Button>
|
||||
<Card className="border-2 border-primary/20 bg-primary/5">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="rounded-full bg-primary/10 p-3">
|
||||
<Play className="w-6 h-6 text-primary" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-lg mb-1">Rekaman webinar tersedia</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Akses rekaman webinar kapan saja. Pelajari materi sesuai kecepatan Anda.
|
||||
</p>
|
||||
<Button onClick={() => navigate(`/webinar/${product.slug}`)} size="lg">
|
||||
<Video className="w-4 h-4 mr-2" />
|
||||
Tonton Sekarang
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -352,15 +400,36 @@ export default function ProductDetail() {
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
<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) => (
|
||||
<div key={lesson.id} className="flex items-center justify-between py-1 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<Play className="w-3 h-3 text-muted-foreground" />
|
||||
<span>{lesson.title}</span>
|
||||
<div key={lesson.id} className="space-y-2">
|
||||
{/* Lesson header */}
|
||||
<div className="flex items-center justify-between py-1 text-sm">
|
||||
<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>
|
||||
{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>
|
||||
))}
|
||||
@@ -378,6 +447,39 @@ export default function ProductDetail() {
|
||||
<AppLayout>
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<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>
|
||||
<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() && (
|
||||
<Badge className="bg-muted text-primary">Telah Lewat</Badge>
|
||||
)}
|
||||
{hasAccess && <Badge className="bg-primary text-primary-foreground">Anda memiliki akses</Badge>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
@@ -424,7 +525,7 @@ export default function ProductDetail() {
|
||||
{product.content && (
|
||||
<Card className="border-2 border-border mb-6">
|
||||
<CardContent className="pt-6">
|
||||
<div
|
||||
<div
|
||||
className="prose max-w-none"
|
||||
dangerouslySetInnerHTML={{ __html: product.content }}
|
||||
/>
|
||||
@@ -432,6 +533,8 @@ export default function ProductDetail() {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{renderWebinarChapters()}
|
||||
|
||||
<div className="flex gap-4 flex-wrap">
|
||||
{renderActionButtons()}
|
||||
</div>
|
||||
|
||||
@@ -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 { supabase } from '@/integrations/supabase/client';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { useVideoProgress } from '@/hooks/useVideoProgress';
|
||||
import { AppLayout } from '@/components/AppLayout';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
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 { TimelineChapters } from '@/components/TimelineChapters';
|
||||
import { ReviewModal } from '@/components/reviews/ReviewModal';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
interface VideoChapter {
|
||||
time: number;
|
||||
@@ -21,10 +24,22 @@ interface Product {
|
||||
title: string;
|
||||
slug: string;
|
||||
recording_url: string | null;
|
||||
m3u8_url?: string | null;
|
||||
mp4_url?: string | null;
|
||||
video_host?: 'youtube' | 'adilo' | 'unknown';
|
||||
description: string | null;
|
||||
chapters?: VideoChapter[];
|
||||
}
|
||||
|
||||
interface UserReview {
|
||||
id: string;
|
||||
rating: number;
|
||||
title?: string;
|
||||
body?: string;
|
||||
is_approved: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export default function WebinarRecording() {
|
||||
const { slug } = useParams<{ slug: string }>();
|
||||
const navigate = useNavigate();
|
||||
@@ -34,6 +49,8 @@ export default function WebinarRecording() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [accentColor, setAccentColor] = useState<string>('');
|
||||
const [userReview, setUserReview] = useState<UserReview | null>(null);
|
||||
const [reviewModalOpen, setReviewModalOpen] = useState(false);
|
||||
const playerRef = useRef<VideoPlayerRef>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -47,7 +64,7 @@ export default function WebinarRecording() {
|
||||
const checkAccessAndFetch = async () => {
|
||||
const { data: productData, error: productError } = await supabase
|
||||
.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('type', 'webinar')
|
||||
.maybeSingle();
|
||||
@@ -103,23 +120,60 @@ export default function WebinarRecording() {
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
|
||||
// Check if user has already reviewed this webinar
|
||||
checkUserReview();
|
||||
};
|
||||
|
||||
const isYouTube = product?.recording_url && (
|
||||
product.recording_url.includes('youtube.com') ||
|
||||
product.recording_url.includes('youtu.be')
|
||||
const checkUserReview = async () => {
|
||||
if (!product || !user) return;
|
||||
|
||||
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
|
||||
if (playerRef.current && playerRef.current.jumpToTime) {
|
||||
playerRef.current.jumpToTime(time);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleTimeUpdate = (time: number) => {
|
||||
const handleTimeUpdate = useCallback((time: number) => {
|
||||
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) {
|
||||
return (
|
||||
@@ -138,69 +192,177 @@ export default function WebinarRecording() {
|
||||
|
||||
return (
|
||||
<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">
|
||||
<ChevronLeft className="w-4 h-4 mr-2" />
|
||||
Kembali ke Dashboard
|
||||
</Button>
|
||||
|
||||
<Card className="border-2 border-border">
|
||||
<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>
|
||||
<h1 className="text-3xl md:text-4xl font-bold mb-6">{product.title}</h1>
|
||||
|
||||
{/* Timeline Chapters */}
|
||||
{hasChapters && (
|
||||
<div className="lg:col-span-1">
|
||||
<TimelineChapters
|
||||
chapters={product.chapters}
|
||||
isYouTube={isYouTube}
|
||||
onChapterClick={handleChapterClick}
|
||||
currentTime={currentTime}
|
||||
accentColor={accentColor}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Video Player */}
|
||||
<div className="mb-6">
|
||||
{(product.recording_url || product.m3u8_url) && (
|
||||
<VideoPlayerWithChapters
|
||||
ref={playerRef}
|
||||
videoUrl={product.recording_url || undefined}
|
||||
m3u8Url={product.m3u8_url || undefined}
|
||||
mp4Url={product.mp4_url || undefined}
|
||||
videoHost={detectedVideoHost}
|
||||
chapters={product.chapters}
|
||||
accentColor={accentColor}
|
||||
onTimeUpdate={handleTimeUpdate}
|
||||
videoId={product.id}
|
||||
videoType="webinar"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{product.description && (
|
||||
{/* Timeline Chapters - video track for navigation */}
|
||||
{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 dangerouslySetInnerHTML={{ __html: product.description }} />
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Instructions */}
|
||||
<Card className="bg-muted border-2 border-border">
|
||||
<CardContent className="pt-6">
|
||||
<h3 className="font-semibold mb-2 flex items-center gap-2">
|
||||
<Play className="w-5 h-5" />
|
||||
Panduan Menonton
|
||||
</h3>
|
||||
<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>Anda dapat memutar ulang video kapan saja</li>
|
||||
<li>Pastikan koneksi internet stabil untuk pengalaman menonton yang lancar</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* Instructions */}
|
||||
<Card className="bg-muted border-2 border-border mb-6">
|
||||
<CardContent className="pt-6">
|
||||
<h3 className="font-semibold mb-2 flex items-center gap-2">
|
||||
<Play className="w-5 h-5" />
|
||||
Panduan Menonton
|
||||
</h3>
|
||||
<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>Anda dapat memutar ulang video kapan saja</li>
|
||||
<li>Pastikan koneksi internet stabil untuk pengalaman menonton yang lancar</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</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>
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -36,12 +36,14 @@ interface Product {
|
||||
content: string;
|
||||
meeting_link: string | null;
|
||||
recording_url: string | null;
|
||||
m3u8_url?: string | null;
|
||||
mp4_url?: string | null;
|
||||
video_host?: 'youtube' | 'adilo' | 'unknown';
|
||||
event_start: string | null;
|
||||
duration_minutes: number | null;
|
||||
price: number;
|
||||
sale_price: number | null;
|
||||
is_active: boolean;
|
||||
video_source?: string;
|
||||
chapters?: VideoChapter[];
|
||||
}
|
||||
|
||||
@@ -53,12 +55,14 @@ const emptyProduct = {
|
||||
content: '',
|
||||
meeting_link: '',
|
||||
recording_url: '',
|
||||
m3u8_url: '',
|
||||
mp4_url: '',
|
||||
video_host: 'youtube' as 'youtube' | 'adilo' | 'unknown',
|
||||
event_start: null as string | null,
|
||||
duration_minutes: null as number | null,
|
||||
price: 0,
|
||||
sale_price: null as number | null,
|
||||
is_active: true,
|
||||
video_source: 'youtube' as string,
|
||||
chapters: [] as VideoChapter[],
|
||||
};
|
||||
|
||||
@@ -84,7 +88,10 @@ export default function AdminProducts() {
|
||||
}, [user, isAdmin, authLoading]);
|
||||
|
||||
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);
|
||||
setLoading(false);
|
||||
};
|
||||
@@ -121,12 +128,14 @@ export default function AdminProducts() {
|
||||
content: product.content || '',
|
||||
meeting_link: product.meeting_link || '',
|
||||
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,
|
||||
duration_minutes: product.duration_minutes,
|
||||
price: product.price,
|
||||
sale_price: product.sale_price,
|
||||
is_active: product.is_active,
|
||||
video_source: product.video_source || 'youtube',
|
||||
chapters: product.chapters || [],
|
||||
});
|
||||
setDialogOpen(true);
|
||||
@@ -152,12 +161,14 @@ export default function AdminProducts() {
|
||||
content: form.content,
|
||||
meeting_link: form.meeting_link || 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,
|
||||
duration_minutes: form.duration_minutes || null,
|
||||
price: form.price,
|
||||
sale_price: form.sale_price || null,
|
||||
is_active: form.is_active,
|
||||
video_source: form.video_source || 'youtube',
|
||||
chapters: form.chapters || [],
|
||||
};
|
||||
|
||||
@@ -461,18 +472,73 @@ export default function AdminProducts() {
|
||||
<Label>Konten</Label>
|
||||
<RichTextEditor content={form.content} onChange={(v) => setForm({ ...form, content: v })} />
|
||||
</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' && (
|
||||
<>
|
||||
<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="space-y-2">
|
||||
<Label>Tanggal & Waktu Webinar</Label>
|
||||
@@ -523,10 +589,10 @@ export default function AdminProducts() {
|
||||
<RadioGroupItem value="embed" id="embed" />
|
||||
<div className="flex-1">
|
||||
<Label htmlFor="embed" className="font-medium cursor-pointer">
|
||||
Custom Embed (Backup)
|
||||
Adilo (Backup)
|
||||
</Label>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -535,7 +601,7 @@ export default function AdminProducts() {
|
||||
<Alert>
|
||||
<Info className="h-4 w-4" />
|
||||
<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>
|
||||
</Alert>
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,8 @@ import { Plus, Pencil, Trash2, ChevronUp, ChevronDown, GripVertical, ArrowLeft,
|
||||
import { cn } from '@/lib/utils';
|
||||
import { RichTextEditor } from '@/components/RichTextEditor';
|
||||
import { AppLayout } from '@/components/AppLayout';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { ChaptersEditor } from '@/components/admin/ChaptersEditor';
|
||||
|
||||
interface Module {
|
||||
id: string;
|
||||
@@ -17,6 +19,11 @@ interface Module {
|
||||
position: number;
|
||||
}
|
||||
|
||||
interface VideoChapter {
|
||||
time: number;
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface Lesson {
|
||||
id: string;
|
||||
module_id: string;
|
||||
@@ -25,8 +32,12 @@ interface Lesson {
|
||||
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;
|
||||
release_at: string | null;
|
||||
chapters?: VideoChapter[];
|
||||
}
|
||||
|
||||
export default function ProductCurriculum() {
|
||||
@@ -52,7 +63,11 @@ export default function ProductCurriculum() {
|
||||
video_url: '',
|
||||
youtube_url: '',
|
||||
embed_code: '',
|
||||
m3u8_url: '',
|
||||
mp4_url: '',
|
||||
video_host: 'youtube' as 'youtube' | 'adilo' | 'unknown',
|
||||
release_at: '',
|
||||
chapters: [] as VideoChapter[],
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -67,7 +82,7 @@ export default function ProductCurriculum() {
|
||||
const [productRes, modulesRes, lessonsRes] = await Promise.all([
|
||||
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_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) {
|
||||
@@ -176,7 +191,11 @@ export default function ProductCurriculum() {
|
||||
video_url: '',
|
||||
youtube_url: '',
|
||||
embed_code: '',
|
||||
m3u8_url: '',
|
||||
mp4_url: '',
|
||||
video_host: 'youtube',
|
||||
release_at: '',
|
||||
chapters: [],
|
||||
});
|
||||
setSelectedModuleId(moduleId);
|
||||
setSelectedLessonId('new');
|
||||
@@ -191,7 +210,11 @@ export default function ProductCurriculum() {
|
||||
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] : '',
|
||||
chapters: lesson.chapters || [],
|
||||
});
|
||||
setSelectedModuleId(lesson.module_id);
|
||||
setSelectedLessonId(lesson.id);
|
||||
@@ -212,7 +235,11 @@ export default function ProductCurriculum() {
|
||||
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,
|
||||
chapters: lessonForm.chapters || [],
|
||||
};
|
||||
|
||||
if (editingLesson) {
|
||||
@@ -543,50 +570,83 @@ export default function ProductCurriculum() {
|
||||
/>
|
||||
</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">
|
||||
<Label>YouTube URL (Primary)</Label>
|
||||
<Label>YouTube URL</Label>
|
||||
<Input
|
||||
value={lessonForm.youtube_url}
|
||||
onChange={(e) => setLessonForm({ ...lessonForm, youtube_url: e.target.value })}
|
||||
value={lessonForm.video_url}
|
||||
onChange={(e) => setLessonForm({ ...lessonForm, video_url: e.target.value })}
|
||||
placeholder="https://www.youtube.com/watch?v=..."
|
||||
className="border-2"
|
||||
/>
|
||||
{lessonForm.youtube_url && (
|
||||
<p className="text-xs text-green-600">✓ YouTube configured</p>
|
||||
)}
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Paste YouTube URL here
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Release Date (optional)</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={lessonForm.release_at}
|
||||
onChange={(e) => setLessonForm({ ...lessonForm, release_at: e.target.value })}
|
||||
className="border-2"
|
||||
/>
|
||||
{/* 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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Embed Code (Backup)</Label>
|
||||
<textarea
|
||||
value={lessonForm.embed_code}
|
||||
onChange={(e) => setLessonForm({ ...lessonForm, embed_code: e.target.value })}
|
||||
placeholder="<iframe>...</iframe>"
|
||||
rows={4}
|
||||
className="w-full px-3 py-2 border-2 border-border rounded-md font-mono text-sm"
|
||||
<Label>Release Date (optional)</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={lessonForm.release_at}
|
||||
onChange={(e) => setLessonForm({ ...lessonForm, release_at: e.target.value })}
|
||||
className="border-2"
|
||||
/>
|
||||
{lessonForm.embed_code && (
|
||||
<p className="text-xs text-green-600">✓ Embed code configured</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-muted border-2 border-border rounded-lg">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
💡 <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.
|
||||
</p>
|
||||
</div>
|
||||
<ChaptersEditor
|
||||
chapters={lessonForm.chapters || []}
|
||||
onChange={(chapters) => setLessonForm({ ...lessonForm, chapters })}
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Content</Label>
|
||||
|
||||
@@ -33,8 +33,8 @@ interface ConsultingSession {
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
status: string;
|
||||
recording_url: string | null;
|
||||
topic_category: string | null;
|
||||
meet_link: string | null;
|
||||
}
|
||||
|
||||
export default function MemberAccess() {
|
||||
@@ -73,7 +73,7 @@ export default function MemberAccess() {
|
||||
// Get completed consulting sessions with recordings
|
||||
supabase
|
||||
.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('status', 'done')
|
||||
.order('date', { ascending: false }),
|
||||
@@ -298,16 +298,16 @@ export default function MemberAccess() {
|
||||
<Clock className="w-4 h-4 ml-2" />
|
||||
<span>{session.start_time.substring(0, 5)} - {session.end_time.substring(0, 5)}</span>
|
||||
</div>
|
||||
{session.recording_url ? (
|
||||
<Button asChild className="shadow-sm">
|
||||
<a href={session.recording_url} target="_blank" rel="noopener noreferrer">
|
||||
{session.meet_link ? (
|
||||
<Button asChild className="shadow-sm" size="sm">
|
||||
<a href={session.meet_link} target="_blank" rel="noopener noreferrer">
|
||||
<Video className="w-4 h-4 mr-2" />
|
||||
Tonton Rekaman
|
||||
Rekam Sesi
|
||||
<ArrowRight className="w-4 h-4 ml-2" />
|
||||
</a>
|
||||
</Button>
|
||||
) : (
|
||||
<Badge className="bg-muted text-primary">Rekaman segera tersedia</Badge>
|
||||
<Badge className="bg-muted text-primary">Selesai</Badge>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -45,8 +45,8 @@ interface ConsultingSlot {
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
status: string;
|
||||
product_id: string | null;
|
||||
meet_link: string | null;
|
||||
topic_category?: string | null;
|
||||
}
|
||||
|
||||
export default function MemberDashboard() {
|
||||
@@ -144,7 +144,7 @@ export default function MemberDashboard() {
|
||||
// Fetch confirmed consulting slots for quick access
|
||||
supabase
|
||||
.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("status", "confirmed")
|
||||
.order("date", { ascending: false }),
|
||||
@@ -178,10 +178,9 @@ export default function MemberDashboard() {
|
||||
|
||||
switch (item.product.type) {
|
||||
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(
|
||||
(slot) =>
|
||||
slot.product_id === item.product.id &&
|
||||
slot.status === "confirmed" &&
|
||||
new Date(slot.date) >= new Date(now.setHours(0, 0, 0, 0))
|
||||
);
|
||||
@@ -350,7 +349,7 @@ export default function MemberDashboard() {
|
||||
</p>
|
||||
</div>
|
||||
<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"}
|
||||
</Badge>
|
||||
<span className="font-bold">{formatIDR(order.total_amount)}</span>
|
||||
|
||||
Reference in New Issue
Block a user