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:
dwindown
2026-01-01 23:54:32 +07:00
parent 41f7b797e7
commit 60baf32f73
29 changed files with 3694 additions and 35048 deletions

View File

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