Add webinar recording page with embedded video player
Changes: - Create WebinarRecording page with embedded video player - Supports YouTube, Vimeo, Google Drive, and direct MP4 - Check access via user_access or paid orders - Update webinar recording buttons to navigate to page instead of new tab - Add route /webinar/:slug This keeps users on the platform for better UX instead of redirecting to external video sites. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,7 @@ import Products from "./pages/Products";
|
|||||||
import ProductDetail from "./pages/ProductDetail";
|
import ProductDetail from "./pages/ProductDetail";
|
||||||
import Checkout from "./pages/Checkout";
|
import Checkout from "./pages/Checkout";
|
||||||
import Bootcamp from "./pages/Bootcamp";
|
import Bootcamp from "./pages/Bootcamp";
|
||||||
|
import WebinarRecording from "./pages/WebinarRecording";
|
||||||
import Events from "./pages/Events";
|
import Events from "./pages/Events";
|
||||||
import ConsultingBooking from "./pages/ConsultingBooking";
|
import ConsultingBooking from "./pages/ConsultingBooking";
|
||||||
import Calendar from "./pages/Calendar";
|
import Calendar from "./pages/Calendar";
|
||||||
@@ -56,6 +57,7 @@ const App = () => (
|
|||||||
<Route path="/checkout" element={<Checkout />} />
|
<Route path="/checkout" element={<Checkout />} />
|
||||||
<Route path="/events" element={<Events />} />
|
<Route path="/events" element={<Events />} />
|
||||||
<Route path="/bootcamp/:slug" element={<Bootcamp />} />
|
<Route path="/bootcamp/:slug" element={<Bootcamp />} />
|
||||||
|
<Route path="/webinar/:slug" element={<WebinarRecording />} />
|
||||||
<Route path="/consulting" element={<ConsultingBooking />} />
|
<Route path="/consulting" element={<ConsultingBooking />} />
|
||||||
<Route path="/calendar" element={<Calendar />} />
|
<Route path="/calendar" element={<Calendar />} />
|
||||||
<Route path="/privacy" element={<Privacy />} />
|
<Route path="/privacy" element={<Privacy />} />
|
||||||
|
|||||||
@@ -97,11 +97,10 @@ export default function Dashboard() {
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{item.product.recording_url && (
|
{item.product.recording_url && (
|
||||||
<Button asChild variant="outline" className="border-2">
|
<Button onClick={() => navigate(`/webinar/${item.product.slug}`)} className="shadow-sm">
|
||||||
<a href={item.product.recording_url} target="_blank" rel="noopener noreferrer">
|
|
||||||
<Video className="w-4 h-4 mr-2" />
|
<Video className="w-4 h-4 mr-2" />
|
||||||
Tonton Rekaman
|
Tonton Rekaman
|
||||||
</a>
|
<ArrowRight className="w-4 h-4 ml-2" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
188
src/pages/WebinarRecording.tsx
Normal file
188
src/pages/WebinarRecording.tsx
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
|
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';
|
||||||
|
|
||||||
|
interface Product {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
slug: string;
|
||||||
|
recording_url: string | null;
|
||||||
|
description: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function WebinarRecording() {
|
||||||
|
const { slug } = useParams<{ slug: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { user, loading: authLoading } = useAuth();
|
||||||
|
|
||||||
|
const [product, setProduct] = useState<Product | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!authLoading && !user) {
|
||||||
|
navigate('/auth');
|
||||||
|
} else if (user && slug) {
|
||||||
|
checkAccessAndFetch();
|
||||||
|
}
|
||||||
|
}, [user, authLoading, slug]);
|
||||||
|
|
||||||
|
const checkAccessAndFetch = async () => {
|
||||||
|
const { data: productData, error: productError } = await supabase
|
||||||
|
.from('products')
|
||||||
|
.select('id, title, slug, recording_url, description')
|
||||||
|
.eq('slug', slug)
|
||||||
|
.eq('type', 'webinar')
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
|
if (productError || !productData) {
|
||||||
|
toast({ title: 'Error', description: 'Webinar tidak ditemukan', variant: 'destructive' });
|
||||||
|
navigate('/dashboard');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setProduct(productData);
|
||||||
|
|
||||||
|
if (!productData.recording_url) {
|
||||||
|
toast({ title: 'Info', description: 'Rekaman webinar belum tersedia', variant: 'destructive' });
|
||||||
|
navigate('/dashboard');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check access via user_access or paid orders
|
||||||
|
const [accessRes, paidOrdersRes] = await Promise.all([
|
||||||
|
supabase
|
||||||
|
.from('user_access')
|
||||||
|
.select('id')
|
||||||
|
.eq('user_id', user!.id)
|
||||||
|
.eq('product_id', productData.id)
|
||||||
|
.maybeSingle(),
|
||||||
|
supabase
|
||||||
|
.from('orders')
|
||||||
|
.select('order_items!inner(product_id)')
|
||||||
|
.eq('user_id', user!.id)
|
||||||
|
.eq('payment_status', 'paid')
|
||||||
|
]);
|
||||||
|
|
||||||
|
const hasDirectAccess = !!accessRes.data;
|
||||||
|
const hasPaidOrderAccess = paidOrdersRes.data?.some((order: any) =>
|
||||||
|
order.order_items?.some((item: any) => item.product_id === productData.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!hasDirectAccess && !hasPaidOrderAccess) {
|
||||||
|
toast({ title: 'Akses ditolak', description: 'Anda tidak memiliki akses ke webinar ini', variant: 'destructive' });
|
||||||
|
navigate('/dashboard');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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]}`;
|
||||||
|
|
||||||
|
// 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 isDirectVideo = (url: string) => {
|
||||||
|
return url.match(/\.(mp4|webm|ogg)$/i) || url.includes('drive.google.com');
|
||||||
|
};
|
||||||
|
|
||||||
|
if (authLoading || loading) {
|
||||||
|
return (
|
||||||
|
<AppLayout>
|
||||||
|
<div className="container mx-auto px-4 py-8">
|
||||||
|
<Skeleton className="h-10 w-1/3 mb-4" />
|
||||||
|
<Skeleton className="aspect-video w-full" />
|
||||||
|
</div>
|
||||||
|
</AppLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!product) return null;
|
||||||
|
|
||||||
|
const embedUrl = product.recording_url ? getVideoEmbed(product.recording_url) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppLayout>
|
||||||
|
<div className="container mx-auto px-4 py-8">
|
||||||
|
<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 Embed */}
|
||||||
|
{embedUrl && (
|
||||||
|
<div className="aspect-video bg-muted rounded-lg overflow-hidden border-2 border-border">
|
||||||
|
{isDirectVideo(embedUrl) ? (
|
||||||
|
<video
|
||||||
|
src={embedUrl}
|
||||||
|
controls
|
||||||
|
className="w-full h-full"
|
||||||
|
>
|
||||||
|
<source src={embedUrl} type="video/mp4" />
|
||||||
|
Browser Anda tidak mendukung pemutaran video.
|
||||||
|
</video>
|
||||||
|
) : (
|
||||||
|
<iframe
|
||||||
|
src={embedUrl}
|
||||||
|
className="w-full h-full"
|
||||||
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||||
|
allowFullScreen
|
||||||
|
title={product.title}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
{product.description && (
|
||||||
|
<div className="prose max-w-none">
|
||||||
|
<div dangerouslySetInnerHTML={{ __html: product.description }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</AppLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -104,11 +104,10 @@ export default function MemberAccess() {
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{item.product.recording_url && (
|
{item.product.recording_url && (
|
||||||
<Button asChild variant="outline" className="border-2">
|
<Button onClick={() => navigate(`/webinar/${item.product.slug}`)} className="shadow-sm">
|
||||||
<a href={item.product.recording_url} target="_blank" rel="noopener noreferrer">
|
|
||||||
<Video className="w-4 h-4 mr-2" />
|
<Video className="w-4 h-4 mr-2" />
|
||||||
Tonton Rekaman
|
Tonton Rekaman
|
||||||
</a>
|
<ArrowRight className="w-4 h-4 ml-2" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -157,7 +157,10 @@ export default function MemberDashboard() {
|
|||||||
case "consulting":
|
case "consulting":
|
||||||
return { label: "Jadwalkan", icon: Calendar, href: item.product.meeting_link };
|
return { label: "Jadwalkan", icon: Calendar, href: item.product.meeting_link };
|
||||||
case "webinar":
|
case "webinar":
|
||||||
return { label: "Tonton", icon: Video, href: item.product.recording_url || item.product.meeting_link };
|
if (item.product.recording_url) {
|
||||||
|
return { label: "Tonton", icon: Video, route: `/webinar/${item.product.slug}` };
|
||||||
|
}
|
||||||
|
return { label: "Gabung", icon: Video, href: item.product.meeting_link };
|
||||||
case "bootcamp":
|
case "bootcamp":
|
||||||
return { label: "Lanjutkan", icon: BookOpen, route: `/bootcamp/${item.product.slug}` };
|
return { label: "Lanjutkan", icon: BookOpen, route: `/bootcamp/${item.product.slug}` };
|
||||||
default:
|
default:
|
||||||
|
|||||||
Reference in New Issue
Block a user