diff --git a/packages/editor/src/core/event-bus.ts b/packages/editor/src/core/event-bus.ts index 8798d65538..34201ec7d4 100644 --- a/packages/editor/src/core/event-bus.ts +++ b/packages/editor/src/core/event-bus.ts @@ -18,6 +18,7 @@ const EVENT_PREFIX = '@react-email/editor:'; export interface EditorEventMap { 'bubble-menu:add-link': undefined; 'node-clicked': NodeClickedEvent; + 'calendar-invite:open': { range: { from: number; to: number }; editorRef: object }; } export type NodeClickedEvent = { diff --git a/packages/editor/src/email-editor/email-editor.tsx b/packages/editor/src/email-editor/email-editor.tsx index 31edeac37e..7d6e087f29 100644 --- a/packages/editor/src/email-editor/email-editor.tsx +++ b/packages/editor/src/email-editor/email-editor.tsx @@ -17,14 +17,17 @@ import { import { createPasteHandler } from '../core/create-paste-handler'; import { composeReactEmail } from '../core/serializer/compose-react-email'; import { StarterKit } from '../extensions'; +import { CalendarInvite, CalendarInvitePlugin, calendarInviteSlashCommand, type CalendarPluginOptions } from '../plugins/calendar-invite'; import { EmailTheming } from '../plugins/email-theming/extension'; import type { EditorThemeInput } from '../plugins/email-theming/types'; import { createImageExtension } from '../plugins/image/extension'; import { BubbleMenu } from '../ui/bubble-menu'; +import { defaultSlashCommands } from '../ui/slash-command/commands'; import { SlashCommandRoot } from '../ui/slash-command/root'; import '../ui/themes/default.css'; import { Placeholder } from '@tiptap/extension-placeholder'; + export interface EmailEditorRef { getEmail: () => Promise<{ html: string; text: string }>; getEmailHTML: () => Promise; @@ -48,6 +51,8 @@ export interface EmailEditorProps { onUploadImage?: (file: File) => Promise<{ url: string }>; className?: string; children?: ReactNode; + /** Options forwarded to the built-in CalendarInvitePlugin */ + calendarInvite?: CalendarPluginOptions; } function buildRef(editor: Editor | null): EmailEditorRef { @@ -129,6 +134,7 @@ export const EmailEditor = forwardRef( onUploadImage, className, children, + calendarInvite, }, ref, ) => { @@ -146,6 +152,7 @@ export const EmailEditor = forwardRef( const extensions = useMemo(() => { const base = extensionsProp ?? [ StarterKit.configure(), + CalendarInvite.configure(), Placeholder.configure({ placeholder: placeholder ?? @@ -166,6 +173,19 @@ export const EmailEditor = forwardRef( return imageExtension ? [...base, imageExtension] : base; }, [extensionsProp, theme, placeholder, imageExtension]); + const hasCalendarInvite = useMemo( + () => extensions.some((ext) => ext.name === 'calendarInvite'), + [extensions], + ); + + const slashCommands = useMemo( + () => + hasCalendarInvite + ? [...defaultSlashCommands, calendarInviteSlashCommand] + : defaultSlashCommands, + [hasCalendarInvite], + ); + const editorProps: UseEditorOptions['editorProps'] = useMemo( () => ({ handlePaste: createPasteHandler({ @@ -194,7 +214,8 @@ export const EmailEditor = forwardRef( - + + {hasCalendarInvite && } {children} ); diff --git a/packages/editor/src/plugins/calendar-invite/editor-card.tsx b/packages/editor/src/plugins/calendar-invite/editor-card.tsx new file mode 100644 index 0000000000..6facc7faa1 --- /dev/null +++ b/packages/editor/src/plugins/calendar-invite/editor-card.tsx @@ -0,0 +1,88 @@ +import { NodeViewWrapper } from '@tiptap/react'; +import type { NodeViewProps } from '@tiptap/react'; +import { computeEndDateTime, formatDisplayTime } from './ical-generator'; +import type { CalendarEvent } from './types'; + +const PROVIDERS = ['Google Calendar', 'Outlook', 'Apple Calendar', 'Yahoo Calendar'] as const; + +export function CalendarEditorCard({ node }: NodeViewProps) { + const attrs = node.attrs as CalendarEvent & { accentColor?: string }; + const { title, date, startTime, duration, timezone, location, accentColor } = attrs; + const isAllDay = duration === -1; + + const formattedDate = date + ? new Date(`${date}T12:00:00`).toLocaleDateString('en-US', { + weekday: 'short', month: 'short', day: 'numeric', year: 'numeric', + }) + : 'β€”'; + + let formattedTime = 'All day'; + if (!isAllDay && startTime) { + const end = computeEndDateTime(date, startTime, duration); + formattedTime = `${formatDisplayTime(startTime)} – ${formatDisplayTime(end.time)}`; + } + + const tzShort = timezone.split('/').pop()?.replace(/_/g, ' ') ?? ''; + const chipColor = accentColor ?? '#1c1c1c'; + + return ( + +
+ {/* Main content */} +
+
+
+ πŸ“… +
+
+
+ {title || 'Untitled Event'} +
+
+ {formattedDate} Β· {formattedTime} + {!isAllDay && tzShort ? ` (${tzShort})` : ''} +
+ {location ? ( +
+ πŸ“ {location} +
+ ) : null} +
+
+
+ + {/* Provider buttons */} +
+ {PROVIDERS.map((label) => ( + + {label} + + ))} +
+
+
+ ); +} diff --git a/packages/editor/src/plugins/calendar-invite/extension.tsx b/packages/editor/src/plugins/calendar-invite/extension.tsx new file mode 100644 index 0000000000..58b121d63a --- /dev/null +++ b/packages/editor/src/plugins/calendar-invite/extension.tsx @@ -0,0 +1,179 @@ +import type { Range } from '@tiptap/core'; +import { ReactNodeViewRenderer } from '@tiptap/react'; +import { Button as ReactEmailButton, Hr, Section, Text } from 'react-email'; +import { editorEventBus } from '../../core/event-bus'; +import { EmailNode } from '../../core/serializer/email-node'; +import { CalendarEditorCard } from './editor-card'; +import { + computeEndDateTime, + formatDisplayTime, + generateAppleCalendarUri, + generateGoogleCalendarUrl, + generateOutlookUrl, + generateYahooCalendarUrl, +} from './ical-generator'; +import type { CalendarEvent } from './types'; + +declare module '@tiptap/core' { + interface Commands { + calendarInvite: { + insertCalendarInvite: (event: CalendarEvent) => ReturnType; + openCalendarInviteModal: (range: Range) => ReturnType; + }; + } +} + +export const CalendarInvite = EmailNode.create({ + name: 'calendarInvite', + group: 'block', + atom: true, + draggable: true, + + addAttributes() { + return { + title: { default: '' }, + date: { default: '' }, + startTime: { default: '09:00' }, + duration: { default: 60 }, + timezone: { default: 'UTC' }, + location: { default: '' }, + description: { default: '' }, + // Card style overrides + cardBg: { default: '#fafafa' }, + accentColor: { default: '#1c1c1c' }, + borderColor: { default: '#e5e5e5' }, + }; + }, + + parseHTML() { + return [{ tag: 'div[data-type="calendar-invite"]' }]; + }, + + renderHTML({ HTMLAttributes }) { + return ['div', { 'data-type': 'calendar-invite', ...HTMLAttributes }]; + }, + + addNodeView() { + return ReactNodeViewRenderer(CalendarEditorCard); + }, + + addCommands() { + return { + insertCalendarInvite: + (event: CalendarEvent) => + ({ commands }) => + // Insert the node + a trailing paragraph so the cursor always lands + // in a text position. Without this, the atom node gets NodeSelection + // and the next insertion replaces it instead of appending. + commands.insertContent([ + { type: 'calendarInvite', attrs: event }, + { type: 'paragraph' }, + ]), + + openCalendarInviteModal: + (range: Range) => + ({ editor }) => { + editorEventBus.dispatch('calendar-invite:open', { range, editorRef: editor }); + return true; + }, + }; + }, + + renderToReactEmail({ node }) { + const event = node.attrs as CalendarEvent & { + cardBg: string; + accentColor: string; + borderColor: string; + }; + const { + title, date, startTime, duration, timezone, + location, description, + cardBg = '#fafafa', + accentColor = '#1c1c1c', + borderColor = '#e5e5e5', + } = event; + + const isAllDay = duration === -1; + const end = isAllDay ? null : computeEndDateTime(date, startTime, duration); + + const googleUrl = generateGoogleCalendarUrl(event); + const outlookUrl = generateOutlookUrl(event); + const yahooUrl = generateYahooCalendarUrl(event); + const appleUrl = generateAppleCalendarUri(event); + + const formattedDate = date + ? new Date(`${date}T12:00:00`).toLocaleDateString('en-US', { + weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', + }) + : ''; + + const formattedTime = isAllDay + ? 'All day' + : `${formatDisplayTime(startTime)}${end ? ` – ${formatDisplayTime(end.time)}` : ''}`; + + const tzLabel = timezone.split('/').pop()?.replace(/_/g, ' ') ?? timezone; + + // ── provider buttons ────────────────────────────────────────────────────── + const providers = [ + { label: 'Google Calendar', href: googleUrl }, + { label: 'Outlook', href: outlookUrl }, + { label: 'Apple Calendar', href: appleUrl }, + { label: 'Yahoo Calendar', href: yahooUrl }, + ] as const; + + const btnStyle: React.CSSProperties = { + display: 'inline-block', + padding: '7px 14px', + backgroundColor: accentColor, + color: '#ffffff', + textDecoration: 'none', + borderRadius: '6px', + fontSize: '12px', + fontWeight: '500' as const, + margin: '0 6px 6px 0', + }; + + return ( +
+ + {title} + + πŸ“… {formattedDate} + + πŸ• {formattedTime} + {!isAllDay ? <> Β· {tzLabel} : null} + + {location ? ( + πŸ“ {location} + ) : null} + {description ? ( + <> +
+ + {description} + + + ) : null} +
+
+ + Add to calendar + + {providers.map((p) => ( + + {p.label} + + ))} +
+
+ ); + }, +}); diff --git a/packages/editor/src/plugins/calendar-invite/ical-generator.ts b/packages/editor/src/plugins/calendar-invite/ical-generator.ts new file mode 100644 index 0000000000..33258630ca --- /dev/null +++ b/packages/editor/src/plugins/calendar-invite/ical-generator.ts @@ -0,0 +1,183 @@ +import { randomBytes } from 'crypto'; +import type { CalendarEvent } from './types'; + +function padZero(n: number): string { + return String(n).padStart(2, '0'); +} + +function formatDateTimeLocal(date: string, time: string): string { + // date: YYYY-MM-DD, time: HH:MM β†’ YYYYMMDDTHHmmss + const [year, month, day] = date.split('-'); + const [hour, minute] = time.split(':'); + return `${year}${month}${day}T${hour}${minute}00`; +} + +function formatDateOnly(date: string): string { + return date.replace(/-/g, ''); +} + +export function computeEndDateTime( + date: string, + startTime: string, + duration: number, +): { date: string; time: string } { + const [h, m] = startTime.split(':').map(Number); + const totalMinutes = h * 60 + m + duration; + const endHour = Math.floor(totalMinutes / 60) % 24; + const endMin = totalMinutes % 60; + const daysOverflow = Math.floor(totalMinutes / (24 * 60)); + + let endDate = date; + if (daysOverflow > 0) { + const [y, mo, dy] = date.split('-').map(Number); + const next = new Date(Date.UTC(y!, mo! - 1, dy! + daysOverflow)); + endDate = [ + next.getUTCFullYear(), + String(next.getUTCMonth() + 1).padStart(2, '0'), + String(next.getUTCDate()).padStart(2, '0'), + ].join('-'); + } + + return { date: endDate, time: `${padZero(endHour)}:${padZero(endMin)}` }; +} + +export function formatDisplayTime(time: string): string { + const [h, m] = time.split(':').map(Number); + const period = (h ?? 0) >= 12 ? 'PM' : 'AM'; + const rawHour = h ?? 0; + const displayHour = rawHour === 0 ? 12 : rawHour > 12 ? rawHour - 12 : rawHour; + return `${displayHour}:${padZero(m ?? 0)} ${period}`; +} + +export function generateICalContent(event: CalendarEvent): string { + const uid = `${Date.now()}-${randomBytes(16).toString('hex')}@react-email`; + const now = new Date(); + const dtstamp = [ + now.getUTCFullYear(), + padZero(now.getUTCMonth() + 1), + padZero(now.getUTCDate()), + 'T', + padZero(now.getUTCHours()), + padZero(now.getUTCMinutes()), + padZero(now.getUTCSeconds()), + 'Z', + ].join(''); + + let dtstart: string; + let dtend: string; + + if (event.duration === -1) { + const [y, m, d] = event.date.split('-').map(Number); + const next = new Date(Date.UTC(y!, m! - 1, d! + 1)); + const nextDay = [ + next.getUTCFullYear(), + String(next.getUTCMonth() + 1).padStart(2, '0'), + String(next.getUTCDate()).padStart(2, '0'), + ].join('-'); + dtstart = `DTSTART;VALUE=DATE:${formatDateOnly(event.date)}`; + dtend = `DTEND;VALUE=DATE:${formatDateOnly(nextDay)}`; + } else { + const end = computeEndDateTime(event.date, event.startTime, event.duration); + dtstart = `DTSTART;TZID=${event.timezone}:${formatDateTimeLocal(event.date, event.startTime)}`; + dtend = `DTEND;TZID=${event.timezone}:${formatDateTimeLocal(end.date, end.time)}`; + } + + const escape = (s: string) => s.replace(/\\/g, '\\\\').replace(/;/g, '\\;').replace(/,/g, '\\,').replace(/\n/g, '\\n'); + + const lines: string[] = [ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'PRODID:-//React Email//Calendar Invite//EN', + 'CALSCALE:GREGORIAN', + 'METHOD:REQUEST', + 'BEGIN:VEVENT', + `UID:${uid}`, + `DTSTAMP:${dtstamp}`, + dtstart, + dtend, + `SUMMARY:${escape(event.title)}`, + ]; + + if (event.location) lines.push(`LOCATION:${escape(event.location)}`); + if (event.description) lines.push(`DESCRIPTION:${escape(event.description)}`); + + lines.push('END:VEVENT', 'END:VCALENDAR'); + + return lines.join('\r\n'); +} + +export function generateGoogleCalendarUrl(event: CalendarEvent): string { + const params = new URLSearchParams({ + action: 'TEMPLATE', + text: event.title, + ctz: event.timezone, + }); + + if (event.duration === -1) { + const d = formatDateOnly(event.date); + params.set('dates', `${d}/${d}`); + } else { + const end = computeEndDateTime(event.date, event.startTime, event.duration); + const start = formatDateTimeLocal(event.date, event.startTime); + const endStr = formatDateTimeLocal(end.date, end.time); + params.set('dates', `${start}/${endStr}`); + } + + if (event.location) params.set('location', event.location); + if (event.description) params.set('details', event.description); + + return `https://calendar.google.com/calendar/render?${params.toString()}`; +} + +export function generateOutlookUrl(event: CalendarEvent): string { + const params = new URLSearchParams({ + rru: 'addevent', + path: '/calendar/action/compose', + subject: event.title, + }); + + if (event.duration === -1) { + params.set('startdt', event.date); + params.set('enddt', event.date); + params.set('allday', 'true'); + } else { + const end = computeEndDateTime(event.date, event.startTime, event.duration); + params.set('startdt', `${event.date}T${event.startTime}:00`); + params.set('enddt', `${end.date}T${end.time}:00`); + } + + if (event.location) params.set('location', event.location); + if (event.description) params.set('body', event.description); + + return `https://outlook.live.com/calendar/0/deeplink/compose?${params.toString()}`; +} + +export function generateYahooCalendarUrl(event: CalendarEvent): string { + const params = new URLSearchParams({ + v: '60', + view: 'd', + type: '20', + title: event.title, + }); + + if (event.duration === -1) { + const d = formatDateOnly(event.date); + params.set('st', d); + params.set('et', d); + params.set('dur', 'allday'); + } else { + const end = computeEndDateTime(event.date, event.startTime, event.duration); + params.set('st', formatDateTimeLocal(event.date, event.startTime)); + params.set('et', formatDateTimeLocal(end.date, end.time)); + } + + if (event.description) params.set('desc', event.description); + if (event.location) params.set('in_loc', event.location); + + return `https://calendar.yahoo.com/?${params.toString()}`; +} + +export function generateAppleCalendarUri(event: CalendarEvent): string { + const ics = generateICalContent(event); + return `data:text/calendar;charset=utf-8,${encodeURIComponent(ics)}`; +} diff --git a/packages/editor/src/plugins/calendar-invite/index.ts b/packages/editor/src/plugins/calendar-invite/index.ts new file mode 100644 index 0000000000..6a19d0099a --- /dev/null +++ b/packages/editor/src/plugins/calendar-invite/index.ts @@ -0,0 +1,14 @@ +export { CalendarInvite } from './extension'; +export { CalendarInvitePlugin } from './plugin'; +export { calendarInviteSlashCommand } from './slash-command'; +export type { CalendarEvent, CalendarPluginOptions } from './types'; +export { CALENDAR_TIMEZONES } from './types'; + +/** Returns the IANA timezone from the browser (e.g. "America/New_York"). Falls back to "UTC". */ +export function detectCalendarTimezone(): string { + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone; + } catch { + return 'UTC'; + } +} diff --git a/packages/editor/src/plugins/calendar-invite/modal.tsx b/packages/editor/src/plugins/calendar-invite/modal.tsx new file mode 100644 index 0000000000..c61898f051 --- /dev/null +++ b/packages/editor/src/plugins/calendar-invite/modal.tsx @@ -0,0 +1,864 @@ +import { + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, +} from 'react'; +import { formatDisplayTime } from './ical-generator'; +import type { CalendarEvent } from './types'; +import { CALENDAR_TIMEZONES } from './types'; + +// ─── constants ──────────────────────────────────────────────────────────────── + +const MONTHS_SHORT = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', +]; +const MONTHS_LONG = [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December', +]; +const WEEKDAYS = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa']; + +/** Half-hour slots from 6:00 AM to 11:00 PM */ +const TIME_SLOTS: string[] = []; +for (let h = 6; h <= 23; h++) { + TIME_SLOTS.push(`${String(h).padStart(2, '0')}:00`); + if (h < 23) TIME_SLOTS.push(`${String(h).padStart(2, '0')}:30`); +} + +const DURATION_PILLS = [ + { label: '15 min', value: 15 }, + { label: '30 min', value: 30 }, + { label: '45 min', value: 45 }, + { label: '1 hr', value: 60 }, + { label: '1.5 hr', value: 90 }, + { label: '2 hr', value: 120 }, + { label: '3 hr', value: 180 }, + { label: '4 hr', value: 240 }, + { label: 'All day', value: -1 }, +]; + +// ─── helpers ────────────────────────────────────────────────────────────────── + +function detectTimezone(): string { + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone; + } catch { + return 'UTC'; + } +} + +function getGmtOffset(tz: string): string { + try { + const parts = new Intl.DateTimeFormat('en', { + timeZone: tz, + timeZoneName: 'shortOffset', + }).formatToParts(new Date()); + return parts.find((p) => p.type === 'timeZoneName')?.value ?? ''; + } catch { + return ''; + } +} + +function buildCalendarGrid(month: number, year: number): (number | null)[][] { + const firstDow = new Date(year, month - 1, 1).getDay(); + const days = new Date(year, month, 0).getDate(); + const cells: (number | null)[] = [ + ...Array(firstDow).fill(null), + ...Array.from({ length: days }, (_, i) => i + 1), + ]; + while (cells.length % 7 !== 0) cells.push(null); + const rows: (number | null)[][] = []; + for (let i = 0; i < cells.length; i += 7) rows.push(cells.slice(i, i + 7)); + return rows; +} + +function getDefaultStartTime(): string { + const now = new Date(); + // Snap forward 1 hour then round up to the next :00/:30 boundary + const msAhead = now.getTime() + 60 * 60 * 1000; + const snapped = new Date(Math.ceil(msAhead / (30 * 60 * 1000)) * (30 * 60 * 1000)); + const h = snapped.getHours(); + const m = snapped.getMinutes(); + // If still on today and within TIME_SLOTS range (06:00–23:00), use it + if (snapped.getDate() === now.getDate() && h >= 6 && h <= 23) { + return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`; + } + return '09:00'; +} + +interface SelectedDate { + year: number; + month: number; + day: number; +} + +interface FormState { + title: string; + selected: SelectedDate; + viewYear: number; + viewMonth: number; + startTime: string; // HH:MM 24h + duration: number; + timezone: string; + location: string; + description: string; +} + +function isValidTimezone(tz: string): boolean { + try { + Intl.DateTimeFormat('en', { timeZone: tz }); + return true; + } catch { + return false; + } +} + +function makeInitialState(defaultTimezone?: string): FormState { + const now = new Date(); + const rawTz = defaultTimezone ?? detectTimezone(); + const tz = isValidTimezone(rawTz) ? rawTz : 'UTC'; + return { + title: '', + selected: { + year: now.getFullYear(), + month: now.getMonth() + 1, + day: now.getDate(), + }, + viewYear: now.getFullYear(), + viewMonth: now.getMonth() + 1, + startTime: getDefaultStartTime(), + duration: 60, + timezone: tz, + location: '', + description: '', + }; +} + +function formToEvent(f: FormState): CalendarEvent { + const m = String(f.selected.month).padStart(2, '0'); + const d = String(f.selected.day).padStart(2, '0'); + return { + title: f.title, + date: `${f.selected.year}-${m}-${d}`, + startTime: f.startTime, + duration: f.duration, + timezone: f.timezone, + location: f.location, + description: f.description, + }; +} + +function Pill({ + label, + active, + onClick, +}: { + label: string; + active: boolean; + onClick: () => void; +}) { + return ( + + ); +} + +function CalendarGrid({ + selected, + viewYear, + viewMonth, + onSelectDay, + onPrevMonth, + onNextMonth, +}: { + selected: SelectedDate; + viewYear: number; + viewMonth: number; + onSelectDay: (day: number, month: number, year: number) => void; + onPrevMonth: () => void; + onNextMonth: () => void; +}) { + const grid = buildCalendarGrid(viewMonth, viewYear); + const today = new Date(); + const todayY = today.getFullYear(); + const todayM = today.getMonth() + 1; + const todayD = today.getDate(); + + const cellBase: React.CSSProperties = { + width: '32px', + height: '32px', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + borderRadius: '50%', + fontSize: '13px', + cursor: 'pointer', + border: 'none', + background: 'none', + margin: '0 auto', + }; + + return ( +
+ {/* Month nav */} +
+ + + {MONTHS_LONG[viewMonth - 1]} {viewYear} + + +
+ + {/* Weekday headers */} +
+ {WEEKDAYS.map((wd) => ( +
+ {wd} +
+ ))} +
+ + {/* Day rows */} + {grid.map((row, ri) => ( +
+ {row.map((day, ci) => { + if (day === null) + return
; + const isSelected = + day === selected.day && + viewMonth === selected.month && + viewYear === selected.year; + const isToday = + day === todayD && viewMonth === todayM && viewYear === todayY; + return ( + + ); + })} +
+ ))} +
+ ); +} + +export interface CalendarModalProps { + open: boolean; + onClose: () => void; + onSubmit: (event: CalendarEvent) => void; + defaultTimezone?: string; +} + +export function CalendarModal({ + open, + onClose, + onSubmit, + defaultTimezone, +}: CalendarModalProps) { + const [form, setForm] = useState(() => + makeInitialState(defaultTimezone), + ); + const [titleError, setTitleError] = useState(false); + const titleRef = useRef(null); + const timePillsRef = useRef(null); + const activeTimePillRef = useRef(null); + + useEffect(() => { + if (open) { + setForm(makeInitialState(defaultTimezone)); + setTitleError(false); + setTimeout(() => titleRef.current?.focus(), 60); + } + }, [open, defaultTimezone]); + + // Scroll selected time pill into view when modal opens + useLayoutEffect(() => { + if (open && activeTimePillRef.current && timePillsRef.current) { + const pill = activeTimePillRef.current; + const container = timePillsRef.current; + const offset = + pill.offsetLeft - container.clientWidth / 2 + pill.clientWidth / 2; + container.scrollLeft = offset; + } + }, [open]); + + const set = useCallback( + (k: K, v: FormState[K]) => { + setForm((p) => ({ ...p, [k]: v })); + }, + [], + ); + + const handleSubmit = useCallback(() => { + if (!form.title.trim()) { + setTitleError(true); + titleRef.current?.focus(); + return; + } + onSubmit(formToEvent(form)); + }, [form, onSubmit]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) handleSubmit(); + }, + [onClose, handleSubmit], + ); + + const prevMonth = useCallback(() => { + setForm((p) => { + let m = p.viewMonth - 1; + let y = p.viewYear; + if (m < 1) { + m = 12; + y--; + } + return { ...p, viewMonth: m, viewYear: y }; + }); + }, []); + + const nextMonth = useCallback(() => { + setForm((p) => { + let m = p.viewMonth + 1; + let y = p.viewYear; + if (m > 12) { + m = 1; + y++; + } + return { ...p, viewMonth: m, viewYear: y }; + }); + }, []); + + const selectDay = useCallback( + (day: number, month: number, year: number) => { + set('selected', { day, month, year }); + }, + [set], + ); + + if (!open) return null; + + const isAllDay = form.duration === -1; + + const inputStyle: React.CSSProperties = { + width: '100%', + height: '32px', + padding: '0 10px', + border: '1px solid var(--re-border, #e5e5e5)', + borderRadius: '6px', + backgroundColor: 'var(--re-bg, #fff)', + color: 'var(--re-text, #1c1c1c)', + fontSize: '13px', + outline: 'none', + boxSizing: 'border-box', + }; + + const labelStyle: React.CSSProperties = { + display: 'block', + fontSize: '11px', + fontWeight: 600, + textTransform: 'uppercase' as const, + letterSpacing: '0.04em', + color: 'var(--re-text-muted, #6b6b6b)', + marginBottom: '8px', + }; + + const sectionStyle: React.CSSProperties = { marginBottom: '20px' }; + + const dividerStyle: React.CSSProperties = { + border: 'none', + borderTop: '1px solid var(--re-border, #e5e5e5)', + margin: '0 -20px 20px', + }; + + return ( +
+ {/* Backdrop */} +