Compare commits

...

2 Commits

Author SHA1 Message Date
dwindown
711a5c5d6b Add webinar calendar integration and consulting slot blocking
- Fix webinar duration field reference in Calendar (duration_minutes)
- Calculate and display webinar end times in calendar view
- Fetch webinars for selected date in consulting booking
- Block consulting slots that overlap with webinar times
- Show warning when webinars are scheduled on selected date
- Properly handle webinar time range conflicts

This prevents booking conflicts when users try to schedule
consulting sessions during webinar times.

Example: Webinar 20:15-22:15 blocks consulting slots 20:00-22:30

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-25 13:46:03 +07:00
dwindown
eea3a1f8d8 Add Tiptap enhancements and webinar date/time fields
- 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>
2025-12-25 13:41:51 +07:00
7 changed files with 176 additions and 9 deletions

14
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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 && (

View File

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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>