Add product-level video source toggle and improve curriculum UX
- Add video source toggle UI (YouTube/Embed) to product edit form for bootcamps - Remove Bootcamp menu from admin navigation (curriculum managed via Products page) - Remove tabs from product add/edit modal (simplified to single form) - Improve ProductCurriculum layout from 3-column (3|5|4) to 2-column (4|8) - Modules and lessons now in left sidebar with accordion-style expansion - Lesson editor takes 67% width instead of 33% for better content editing UX - Add helpful tip about configuring both video sources for redundancy 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -43,7 +43,6 @@ const userNavItems: NavItem[] = [
|
||||
const adminNavItems: NavItem[] = [
|
||||
{ label: 'Dashboard', href: '/admin', icon: LayoutDashboard },
|
||||
{ label: 'Produk', href: '/admin/products', icon: Package },
|
||||
{ label: 'Bootcamp', href: '/admin/bootcamp', icon: BookOpen },
|
||||
{ label: 'Konsultasi', href: '/admin/consulting', icon: Video },
|
||||
{ label: 'Order', href: '/admin/orders', icon: Receipt },
|
||||
{ label: 'Member', href: '/admin/members', icon: Users },
|
||||
|
||||
@@ -12,13 +12,14 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { toast } from '@/hooks/use-toast';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Plus, Pencil, Trash2, Search, X, BookOpen } from 'lucide-react';
|
||||
import { CurriculumEditor } from '@/components/admin/CurriculumEditor';
|
||||
import { RichTextEditor } from '@/components/RichTextEditor';
|
||||
import { formatIDR } from '@/lib/format';
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Info } from 'lucide-react';
|
||||
|
||||
interface Product {
|
||||
id: string;
|
||||
@@ -34,6 +35,7 @@ interface Product {
|
||||
price: number;
|
||||
sale_price: number | null;
|
||||
is_active: boolean;
|
||||
video_source?: string;
|
||||
}
|
||||
|
||||
const emptyProduct = {
|
||||
@@ -49,6 +51,7 @@ const emptyProduct = {
|
||||
price: 0,
|
||||
sale_price: null as number | null,
|
||||
is_active: true,
|
||||
video_source: 'youtube' as string,
|
||||
};
|
||||
|
||||
export default function AdminProducts() {
|
||||
@@ -60,7 +63,6 @@ export default function AdminProducts() {
|
||||
const [editingProduct, setEditingProduct] = useState<Product | null>(null);
|
||||
const [form, setForm] = useState(emptyProduct);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState('details');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [filterType, setFilterType] = useState<string>('all');
|
||||
const [filterStatus, setFilterStatus] = useState<string>('all');
|
||||
@@ -116,15 +118,14 @@ export default function AdminProducts() {
|
||||
price: product.price,
|
||||
sale_price: product.sale_price,
|
||||
is_active: product.is_active,
|
||||
video_source: product.video_source || 'youtube',
|
||||
});
|
||||
setActiveTab('details');
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleNew = () => {
|
||||
setEditingProduct(null);
|
||||
setForm(emptyProduct);
|
||||
setActiveTab('details');
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
@@ -147,6 +148,7 @@ export default function AdminProducts() {
|
||||
price: form.price,
|
||||
sale_price: form.sale_price || null,
|
||||
is_active: form.is_active,
|
||||
video_source: form.video_source || 'youtube',
|
||||
};
|
||||
|
||||
if (editingProduct) {
|
||||
@@ -420,12 +422,7 @@ export default function AdminProducts() {
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingProduct ? 'Edit Produk' : 'Produk Baru'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="mt-4">
|
||||
<TabsList className="border-2 border-border">
|
||||
<TabsTrigger value="details">Detail</TabsTrigger>
|
||||
{editingProduct && form.type === 'bootcamp' && <TabsTrigger value="curriculum">Kurikulum</TabsTrigger>}
|
||||
</TabsList>
|
||||
<TabsContent value="details" className="space-y-4 py-4">
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Judul *</Label>
|
||||
@@ -487,6 +484,46 @@ export default function AdminProducts() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{form.type === 'bootcamp' && (
|
||||
<div className="space-y-4">
|
||||
<Label className="text-base font-semibold">Video Source Settings</Label>
|
||||
<RadioGroup
|
||||
value={form.video_source || 'youtube'}
|
||||
onValueChange={(value) => setForm({ ...form, video_source: value })}
|
||||
>
|
||||
<div className="flex items-center space-x-2 p-3 border-2 border-border rounded-lg">
|
||||
<RadioGroupItem value="youtube" id="youtube" />
|
||||
<div className="flex-1">
|
||||
<Label htmlFor="youtube" className="font-medium cursor-pointer">
|
||||
YouTube (Primary)
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Use YouTube URLs for all lessons
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2 p-3 border-2 border-border rounded-lg">
|
||||
<RadioGroupItem value="embed" id="embed" />
|
||||
<div className="flex-1">
|
||||
<Label htmlFor="embed" className="font-medium cursor-pointer">
|
||||
Custom Embed (Backup)
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Use custom embed codes (Adilo, Vimeo, etc.) for all lessons
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
|
||||
<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.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Harga *</Label>
|
||||
@@ -504,13 +541,7 @@ export default function AdminProducts() {
|
||||
<Button onClick={handleSave} className="w-full shadow-sm" disabled={saving}>
|
||||
{saving ? 'Menyimpan...' : 'Simpan Produk'}
|
||||
</Button>
|
||||
</TabsContent>
|
||||
{editingProduct && form.type === 'bootcamp' && (
|
||||
<TabsContent value="curriculum" className="py-4">
|
||||
<CurriculumEditor productId={editingProduct.id} />
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
@@ -314,43 +314,39 @@ export default function ProductCurriculum() {
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-12 gap-6">
|
||||
{/* Left: Modules List (3 columns) */}
|
||||
<div className="col-span-3">
|
||||
{/* Left Sidebar: Modules & Lessons (4 columns) */}
|
||||
<div className="col-span-4 space-y-6">
|
||||
{/* Modules Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base">Modules</CardTitle>
|
||||
<Button size="sm" onClick={handleAddModule} className="w-full">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Add Module
|
||||
<Button size="sm" onClick={handleAddModule}>
|
||||
<Plus className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{modules.map((module, index) => {
|
||||
const moduleLessons = getLessonsForModule(module.id);
|
||||
const isSelected = selectedModuleId === module.id;
|
||||
const isExpanded = expandedModules.has(module.id);
|
||||
|
||||
return (
|
||||
<div key={module.id} className="border-2 border-border rounded-lg overflow-hidden">
|
||||
{/* Module Header */}
|
||||
<div
|
||||
key={module.id}
|
||||
className={cn(
|
||||
"p-3 border rounded cursor-pointer transition-colors group",
|
||||
isSelected ? "border-primary bg-primary/5" : "hover:bg-gray-50 border-border"
|
||||
"p-3 cursor-pointer transition-colors",
|
||||
isSelected ? "bg-primary/10" : "bg-muted hover:bg-muted/80"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div
|
||||
className="flex items-center gap-2 flex-1 min-w-0"
|
||||
onClick={() => {
|
||||
setSelectedModuleId(module.id);
|
||||
if (expandedModules.has(module.id)) {
|
||||
toggleModule(module.id);
|
||||
} else {
|
||||
const newExpanded = new Set(expandedModules);
|
||||
newExpanded.add(module.id);
|
||||
setExpandedModules(newExpanded);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<GripVertical className="w-4 h-4 text-muted-foreground shrink-0" />
|
||||
<span className="font-medium truncate">{module.title}</span>
|
||||
</div>
|
||||
@@ -359,7 +355,10 @@ export default function ProductCurriculum() {
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={() => moveModule(module.id, 'up')}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
moveModule(module.id, 'up');
|
||||
}}
|
||||
disabled={index === 0}
|
||||
>
|
||||
<ChevronUp className="w-3 h-3" />
|
||||
@@ -368,7 +367,10 @@ export default function ProductCurriculum() {
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={() => moveModule(module.id, 'down')}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
moveModule(module.id, 'down');
|
||||
}}
|
||||
disabled={index === modules.length - 1}
|
||||
>
|
||||
<ChevronDown className="w-3 h-3" />
|
||||
@@ -377,7 +379,10 @@ export default function ProductCurriculum() {
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={() => handleEditModule(module)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEditModule(module);
|
||||
}}
|
||||
>
|
||||
<Pencil className="w-3 h-3" />
|
||||
</Button>
|
||||
@@ -385,7 +390,10 @@ export default function ProductCurriculum() {
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={() => handleDeleteModule(module.id)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteModule(module.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</Button>
|
||||
@@ -395,56 +403,43 @@ export default function ProductCurriculum() {
|
||||
{moduleLessons.length} lesson{moduleLessons.length !== 1 ? 's' : ''}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{modules.length === 0 && (
|
||||
<div className="text-center text-sm text-muted-foreground py-4">
|
||||
No modules yet
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Middle: Lessons List (5 columns) */}
|
||||
<div className="col-span-5">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Lessons</CardTitle>
|
||||
{selectedModuleId && (
|
||||
<Button size="sm" onClick={() => handleAddLesson(selectedModuleId)} className="w-full">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
{/* Lessons List (expanded) */}
|
||||
{isExpanded && (
|
||||
<div className="border-t-2 border-border bg-card">
|
||||
<div className="p-2 space-y-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleAddLesson(module.id);
|
||||
}}
|
||||
className="w-full border-dashed text-xs"
|
||||
>
|
||||
<Plus className="w-3 h-3 mr-1" />
|
||||
Add Lesson
|
||||
</Button>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!selectedModuleId ? (
|
||||
<p className="text-muted-foreground text-center py-8 text-sm">
|
||||
Select a module to view lessons
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{getLessonsForModule(selectedModuleId).map((lesson, index) => {
|
||||
const isSelected = selectedLessonId === lesson.id;
|
||||
|
||||
{moduleLessons.map((lesson, lessonIndex) => {
|
||||
const isLessonSelected = selectedLessonId === lesson.id;
|
||||
return (
|
||||
<div
|
||||
key={lesson.id}
|
||||
className={cn(
|
||||
"p-3 border rounded cursor-pointer transition-colors group",
|
||||
isSelected ? "border-primary bg-primary/5" : "hover:bg-gray-50 border-border"
|
||||
"p-2 rounded cursor-pointer transition-colors group",
|
||||
isLessonSelected ? "bg-primary/20 border border-primary" : "hover:bg-muted"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEditLesson(lesson);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div
|
||||
className="flex-1 min-w-0"
|
||||
onClick={() => handleEditLesson(lesson)}
|
||||
>
|
||||
<p className="font-medium text-sm truncate">{lesson.title}</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{lesson.title}</p>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{index + 1}. {lesson.video_url || lesson.youtube_url || lesson.embed_code ? '✓ Video' : 'No video'}
|
||||
{lessonIndex + 1}.
|
||||
</span>
|
||||
{lesson.youtube_url && (
|
||||
<span className="text-xs text-blue-600">YouTube</span>
|
||||
@@ -457,30 +452,39 @@ export default function ProductCurriculum() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={() => moveLesson(lesson.id, 'up')}
|
||||
disabled={index === 0}
|
||||
className="h-5 w-5 p-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
moveLesson(lesson.id, 'up');
|
||||
}}
|
||||
disabled={lessonIndex === 0}
|
||||
>
|
||||
<ChevronUp className="w-3 h-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={() => moveLesson(lesson.id, 'down')}
|
||||
disabled={index === getLessonsForModule(selectedModuleId).length - 1}
|
||||
className="h-5 w-5 p-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
moveLesson(lesson.id, 'down');
|
||||
}}
|
||||
disabled={lessonIndex === moduleLessons.length - 1}
|
||||
>
|
||||
<ChevronDown className="w-3 h-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={() => handleDeleteLesson(lesson.id)}
|
||||
className="h-5 w-5 p-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteLesson(lesson.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</Button>
|
||||
@@ -489,40 +493,57 @@ export default function ProductCurriculum() {
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{getLessonsForModule(selectedModuleId).length === 0 && (
|
||||
<div className="text-center text-sm text-muted-foreground py-4">
|
||||
{moduleLessons.length === 0 && (
|
||||
<div className="text-center text-xs text-muted-foreground py-2">
|
||||
No lessons yet
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{modules.length === 0 && (
|
||||
<div className="text-center text-sm text-muted-foreground py-4">
|
||||
No modules yet. Click + to create one.
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Right: Lesson Editor (4 columns) */}
|
||||
<div className="col-span-4">
|
||||
{/* Right: Lesson Editor (8 columns - full width for better UX) */}
|
||||
<div className="col-span-8">
|
||||
<Card className="sticky top-4">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Lesson Editor</CardTitle>
|
||||
<CardTitle>
|
||||
{selectedLessonId === 'new' ? 'New Lesson' : editingLesson ? 'Edit Lesson' : 'Lesson Editor'}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!selectedLessonId ? (
|
||||
<p className="text-muted-foreground text-center py-8 text-sm">
|
||||
Select a lesson to edit
|
||||
<div className="text-center py-16">
|
||||
<p className="text-muted-foreground mb-4">
|
||||
Select or create a lesson to start editing
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Click on a module to expand it, then click "Add Lesson" or select an existing lesson.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label>Title *</Label>
|
||||
<Input
|
||||
value={lessonForm.title}
|
||||
onChange={(e) => setLessonForm({ ...lessonForm, title: e.target.value })}
|
||||
placeholder="Lesson title"
|
||||
className="border-2"
|
||||
className="border-2 text-base"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>YouTube URL (Primary)</Label>
|
||||
<Input
|
||||
@@ -536,6 +557,17 @@ export default function ProductCurriculum() {
|
||||
)}
|
||||
</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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Embed Code (Backup)</Label>
|
||||
<textarea
|
||||
@@ -550,9 +582,9 @@ export default function ProductCurriculum() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-muted rounded-md">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
💡 <strong>Tip:</strong> Configure both YouTube URL and embed code for redundancy. Use product settings to toggle between sources.
|
||||
<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>
|
||||
|
||||
@@ -562,25 +594,15 @@ export default function ProductCurriculum() {
|
||||
content={lessonForm.content}
|
||||
onChange={(html) => setLessonForm({ ...lessonForm, content: html })}
|
||||
placeholder="Write your lesson content here... Use code blocks for syntax highlighting."
|
||||
className="min-h-[300px]"
|
||||
className="min-h-[400px]"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Supports rich text formatting, code blocks with syntax highlighting, images, and more.
|
||||
</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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleSaveLesson} disabled={saving} className="flex-1">
|
||||
<div className="flex gap-3 pt-4">
|
||||
<Button onClick={handleSaveLesson} disabled={saving} className="flex-1 shadow-sm" size="lg">
|
||||
{saving ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
@@ -598,6 +620,7 @@ export default function ProductCurriculum() {
|
||||
onClick={() => setSelectedLessonId(null)}
|
||||
disabled={saving}
|
||||
className="border-2"
|
||||
size="lg"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
Reference in New Issue
Block a user