Compare commits
2 Commits
fa274bd8cc
...
711a5c5d6b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
711a5c5d6b | ||
|
|
eea3a1f8d8 |
14
package-lock.json
generated
14
package-lock.json
generated
@@ -45,6 +45,7 @@
|
||||
"@tiptap/extension-table-cell": "^3.14.0",
|
||||
"@tiptap/extension-table-header": "^3.14.0",
|
||||
"@tiptap/extension-table-row": "^3.14.0",
|
||||
"@tiptap/extension-text-align": "^3.14.0",
|
||||
"@tiptap/react": "^3.13.0",
|
||||
"@tiptap/starter-kit": "^3.13.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
@@ -3267,6 +3268,19 @@
|
||||
"@tiptap/core": "^3.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-text-align": {
|
||||
"version": "3.14.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-text-align/-/extension-text-align-3.14.0.tgz",
|
||||
"integrity": "sha512-CaxxlbAvfofZZ7KPL28Kg8xuMv8t4rvt5GPwZAqE+jd3rwrucpovpX/SdgclYDc75xs0t8qeoxDFe9HQmG5XZA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^3.14.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-underline": {
|
||||
"version": "3.13.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.13.0.tgz",
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
"@tiptap/extension-table-cell": "^3.14.0",
|
||||
"@tiptap/extension-table-header": "^3.14.0",
|
||||
"@tiptap/extension-table-row": "^3.14.0",
|
||||
"@tiptap/extension-text-align": "^3.14.0",
|
||||
"@tiptap/react": "^3.13.0",
|
||||
"@tiptap/starter-kit": "^3.13.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
|
||||
@@ -3,12 +3,13 @@ import StarterKit from '@tiptap/starter-kit';
|
||||
import Link from '@tiptap/extension-link';
|
||||
import Image from '@tiptap/extension-image';
|
||||
import Placeholder from '@tiptap/extension-placeholder';
|
||||
import TextAlign from '@tiptap/extension-text-align';
|
||||
import { Node } from '@tiptap/core';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Bold, Italic, List, ListOrdered, Quote, Link as LinkIcon,
|
||||
Image as ImageIcon, Heading1, Heading2, Undo, Redo,
|
||||
Maximize2, Minimize2, MousePointer, Square
|
||||
Maximize2, Minimize2, MousePointer, Square, AlignLeft, AlignCenter, AlignRight, AlignJustify, MoreVertical, Minus
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
@@ -243,7 +244,15 @@ export function RichTextEditor({ content, onChange, placeholder = 'Tulis konten.
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit,
|
||||
StarterKit.configure({
|
||||
heading: {
|
||||
levels: [1, 2, 3],
|
||||
},
|
||||
horizontalRule: true,
|
||||
}),
|
||||
TextAlign.configure({
|
||||
types: ['heading', 'paragraph'],
|
||||
}),
|
||||
Link.configure({
|
||||
openOnClick: false,
|
||||
HTMLAttributes: {
|
||||
@@ -517,6 +526,63 @@ export function RichTextEditor({ content, onChange, placeholder = 'Tulis konten.
|
||||
<LinkIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
{/* Text Align Separator */}
|
||||
<div className="w-px h-6 bg-border mx-1" />
|
||||
|
||||
{/* Text Align Buttons */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => editor.chain().focus().setTextAlign('left').run()}
|
||||
className={editor.isActive({ textAlign: 'left' }) ? 'bg-primary text-primary-foreground' : ''}
|
||||
title="Align Left"
|
||||
>
|
||||
<AlignLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => editor.chain().focus().setTextAlign('center').run()}
|
||||
className={editor.isActive({ textAlign: 'center' }) ? 'bg-primary text-primary-foreground' : ''}
|
||||
title="Align Center"
|
||||
>
|
||||
<AlignCenter className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => editor.chain().focus().setTextAlign('right').run()}
|
||||
className={editor.isActive({ textAlign: 'right' }) ? 'bg-primary text-primary-foreground' : ''}
|
||||
title="Align Right"
|
||||
>
|
||||
<AlignRight className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => editor.chain().focus().setTextAlign('justify').run()}
|
||||
className={editor.isActive({ textAlign: 'justify' }) ? 'bg-primary text-primary-foreground' : ''}
|
||||
title="Justify"
|
||||
>
|
||||
<AlignJustify className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
{/* Spacer/Separator */}
|
||||
<div className="w-px h-6 bg-border mx-1" />
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => editor.chain().focus().setHorizontalRule().run()}
|
||||
title="Insert Spacer"
|
||||
>
|
||||
<Minus className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
{/* Email Components Separator */}
|
||||
<div className="w-px h-6 bg-border mx-1" />
|
||||
|
||||
@@ -628,7 +694,7 @@ 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] [&_img]:cursor-pointer [&_img.ProseMirror-selectednode]:ring-2 [&_img.ProseMirror-selectednode]:ring-primary [&_h1]:font-bold [&_h1]:text-2xl [&_h1]:mb-4 [&_h1]:mt-6 [&_h2]:font-bold [&_h2]:text-xl [&_h2]:mb-3 [&_h2]:mt-5 [&_blockquote]:border-l-4 [&_blockquote]:border-primary [&_blockquote]:pl-4 [&_blockquote]:italic [&_blockquote]:text-muted-foreground [&_blockquote]:my-4 [&_ul]:list-disc [&_ul]:pl-6 [&_ul]:space-y-1 [&_ol]:list-decimal [&_ol]:pl-6 [&_ol]:space-y-1 [&_li]:marker:text-primary"
|
||||
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 [&_h1]:font-bold [&_h1]:text-2xl [&_h1]:mb-4 [&_h1]:mt-6 [&_h2]:font-bold [&_h2]:text-xl [&_h2]:mb-3 [&_h2]:mt-5 [&_blockquote]:border-l-4 [&_blockquote]:border-primary [&_blockquote]:pl-4 [&_blockquote]:italic [&_blockquote]:text-muted-foreground [&_blockquote]:my-4 [&_ul]:list-disc [&_ul]:pl-6 [&_ul]:space-y-1 [&_ol]:list-decimal [&_ol]:pl-6 [&_ol]:space-y-1 [&_li]:marker:text-primary [&_hr]:border-border [&_hr]:my-4 [&_hr]:border-t-2"
|
||||
/>
|
||||
</div>
|
||||
{uploading && (
|
||||
|
||||
@@ -46,7 +46,7 @@ export default function Calendar() {
|
||||
// Fetch webinar events
|
||||
const { data: webinars } = await supabase
|
||||
.from('products')
|
||||
.select('id, title, event_start, duration')
|
||||
.select('id, title, event_start, duration_minutes')
|
||||
.eq('type', 'webinar')
|
||||
.eq('is_active', true)
|
||||
.gte('event_start', start)
|
||||
@@ -76,12 +76,16 @@ export default function Calendar() {
|
||||
webinars?.forEach(w => {
|
||||
if (w.event_start) {
|
||||
const eventDate = new Date(w.event_start);
|
||||
const durationMs = (w.duration_minutes || 60) * 60 * 1000;
|
||||
const endDate = new Date(eventDate.getTime() + durationMs);
|
||||
|
||||
allEvents.push({
|
||||
id: w.id,
|
||||
title: w.title,
|
||||
type: 'webinar',
|
||||
date: format(eventDate, 'yyyy-MM-dd'),
|
||||
start_time: format(eventDate, 'HH:mm'),
|
||||
end_time: format(endDate, 'HH:mm'),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -37,6 +37,13 @@ interface ConfirmedSlot {
|
||||
end_time: string;
|
||||
}
|
||||
|
||||
interface Webinar {
|
||||
id: string;
|
||||
title: string;
|
||||
event_start: string;
|
||||
duration_minutes: number | null;
|
||||
}
|
||||
|
||||
interface TimeSlot {
|
||||
start: string;
|
||||
end: string;
|
||||
@@ -54,6 +61,7 @@ export default function ConsultingBooking() {
|
||||
const [settings, setSettings] = useState<ConsultingSettings | null>(null);
|
||||
const [workhours, setWorkhours] = useState<Workhour[]>([]);
|
||||
const [confirmedSlots, setConfirmedSlots] = useState<ConfirmedSlot[]>([]);
|
||||
const [webinars, setWebinars] = useState<Webinar[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [profile, setProfile] = useState<Profile | null>(null);
|
||||
|
||||
@@ -78,6 +86,7 @@ export default function ConsultingBooking() {
|
||||
useEffect(() => {
|
||||
if (selectedDate) {
|
||||
fetchConfirmedSlots(selectedDate);
|
||||
fetchWebinars(selectedDate);
|
||||
}
|
||||
}, [selectedDate]);
|
||||
|
||||
@@ -101,10 +110,22 @@ export default function ConsultingBooking() {
|
||||
.select('date, start_time, end_time')
|
||||
.eq('date', dateStr)
|
||||
.in('status', ['pending_payment', 'confirmed']);
|
||||
|
||||
|
||||
if (data) setConfirmedSlots(data);
|
||||
};
|
||||
|
||||
const fetchWebinars = async (date: Date) => {
|
||||
const dateStr = format(date, 'yyyy-MM-dd');
|
||||
const { data } = await supabase
|
||||
.from('products')
|
||||
.select('id, title, event_start, duration_minutes')
|
||||
.eq('type', 'webinar')
|
||||
.eq('is_active', true)
|
||||
.like('event_start', `${dateStr}%`);
|
||||
|
||||
if (data) setWebinars(data);
|
||||
};
|
||||
|
||||
const categories = useMemo(() => {
|
||||
if (!settings?.consulting_categories) return [];
|
||||
return settings.consulting_categories.split(',').map(c => c.trim()).filter(Boolean);
|
||||
@@ -131,20 +152,36 @@ export default function ConsultingBooking() {
|
||||
const slotStart = format(current, 'HH:mm');
|
||||
const slotEnd = format(addMinutes(current, duration), 'HH:mm');
|
||||
|
||||
// Check if slot conflicts with confirmed/pending slots
|
||||
// Check if slot conflicts with confirmed/pending consulting slots
|
||||
const isConflict = confirmedSlots.some(cs => {
|
||||
const csStart = cs.start_time.substring(0, 5);
|
||||
const csEnd = cs.end_time.substring(0, 5);
|
||||
return !(slotEnd <= csStart || slotStart >= csEnd);
|
||||
});
|
||||
|
||||
// Check if slot conflicts with webinars
|
||||
const webinarConflict = webinars.some(w => {
|
||||
const webinarStart = new Date(w.event_start);
|
||||
const webinarDurationMs = (w.duration_minutes || 60) * 60 * 1000;
|
||||
const webinarEnd = new Date(webinarStart.getTime() + webinarDurationMs);
|
||||
|
||||
const slotStartTime = new Date(selectedDate);
|
||||
slotStartTime.setHours(parseInt(slotStart.split(':')[0]), parseInt(slotStart.split(':')[1]), 0);
|
||||
|
||||
const slotEndTime = new Date(selectedDate);
|
||||
slotEndTime.setHours(parseInt(slotEnd.split(':')[0]), parseInt(slotEnd.split(':')[1]), 0);
|
||||
|
||||
// Block if slot overlaps with webinar time
|
||||
return slotStartTime < webinarEnd && slotEndTime > webinarStart;
|
||||
});
|
||||
|
||||
// Check if slot is in the past for today
|
||||
const isPassed = isToday && isBefore(current, now);
|
||||
|
||||
slots.push({
|
||||
start: slotStart,
|
||||
end: slotEnd,
|
||||
available: !isConflict && !isPassed,
|
||||
available: !isConflict && !webinarConflict && !isPassed,
|
||||
});
|
||||
|
||||
current = addMinutes(current, duration);
|
||||
@@ -152,7 +189,7 @@ export default function ConsultingBooking() {
|
||||
}
|
||||
|
||||
return slots;
|
||||
}, [selectedDate, workhours, confirmedSlots, settings]);
|
||||
}, [selectedDate, workhours, confirmedSlots, webinars, settings]);
|
||||
|
||||
// Helper: Get all slots between start and end (inclusive)
|
||||
const getSlotsInRange = useMemo(() => {
|
||||
@@ -414,6 +451,11 @@ export default function ConsultingBooking() {
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Klik slot awal dan akhir untuk memilih rentang waktu. {settings.consulting_block_duration_minutes} menit per blok.
|
||||
{webinars.length > 0 && (
|
||||
<span className="block mt-1 text-amber-600 dark:text-amber-400">
|
||||
⚠️ {webinars.length} webinar terjadwal - beberapa slot mungkin tidak tersedia
|
||||
</span>
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
|
||||
@@ -345,8 +345,17 @@ export default function ProductDetail() {
|
||||
<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>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Badge className="bg-primary text-primary-foreground capitalize">{product.type}</Badge>
|
||||
{product.type === 'webinar' && product.recording_url && (
|
||||
<Badge className="bg-secondary text-primary">Rekaman Tersedia</Badge>
|
||||
)}
|
||||
{product.type === 'webinar' && !product.recording_url && product.event_start && new Date(product.event_start) > new Date() && (
|
||||
<Badge className="bg-brand-accent text-white">Segera Hadir</Badge>
|
||||
)}
|
||||
{product.type === 'webinar' && !product.recording_url && product.event_start && new Date(product.event_start) <= new Date() && (
|
||||
<Badge className="bg-muted text-primary">Telah Selesai</Badge>
|
||||
)}
|
||||
{hasAccess && <Badge className="bg-primary text-primary-foreground">Anda memiliki akses</Badge>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -29,6 +29,8 @@ interface Product {
|
||||
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;
|
||||
@@ -42,6 +44,8 @@ const emptyProduct = {
|
||||
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,
|
||||
@@ -84,6 +88,8 @@ export default function AdminProducts() {
|
||||
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,
|
||||
@@ -113,6 +119,8 @@ export default function AdminProducts() {
|
||||
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,
|
||||
@@ -322,6 +330,29 @@ export default function AdminProducts() {
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user