Files
meet-hub/src/pages/WebinarRecording.tsx
dwindown 60baf32f73 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>
2026-01-01 23:54:32 +07:00

369 lines
12 KiB
TypeScript

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, 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;
title: string;
}
interface Product {
id: string;
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();
const { user, loading: authLoading } = useAuth();
const [product, setProduct] = useState<Product | null>(null);
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(() => {
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, m3u8_url, mp4_url, video_host, description, chapters')
.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;
}
// Fetch accent color from settings
const { data: settings } = await supabase
.from('platform_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
.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);
// Check if user has already reviewed this webinar
checkUserReview();
};
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 = useCallback((time: number) => {
// VideoPlayerWithChapters will handle the jump
if (playerRef.current && playerRef.current.jumpToTime) {
playerRef.current.jumpToTime(time);
}
}, []);
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 (
<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 hasChapters = product.chapters && product.chapters.length > 0;
return (
<AppLayout>
<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>
<h1 className="text-3xl md:text-4xl font-bold mb-6">{product.title}</h1>
{/* 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>
{/* 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 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>
);
}