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-cell": "^3.14.0",
"@tiptap/extension-table-header": "^3.14.0", "@tiptap/extension-table-header": "^3.14.0",
"@tiptap/extension-table-row": "^3.14.0", "@tiptap/extension-table-row": "^3.14.0",
"@tiptap/extension-text-align": "^3.14.0",
"@tiptap/react": "^3.13.0", "@tiptap/react": "^3.13.0",
"@tiptap/starter-kit": "^3.13.0", "@tiptap/starter-kit": "^3.13.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
@@ -3267,6 +3268,19 @@
"@tiptap/core": "^3.13.0" "@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": { "node_modules/@tiptap/extension-underline": {
"version": "3.13.0", "version": "3.13.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.13.0.tgz", "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-cell": "^3.14.0",
"@tiptap/extension-table-header": "^3.14.0", "@tiptap/extension-table-header": "^3.14.0",
"@tiptap/extension-table-row": "^3.14.0", "@tiptap/extension-table-row": "^3.14.0",
"@tiptap/extension-text-align": "^3.14.0",
"@tiptap/react": "^3.13.0", "@tiptap/react": "^3.13.0",
"@tiptap/starter-kit": "^3.13.0", "@tiptap/starter-kit": "^3.13.0",
"class-variance-authority": "^0.7.1", "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 Link from '@tiptap/extension-link';
import Image from '@tiptap/extension-image'; import Image from '@tiptap/extension-image';
import Placeholder from '@tiptap/extension-placeholder'; import Placeholder from '@tiptap/extension-placeholder';
import TextAlign from '@tiptap/extension-text-align';
import { Node } from '@tiptap/core'; import { Node } from '@tiptap/core';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
Bold, Italic, List, ListOrdered, Quote, Link as LinkIcon, Bold, Italic, List, ListOrdered, Quote, Link as LinkIcon,
Image as ImageIcon, Heading1, Heading2, Undo, Redo, Image as ImageIcon, Heading1, Heading2, Undo, Redo,
Maximize2, Minimize2, MousePointer, Square Maximize2, Minimize2, MousePointer, Square, AlignLeft, AlignCenter, AlignRight, AlignJustify, MoreVertical, Minus
} from 'lucide-react'; } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
@@ -243,7 +244,15 @@ export function RichTextEditor({ content, onChange, placeholder = 'Tulis konten.
const editor = useEditor({ const editor = useEditor({
extensions: [ extensions: [
StarterKit, StarterKit.configure({
heading: {
levels: [1, 2, 3],
},
horizontalRule: true,
}),
TextAlign.configure({
types: ['heading', 'paragraph'],
}),
Link.configure({ Link.configure({
openOnClick: false, openOnClick: false,
HTMLAttributes: { HTMLAttributes: {
@@ -517,6 +526,63 @@ export function RichTextEditor({ content, onChange, placeholder = 'Tulis konten.
<LinkIcon className="w-4 h-4" /> <LinkIcon className="w-4 h-4" />
</Button> </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 */} {/* Email Components Separator */}
<div className="w-px h-6 bg-border mx-1" /> <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}> <div onPaste={handlePaste}>
<EditorContent <EditorContent
editor={editor} 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> </div>
{uploading && ( {uploading && (

View File

@@ -46,7 +46,7 @@ export default function Calendar() {
// Fetch webinar events // Fetch webinar events
const { data: webinars } = await supabase const { data: webinars } = await supabase
.from('products') .from('products')
.select('id, title, event_start, duration') .select('id, title, event_start, duration_minutes')
.eq('type', 'webinar') .eq('type', 'webinar')
.eq('is_active', true) .eq('is_active', true)
.gte('event_start', start) .gte('event_start', start)
@@ -76,12 +76,16 @@ export default function Calendar() {
webinars?.forEach(w => { webinars?.forEach(w => {
if (w.event_start) { if (w.event_start) {
const eventDate = new Date(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({ allEvents.push({
id: w.id, id: w.id,
title: w.title, title: w.title,
type: 'webinar', type: 'webinar',
date: format(eventDate, 'yyyy-MM-dd'), date: format(eventDate, 'yyyy-MM-dd'),
start_time: format(eventDate, 'HH:mm'), start_time: format(eventDate, 'HH:mm'),
end_time: format(endDate, 'HH:mm'),
}); });
} }
}); });

View File

@@ -37,6 +37,13 @@ interface ConfirmedSlot {
end_time: string; end_time: string;
} }
interface Webinar {
id: string;
title: string;
event_start: string;
duration_minutes: number | null;
}
interface TimeSlot { interface TimeSlot {
start: string; start: string;
end: string; end: string;
@@ -54,6 +61,7 @@ export default function ConsultingBooking() {
const [settings, setSettings] = useState<ConsultingSettings | null>(null); const [settings, setSettings] = useState<ConsultingSettings | null>(null);
const [workhours, setWorkhours] = useState<Workhour[]>([]); const [workhours, setWorkhours] = useState<Workhour[]>([]);
const [confirmedSlots, setConfirmedSlots] = useState<ConfirmedSlot[]>([]); const [confirmedSlots, setConfirmedSlots] = useState<ConfirmedSlot[]>([]);
const [webinars, setWebinars] = useState<Webinar[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [profile, setProfile] = useState<Profile | null>(null); const [profile, setProfile] = useState<Profile | null>(null);
@@ -78,6 +86,7 @@ export default function ConsultingBooking() {
useEffect(() => { useEffect(() => {
if (selectedDate) { if (selectedDate) {
fetchConfirmedSlots(selectedDate); fetchConfirmedSlots(selectedDate);
fetchWebinars(selectedDate);
} }
}, [selectedDate]); }, [selectedDate]);
@@ -105,6 +114,18 @@ export default function ConsultingBooking() {
if (data) setConfirmedSlots(data); 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(() => { const categories = useMemo(() => {
if (!settings?.consulting_categories) return []; if (!settings?.consulting_categories) return [];
return settings.consulting_categories.split(',').map(c => c.trim()).filter(Boolean); 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 slotStart = format(current, 'HH:mm');
const slotEnd = format(addMinutes(current, duration), '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 isConflict = confirmedSlots.some(cs => {
const csStart = cs.start_time.substring(0, 5); const csStart = cs.start_time.substring(0, 5);
const csEnd = cs.end_time.substring(0, 5); const csEnd = cs.end_time.substring(0, 5);
return !(slotEnd <= csStart || slotStart >= csEnd); 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 // Check if slot is in the past for today
const isPassed = isToday && isBefore(current, now); const isPassed = isToday && isBefore(current, now);
slots.push({ slots.push({
start: slotStart, start: slotStart,
end: slotEnd, end: slotEnd,
available: !isConflict && !isPassed, available: !isConflict && !webinarConflict && !isPassed,
}); });
current = addMinutes(current, duration); current = addMinutes(current, duration);
@@ -152,7 +189,7 @@ export default function ConsultingBooking() {
} }
return slots; return slots;
}, [selectedDate, workhours, confirmedSlots, settings]); }, [selectedDate, workhours, confirmedSlots, webinars, settings]);
// Helper: Get all slots between start and end (inclusive) // Helper: Get all slots between start and end (inclusive)
const getSlotsInRange = useMemo(() => { const getSlotsInRange = useMemo(() => {
@@ -414,6 +451,11 @@ export default function ConsultingBooking() {
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
Klik slot awal dan akhir untuk memilih rentang waktu. {settings.consulting_block_duration_minutes} menit per blok. 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> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <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 className="flex flex-col md:flex-row md:items-start justify-between gap-4 mb-6">
<div> <div>
<h1 className="text-4xl font-bold mb-2">{product.title}</h1> <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> <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>} {hasAccess && <Badge className="bg-primary text-primary-foreground">Anda memiliki akses</Badge>}
</div> </div>
</div> </div>

View File

@@ -29,6 +29,8 @@ interface Product {
content: string; content: string;
meeting_link: string | null; meeting_link: string | null;
recording_url: string | null; recording_url: string | null;
event_start: string | null;
duration_minutes: number | null;
price: number; price: number;
sale_price: number | null; sale_price: number | null;
is_active: boolean; is_active: boolean;
@@ -42,6 +44,8 @@ const emptyProduct = {
content: '', content: '',
meeting_link: '', meeting_link: '',
recording_url: '', recording_url: '',
event_start: null as string | null,
duration_minutes: null as number | null,
price: 0, price: 0,
sale_price: null as number | null, sale_price: null as number | null,
is_active: true, is_active: true,
@@ -84,6 +88,8 @@ export default function AdminProducts() {
content: product.content || '', content: product.content || '',
meeting_link: product.meeting_link || '', meeting_link: product.meeting_link || '',
recording_url: product.recording_url || '', recording_url: product.recording_url || '',
event_start: product.event_start,
duration_minutes: product.duration_minutes,
price: product.price, price: product.price,
sale_price: product.sale_price, sale_price: product.sale_price,
is_active: product.is_active, is_active: product.is_active,
@@ -113,6 +119,8 @@ export default function AdminProducts() {
content: form.content, content: form.content,
meeting_link: form.meeting_link || null, meeting_link: form.meeting_link || null,
recording_url: form.recording_url || null, recording_url: form.recording_url || null,
event_start: form.event_start || null,
duration_minutes: form.duration_minutes || null,
price: form.price, price: form.price,
sale_price: form.sale_price || null, sale_price: form.sale_price || null,
is_active: form.is_active, 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" /> <Input value={form.recording_url} onChange={(e) => setForm({ ...form, recording_url: e.target.value })} placeholder="https://youtube.com/..." className="border-2" />
</div> </div>
</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="grid grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<Label>Harga *</Label> <Label>Harga *</Label>