diff --git a/apps/mail/components/create/schedule-send-picker.tsx b/apps/mail/components/create/schedule-send-picker.tsx index 3288e86783..c75f434246 100644 --- a/apps/mail/components/create/schedule-send-picker.tsx +++ b/apps/mail/components/create/schedule-send-picker.tsx @@ -1,9 +1,15 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; -import { Clock } from 'lucide-react'; -import { format, isValid } from 'date-fns'; -import { useState, useEffect } from 'react'; +import { Clock, Calendar as CalendarIcon } from 'lucide-react'; +import { format, startOfToday } from 'date-fns'; +import { useState, useCallback } from 'react'; import { cn } from '@/lib/utils'; import { toast } from 'sonner'; +import { Calendar } from '@/components/ui/calendar'; +import { Input } from '@/components/ui/input'; + +const pad2 = (n: number) => n.toString().padStart(2, '0'); +const getLocalTimeFromDate = (d: Date) => `${pad2(d.getHours())}:${pad2(d.getMinutes())}`; +const getNowTime = () => getLocalTimeFromDate(new Date()); interface ScheduleSendPickerProps { value?: string | undefined; @@ -12,107 +18,191 @@ interface ScheduleSendPickerProps { onValidityChange?: (isValid: boolean) => void; } -const toLocalInputValue = (date: Date) => { - const tzOffsetMs = date.getTimezoneOffset() * 60 * 1000; - const local = new Date(date.getTime() - tzOffsetMs); - return local.toISOString().slice(0, 16); -}; - export const ScheduleSendPicker: React.FC = ({ value, onChange, className, onValidityChange, }) => { - const [isOpen, setIsOpen] = useState(false); + const [datePickerOpen, setDatePickerOpen] = useState(false); + const [timePickerOpen, setTimePickerOpen] = useState(false); - const [localValue, setLocalValue] = useState(() => { - if (value) { - const d = new Date(value); - if (!isNaN(d.getTime())) return toLocalInputValue(d); - } - return ''; - }); - - useEffect(() => { - if (value) { - const d = new Date(value); - if (!isNaN(d.getTime())) { - setLocalValue(toLocalInputValue(d)); - } - } else { - setLocalValue(''); - } - }, [value]); - - const handleChange = (e: React.ChangeEvent) => { - const val = e.target.value; - setLocalValue(val); + const isScheduling = !!value; + const selectedDate = value ? new Date(value) : undefined; + const time = value ? getLocalTimeFromDate(new Date(value)) : getNowTime(); - if (!val) { + const emitChange = useCallback((datePart: Date | undefined, timePart: string, validate: boolean = false) => { + if (!datePart) { onChange(undefined); - onValidityChange?.(true); + if (validate) { + onValidityChange?.(true); + } return; } - const maybeDate = new Date(val); + const [hhStr, mmStr = '00'] = timePart.split(':'); + const hours = Number(hhStr); + const minutes = Number(mmStr); - // Invalid date string - if (isNaN(maybeDate.getTime())) { - onValidityChange?.(false); + if (Number.isNaN(hours) || Number.isNaN(minutes) || hours < 0 || hours > 23 || minutes < 0 || minutes > 59) { + if (validate) { + onValidityChange?.(false); + } return; } - const now = new Date(); - if (maybeDate.getTime() < now.getTime()) { + const combinedDate = new Date(datePart); + combinedDate.setHours(hours, minutes, 0, 0); + + if (validate && combinedDate.getTime() < Date.now()) { toast.error('Scheduled time cannot be in the past'); onValidityChange?.(false); return; } - onValidityChange?.(true); - onChange(maybeDate.toISOString()); + if (validate) { + onValidityChange?.(true); + } + onChange(combinedDate.toISOString()); + }, [onChange, onValidityChange]); + + const handleDateSelect = useCallback((d?: Date) => { + emitChange(d, time, false); + }, [emitChange, time]); + + const handleTimeChange = useCallback((e: React.ChangeEvent) => { + const val = e.target.value; + emitChange(selectedDate, val, false); + }, [selectedDate, emitChange]); + + const handleDatePickerClose = useCallback((open: boolean) => { + setDatePickerOpen(open); + if (!open && selectedDate) { + emitChange(selectedDate, time, true); + } + }, [selectedDate, time, emitChange]); + + const handleTimePickerClose = useCallback((open: boolean) => { + setTimePickerOpen(open); + if (!open && selectedDate) { + emitChange(selectedDate, time, true); + } + }, [selectedDate, time, emitChange]); + + const handleToggleScheduling = useCallback(() => { + if (isScheduling) { + onChange(undefined); + } else { + const now = new Date(); + emitChange(now, getNowTime()); + } + }, [isScheduling, onChange, emitChange]); + + const formatTime12Hour = (timeStr: string) => { + try { + const [hhStr, mmStr = '00'] = timeStr.split(':'); + const preview = new Date(); + preview.setHours(Number(hhStr), Number(mmStr), 0, 0); + return format(preview, 'hh:mm aaa'); + } catch { + return timeStr; + } }; - const displayValue = localValue || toLocalInputValue(new Date()); + const triggerLabel = (() => { + if (!selectedDate) return 'Send later'; + try { + const formattedTime = formatTime12Hour(time); + const formattedDate = format(selectedDate, 'dd MMM yyyy'); + return `${formattedDate} ${formattedTime}`; + } catch { + return 'Send later'; + } + })(); + + if (isScheduling) { + return ( + <> + + + + + +
+ +
+
+
+ + + + + + +
+

Select Time

+ +
+
+
- return ( - - - - -
- - -
-
-
+ + ); + } + + return ( + ); }; \ No newline at end of file diff --git a/apps/mail/components/ui/calendar.tsx b/apps/mail/components/ui/calendar.tsx index 158c0a9ed3..2f3c9ae6b7 100644 --- a/apps/mail/components/ui/calendar.tsx +++ b/apps/mail/components/ui/calendar.tsx @@ -1,17 +1,60 @@ import { ChevronLeft, ChevronRight } from 'lucide-react'; import { DayPicker } from 'react-day-picker'; import * as React from 'react'; +import { useCallback, useMemo } from 'react'; +import { addMonths, subMonths, getYear, getMonth, setYear, setMonth, format } from 'date-fns'; import { buttonVariants } from '@/components/ui/button'; import { cn } from '@/lib/utils'; -export type CalendarProps = React.ComponentProps; +export type CalendarProps = React.ComponentProps & { + yearRange?: number; +}; + +function Calendar({ className, classNames, showOutsideDays = true, captionLayout, yearRange = 10, ...props }: CalendarProps) { + const [currentMonth, setCurrentMonth] = React.useState(new Date()); + + const years = useMemo(() => Array.from({ length: yearRange }, (_, i) => new Date().getFullYear() + i), [yearRange]); + + const handleMonthChange = useCallback((monthIndex: string) => { + const parsedMonth = parseInt(monthIndex, 10); + + + if (!Number.isFinite(parsedMonth) || parsedMonth < 0 || parsedMonth > 11) { + console.warn(`Invalid month value: ${monthIndex}. Expected 0-11, got ${parsedMonth}`); + return; + } + + const newDate = setMonth(currentMonth, parsedMonth); + setCurrentMonth(newDate); + }, [currentMonth]); + + const handleYearChange = useCallback((year: string) => { + const parsedYear = parseInt(year, 10); + if (!Number.isFinite(parsedYear) || parsedYear < 1900 || parsedYear > 2100) { + console.warn(`Invalid year value: ${year}. Expected 1900-2100, got ${parsedYear}`); + return; + } + + const newDate = setYear(currentMonth, parsedYear); + setCurrentMonth(newDate); + }, [currentMonth]); + + const handlePreviousMonth = useCallback((displayMonth: Date) => { + setCurrentMonth(subMonths(displayMonth, 1)); + }, []); + + const handleNextMonth = useCallback((displayMonth: Date) => { + setCurrentMonth(addMonths(displayMonth, 1)); + }, []); -function Calendar({ className, classNames, showOutsideDays = true, ...props }: CalendarProps) { return ( ( ), + Caption: ({ displayMonth }) => ( +
+ + +
+ + + +
+ + +
+ ), }} {...props} />