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>
This commit is contained in:
@@ -1,15 +1,18 @@
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
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 } from 'lucide-react';
|
||||
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;
|
||||
@@ -21,10 +24,22 @@ interface Product {
|
||||
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();
|
||||
@@ -34,6 +49,8 @@ export default function WebinarRecording() {
|
||||
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(() => {
|
||||
@@ -47,7 +64,7 @@ export default function WebinarRecording() {
|
||||
const checkAccessAndFetch = async () => {
|
||||
const { data: productData, error: productError } = await supabase
|
||||
.from('products')
|
||||
.select('id, title, slug, recording_url, description, chapters')
|
||||
.select('id, title, slug, recording_url, m3u8_url, mp4_url, video_host, description, chapters')
|
||||
.eq('slug', slug)
|
||||
.eq('type', 'webinar')
|
||||
.maybeSingle();
|
||||
@@ -103,23 +120,60 @@ export default function WebinarRecording() {
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
|
||||
// Check if user has already reviewed this webinar
|
||||
checkUserReview();
|
||||
};
|
||||
|
||||
const isYouTube = product?.recording_url && (
|
||||
product.recording_url.includes('youtube.com') ||
|
||||
product.recording_url.includes('youtu.be')
|
||||
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 = (time: number) => {
|
||||
const handleChapterClick = useCallback((time: number) => {
|
||||
// VideoPlayerWithChapters will handle the jump
|
||||
if (playerRef.current && playerRef.current.jumpToTime) {
|
||||
playerRef.current.jumpToTime(time);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleTimeUpdate = (time: number) => {
|
||||
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 (
|
||||
@@ -138,69 +192,177 @@ export default function WebinarRecording() {
|
||||
|
||||
return (
|
||||
<AppLayout>
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<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>
|
||||
|
||||
<Card className="border-2 border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl md:text-3xl">{product.title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Video Player with Chapters */}
|
||||
<div className={hasChapters ? "grid grid-cols-1 lg:grid-cols-3 gap-6" : ""}>
|
||||
<div className={hasChapters ? "lg:col-span-2" : ""}>
|
||||
{product.recording_url && (
|
||||
<VideoPlayerWithChapters
|
||||
ref={playerRef}
|
||||
videoUrl={product.recording_url}
|
||||
chapters={product.chapters}
|
||||
accentColor={accentColor}
|
||||
onTimeUpdate={handleTimeUpdate}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<h1 className="text-3xl md:text-4xl font-bold mb-6">{product.title}</h1>
|
||||
|
||||
{/* Timeline Chapters */}
|
||||
{hasChapters && (
|
||||
<div className="lg:col-span-1">
|
||||
<TimelineChapters
|
||||
chapters={product.chapters}
|
||||
isYouTube={isYouTube}
|
||||
onChapterClick={handleChapterClick}
|
||||
currentTime={currentTime}
|
||||
accentColor={accentColor}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* 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>
|
||||
|
||||
{/* Description */}
|
||||
{product.description && (
|
||||
{/* 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">
|
||||
<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>
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user