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 Checkout from "./pages/Checkout";
|
||||
import Bootcamp from "./pages/Bootcamp";
|
||||
import WebinarRecording from "./pages/WebinarRecording";
|
||||
import Events from "./pages/Events";
|
||||
import ConsultingBooking from "./pages/ConsultingBooking";
|
||||
import Calendar from "./pages/Calendar";
|
||||
@@ -56,6 +57,7 @@ const App = () => (
|
||||
<Route path="/checkout" element={<Checkout />} />
|
||||
<Route path="/events" element={<Events />} />
|
||||
<Route path="/bootcamp/:slug" element={<Bootcamp />} />
|
||||
<Route path="/webinar/:slug" element={<WebinarRecording />} />
|
||||
<Route path="/consulting" element={<ConsultingBooking />} />
|
||||
<Route path="/calendar" element={<Calendar />} />
|
||||
<Route path="/privacy" element={<Privacy />} />
|
||||
|
||||
@@ -97,11 +97,10 @@ export default function Dashboard() {
|
||||
</Button>
|
||||
)}
|
||||
{item.product.recording_url && (
|
||||
<Button asChild variant="outline" className="border-2">
|
||||
<a href={item.product.recording_url} target="_blank" rel="noopener noreferrer">
|
||||
<Video className="w-4 h-4 mr-2" />
|
||||
Tonton Rekaman
|
||||
</a>
|
||||
<Button onClick={() => navigate(`/webinar/${item.product.slug}`)} className="shadow-sm">
|
||||
<Video className="w-4 h-4 mr-2" />
|
||||
Tonton Rekaman
|
||||
<ArrowRight className="w-4 h-4 ml-2" />
|
||||
</Button>
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
{item.product.recording_url && (
|
||||
<Button asChild variant="outline" className="border-2">
|
||||
<a href={item.product.recording_url} target="_blank" rel="noopener noreferrer">
|
||||
<Video className="w-4 h-4 mr-2" />
|
||||
Tonton Rekaman
|
||||
</a>
|
||||
<Button onClick={() => navigate(`/webinar/${item.product.slug}`)} className="shadow-sm">
|
||||
<Video className="w-4 h-4 mr-2" />
|
||||
Tonton Rekaman
|
||||
<ArrowRight className="w-4 h-4 ml-2" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -157,7 +157,10 @@ export default function MemberDashboard() {
|
||||
case "consulting":
|
||||
return { label: "Jadwalkan", icon: Calendar, href: item.product.meeting_link };
|
||||
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":
|
||||
return { label: "Lanjutkan", icon: BookOpen, route: `/bootcamp/${item.product.slug}` };
|
||||
default:
|
||||
|
||||
Reference in New Issue
Block a user