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:
dwindown
2025-12-25 23:05:32 +07:00
parent e347a780f8
commit 2dd9d544ee
5 changed files with 202 additions and 11 deletions

View File

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

View File

@@ -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">
<Button onClick={() => navigate(`/webinar/${item.product.slug}`)} className="shadow-sm">
<Video className="w-4 h-4 mr-2" />
Tonton Rekaman
</a>
<ArrowRight className="w-4 h-4 ml-2" />
</Button>
)}
</div>

View 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>
);
}

View File

@@ -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">
<Button onClick={() => navigate(`/webinar/${item.product.slug}`)} className="shadow-sm">
<Video className="w-4 h-4 mr-2" />
Tonton Rekaman
</a>
<ArrowRight className="w-4 h-4 ml-2" />
</Button>
)}
</div>

View File

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