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:
@@ -26,8 +26,12 @@ interface Product {
|
||||
sale_price: number | null;
|
||||
meeting_link: string | null;
|
||||
recording_url: string | null;
|
||||
m3u8_url: string | null;
|
||||
mp4_url: string | null;
|
||||
video_host: 'youtube' | 'adilo' | 'unknown' | null;
|
||||
event_start: string | null;
|
||||
duration_minutes: number | null;
|
||||
chapters?: { time: number; title: string; }[];
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
@@ -43,6 +47,7 @@ interface Lesson {
|
||||
title: string;
|
||||
duration_seconds: number | null;
|
||||
position: number;
|
||||
chapters?: { time: number; title: string; }[];
|
||||
}
|
||||
|
||||
interface UserReview {
|
||||
@@ -105,7 +110,7 @@ export default function ProductDetail() {
|
||||
|
||||
const fetchCurriculum = async () => {
|
||||
if (!product) return;
|
||||
|
||||
|
||||
const { data: modulesData } = await supabase
|
||||
.from('bootcamp_modules')
|
||||
.select(`
|
||||
@@ -116,7 +121,8 @@ export default function ProductDetail() {
|
||||
id,
|
||||
title,
|
||||
duration_seconds,
|
||||
position
|
||||
position,
|
||||
chapters
|
||||
)
|
||||
`)
|
||||
.eq('product_id', product.id)
|
||||
@@ -215,6 +221,43 @@ export default function ProductDetail() {
|
||||
|
||||
const isInCart = product ? items.some(item => item.id === product.id) : false;
|
||||
|
||||
const formatChapterTime = (seconds: number) => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
return `${mins}:${String(secs).padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const renderWebinarChapters = () => {
|
||||
if (product?.type !== 'webinar' || !product.chapters || product.chapters.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Card className="border-2 border-border mb-6">
|
||||
<CardContent className="pt-6">
|
||||
<h3 className="text-xl font-bold mb-4">Daftar isi Webinar</h3>
|
||||
<div className="space-y-3">
|
||||
{product.chapters.map((chapter, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center gap-3 p-3 rounded-lg hover:bg-accent transition-colors cursor-pointer group"
|
||||
onClick={() => product && navigate(`/webinar/${product.slug}`)}
|
||||
>
|
||||
<div className="flex-shrink-0 w-12 text-center">
|
||||
<span className="text-sm font-mono text-muted-foreground group-hover:text-primary">
|
||||
{formatChapterTime(chapter.time)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">{chapter.title}</p>
|
||||
</div>
|
||||
<Play className="w-4 h-4 text-muted-foreground group-hover:text-primary flex-shrink-0" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const getVideoEmbed = (url: string) => {
|
||||
const youtubeMatch = url.match(/(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/)([^&\s]+)/);
|
||||
if (youtubeMatch) return `https://www.youtube.com/embed/${youtubeMatch[1]}`;
|
||||
@@ -268,20 +311,25 @@ export default function ProductDetail() {
|
||||
if (product.recording_url) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="aspect-video bg-muted rounded-none overflow-hidden border-2 border-border">
|
||||
<iframe
|
||||
src={getVideoEmbed(product.recording_url)}
|
||||
className="w-full h-full"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowFullScreen
|
||||
/>
|
||||
</div>
|
||||
<Button asChild variant="outline" className="border-2">
|
||||
<a href={product.recording_url} target="_blank" rel="noopener noreferrer">
|
||||
<Video className="w-4 h-4 mr-2" />
|
||||
Tonton Rekaman
|
||||
</a>
|
||||
</Button>
|
||||
<Card className="border-2 border-primary/20 bg-primary/5">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="rounded-full bg-primary/10 p-3">
|
||||
<Play className="w-6 h-6 text-primary" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-lg mb-1">Rekaman webinar tersedia</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Akses rekaman webinar kapan saja. Pelajari materi sesuai kecepatan Anda.
|
||||
</p>
|
||||
<Button onClick={() => navigate(`/webinar/${product.slug}`)} size="lg">
|
||||
<Video className="w-4 h-4 mr-2" />
|
||||
Tonton Sekarang
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -352,15 +400,36 @@ export default function ProductDetail() {
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className="border-l-2 border-border ml-4 pl-4 py-2 space-y-2">
|
||||
<div className="border-l-2 border-border ml-4 pl-4 py-2 space-y-3">
|
||||
{module.lessons.map((lesson) => (
|
||||
<div key={lesson.id} className="flex items-center justify-between py-1 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<Play className="w-3 h-3 text-muted-foreground" />
|
||||
<span>{lesson.title}</span>
|
||||
<div key={lesson.id} className="space-y-2">
|
||||
{/* Lesson header */}
|
||||
<div className="flex items-center justify-between py-1 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<Play className="w-3 h-3 text-muted-foreground" />
|
||||
<span className="font-medium">{lesson.title}</span>
|
||||
</div>
|
||||
{lesson.duration_seconds && (
|
||||
<span className="text-muted-foreground">{formatDuration(lesson.duration_seconds)}</span>
|
||||
)}
|
||||
</div>
|
||||
{lesson.duration_seconds && (
|
||||
<span className="text-muted-foreground">{formatDuration(lesson.duration_seconds)}</span>
|
||||
|
||||
{/* Lesson chapters (if any) */}
|
||||
{lesson.chapters && lesson.chapters.length > 0 && (
|
||||
<div className="ml-5 space-y-1">
|
||||
{lesson.chapters.map((chapter, chapterIndex) => (
|
||||
<div
|
||||
key={chapterIndex}
|
||||
className="flex items-center gap-2 py-1 px-2 text-xs text-muted-foreground hover:bg-accent/50 rounded transition-colors cursor-pointer group"
|
||||
onClick={() => product && navigate(`/bootcamp/${product.slug}`)}
|
||||
>
|
||||
<span className="font-mono w-10 text-center group-hover:text-primary">
|
||||
{formatChapterTime(chapter.time)}
|
||||
</span>
|
||||
<span className="flex-1 group-hover:text-foreground">{chapter.title}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
@@ -378,6 +447,39 @@ export default function ProductDetail() {
|
||||
<AppLayout>
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{/* Ownership Banner - shown at top for purchased users */}
|
||||
{hasAccess && (
|
||||
<div className="bg-green-50 dark:bg-green-950 border-2 border-green-200 dark:border-green-800 rounded-lg p-4 mb-6">
|
||||
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||
<div className="flex items-center gap-3">
|
||||
<CheckCircle className="w-6 h-6 text-green-600 dark:text-green-400 flex-shrink-0" />
|
||||
<div>
|
||||
<p className="font-semibold text-green-900 dark:text-green-100">
|
||||
Anda memiliki akses ke produk ini
|
||||
</p>
|
||||
<p className="text-sm text-green-700 dark:text-green-300">
|
||||
{product.type === 'webinar' && 'Selamat menonton rekaman webinar!'}
|
||||
{product.type === 'bootcamp' && 'Mulai belajar sekarang!'}
|
||||
{product.type === 'consulting' && 'Jadwalkan sesi konsultasi Anda.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (product.type === 'webinar') {
|
||||
navigate(`/webinar/${product.slug}`);
|
||||
} else if (product.type === 'bootcamp') {
|
||||
navigate(`/bootcamp/${product.slug}`);
|
||||
}
|
||||
}}
|
||||
className="bg-green-600 hover:bg-green-700 text-white shadow-sm"
|
||||
>
|
||||
Tonton Sekarang →
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col md:flex-row md:items-start justify-between gap-4 mb-6">
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold mb-2">{product.title}</h1>
|
||||
@@ -392,7 +494,6 @@ export default function ProductDetail() {
|
||||
{product.type === 'webinar' && !product.recording_url && product.event_start && new Date(product.event_start) <= new Date() && (
|
||||
<Badge className="bg-muted text-primary">Telah Lewat</Badge>
|
||||
)}
|
||||
{hasAccess && <Badge className="bg-primary text-primary-foreground">Anda memiliki akses</Badge>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
@@ -424,7 +525,7 @@ export default function ProductDetail() {
|
||||
{product.content && (
|
||||
<Card className="border-2 border-border mb-6">
|
||||
<CardContent className="pt-6">
|
||||
<div
|
||||
<div
|
||||
className="prose max-w-none"
|
||||
dangerouslySetInnerHTML={{ __html: product.content }}
|
||||
/>
|
||||
@@ -432,6 +533,8 @@ export default function ProductDetail() {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{renderWebinarChapters()}
|
||||
|
||||
<div className="flex gap-4 flex-wrap">
|
||||
{renderActionButtons()}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user