- Add text alignment controls to Tiptap editor (left, center, right, justify) - Add horizontal rule/spacer button to Tiptap toolbar - Add event_start and duration_minutes fields to webinar products - Add webinar status badges (Recording Available, Coming Soon, Ended) - Install @tiptap/extension-text-align package 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
386 lines
17 KiB
TypeScript
386 lines
17 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { useAuth } from '@/hooks/useAuth';
|
|
import { supabase } from '@/integrations/supabase/client';
|
|
import { AppLayout } from '@/components/AppLayout';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Label } from '@/components/ui/label';
|
|
import { Textarea } from '@/components/ui/textarea';
|
|
import { Card, CardContent } from '@/components/ui/card';
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
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 } from 'lucide-react';
|
|
import { CurriculumEditor } from '@/components/admin/CurriculumEditor';
|
|
import { RichTextEditor } from '@/components/RichTextEditor';
|
|
import { formatIDR } from '@/lib/format';
|
|
|
|
interface Product {
|
|
id: string;
|
|
title: string;
|
|
slug: string;
|
|
type: string;
|
|
description: string;
|
|
content: string;
|
|
meeting_link: string | null;
|
|
recording_url: string | null;
|
|
event_start: string | null;
|
|
duration_minutes: number | null;
|
|
price: number;
|
|
sale_price: number | null;
|
|
is_active: boolean;
|
|
}
|
|
|
|
const emptyProduct = {
|
|
title: '',
|
|
slug: '',
|
|
type: 'webinar',
|
|
description: '',
|
|
content: '',
|
|
meeting_link: '',
|
|
recording_url: '',
|
|
event_start: null as string | null,
|
|
duration_minutes: null as number | null,
|
|
price: 0,
|
|
sale_price: null as number | null,
|
|
is_active: true,
|
|
};
|
|
|
|
export default function AdminProducts() {
|
|
const { user, isAdmin, loading: authLoading } = useAuth();
|
|
const navigate = useNavigate();
|
|
const [products, setProducts] = useState<Product[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [dialogOpen, setDialogOpen] = useState(false);
|
|
const [editingProduct, setEditingProduct] = useState<Product | null>(null);
|
|
const [form, setForm] = useState(emptyProduct);
|
|
const [saving, setSaving] = useState(false);
|
|
const [activeTab, setActiveTab] = useState('details');
|
|
|
|
useEffect(() => {
|
|
if (!authLoading) {
|
|
if (!user) navigate('/auth');
|
|
else if (!isAdmin) navigate('/dashboard');
|
|
else fetchProducts();
|
|
}
|
|
}, [user, isAdmin, authLoading]);
|
|
|
|
const fetchProducts = async () => {
|
|
const { data, error } = await supabase.from('products').select('*').order('created_at', { ascending: false });
|
|
if (!error && data) setProducts(data);
|
|
setLoading(false);
|
|
};
|
|
|
|
const generateSlug = (title: string) => title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
|
|
|
|
const handleEdit = (product: Product) => {
|
|
setEditingProduct(product);
|
|
setForm({
|
|
title: product.title,
|
|
slug: product.slug,
|
|
type: product.type,
|
|
description: product.description,
|
|
content: product.content || '',
|
|
meeting_link: product.meeting_link || '',
|
|
recording_url: product.recording_url || '',
|
|
event_start: product.event_start,
|
|
duration_minutes: product.duration_minutes,
|
|
price: product.price,
|
|
sale_price: product.sale_price,
|
|
is_active: product.is_active,
|
|
});
|
|
setActiveTab('details');
|
|
setDialogOpen(true);
|
|
};
|
|
|
|
const handleNew = () => {
|
|
setEditingProduct(null);
|
|
setForm(emptyProduct);
|
|
setActiveTab('details');
|
|
setDialogOpen(true);
|
|
};
|
|
|
|
const handleSave = async () => {
|
|
if (!form.title || !form.slug || form.price <= 0) {
|
|
toast({ title: 'Validasi error', description: 'Lengkapi semua field wajib', variant: 'destructive' });
|
|
return;
|
|
}
|
|
setSaving(true);
|
|
const productData = {
|
|
title: form.title,
|
|
slug: form.slug,
|
|
type: form.type,
|
|
description: form.description,
|
|
content: form.content,
|
|
meeting_link: form.meeting_link || null,
|
|
recording_url: form.recording_url || null,
|
|
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,
|
|
};
|
|
|
|
if (editingProduct) {
|
|
const { error } = await supabase.from('products').update(productData).eq('id', editingProduct.id);
|
|
if (error) toast({ title: 'Error', description: 'Gagal mengupdate produk', variant: 'destructive' });
|
|
else { toast({ title: 'Berhasil', description: 'Produk diupdate' }); setDialogOpen(false); fetchProducts(); }
|
|
} else {
|
|
const { error } = await supabase.from('products').insert(productData);
|
|
if (error) toast({ title: 'Error', description: error.message, variant: 'destructive' });
|
|
else { toast({ title: 'Berhasil', description: 'Produk dibuat' }); setDialogOpen(false); fetchProducts(); }
|
|
}
|
|
setSaving(false);
|
|
};
|
|
|
|
const handleDelete = async (id: string) => {
|
|
if (!confirm('Hapus produk ini?')) return;
|
|
const { error } = await supabase.from('products').delete().eq('id', id);
|
|
if (error) toast({ title: 'Error', description: 'Gagal menghapus produk', variant: 'destructive' });
|
|
else { toast({ title: 'Berhasil', description: 'Produk dihapus' }); fetchProducts(); }
|
|
};
|
|
|
|
if (authLoading || loading) {
|
|
return (
|
|
<AppLayout>
|
|
<div className="container mx-auto px-4 py-8">
|
|
<Skeleton className="h-10 w-1/3 mb-8" />
|
|
<Skeleton className="h-64 w-full" />
|
|
</div>
|
|
</AppLayout>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<AppLayout>
|
|
<div className="container mx-auto px-4 py-8">
|
|
<div className="flex items-center justify-between mb-8">
|
|
<div>
|
|
<h1 className="text-4xl font-bold">Manajemen Produk</h1>
|
|
<p className="text-muted-foreground">Kelola semua produk</p>
|
|
</div>
|
|
<Button onClick={handleNew} className="shadow-sm">
|
|
<Plus className="w-4 h-4 mr-2" />
|
|
Tambah Produk
|
|
</Button>
|
|
</div>
|
|
|
|
<Card className="border-2 border-border hidden md:block">
|
|
<CardContent className="p-0">
|
|
{/* Desktop Table */}
|
|
<div className="overflow-x-auto">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead className="whitespace-nowrap">Judul</TableHead>
|
|
<TableHead className="whitespace-nowrap">Tipe</TableHead>
|
|
<TableHead className="whitespace-nowrap">Harga</TableHead>
|
|
<TableHead className="whitespace-nowrap">Status</TableHead>
|
|
<TableHead className="text-right whitespace-nowrap">Aksi</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{products.map((product) => (
|
|
<TableRow key={product.id}>
|
|
<TableCell className="font-medium">{product.title}</TableCell>
|
|
<TableCell className="capitalize">{product.type}</TableCell>
|
|
<TableCell>
|
|
{product.sale_price ? (
|
|
<span>
|
|
<span className="font-bold">{formatIDR(product.sale_price)}</span>
|
|
<span className="text-muted-foreground line-through ml-2">{formatIDR(product.price)}</span>
|
|
</span>
|
|
) : (
|
|
<span className="font-bold">{formatIDR(product.price)}</span>
|
|
)}
|
|
</TableCell>
|
|
<TableCell>
|
|
<span className={product.is_active ? 'text-foreground' : 'text-muted-foreground'}>
|
|
{product.is_active ? 'Aktif' : 'Nonaktif'}
|
|
</span>
|
|
</TableCell>
|
|
<TableCell className="text-right">
|
|
<Button variant="ghost" size="sm" onClick={() => handleEdit(product)}>
|
|
<Pencil className="w-4 h-4" />
|
|
</Button>
|
|
<Button variant="ghost" size="sm" onClick={() => handleDelete(product.id)}>
|
|
<Trash2 className="w-4 h-4" />
|
|
</Button>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
{products.length === 0 && (
|
|
<TableRow>
|
|
<TableCell colSpan={5} className="text-center py-8 text-muted-foreground">
|
|
Belum ada produk
|
|
</TableCell>
|
|
</TableRow>
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Mobile Card Layout */}
|
|
<div className="md:hidden space-y-3">
|
|
{products.map((product) => (
|
|
<div key={product.id} className="border-2 border-border rounded-lg p-4 space-y-3 bg-card shadow-sm">
|
|
<div className="flex items-start justify-between gap-2">
|
|
<div className="flex-1 min-w-0">
|
|
<h3 className="font-semibold text-base line-clamp-1">{product.title}</h3>
|
|
<p className="text-sm text-muted-foreground capitalize">{product.type}</p>
|
|
</div>
|
|
<div className="flex gap-1 shrink-0">
|
|
<Button variant="ghost" size="sm" onClick={() => handleEdit(product)}>
|
|
<Pencil className="w-4 h-4" />
|
|
</Button>
|
|
<Button variant="ghost" size="sm" onClick={() => handleDelete(product.id)}>
|
|
<Trash2 className="w-4 h-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-sm text-muted-foreground">Harga:</span>
|
|
<div className="text-right">
|
|
{product.sale_price ? (
|
|
<div className="flex items-center gap-2">
|
|
<span className="font-bold">{formatIDR(product.sale_price)}</span>
|
|
<span className="text-sm text-muted-foreground line-through">{formatIDR(product.price)}</span>
|
|
</div>
|
|
) : (
|
|
<span className="font-bold">{formatIDR(product.price)}</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-sm text-muted-foreground">Status:</span>
|
|
<span className={product.is_active ? 'text-foreground' : 'text-muted-foreground'}>
|
|
{product.is_active ? 'Aktif' : 'Nonaktif'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
{products.length === 0 && (
|
|
<div className="text-center py-8 text-muted-foreground">
|
|
Belum ada produk
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<Dialog open={dialogOpen} onOpenChange={(open) => {
|
|
if (!open) {
|
|
const confirmed = window.confirm('Tutup dialog? Data yang belum disimpan akan hilang.');
|
|
if (!confirmed) return;
|
|
}
|
|
setDialogOpen(open);
|
|
}}>
|
|
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto border-2 border-border">
|
|
<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="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
<Label>Judul *</Label>
|
|
<Input value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value, slug: generateSlug(e.target.value) })} className="border-2" />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Slug *</Label>
|
|
<Input value={form.slug} onChange={(e) => setForm({ ...form, slug: e.target.value })} className="border-2" />
|
|
</div>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Tipe</Label>
|
|
<Select value={form.type} onValueChange={(v) => setForm({ ...form, type: v })}>
|
|
<SelectTrigger className="border-2"><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="webinar">Webinar</SelectItem>
|
|
<SelectItem value="bootcamp">Bootcamp</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Deskripsi</Label>
|
|
<RichTextEditor content={form.description} onChange={(v) => setForm({ ...form, description: v })} />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<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="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
<Label>Tanggal & Waktu Webinar</Label>
|
|
<Input
|
|
type="datetime-local"
|
|
value={form.event_start || ''}
|
|
onChange={(e) => setForm({ ...form, event_start: e.target.value || null })}
|
|
className="border-2"
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Durasi (menit)</Label>
|
|
<Input
|
|
type="number"
|
|
value={form.duration_minutes || ''}
|
|
onChange={(e) => setForm({ ...form, duration_minutes: e.target.value ? parseInt(e.target.value) : null })}
|
|
placeholder="60"
|
|
className="border-2"
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
<Label>Harga *</Label>
|
|
<Input type="number" value={form.price} onChange={(e) => setForm({ ...form, price: parseFloat(e.target.value) || 0 })} className="border-2" />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Harga Promo</Label>
|
|
<Input type="number" value={form.sale_price || ''} onChange={(e) => setForm({ ...form, sale_price: e.target.value ? parseFloat(e.target.value) : null })} placeholder="Kosongkan jika tidak promo" className="border-2" />
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Switch checked={form.is_active} onCheckedChange={(checked) => setForm({ ...form, is_active: checked })} />
|
|
<Label>Aktif</Label>
|
|
</div>
|
|
<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>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
</AppLayout>
|
|
);
|
|
}
|