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

@@ -25,7 +25,6 @@ interface Product {
id: string;
title: string;
slug: string;
video_source?: string;
}
interface Module {
@@ -42,6 +41,9 @@ interface Lesson {
video_url: string | null;
youtube_url: string | null;
embed_code: string | null;
m3u8_url?: string | null;
mp4_url?: string | null;
video_host?: 'youtube' | 'adilo' | 'unknown';
duration_seconds: number | null;
position: number;
release_at: string | null;
@@ -91,7 +93,7 @@ export default function Bootcamp() {
const checkAccessAndFetch = async () => {
const { data: productData, error: productError } = await supabase
.from('products')
.select('id, title, slug, video_source')
.select('id, title, slug')
.eq('slug', slug)
.eq('type', 'bootcamp')
.maybeSingle();
@@ -140,6 +142,9 @@ export default function Bootcamp() {
video_url,
youtube_url,
embed_code,
m3u8_url,
mp4_url,
video_host,
duration_seconds,
position,
release_at,
@@ -283,12 +288,39 @@ export default function Bootcamp() {
};
const VideoPlayer = ({ lesson }: { lesson: Lesson }) => {
const activeSource = product?.video_source || 'youtube';
const hasChapters = lesson.chapters && lesson.chapters.length > 0;
// Get video based on product's active source
// Get video based on lesson's video_host (prioritize Adilo)
const getVideoSource = () => {
if (activeSource === 'youtube') {
// If video_host is explicitly set, use it. Otherwise auto-detect with Adilo priority.
const lessonVideoHost = lesson.video_host || (
lesson.m3u8_url ? 'adilo' :
lesson.video_url?.includes('adilo.bigcommand.com') ? 'adilo' :
lesson.youtube_url || lesson.video_url?.includes('youtube.com') || lesson.video_url?.includes('youtu.be') ? 'youtube' :
'unknown'
);
if (lessonVideoHost === 'adilo') {
// Adilo M3U8 streaming
if (lesson.m3u8_url && lesson.m3u8_url.trim()) {
return {
type: 'adilo',
m3u8Url: lesson.m3u8_url,
mp4Url: lesson.mp4_url || undefined,
videoHost: 'adilo'
};
} else if (lesson.mp4_url && lesson.mp4_url.trim()) {
// Fallback to MP4 only
return {
type: 'adilo',
mp4Url: lesson.mp4_url,
videoHost: 'adilo'
};
}
}
// YouTube or fallback
if (lessonVideoHost === 'youtube') {
if (lesson.youtube_url && lesson.youtube_url.trim()) {
return {
type: 'youtube',
@@ -302,32 +334,14 @@ export default function Bootcamp() {
url: lesson.video_url,
embedUrl: getYouTubeEmbedUrl(lesson.video_url)
};
} else {
// Fallback to embed if YouTube not available
return lesson.embed_code && lesson.embed_code.trim() ? {
type: 'embed',
html: lesson.embed_code
} : null;
}
} else {
if (lesson.embed_code && lesson.embed_code.trim()) {
return {
type: 'embed',
html: lesson.embed_code
};
} else {
// Fallback to YouTube if embed not available
return lesson.youtube_url && lesson.youtube_url.trim() ? {
type: 'youtube',
url: lesson.youtube_url,
embedUrl: getYouTubeEmbedUrl(lesson.youtube_url)
} : lesson.video_url && lesson.video_url.trim() ? {
type: 'youtube',
url: lesson.video_url,
embedUrl: getYouTubeEmbedUrl(lesson.video_url)
} : null;
}
}
// Final fallback: try embed code
return lesson.embed_code && lesson.embed_code.trim() ? {
type: 'embed',
html: lesson.embed_code
} : null;
};
const video = getVideoSource();
@@ -355,7 +369,6 @@ export default function Bootcamp() {
<div className="mt-4">
<TimelineChapters
chapters={lesson.chapters}
isYouTube={false}
currentTime={currentTime}
accentColor={accentColor}
/>
@@ -365,19 +378,24 @@ export default function Bootcamp() {
);
}
// YouTube with chapters support
// Adilo or YouTube with chapters support
const isYouTube = video.type === 'youtube';
const isAdilo = video.type === 'adilo';
return (
<div className={hasChapters ? "grid grid-cols-1 lg:grid-cols-3 gap-6 mb-6" : "mb-6"}>
<div className={hasChapters ? "lg:col-span-2" : ""}>
<VideoPlayerWithChapters
ref={playerRef}
videoUrl={video.url}
embedCode={lesson.embed_code}
videoUrl={isYouTube ? video.url : undefined}
m3u8Url={isAdilo ? video.m3u8Url : undefined}
mp4Url={isAdilo ? video.mp4Url : undefined}
videoHost={isAdilo ? 'adilo' : isYouTube ? 'youtube' : 'unknown'}
chapters={lesson.chapters}
accentColor={accentColor}
onTimeUpdate={setCurrentTime}
videoId={lesson.id}
videoType="lesson"
/>
</div>
@@ -385,7 +403,6 @@ export default function Bootcamp() {
<div className="lg:col-span-1">
<TimelineChapters
chapters={lesson.chapters}
isYouTube={isYouTube}
onChapterClick={(time) => {
if (playerRef.current) {
playerRef.current.jumpToTime(time);
@@ -704,6 +721,12 @@ export default function Bootcamp() {
productId={product.id}
type="bootcamp"
contextLabel={product.title}
existingReview={userReview ? {
id: userReview.id,
rating: userReview.rating,
title: userReview.title,
body: userReview.body,
} : undefined}
onSuccess={() => {
// Refresh review data
const refreshReview = async () => {

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>

View File

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

View File

@@ -36,12 +36,14 @@ interface Product {
content: string;
meeting_link: string | null;
recording_url: string | null;
m3u8_url?: string | null;
mp4_url?: string | null;
video_host?: 'youtube' | 'adilo' | 'unknown';
event_start: string | null;
duration_minutes: number | null;
price: number;
sale_price: number | null;
is_active: boolean;
video_source?: string;
chapters?: VideoChapter[];
}
@@ -53,12 +55,14 @@ const emptyProduct = {
content: '',
meeting_link: '',
recording_url: '',
m3u8_url: '',
mp4_url: '',
video_host: 'youtube' as 'youtube' | 'adilo' | 'unknown',
event_start: null as string | null,
duration_minutes: null as number | null,
price: 0,
sale_price: null as number | null,
is_active: true,
video_source: 'youtube' as string,
chapters: [] as VideoChapter[],
};
@@ -84,7 +88,10 @@ export default function AdminProducts() {
}, [user, isAdmin, authLoading]);
const fetchProducts = async () => {
const { data, error } = await supabase.from('products').select('*').order('created_at', { ascending: false });
const { data, error } = await supabase
.from('products')
.select('id, title, slug, type, description, meeting_link, recording_url, m3u8_url, mp4_url, video_host, event_start, duration_minutes, price, sale_price, is_active, chapters')
.order('created_at', { ascending: false });
if (!error && data) setProducts(data);
setLoading(false);
};
@@ -121,12 +128,14 @@ export default function AdminProducts() {
content: product.content || '',
meeting_link: product.meeting_link || '',
recording_url: product.recording_url || '',
m3u8_url: product.m3u8_url || '',
mp4_url: product.mp4_url || '',
video_host: product.video_host || 'youtube',
event_start: product.event_start ? product.event_start.slice(0, 16) : null,
duration_minutes: product.duration_minutes,
price: product.price,
sale_price: product.sale_price,
is_active: product.is_active,
video_source: product.video_source || 'youtube',
chapters: product.chapters || [],
});
setDialogOpen(true);
@@ -152,12 +161,14 @@ export default function AdminProducts() {
content: form.content,
meeting_link: form.meeting_link || null,
recording_url: form.recording_url || null,
m3u8_url: form.m3u8_url || null,
mp4_url: form.mp4_url || null,
video_host: form.video_host || 'youtube',
event_start: form.event_start || null,
duration_minutes: form.duration_minutes || null,
price: form.price,
sale_price: form.sale_price || null,
is_active: form.is_active,
video_source: form.video_source || 'youtube',
chapters: form.chapters || [],
};
@@ -461,18 +472,73 @@ export default function AdminProducts() {
<Label>Konten</Label>
<RichTextEditor content={form.content} onChange={(v) => setForm({ ...form, content: v })} />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Meeting Link</Label>
<Input value={form.meeting_link} onChange={(e) => setForm({ ...form, meeting_link: e.target.value })} placeholder="https://meet.google.com/..." className="border-2" />
</div>
<div className="space-y-2">
<Label>Recording URL</Label>
<Input value={form.recording_url} onChange={(e) => setForm({ ...form, recording_url: e.target.value })} placeholder="https://youtube.com/..." className="border-2" />
</div>
</div>
{form.type === 'webinar' && (
<>
<div className="space-y-2">
<Label>Meeting Link</Label>
<Input value={form.meeting_link} onChange={(e) => setForm({ ...form, meeting_link: e.target.value })} placeholder="https://meet.google.com/..." className="border-2" />
</div>
<div className="space-y-2">
<Label>Video Host</Label>
<Select value={form.video_host} onValueChange={(value: 'youtube' | 'adilo') => setForm({ ...form, video_host: value })}>
<SelectTrigger className="border-2">
<SelectValue placeholder="Select video host" />
</SelectTrigger>
<SelectContent>
<SelectItem value="youtube">YouTube</SelectItem>
<SelectItem value="adilo">Adilo (M3U8)</SelectItem>
</SelectContent>
</Select>
</div>
{/* YouTube URL */}
{form.video_host === 'youtube' && (
<div className="space-y-2">
<Label>YouTube Recording URL</Label>
<Input
value={form.recording_url}
onChange={(e) => setForm({ ...form, recording_url: e.target.value })}
placeholder="https://www.youtube.com/watch?v=..."
className="border-2"
/>
<p className="text-sm text-muted-foreground">
Paste YouTube URL here
</p>
</div>
)}
{/* Adilo URLs */}
{form.video_host === 'adilo' && (
<div className="space-y-4 p-4 bg-muted border-2 border-border rounded-lg">
<div className="space-y-2">
<Label>M3U8 URL (Primary)</Label>
<Input
value={form.m3u8_url}
onChange={(e) => setForm({ ...form, m3u8_url: e.target.value })}
placeholder="https://adilo.bigcommand.com/m3u8/..."
className="border-2 font-mono text-sm"
/>
<p className="text-sm text-muted-foreground">
HLS streaming URL from Adilo
</p>
</div>
<div className="space-y-2">
<Label>MP4 URL (Optional Fallback)</Label>
<Input
value={form.mp4_url}
onChange={(e) => setForm({ ...form, mp4_url: e.target.value })}
placeholder="https://adilo.bigcommand.com/videos/..."
className="border-2 font-mono text-sm"
/>
<p className="text-sm text-muted-foreground">
Direct MP4 file for legacy browsers (optional)
</p>
</div>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Tanggal & Waktu Webinar</Label>
@@ -523,10 +589,10 @@ export default function AdminProducts() {
<RadioGroupItem value="embed" id="embed" />
<div className="flex-1">
<Label htmlFor="embed" className="font-medium cursor-pointer">
Custom Embed (Backup)
Adilo (Backup)
</Label>
<p className="text-sm text-muted-foreground">
Use custom embed codes (Adilo, Vimeo, etc.) for all lessons
Use Adilo M3U8/MP4 URLs for all lessons
</p>
</div>
</div>
@@ -535,7 +601,7 @@ export default function AdminProducts() {
<Alert>
<Info className="h-4 w-4" />
<AlertDescription>
This setting affects ALL lessons in this bootcamp. Configure both YouTube URLs and embed codes for each lesson in the curriculum editor. Use this toggle to switch between sources instantly.
This setting affects ALL lessons in this bootcamp. Configure video URLs for each lesson in the curriculum editor. Use this toggle to switch between YouTube and Adilo sources instantly.
</AlertDescription>
</Alert>
</div>

View File

@@ -10,6 +10,8 @@ import { Plus, Pencil, Trash2, ChevronUp, ChevronDown, GripVertical, ArrowLeft,
import { cn } from '@/lib/utils';
import { RichTextEditor } from '@/components/RichTextEditor';
import { AppLayout } from '@/components/AppLayout';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { ChaptersEditor } from '@/components/admin/ChaptersEditor';
interface Module {
id: string;
@@ -17,6 +19,11 @@ interface Module {
position: number;
}
interface VideoChapter {
time: number;
title: string;
}
interface Lesson {
id: string;
module_id: string;
@@ -25,8 +32,12 @@ interface Lesson {
video_url: string | null;
youtube_url: string | null;
embed_code: string | null;
m3u8_url?: string | null;
mp4_url?: string | null;
video_host?: 'youtube' | 'adilo' | 'unknown';
position: number;
release_at: string | null;
chapters?: VideoChapter[];
}
export default function ProductCurriculum() {
@@ -52,7 +63,11 @@ export default function ProductCurriculum() {
video_url: '',
youtube_url: '',
embed_code: '',
m3u8_url: '',
mp4_url: '',
video_host: 'youtube' as 'youtube' | 'adilo' | 'unknown',
release_at: '',
chapters: [] as VideoChapter[],
});
useEffect(() => {
@@ -67,7 +82,7 @@ export default function ProductCurriculum() {
const [productRes, modulesRes, lessonsRes] = await Promise.all([
supabase.from('products').select('id, title, slug').eq('id', id).single(),
supabase.from('bootcamp_modules').select('*').eq('product_id', id).order('position'),
supabase.from('bootcamp_lessons').select('*').order('position'),
supabase.from('bootcamp_lessons').select('id, module_id, title, content, video_url, youtube_url, embed_code, m3u8_url, mp4_url, video_host, position, release_at, chapters').order('position'),
]);
if (productRes.data) {
@@ -176,7 +191,11 @@ export default function ProductCurriculum() {
video_url: '',
youtube_url: '',
embed_code: '',
m3u8_url: '',
mp4_url: '',
video_host: 'youtube',
release_at: '',
chapters: [],
});
setSelectedModuleId(moduleId);
setSelectedLessonId('new');
@@ -191,7 +210,11 @@ export default function ProductCurriculum() {
video_url: lesson.video_url || '',
youtube_url: lesson.youtube_url || '',
embed_code: lesson.embed_code || '',
m3u8_url: lesson.m3u8_url || '',
mp4_url: lesson.mp4_url || '',
video_host: lesson.video_host || 'youtube',
release_at: lesson.release_at ? lesson.release_at.split('T')[0] : '',
chapters: lesson.chapters || [],
});
setSelectedModuleId(lesson.module_id);
setSelectedLessonId(lesson.id);
@@ -212,7 +235,11 @@ export default function ProductCurriculum() {
video_url: lessonForm.video_url || null,
youtube_url: lessonForm.youtube_url || null,
embed_code: lessonForm.embed_code || null,
m3u8_url: lessonForm.m3u8_url || null,
mp4_url: lessonForm.mp4_url || null,
video_host: lessonForm.video_host || 'youtube',
release_at: lessonForm.release_at ? new Date(lessonForm.release_at).toISOString() : null,
chapters: lessonForm.chapters || [],
};
if (editingLesson) {
@@ -543,50 +570,83 @@ export default function ProductCurriculum() {
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Video Host</Label>
<Select
value={lessonForm.video_host}
onValueChange={(value: 'youtube' | 'adilo') => setLessonForm({ ...lessonForm, video_host: value })}
>
<SelectTrigger className="border-2">
<SelectValue placeholder="Select video host" />
</SelectTrigger>
<SelectContent>
<SelectItem value="youtube">YouTube</SelectItem>
<SelectItem value="adilo">Adilo (M3U8)</SelectItem>
</SelectContent>
</Select>
</div>
{/* YouTube URL */}
{lessonForm.video_host === 'youtube' && (
<div className="space-y-2">
<Label>YouTube URL (Primary)</Label>
<Label>YouTube URL</Label>
<Input
value={lessonForm.youtube_url}
onChange={(e) => setLessonForm({ ...lessonForm, youtube_url: e.target.value })}
value={lessonForm.video_url}
onChange={(e) => setLessonForm({ ...lessonForm, video_url: e.target.value })}
placeholder="https://www.youtube.com/watch?v=..."
className="border-2"
/>
{lessonForm.youtube_url && (
<p className="text-xs text-green-600"> YouTube configured</p>
)}
<p className="text-sm text-muted-foreground">
Paste YouTube URL here
</p>
</div>
)}
<div className="space-y-2">
<Label>Release Date (optional)</Label>
<Input
type="date"
value={lessonForm.release_at}
onChange={(e) => setLessonForm({ ...lessonForm, release_at: e.target.value })}
className="border-2"
/>
{/* Adilo URLs */}
{lessonForm.video_host === 'adilo' && (
<div className="space-y-4 p-4 bg-muted border-2 border-border rounded-lg">
<div className="space-y-2">
<Label>M3U8 URL (Primary)</Label>
<Input
value={lessonForm.m3u8_url}
onChange={(e) => setLessonForm({ ...lessonForm, m3u8_url: e.target.value })}
placeholder="https://adilo.bigcommand.com/m3u8/..."
className="border-2 font-mono text-sm"
/>
<p className="text-sm text-muted-foreground">
HLS streaming URL from Adilo
</p>
</div>
<div className="space-y-2">
<Label>MP4 URL (Optional Fallback)</Label>
<Input
value={lessonForm.mp4_url}
onChange={(e) => setLessonForm({ ...lessonForm, mp4_url: e.target.value })}
placeholder="https://adilo.bigcommand.com/videos/..."
className="border-2 font-mono text-sm"
/>
<p className="text-sm text-muted-foreground">
Direct MP4 file for legacy browsers (optional)
</p>
</div>
</div>
</div>
)}
<div className="space-y-2">
<Label>Embed Code (Backup)</Label>
<textarea
value={lessonForm.embed_code}
onChange={(e) => setLessonForm({ ...lessonForm, embed_code: e.target.value })}
placeholder="<iframe>...</iframe>"
rows={4}
className="w-full px-3 py-2 border-2 border-border rounded-md font-mono text-sm"
<Label>Release Date (optional)</Label>
<Input
type="date"
value={lessonForm.release_at}
onChange={(e) => setLessonForm({ ...lessonForm, release_at: e.target.value })}
className="border-2"
/>
{lessonForm.embed_code && (
<p className="text-xs text-green-600"> Embed code configured</p>
)}
</div>
<div className="p-4 bg-muted border-2 border-border rounded-lg">
<p className="text-sm text-muted-foreground">
💡 <strong>Tip:</strong> Configure both YouTube URL and embed code for redundancy. Use product settings to toggle between sources. This setting affects ALL lessons in the bootcamp.
</p>
</div>
<ChaptersEditor
chapters={lessonForm.chapters || []}
onChange={(chapters) => setLessonForm({ ...lessonForm, chapters })}
/>
<div className="space-y-2">
<Label>Content</Label>

View File

@@ -33,8 +33,8 @@ interface ConsultingSession {
start_time: string;
end_time: string;
status: string;
recording_url: string | null;
topic_category: string | null;
meet_link: string | null;
}
export default function MemberAccess() {
@@ -73,7 +73,7 @@ export default function MemberAccess() {
// Get completed consulting sessions with recordings
supabase
.from('consulting_slots')
.select('id, date, start_time, end_time, status, recording_url, topic_category')
.select('id, date, start_time, end_time, status, topic_category, meet_link')
.eq('user_id', user!.id)
.eq('status', 'done')
.order('date', { ascending: false }),
@@ -298,16 +298,16 @@ export default function MemberAccess() {
<Clock className="w-4 h-4 ml-2" />
<span>{session.start_time.substring(0, 5)} - {session.end_time.substring(0, 5)}</span>
</div>
{session.recording_url ? (
<Button asChild className="shadow-sm">
<a href={session.recording_url} target="_blank" rel="noopener noreferrer">
{session.meet_link ? (
<Button asChild className="shadow-sm" size="sm">
<a href={session.meet_link} target="_blank" rel="noopener noreferrer">
<Video className="w-4 h-4 mr-2" />
Tonton Rekaman
Rekam Sesi
<ArrowRight className="w-4 h-4 ml-2" />
</a>
</Button>
) : (
<Badge className="bg-muted text-primary">Rekaman segera tersedia</Badge>
<Badge className="bg-muted text-primary">Selesai</Badge>
)}
</CardContent>
</Card>

View File

@@ -45,8 +45,8 @@ interface ConsultingSlot {
start_time: string;
end_time: string;
status: string;
product_id: string | null;
meet_link: string | null;
topic_category?: string | null;
}
export default function MemberDashboard() {
@@ -144,7 +144,7 @@ export default function MemberDashboard() {
// Fetch confirmed consulting slots for quick access
supabase
.from("consulting_slots")
.select("id, date, start_time, end_time, status, product_id, meet_link")
.select("id, date, start_time, end_time, status, meet_link, topic_category")
.eq("user_id", user!.id)
.eq("status", "confirmed")
.order("date", { ascending: false }),
@@ -178,10 +178,9 @@ export default function MemberDashboard() {
switch (item.product.type) {
case "consulting": {
// Only show if user has a confirmed upcoming consulting slot for this product
// Only show if user has a confirmed upcoming consulting slot
const upcomingSlot = consultingSlots.find(
(slot) =>
slot.product_id === item.product.id &&
slot.status === "confirmed" &&
new Date(slot.date) >= new Date(now.setHours(0, 0, 0, 0))
);
@@ -350,7 +349,7 @@ export default function MemberDashboard() {
</p>
</div>
<div className="flex items-center gap-4">
<Badge className={order.payment_status === "paid" ? "bg-brand-accent text-white" : "bg-amber-500 text-white"} rounded-full>
<Badge className={order.payment_status === "paid" ? "bg-brand-accent text-white rounded-full" : "bg-amber-500 text-white rounded-full"}>
{order.payment_status === "paid" ? "Lunas" : "Pending"}
</Badge>
<span className="font-bold">{formatIDR(order.total_amount)}</span>