This commit is contained in:
gpt-engineer-app[bot]
2025-12-19 16:02:31 +00:00
parent d410b5b7c1
commit e569c2cf7e
12 changed files with 1282 additions and 48 deletions

View File

@@ -14,6 +14,9 @@ import Checkout from "./pages/Checkout";
import Bootcamp from "./pages/Bootcamp";
import Events from "./pages/Events";
import ConsultingBooking from "./pages/ConsultingBooking";
import Calendar from "./pages/Calendar";
import Privacy from "./pages/Privacy";
import Terms from "./pages/Terms";
import NotFound from "./pages/NotFound";
// Member pages
@@ -53,6 +56,9 @@ const App = () => (
<Route path="/events" element={<Events />} />
<Route path="/bootcamp/:slug" element={<Bootcamp />} />
<Route path="/consulting" element={<ConsultingBooking />} />
<Route path="/calendar" element={<Calendar />} />
<Route path="/privacy" element={<Privacy />} />
<Route path="/terms" element={<Terms />} />
{/* Member routes */}
<Route path="/dashboard" element={<MemberDashboard />} />

View File

@@ -6,12 +6,16 @@ import Placeholder from '@tiptap/extension-placeholder';
import { Button } from '@/components/ui/button';
import {
Bold, Italic, List, ListOrdered, Quote, Link as LinkIcon,
Image as ImageIcon, Heading1, Heading2, Undo, Redo
Image as ImageIcon, Heading1, Heading2, Undo, Redo,
Maximize2, Minimize2
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { useCallback, useEffect } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { supabase } from '@/integrations/supabase/client';
import { toast } from '@/hooks/use-toast';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
interface RichTextEditorProps {
content: string;
@@ -20,7 +24,37 @@ interface RichTextEditorProps {
className?: string;
}
// Custom Image extension with resize support
const ResizableImage = Image.extend({
addAttributes() {
return {
...this.parent?.(),
width: {
default: null,
parseHTML: element => element.getAttribute('width') || element.style.width?.replace('px', ''),
renderHTML: attributes => {
if (!attributes.width) return {};
return { width: attributes.width, style: `width: ${attributes.width}px` };
},
},
height: {
default: null,
parseHTML: element => element.getAttribute('height') || element.style.height?.replace('px', ''),
renderHTML: attributes => {
if (!attributes.height) return {};
return { height: attributes.height, style: `height: ${attributes.height}px` };
},
},
};
},
});
export function RichTextEditor({ content, onChange, placeholder = 'Tulis konten...', className }: RichTextEditorProps) {
const [uploading, setUploading] = useState(false);
const [selectedImage, setSelectedImage] = useState<{ src: string; width?: number; height?: number } | null>(null);
const [imageWidth, setImageWidth] = useState('');
const [imageHeight, setImageHeight] = useState('');
const editor = useEditor({
extensions: [
StarterKit,
@@ -30,9 +64,9 @@ export function RichTextEditor({ content, onChange, placeholder = 'Tulis konten.
class: 'text-primary underline',
},
}),
Image.configure({
ResizableImage.configure({
HTMLAttributes: {
class: 'max-w-full h-auto rounded-md',
class: 'max-w-full h-auto rounded-md cursor-pointer',
},
}),
Placeholder.configure({
@@ -43,9 +77,25 @@ export function RichTextEditor({ content, onChange, placeholder = 'Tulis konten.
onUpdate: ({ editor }) => {
onChange(editor.getHTML());
},
onSelectionUpdate: ({ editor }) => {
// Check if an image is selected
const { selection } = editor.state;
const node = editor.state.doc.nodeAt(selection.from);
if (node?.type.name === 'image') {
setSelectedImage({
src: node.attrs.src,
width: node.attrs.width,
height: node.attrs.height,
});
setImageWidth(node.attrs.width?.toString() || '');
setImageHeight(node.attrs.height?.toString() || '');
} else {
setSelectedImage(null);
}
},
});
// Sync content when it changes externally (e.g., when editing different items)
// Sync content when it changes externally
useEffect(() => {
if (editor && content !== editor.getHTML()) {
editor.commands.setContent(content || '');
@@ -60,18 +110,65 @@ export function RichTextEditor({ content, onChange, placeholder = 'Tulis konten.
}
}, [editor]);
const uploadImageToStorage = async (file: File): Promise<string | null> => {
try {
const fileExt = file.name.split('.').pop();
const fileName = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}.${fileExt}`;
const filePath = `editor-images/${fileName}`;
const { data, error } = await supabase.storage
.from('content')
.upload(filePath, file, {
cacheControl: '3600',
upsert: false,
});
if (error) {
console.error('Storage upload error:', error);
// Fall back to base64 if storage fails
return null;
}
const { data: urlData } = supabase.storage.from('content').getPublicUrl(filePath);
return urlData.publicUrl;
} catch (err) {
console.error('Upload error:', err);
return null;
}
};
const convertToDataUrl = (file: File): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(file);
});
};
const handleImageUpload = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file || !editor) return;
// For now, convert to base64 data URL since storage bucket may not be configured
// In production, you would upload to Supabase Storage
const reader = new FileReader();
reader.onload = () => {
const dataUrl = reader.result as string;
editor.chain().focus().setImage({ src: dataUrl }).run();
};
reader.readAsDataURL(file);
setUploading(true);
// Try to upload to storage first
let imageUrl = await uploadImageToStorage(file);
// Fall back to base64 if storage upload fails
if (!imageUrl) {
toast({
title: 'Info',
description: 'Menggunakan penyimpanan lokal untuk gambar',
});
imageUrl = await convertToDataUrl(file);
}
editor.chain().focus().setImage({ src: imageUrl }).run();
setUploading(false);
// Reset input
e.target.value = '';
}, [editor]);
const handlePaste = useCallback(async (e: React.ClipboardEvent) => {
@@ -84,17 +181,47 @@ export function RichTextEditor({ content, onChange, placeholder = 'Tulis konten.
const file = item.getAsFile();
if (!file) continue;
const reader = new FileReader();
reader.onload = () => {
const dataUrl = reader.result as string;
editor.chain().focus().setImage({ src: dataUrl }).run();
};
reader.readAsDataURL(file);
setUploading(true);
let imageUrl = await uploadImageToStorage(file);
if (!imageUrl) {
imageUrl = await convertToDataUrl(file);
}
editor.chain().focus().setImage({ src: imageUrl }).run();
setUploading(false);
break;
}
}
}, [editor]);
const updateImageSize = useCallback(() => {
if (!editor || !selectedImage) return;
const width = imageWidth ? parseInt(imageWidth) : null;
const height = imageHeight ? parseInt(imageHeight) : null;
editor.chain().focus().updateAttributes('image', { width, height }).run();
toast({ title: 'Berhasil', description: 'Ukuran gambar diperbarui' });
}, [editor, selectedImage, imageWidth, imageHeight]);
const setPresetSize = useCallback((size: 'small' | 'medium' | 'large' | 'full') => {
if (!editor) return;
const sizes = {
small: { width: 200, height: null },
medium: { width: 400, height: null },
large: { width: 600, height: null },
full: { width: null, height: null },
};
const { width, height } = sizes[size];
editor.chain().focus().updateAttributes('image', { width, height }).run();
setImageWidth(width?.toString() || '');
setImageHeight(height?.toString() || '');
}, [editor]);
if (!editor) return null;
return (
@@ -173,8 +300,8 @@ export function RichTextEditor({ content, onChange, placeholder = 'Tulis konten.
<LinkIcon className="w-4 h-4" />
</Button>
<label>
<Button type="button" variant="ghost" size="sm" asChild>
<span>
<Button type="button" variant="ghost" size="sm" asChild disabled={uploading}>
<span className={uploading ? 'opacity-50' : ''}>
<ImageIcon className="w-4 h-4" />
</span>
</Button>
@@ -183,8 +310,57 @@ export function RichTextEditor({ content, onChange, placeholder = 'Tulis konten.
accept="image/*"
onChange={handleImageUpload}
className="hidden"
disabled={uploading}
/>
</label>
{/* Image resize popover */}
{selectedImage && (
<Popover>
<PopoverTrigger asChild>
<Button type="button" variant="ghost" size="sm" className="bg-accent">
<Maximize2 className="w-4 h-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-64">
<div className="space-y-4">
<h4 className="font-medium">Ukuran Gambar</h4>
<div className="grid grid-cols-4 gap-2">
<Button type="button" variant="outline" size="sm" onClick={() => setPresetSize('small')}>S</Button>
<Button type="button" variant="outline" size="sm" onClick={() => setPresetSize('medium')}>M</Button>
<Button type="button" variant="outline" size="sm" onClick={() => setPresetSize('large')}>L</Button>
<Button type="button" variant="outline" size="sm" onClick={() => setPresetSize('full')}>Full</Button>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<Label className="text-xs">Lebar (px)</Label>
<Input
type="number"
value={imageWidth}
onChange={(e) => setImageWidth(e.target.value)}
placeholder="Auto"
className="h-8"
/>
</div>
<div>
<Label className="text-xs">Tinggi (px)</Label>
<Input
type="number"
value={imageHeight}
onChange={(e) => setImageHeight(e.target.value)}
placeholder="Auto"
className="h-8"
/>
</div>
</div>
<Button type="button" size="sm" onClick={updateImageSize} className="w-full">
Terapkan
</Button>
</div>
</PopoverContent>
</Popover>
)}
<div className="flex-1" />
<Button
type="button"
@@ -208,9 +384,14 @@ export function RichTextEditor({ content, onChange, placeholder = 'Tulis konten.
<div onPaste={handlePaste}>
<EditorContent
editor={editor}
className="prose prose-sm max-w-none p-4 min-h-[200px] focus:outline-none [&_.ProseMirror]:outline-none [&_.ProseMirror]:min-h-[180px]"
className="prose prose-sm max-w-none p-4 min-h-[200px] focus:outline-none [&_.ProseMirror]:outline-none [&_.ProseMirror]:min-h-[180px] [&_img]:cursor-pointer [&_img.ProseMirror-selectednode]:ring-2 [&_img.ProseMirror-selectednode]:ring-primary"
/>
</div>
{uploading && (
<div className="p-2 text-sm text-muted-foreground text-center border-t border-border">
Mengunggah gambar...
</div>
)}
</div>
);
}

View File

@@ -213,6 +213,7 @@ export function IntegrasiTab() {
<SelectContent>
<SelectItem value="smtp">SMTP (Default)</SelectItem>
<SelectItem value="resend">Resend</SelectItem>
<SelectItem value="elasticemail">ElasticEmail</SelectItem>
<SelectItem value="mailgun">Mailgun</SelectItem>
<SelectItem value="sendgrid">SendGrid</SelectItem>
</SelectContent>

303
src/pages/Calendar.tsx Normal file
View File

@@ -0,0 +1,303 @@
import { useEffect, useState } from 'react';
import { supabase } from '@/integrations/supabase/client';
import { AppLayout } from '@/components/AppLayout';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { Calendar as CalendarIcon, Video, BookOpen, Users, Clock, ChevronLeft, ChevronRight } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { format, startOfMonth, endOfMonth, eachDayOfInterval, isSameDay, isSameMonth, addMonths, subMonths, parseISO } from 'date-fns';
import { id } from 'date-fns/locale';
import { cn } from '@/lib/utils';
interface CalendarEvent {
id: string;
title: string;
type: 'bootcamp' | 'webinar' | 'consulting';
date: string;
start_time?: string;
end_time?: string;
description?: string;
}
export default function Calendar() {
const [events, setEvents] = useState<CalendarEvent[]>([]);
const [loading, setLoading] = useState(true);
const [currentMonth, setCurrentMonth] = useState(new Date());
const [selectedDate, setSelectedDate] = useState<Date | null>(null);
useEffect(() => {
fetchEvents();
}, [currentMonth]);
const fetchEvents = async () => {
const start = format(startOfMonth(currentMonth), 'yyyy-MM-dd');
const end = format(endOfMonth(currentMonth), 'yyyy-MM-dd');
// Fetch bootcamp events
const { data: bootcamps } = await supabase
.from('products')
.select('id, title, event_start')
.eq('type', 'bootcamp')
.eq('is_active', true)
.gte('event_start', start)
.lte('event_start', end);
// Fetch webinar events
const { data: webinars } = await supabase
.from('products')
.select('id, title, event_start, duration')
.eq('type', 'webinar')
.eq('is_active', true)
.gte('event_start', start)
.lte('event_start', end);
// Fetch confirmed consulting slots
const { data: consultings } = await supabase
.from('consulting_slots')
.select('id, date, start_time, end_time, topic_category')
.eq('status', 'confirmed')
.gte('date', start)
.lte('date', end);
const allEvents: CalendarEvent[] = [];
bootcamps?.forEach(b => {
if (b.event_start) {
allEvents.push({
id: b.id,
title: b.title,
type: 'bootcamp',
date: b.event_start.split('T')[0],
});
}
});
webinars?.forEach(w => {
if (w.event_start) {
const eventDate = new Date(w.event_start);
allEvents.push({
id: w.id,
title: w.title,
type: 'webinar',
date: format(eventDate, 'yyyy-MM-dd'),
start_time: format(eventDate, 'HH:mm'),
});
}
});
consultings?.forEach(c => {
allEvents.push({
id: c.id,
title: `Konsultasi: ${c.topic_category}`,
type: 'consulting',
date: c.date,
start_time: c.start_time?.substring(0, 5),
end_time: c.end_time?.substring(0, 5),
});
});
setEvents(allEvents);
setLoading(false);
};
const days = eachDayOfInterval({
start: startOfMonth(currentMonth),
end: endOfMonth(currentMonth),
});
const getEventsForDate = (date: Date) => {
return events.filter(e => isSameDay(parseISO(e.date), date));
};
const getEventIcon = (type: string) => {
switch (type) {
case 'bootcamp': return <BookOpen className="w-3 h-3" />;
case 'webinar': return <Video className="w-3 h-3" />;
case 'consulting': return <Users className="w-3 h-3" />;
default: return <CalendarIcon className="w-3 h-3" />;
}
};
const getEventColor = (type: string) => {
switch (type) {
case 'bootcamp': return 'bg-primary text-primary-foreground';
case 'webinar': return 'bg-accent text-primary';
case 'consulting': return 'bg-secondary text-secondary-foreground';
default: return 'bg-muted';
}
};
const selectedDateEvents = selectedDate ? getEventsForDate(selectedDate) : [];
if (loading) {
return (
<AppLayout>
<div className="container mx-auto px-4 py-8">
<Skeleton className="h-10 w-1/3 mb-8" />
<Skeleton className="h-96 w-full" />
</div>
</AppLayout>
);
}
return (
<AppLayout>
<div className="container mx-auto px-4 py-8">
<h1 className="text-4xl font-bold mb-2 flex items-center gap-3">
<CalendarIcon className="w-10 h-10" />
Kalender Event
</h1>
<p className="text-muted-foreground mb-8">
Lihat jadwal bootcamp, webinar, dan konsultasi
</p>
{/* Legend */}
<div className="flex flex-wrap gap-4 mb-6">
<div className="flex items-center gap-2">
<div className="w-4 h-4 bg-primary rounded" />
<span className="text-sm">Bootcamp</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 bg-accent rounded" />
<span className="text-sm">Webinar</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 bg-secondary rounded" />
<span className="text-sm">Konsultasi</span>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Calendar Grid */}
<Card className="border-2 border-border lg:col-span-2">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>
{format(currentMonth, 'MMMM yyyy', { locale: id })}
</CardTitle>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => setCurrentMonth(subMonths(currentMonth, 1))}>
<ChevronLeft className="w-4 h-4" />
</Button>
<Button variant="outline" size="sm" onClick={() => setCurrentMonth(new Date())}>
Hari Ini
</Button>
<Button variant="outline" size="sm" onClick={() => setCurrentMonth(addMonths(currentMonth, 1))}>
<ChevronRight className="w-4 h-4" />
</Button>
</div>
</CardHeader>
<CardContent>
{/* Day headers */}
<div className="grid grid-cols-7 gap-1 mb-2">
{['Min', 'Sen', 'Sel', 'Rab', 'Kam', 'Jum', 'Sab'].map(day => (
<div key={day} className="text-center text-sm font-medium text-muted-foreground py-2">
{day}
</div>
))}
</div>
{/* Calendar days */}
<div className="grid grid-cols-7 gap-1">
{/* Empty cells for days before month start */}
{Array.from({ length: startOfMonth(currentMonth).getDay() }).map((_, i) => (
<div key={`empty-${i}`} className="aspect-square p-1" />
))}
{days.map(day => {
const dayEvents = getEventsForDate(day);
const isSelected = selectedDate && isSameDay(day, selectedDate);
const isToday = isSameDay(day, new Date());
return (
<button
key={day.toISOString()}
onClick={() => setSelectedDate(day)}
className={cn(
"aspect-square p-1 text-sm rounded-md transition-colors relative",
isSelected && "bg-primary text-primary-foreground",
!isSelected && isToday && "bg-accent",
!isSelected && !isToday && "hover:bg-muted",
!isSameMonth(day, currentMonth) && "text-muted-foreground opacity-50"
)}
>
<span className="block">{format(day, 'd')}</span>
{dayEvents.length > 0 && (
<div className="flex gap-0.5 justify-center mt-1">
{dayEvents.slice(0, 3).map((e, i) => (
<div
key={i}
className={cn(
"w-1.5 h-1.5 rounded-full",
e.type === 'bootcamp' && "bg-primary",
e.type === 'webinar' && "bg-accent",
e.type === 'consulting' && "bg-secondary"
)}
/>
))}
{dayEvents.length > 3 && (
<span className="text-[8px]">+{dayEvents.length - 3}</span>
)}
</div>
)}
</button>
);
})}
</div>
</CardContent>
</Card>
{/* Event Details */}
<Card className="border-2 border-border">
<CardHeader>
<CardTitle className="text-lg">
{selectedDate
? format(selectedDate, 'd MMMM yyyy', { locale: id })
: 'Pilih Tanggal'
}
</CardTitle>
</CardHeader>
<CardContent>
{!selectedDate ? (
<p className="text-muted-foreground text-sm">
Klik tanggal untuk melihat event
</p>
) : selectedDateEvents.length === 0 ? (
<p className="text-muted-foreground text-sm">
Tidak ada event di tanggal ini
</p>
) : (
<div className="space-y-3">
{selectedDateEvents.map(event => (
<div
key={event.id}
className={cn(
"p-3 rounded-md",
getEventColor(event.type)
)}
>
<div className="flex items-center gap-2 mb-1">
{getEventIcon(event.type)}
<Badge variant="outline" className="text-xs capitalize">
{event.type}
</Badge>
</div>
<p className="font-medium">{event.title}</p>
{event.start_time && (
<p className="text-sm flex items-center gap-1 mt-1">
<Clock className="w-3 h-3" />
{event.start_time}
{event.end_time && ` - ${event.end_time}`}
</p>
)}
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
</div>
</AppLayout>
);
}

89
src/pages/Privacy.tsx Normal file
View File

@@ -0,0 +1,89 @@
import { AppLayout } from '@/components/AppLayout';
import { useBranding } from '@/hooks/useBranding';
import { Card, CardContent } from '@/components/ui/card';
export default function Privacy() {
const branding = useBranding();
const platformName = branding.brand_name || 'LearnHub';
return (
<AppLayout>
<div className="container mx-auto px-4 py-8 max-w-4xl">
<h1 className="text-4xl font-bold mb-8">Kebijakan Privasi</h1>
<Card className="border-2 border-border">
<CardContent className="prose max-w-none pt-6">
<p className="text-muted-foreground">
Terakhir diperbarui: {new Date().toLocaleDateString('id-ID')}
</p>
<h2>1. Informasi yang Kami Kumpulkan</h2>
<p>
{platformName} mengumpulkan informasi yang Anda berikan secara langsung, termasuk:
</p>
<ul>
<li>Nama lengkap dan alamat email saat pendaftaran akun</li>
<li>Informasi pembayaran untuk pembelian produk</li>
<li>Data progres pembelajaran untuk bootcamp</li>
<li>Catatan dan preferensi konsultasi</li>
</ul>
<h2>2. Penggunaan Informasi</h2>
<p>Kami menggunakan informasi Anda untuk:</p>
<ul>
<li>Menyediakan dan mengelola akun Anda</li>
<li>Memproses transaksi dan mengirim notifikasi terkait</li>
<li>Menyediakan akses ke konten yang telah dibeli</li>
<li>Mengirim pembaruan tentang produk dan layanan kami</li>
<li>Menjawab pertanyaan dan memberikan dukungan pelanggan</li>
</ul>
<h2>3. Keamanan Data</h2>
<p>
Kami menerapkan langkah-langkah keamanan teknis dan organisasi untuk melindungi
informasi pribadi Anda dari akses tidak sah, penggunaan yang salah, atau pengungkapan.
</p>
<h2>4. Berbagi Informasi</h2>
<p>
Kami tidak menjual atau menyewakan informasi pribadi Anda kepada pihak ketiga.
Kami hanya berbagi informasi dengan:
</p>
<ul>
<li>Penyedia layanan pembayaran untuk memproses transaksi</li>
<li>Penyedia layanan email untuk mengirim notifikasi</li>
<li>Otoritas hukum jika diwajibkan oleh hukum</li>
</ul>
<h2>5. Cookie dan Teknologi Pelacakan</h2>
<p>
Kami menggunakan cookie dan teknologi serupa untuk meningkatkan pengalaman pengguna,
menganalisis penggunaan situs, dan menyediakan konten yang relevan.
</p>
<h2>6. Hak Anda</h2>
<p>Anda memiliki hak untuk:</p>
<ul>
<li>Mengakses dan memperbarui informasi pribadi Anda</li>
<li>Meminta penghapusan akun dan data Anda</li>
<li>Menolak menerima email pemasaran</li>
<li>Mengajukan keluhan tentang penggunaan data Anda</li>
</ul>
<h2>7. Perubahan Kebijakan</h2>
<p>
Kami dapat memperbarui kebijakan privasi ini dari waktu ke waktu.
Perubahan signifikan akan diberitahukan melalui email atau notifikasi di platform.
</p>
<h2>8. Hubungi Kami</h2>
<p>
Jika Anda memiliki pertanyaan tentang kebijakan privasi ini, silakan hubungi kami
melalui email atau fitur dukungan yang tersedia di platform.
</p>
</CardContent>
</Card>
</div>
</AppLayout>
);
}

115
src/pages/Terms.tsx Normal file
View File

@@ -0,0 +1,115 @@
import { AppLayout } from '@/components/AppLayout';
import { useBranding } from '@/hooks/useBranding';
import { Card, CardContent } from '@/components/ui/card';
export default function Terms() {
const branding = useBranding();
const platformName = branding.brand_name || 'LearnHub';
return (
<AppLayout>
<div className="container mx-auto px-4 py-8 max-w-4xl">
<h1 className="text-4xl font-bold mb-8">Syarat & Ketentuan</h1>
<Card className="border-2 border-border">
<CardContent className="prose max-w-none pt-6">
<p className="text-muted-foreground">
Terakhir diperbarui: {new Date().toLocaleDateString('id-ID')}
</p>
<h2>1. Penerimaan Syarat</h2>
<p>
Dengan mengakses dan menggunakan {platformName}, Anda menyetujui untuk terikat
dengan syarat dan ketentuan ini. Jika Anda tidak setuju dengan bagian apapun
dari syarat ini, Anda tidak diperbolehkan menggunakan layanan kami.
</p>
<h2>2. Akun Pengguna</h2>
<ul>
<li>Anda bertanggung jawab untuk menjaga kerahasiaan kredensial akun Anda</li>
<li>Anda harus memberikan informasi yang akurat dan lengkap saat mendaftar</li>
<li>Anda bertanggung jawab atas semua aktivitas yang terjadi di akun Anda</li>
<li>Anda harus segera memberitahu kami jika ada penggunaan tidak sah</li>
</ul>
<h2>3. Pembelian dan Pembayaran</h2>
<ul>
<li>Semua harga ditampilkan dalam Rupiah Indonesia (IDR)</li>
<li>Pembayaran diproses melalui gateway pembayaran pihak ketiga yang aman</li>
<li>Akses ke produk diberikan setelah pembayaran dikonfirmasi</li>
<li>Kami berhak mengubah harga produk tanpa pemberitahuan sebelumnya</li>
</ul>
<h2>4. Kebijakan Pengembalian Dana</h2>
<p>
Karena sifat produk digital kami, pengembalian dana umumnya tidak tersedia
setelah akses diberikan. Namun, kami dapat mempertimbangkan pengembalian dana
dalam kasus-kasus berikut:
</p>
<ul>
<li>Produk tidak sesuai dengan deskripsi secara signifikan</li>
<li>Masalah teknis yang tidak dapat diselesaikan</li>
<li>Permintaan dalam 7 hari pertama dengan penggunaan minimal</li>
</ul>
<h2>5. Hak Kekayaan Intelektual</h2>
<p>
Semua konten di {platformName}, termasuk teks, gambar, video, dan materi kursus,
dilindungi oleh hak cipta. Anda tidak diperbolehkan untuk:
</p>
<ul>
<li>Menyalin, mendistribusikan, atau menjual ulang konten kami</li>
<li>Membagikan akses akun dengan orang lain</li>
<li>Merekam atau mengunduh konten tanpa izin</li>
<li>Menggunakan konten untuk tujuan komersial tanpa lisensi</li>
</ul>
<h2>6. Layanan Konsultasi</h2>
<ul>
<li>Jadwal konsultasi harus dipesan minimal 24 jam sebelumnya</li>
<li>Pembatalan harus dilakukan minimal 12 jam sebelum jadwal</li>
<li>Ketidakhadiran tanpa pemberitahuan dapat mengakibatkan kehilangan sesi</li>
<li>Konsultasi dilakukan melalui platform video yang ditentukan</li>
</ul>
<h2>7. Perilaku Pengguna</h2>
<p>Anda setuju untuk tidak:</p>
<ul>
<li>Menggunakan layanan untuk tujuan ilegal</li>
<li>Mengganggu atau melecehkan pengguna lain</li>
<li>Menyebarkan malware atau konten berbahaya</li>
<li>Mencoba mengakses sistem kami secara tidak sah</li>
</ul>
<h2>8. Batasan Tanggung Jawab</h2>
<p>
{platformName} tidak bertanggung jawab atas kerugian tidak langsung, insidental,
atau konsekuensial yang timbul dari penggunaan layanan kami. Tanggung jawab
maksimum kami terbatas pada jumlah yang Anda bayarkan untuk produk terkait.
</p>
<h2>9. Perubahan Syarat</h2>
<p>
Kami berhak memperbarui syarat dan ketentuan ini kapan saja.
Perubahan akan efektif segera setelah dipublikasikan di situs.
Penggunaan berkelanjutan setelah perubahan berarti penerimaan syarat baru.
</p>
<h2>10. Hukum yang Berlaku</h2>
<p>
Syarat dan ketentuan ini diatur oleh hukum Republik Indonesia.
Setiap sengketa akan diselesaikan melalui mediasi atau pengadilan
yang berwenang di Indonesia.
</p>
<h2>11. Hubungi Kami</h2>
<p>
Untuk pertanyaan tentang syarat dan ketentuan ini, silakan hubungi
tim dukungan kami melalui email atau fitur bantuan di platform.
</p>
</CardContent>
</Card>
</div>
</AppLayout>
);
}

View File

@@ -9,7 +9,7 @@ import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { Separator } from "@/components/ui/separator";
import { formatIDR, formatDate } from "@/lib/format";
import { ArrowLeft, Package, CreditCard, Calendar, Clock } from "lucide-react";
import { ArrowLeft, Package, CreditCard, Calendar, AlertCircle } from "lucide-react";
interface OrderItem {
id: string;
@@ -42,36 +42,64 @@ export default function OrderDetail() {
const navigate = useNavigate();
const [order, setOrder] = useState<Order | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!authLoading && !user) navigate("/auth");
else if (user && id) fetchOrder();
}, [user, authLoading, id]);
const fetchOrder = async () => {
const { data, error } = await supabase
.from("orders")
.select(`
*,
order_items (
id,
product_id,
quantity,
price,
products (title, type, slug)
)
`)
.eq("id", id)
.eq("user_id", user!.id)
.single();
if (error || !data) {
navigate("/orders");
if (authLoading) return;
if (!user) {
navigate("/auth");
return;
}
setOrder(data as Order);
setLoading(false);
if (id) {
fetchOrder();
}
}, [user, authLoading, id]);
const fetchOrder = async () => {
if (!user || !id) return;
setLoading(true);
setError(null);
try {
const { data, error: queryError } = await supabase
.from("orders")
.select(`
*,
order_items (
id,
product_id,
quantity,
price,
products (title, type, slug)
)
`)
.eq("id", id)
.eq("user_id", user.id)
.single();
if (queryError) {
console.error("Order fetch error:", queryError);
setError("Gagal mengambil data order");
setLoading(false);
return;
}
if (!data) {
setError("Order tidak ditemukan");
setLoading(false);
return;
}
setOrder(data as Order);
} catch (err) {
console.error("Unexpected error:", err);
setError("Terjadi kesalahan");
} finally {
setLoading(false);
}
};
const getStatusColor = (status: string) => {
@@ -127,6 +155,33 @@ export default function OrderDetail() {
);
}
if (error) {
return (
<AppLayout>
<div className="container mx-auto px-4 py-8 max-w-3xl">
<Button
variant="ghost"
onClick={() => navigate("/orders")}
className="mb-4"
>
<ArrowLeft className="w-4 h-4 mr-2" />
Kembali ke Riwayat Order
</Button>
<Card className="border-2 border-destructive">
<CardContent className="py-8 text-center">
<AlertCircle className="w-12 h-12 mx-auto mb-4 text-destructive" />
<h2 className="text-xl font-bold mb-2">Error</h2>
<p className="text-muted-foreground mb-4">{error}</p>
<Button onClick={() => navigate("/orders")}>
Kembali ke Riwayat Order
</Button>
</CardContent>
</Card>
</div>
</AppLayout>
);
}
if (!order) return null;
return (