);
}
- // YouTube or other URL-based videos
+ // YouTube with chapters support
+ const isYouTube = video.type === 'youtube';
+
return (
-
-
+
+
+
+
+
+ {hasChapters && (
+
+ {
+ if (playerRef.current) {
+ playerRef.current.jumpToTime(time);
+ }
+ }}
+ currentTime={currentTime}
+ accentColor={accentColor}
+ />
+
+ )}
);
};
diff --git a/src/pages/WebinarRecording.tsx b/src/pages/WebinarRecording.tsx
index 7dd7e25..05b45b8 100644
--- a/src/pages/WebinarRecording.tsx
+++ b/src/pages/WebinarRecording.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useState } from 'react';
+import { useEffect, useState, useRef } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { supabase } from '@/integrations/supabase/client';
import { useAuth } from '@/hooks/useAuth';
@@ -8,6 +8,13 @@ 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 { VideoPlayerWithChapters, VideoPlayerRef } from '@/components/VideoPlayerWithChapters';
+import { TimelineChapters } from '@/components/TimelineChapters';
+
+interface VideoChapter {
+ time: number;
+ title: string;
+}
interface Product {
id: string;
@@ -15,6 +22,7 @@ interface Product {
slug: string;
recording_url: string | null;
description: string | null;
+ chapters?: VideoChapter[];
}
export default function WebinarRecording() {
@@ -24,6 +32,9 @@ export default function WebinarRecording() {
const [product, setProduct] = useState
(null);
const [loading, setLoading] = useState(true);
+ const [currentTime, setCurrentTime] = useState(0);
+ const [accentColor, setAccentColor] = useState('#f97316');
+ const playerRef = useRef(null);
useEffect(() => {
if (!authLoading && !user) {
@@ -36,7 +47,7 @@ export default function WebinarRecording() {
const checkAccessAndFetch = async () => {
const { data: productData, error: productError } = await supabase
.from('products')
- .select('id, title, slug, recording_url, description')
+ .select('id, title, slug, recording_url, description, chapters')
.eq('slug', slug)
.eq('type', 'webinar')
.maybeSingle();
@@ -55,6 +66,16 @@ export default function WebinarRecording() {
return;
}
+ // Fetch accent color from settings
+ const { data: settings } = await supabase
+ .from('site_settings')
+ .select('brand_accent_color')
+ .single();
+
+ if (settings?.brand_accent_color) {
+ setAccentColor(settings.brand_accent_color);
+ }
+
// Check access via user_access or paid orders
const [accessRes, paidOrdersRes] = await Promise.all([
supabase
@@ -84,27 +105,20 @@ export default function WebinarRecording() {
setLoading(false);
};
- const getVideoEmbed = (url: string) => {
- // YouTube
- const youtubeMatch = url.match(/(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/)([^&\s]+)/);
- if (youtubeMatch) return `https://www.youtube.com/embed/${youtubeMatch[1]}`;
+ const isYouTube = product?.recording_url && (
+ product.recording_url.includes('youtube.com') ||
+ product.recording_url.includes('youtu.be')
+ );
- // Vimeo
- const vimeoMatch = url.match(/vimeo\.com\/(\d+)/);
- if (vimeoMatch) return `https://player.vimeo.com/video/${vimeoMatch[1]}`;
-
- // Google Drive
- const driveMatch = url.match(/drive\.google\.com\/file\/d\/([^\/]+)/);
- if (driveMatch) return `https://drive.google.com/file/d/${driveMatch[1]}/preview`;
-
- // Direct MP4 or other video files
- if (url.match(/\.(mp4|webm|ogg)$/i)) return url;
-
- return url;
+ const handleChapterClick = (time: number) => {
+ // VideoPlayerWithChapters will handle the jump
+ if (playerRef.current && playerRef.current.jumpToTime) {
+ playerRef.current.jumpToTime(time);
+ }
};
- const isDirectVideo = (url: string) => {
- return url.match(/\.(mp4|webm|ogg)$/i) || url.includes('drive.google.com');
+ const handleTimeUpdate = (time: number) => {
+ setCurrentTime(time);
};
if (authLoading || loading) {
@@ -120,7 +134,7 @@ export default function WebinarRecording() {
if (!product) return null;
- const embedUrl = product.recording_url ? getVideoEmbed(product.recording_url) : null;
+ const hasChapters = product.chapters && product.chapters.length > 0;
return (
@@ -135,29 +149,33 @@ export default function WebinarRecording() {
{product.title}
- {/* Video Embed */}
- {embedUrl && (
-
- {isDirectVideo(embedUrl) ? (
-
-
- Browser Anda tidak mendukung pemutaran video.
-
- ) : (
-
{/* Description */}
{product.description && (
diff --git a/src/pages/admin/AdminProducts.tsx b/src/pages/admin/AdminProducts.tsx
index c584c6d..735c008 100644
--- a/src/pages/admin/AdminProducts.tsx
+++ b/src/pages/admin/AdminProducts.tsx
@@ -20,6 +20,12 @@ import { formatIDR } from '@/lib/format';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Info } from 'lucide-react';
+import { ChaptersEditor } from '@/components/admin/ChaptersEditor';
+
+interface VideoChapter {
+ time: number;
+ title: string;
+}
interface Product {
id: string;
@@ -36,6 +42,7 @@ interface Product {
sale_price: number | null;
is_active: boolean;
video_source?: string;
+ chapters?: VideoChapter[];
}
const emptyProduct = {
@@ -52,6 +59,7 @@ const emptyProduct = {
sale_price: null as number | null,
is_active: true,
video_source: 'youtube' as string,
+ chapters: [] as VideoChapter[],
};
export default function AdminProducts() {
@@ -119,6 +127,7 @@ export default function AdminProducts() {
sale_price: product.sale_price,
is_active: product.is_active,
video_source: product.video_source || 'youtube',
+ chapters: product.chapters || [],
});
setDialogOpen(true);
};
@@ -149,6 +158,7 @@ export default function AdminProducts() {
sale_price: form.sale_price || null,
is_active: form.is_active,
video_source: form.video_source || 'youtube',
+ chapters: form.chapters || [],
};
if (editingProduct) {
@@ -462,27 +472,33 @@ export default function AdminProducts() {