Display bootcamp lesson chapters on Product Detail page as marketing content

This commit implements displaying lesson chapters/timeline as marketing content
on the Product Detail page for bootcamp products, helping potential buyers
understand the detailed breakdown of what they'll learn.

## Changes

### Product Detail Page (src/pages/ProductDetail.tsx)
- Updated Lesson interface to include optional chapters property
- Modified fetchCurriculum to fetch chapters along with lessons
- Enhanced renderCurriculumPreview to display chapters as nested content under lessons
- Chapters shown with timestamps and titles, clickable to navigate to bootcamp access page
- Visual hierarchy: Module → Lesson → Chapters with proper indentation and styling

### Review System Fixes
- Fixed review prompt re-appearing after submission (before admin approval)
- Added hasSubmittedReview check to prevent showing prompt when review exists
- Fixed edit review functionality to pre-populate form with existing data
- ReviewModal now handles both INSERT (new) and UPDATE (edit) operations
- Edit resets is_approved to false requiring re-approval

### Video Player Enhancements
- Implemented Adilo/Video.js integration for M3U8/HLS playback
- Added video progress tracking with refs pattern for reliability
- Implemented chapter navigation for both Adilo and YouTube players
- Added keyboard shortcuts (Space, Arrows, F, M, J, L)
- Resume prompt for returning users with saved progress

### Database Migrations
- Added Adilo video support fields (m3u8_url, mp4_url, video_host)
- Created video_progress table for tracking user watch progress
- Fixed consulting slots user_id foreign key
- Added chapters support to products and bootcamp_lessons tables

### Documentation
- Added Adilo implementation plan and quick reference docs
- Cleaned up transcript analysis files

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
dwindown
2026-01-01 23:54:32 +07:00
parent 41f7b797e7
commit 60baf32f73
29 changed files with 3694 additions and 35048 deletions

View File

@@ -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>
);

View File

@@ -1,6 +1,10 @@
import { useEffect, useRef, useState, forwardRef, useImperativeHandle } from 'react';
import { useEffect, useRef, useState, forwardRef, useImperativeHandle, useCallback } from 'react';
import { Plyr } from 'plyr-react';
import 'plyr/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>

View File

@@ -139,7 +139,7 @@ export function ChaptersEditor({ chapters, onChange, className = '' }: ChaptersE
<div className="text-xs text-muted-foreground space-y-1 pt-2 border-t">
<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>

View File

@@ -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 })}

View File

@@ -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
View File

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

View File

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

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

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

View File

@@ -25,7 +25,6 @@ interface Product {
id: string;
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 () => {

View File

@@ -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>

View File

@@ -1,15 +1,18 @@
import { useEffect, useState, useRef } from 'react';
import { useEffect, useState, useRef, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { 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>
);
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>