-
- {days.map((day) => {
- const isSelected = selectedDays[day] === 1;
- return (
-
- );
- })}
-
-
- {/*
*/}
+
+ {days.map((day, index) => {
+ const isActive = index >= rangeStart && index <= rangeEnd;
+ const isStart = index === rangeStart;
+ const isEnd = index === rangeEnd;
+
+ return (
+
+ );
+ })}
);
}
diff --git a/src/features/event/editor/date-range/date-range-props.ts b/src/features/event/editor/date-range/date-range-props.ts
index 9ed09db2..ee0a68da 100644
--- a/src/features/event/editor/date-range/date-range-props.ts
+++ b/src/features/event/editor/date-range/date-range-props.ts
@@ -1,22 +1,9 @@
-import { DateRange } from "react-day-picker";
-
-import { EventRange, WeekdayMap } from "@/core/event/types";
-
export type DateRangeProps = {
- earliestDate?: Date;
- eventRange: EventRange;
- tooManyDays?: boolean;
editing?: boolean;
+};
- // update functions
- setTitle?: (title: string) => void;
- setCustomCode?: (code: string) => void;
- setEventType?: (type: "specific" | "weekday") => void;
- setTimezone?: (tz: string) => void;
- setDuration?: (duration: number) => void;
- setTimeRange?: (timeRange: { from: number; to: number }) => void;
- setDateRange?: (dateRange: DateRange | undefined) => void;
- setWeekdayRange?: (weekdays: WeekdayMap) => void;
-
- displayCalendar?: boolean;
+export type SpecificDateRangeDisplayProps = {
+ earliestDate: Date;
+ startDate: Date;
+ endDate: Date;
};
diff --git a/src/features/event/editor/date-range/drawer.tsx b/src/features/event/editor/date-range/drawer.tsx
index 2f422f48..00717047 100644
--- a/src/features/event/editor/date-range/drawer.tsx
+++ b/src/features/event/editor/date-range/drawer.tsx
@@ -1,43 +1,34 @@
+"use client";
+
import { useState } from "react";
import * as Dialog from "@radix-ui/react-dialog";
-import { ExclamationTriangleIcon } from "@radix-ui/react-icons";
-import { fromZonedTime } from "date-fns-tz";
-import { DateRange } from "react-day-picker";
+import { ExclamationTriangleIcon, Cross1Icon } from "@radix-ui/react-icons";
-import { DateRangeProps } from "@/features/event/editor/date-range/date-range-props";
-import DateRangeInput from "@/features/event/editor/date-range/input";
-import EventTypeSelect from "@/features/event/editor/event-type-select";
-import { Calendar } from "@/features/event/editor/month-calendar";
-import { checkInvalidDateRangeLength } from "@/features/event/editor/validate-data";
-import WeekdayCalendar from "@/features/event/editor/weekday-calendar";
+import { useEventContext } from "@/core/event/context";
+import ActionButton from "@/features/button/components/action";
+import { Calendar } from "@/features/event/editor/date-range/calendars/month";
+import { SpecificDateRangeDisplayProps } from "@/features/event/editor/date-range/date-range-props";
+import SpecificDateRangeDisplay from "@/features/event/editor/date-range/specific-date-display";
export default function DateRangeDrawer({
earliestDate,
- eventRange,
- editing = false,
- setEventType = () => {},
- setWeekdayRange = () => {},
- setDateRange = () => {},
-}: DateRangeProps) {
- const rangeType = eventRange?.type ?? "specific";
- const [tooManyDays, setTooManyDays] = useState(false);
+ startDate,
+ endDate,
+}: SpecificDateRangeDisplayProps) {
+ const { errors, setDateRange } = useEventContext();
+
+ const [open, setOpen] = useState(false);
- const checkDateRange = (range: DateRange | undefined) => {
- setTooManyDays(checkInvalidDateRangeLength(range));
- setDateRange(range);
+ const handleClose = () => {
+ setOpen(false);
+ return true;
};
return (
-
-
-
-
-
+
+
+
@@ -46,27 +37,43 @@ export default function DateRangeDrawer({
className="animate-slideUp data-[state=closed]:animate-slideDown fixed bottom-0 left-0 right-0 z-50 flex h-[500px] w-full flex-col"
aria-label="Date range picker"
>
-
+
-
-
-
+ }
+ label="Close Drawer"
+ shrinkOnMobile
+ onClick={handleClose}
/>
-
-
+ Select Specific Date Range
+ {errors.dateRange ? (
+
+
+ {errors.dateRange}
+
+ ) : (
+
+ Choose a start and end date
+
+ )}
+
+
+
+
@@ -74,64 +81,3 @@ export default function DateRangeDrawer({
);
}
-
-const DateRangeDrawerSelector = ({
- earliestDate,
- eventRange,
- displayCalendar,
- tooManyDays,
- setWeekdayRange = () => {},
- setDateRange = () => {},
-}: DateRangeProps) => {
- if (eventRange?.type === "specific") {
- const startDate = fromZonedTime(
- eventRange.dateRange.from,
- eventRange.timezone,
- );
- const endDate = fromZonedTime(eventRange.dateRange.to, eventRange.timezone);
- return (
-
- {displayCalendar ? (
-
- ) : (
- <>
-
-
- >
- )}
-
- );
- }
- return (
-
- {!displayCalendar && }
-
-
- );
-};
diff --git a/src/features/event/editor/date-range/event-type-select.tsx b/src/features/event/editor/date-range/event-type-select.tsx
new file mode 100644
index 00000000..ff3db578
--- /dev/null
+++ b/src/features/event/editor/date-range/event-type-select.tsx
@@ -0,0 +1,31 @@
+import { useEventContext } from "@/core/event/context";
+import Dropdown from "@/features/selector/components/dropdown";
+
+type EventType = "specific" | "weekday";
+
+type EventTypeSelectProps = {
+ id: string;
+ disabled?: boolean;
+};
+
+export default function EventTypeSelect({
+ id,
+ disabled = false,
+}: EventTypeSelectProps) {
+ const { state, setEventType } = useEventContext();
+ const rangeType = state.eventRange?.type || "specific";
+
+ return (
+ setEventType(value)}
+ className="w-fit min-w-[100px] border-none"
+ />
+ );
+}
diff --git a/src/features/event/editor/date-range/input.tsx b/src/features/event/editor/date-range/input.tsx
deleted file mode 100644
index cce61fa2..00000000
--- a/src/features/event/editor/date-range/input.tsx
+++ /dev/null
@@ -1,47 +0,0 @@
-import { format } from "date-fns";
-
-type DateRangeInputProps = {
- startDate: Date;
- endDate: Date;
-};
-
-export default function DateRangeInput({
- startDate,
- endDate,
-}: DateRangeInputProps) {
- const displayFrom = startDate ? format(startDate, "EEE MMMM d, yyyy") : "";
- const displayTo = endDate ? format(endDate, "EEE MMMM d, yyyy") : "";
- return (
-
- );
-}
diff --git a/src/features/event/editor/date-range/popover.tsx b/src/features/event/editor/date-range/popover.tsx
index bbba7a5c..706d8492 100644
--- a/src/features/event/editor/date-range/popover.tsx
+++ b/src/features/event/editor/date-range/popover.tsx
@@ -1,40 +1,30 @@
import * as Popover from "@radix-ui/react-popover";
-import { fromZonedTime } from "date-fns-tz";
-import { DateRangeProps } from "@/features/event/editor/date-range/date-range-props";
-import DateRangeInput from "@/features/event/editor/date-range/input";
-import { Calendar } from "@/features/event/editor/month-calendar";
+import { useEventContext } from "@/core/event/context";
+import { Calendar } from "@/features/event/editor/date-range/calendars/month";
+import { SpecificDateRangeDisplayProps } from "@/features/event/editor/date-range/date-range-props";
+import SpecificDateRangeDisplay from "@/features/event/editor/date-range/specific-date-display";
import { cn } from "@/lib/utils/classname";
export default function DateRangePopover({
earliestDate,
- eventRange,
- setDateRange = () => {},
-}: DateRangeProps) {
- // If the event range is not specific, return null
- if (eventRange.type !== "specific") {
- return null;
- }
-
- const startDate = fromZonedTime(
- eventRange.dateRange.from,
- eventRange.timezone,
- );
- const endDate = fromZonedTime(eventRange.dateRange.to, eventRange.timezone);
+ startDate,
+ endDate,
+}: SpecificDateRangeDisplayProps) {
+ const { errors, setDateRange } = useEventContext();
return (
-
-
-
-
+
+
diff --git a/src/features/event/editor/date-range/selector.tsx b/src/features/event/editor/date-range/selector.tsx
index da2dc3d8..7b7aa5a0 100644
--- a/src/features/event/editor/date-range/selector.tsx
+++ b/src/features/event/editor/date-range/selector.tsx
@@ -1,89 +1,98 @@
-import { useState } from "react";
-
import { ExclamationTriangleIcon } from "@radix-ui/react-icons";
-import { DateRange } from "react-day-picker";
+import { fromZonedTime } from "date-fns-tz";
+import Switch from "@/components/switch";
+import { useEventContext } from "@/core/event/context";
+import { SpecificDateRange } from "@/core/event/types";
+import WeekdayCalendar from "@/features/event/editor/date-range/calendars/weekday";
import { DateRangeProps } from "@/features/event/editor/date-range/date-range-props";
import DateRangeDrawer from "@/features/event/editor/date-range/drawer";
+import EventTypeSelect from "@/features/event/editor/date-range/event-type-select";
import DateRangePopover from "@/features/event/editor/date-range/popover";
-import EventTypeSelect from "@/features/event/editor/event-type-select";
-import { checkInvalidDateRangeLength } from "@/features/event/editor/validate-data";
-import WeekdayCalendar from "@/features/event/editor/weekday-calendar";
+import FormSelectorField from "@/features/selector/components/selector-field";
import useCheckMobile from "@/lib/hooks/use-check-mobile";
-export default function DateRangeSelector({
- earliestDate,
- eventRange,
+export default function DateRangeSelection({
editing = false,
- setEventType = () => {},
- setWeekdayRange = () => {},
- setDateRange = () => {},
}: DateRangeProps) {
- const isMobile = useCheckMobile();
+ const { state, setEventType, setWeekdayRange, errors } = useEventContext();
+ const { eventRange } = state;
+
const rangeType = eventRange?.type ?? "specific";
- const [tooManyDays, setTooManyDays] = useState(false);
- const checkDateRange = (range: DateRange | undefined) => {
- setTooManyDays(checkInvalidDateRangeLength(range));
- setDateRange(range);
- };
+ return (
+
+
+
+
+
+
+
+ Possible Dates
+ {errors.dateRange && (
+
+ )}
+
+
+
+
+ setEventType(checked ? "weekday" : "specific")
+ }
+ disabled={editing}
+ />
+
+
+ {eventRange?.type === "specific" ? (
+
+ ) : (
+
+ )}
+
+
+ );
+}
+
+function SpecificDateRangeDisplay({
+ eventRange,
+}: {
+ eventRange: SpecificDateRange;
+}) {
+ const isMobile = useCheckMobile();
+
+ const earliestDate = new Date(eventRange.dateRange.from);
+ const startDate = fromZonedTime(
+ eventRange.dateRange.from,
+ eventRange.timezone,
+ );
+ const endDate = fromZonedTime(eventRange.dateRange.to, eventRange.timezone);
if (isMobile) {
return (
);
} else {
return (
-
-
-
-
-
-
- {eventRange?.type === "specific" ? (
- <>
-
-
- >
- ) : (
-
- )}
-
-
+
);
}
}
diff --git a/src/features/event/editor/date-range/specific-date-display.tsx b/src/features/event/editor/date-range/specific-date-display.tsx
new file mode 100644
index 00000000..683c18fb
--- /dev/null
+++ b/src/features/event/editor/date-range/specific-date-display.tsx
@@ -0,0 +1,39 @@
+import { format } from "date-fns";
+
+type SpecificDateRangeDisplayProps = {
+ startDate: Date;
+ endDate: Date;
+};
+
+export default function SpecificDateRangeDisplay({
+ startDate,
+ endDate,
+}: SpecificDateRangeDisplayProps) {
+ const displayFrom = startDate ? format(startDate, "EEE MMMM d, yyyy") : "";
+ const displayTo = endDate ? format(endDate, "EEE MMMM d, yyyy") : "";
+
+ const displayStyle =
+ "text-accent bg-accent/15 hover:bg-accent/25 active:bg-accent/40 rounded-2xl px-3 py-1 focus:outline-none";
+
+ return (
+
+ );
+}
diff --git a/src/features/event/editor/duration-selector.tsx b/src/features/event/editor/duration-selector.tsx
deleted file mode 100644
index 7593c92c..00000000
--- a/src/features/event/editor/duration-selector.tsx
+++ /dev/null
@@ -1,135 +0,0 @@
-import { useState, useEffect, useRef } from "react";
-
-import * as Dialog from "@radix-ui/react-dialog";
-import { ChevronDownIcon } from "@radix-ui/react-icons";
-
-import CustomSelect from "@/components/select";
-import useCheckMobile from "@/lib/hooks/use-check-mobile";
-import { cn } from "@/lib/utils/classname";
-
-const durationOptions = [
- { label: "None", value: 0 },
- { label: "30 minutes", value: 30 },
- { label: "45 minutes", value: 45 },
- { label: "1 hour", value: 60 },
-];
-
-type DurationSelectorProps = {
- id: string;
- onChange: (duration: string | number) => void;
- value: number;
-};
-
-export default function DurationSelector({
- id,
- onChange,
- value,
-}: DurationSelectorProps) {
- const isMobile = useCheckMobile();
-
- if (!isMobile) {
- return (
-
- );
- } else {
- return (
-
- );
- }
-}
-
-function DurationDrawer({
- id,
- value,
- options,
- onChange,
-}: {
- id: string;
- value: number;
- options: { label: string; value: number }[];
- onChange: (duration: number) => void;
-}) {
- const valueLabel = options.find((opt) => opt.value === value)?.label || "";
-
- const [open, setOpen] = useState(false);
- const selectedItemRef = useRef(null);
-
- useEffect(() => {
- if (open) {
- const timer = setTimeout(() => {
- selectedItemRef.current?.scrollIntoView({
- block: "center",
- behavior: "auto",
- });
- }, 0);
-
- return () => clearTimeout(timer);
- }
- }, [open]);
-
- return (
-
-
-
- {valueLabel}
-
-
-
-
-
-
-
-
-
-
- Select Time
-
-
- {options.map((option) => {
- const isSelected = option.value === value;
- return (
-
{
- onChange(option.value);
- setOpen(false);
- }}
- className={cn(
- "p-4 text-center",
- isSelected && "bg-accent rounded-full text-white",
- )}
- >
- {option.label}
-
- );
- })}
-
-
-
-
- );
-}
diff --git a/src/features/event/editor/editor.tsx b/src/features/event/editor/editor.tsx
index 3b08a9cd..73780da1 100644
--- a/src/features/event/editor/editor.tsx
+++ b/src/features/event/editor/editor.tsx
@@ -1,123 +1,73 @@
"use client";
-import { useState } from "react";
+import { useState, memo } from "react";
import { ExclamationTriangleIcon } from "@radix-ui/react-icons";
import { useRouter } from "next/navigation";
-import { useDebouncedCallback } from "use-debounce";
import RateLimitBanner from "@/components/banner/rate-limit";
import HeaderSpacer from "@/components/header-spacer";
import MobileFooterTray from "@/components/mobile-footer-tray";
+import SegmentedControl from "@/components/segmented-control";
import TextInputField from "@/components/text-input-field";
-import { EventRange, SpecificDateRange } from "@/core/event/types";
-import { useEventInfo } from "@/core/event/use-event-info";
+import { EventProvider, useEventContext } from "@/core/event/context";
+import { EventInformation } from "@/core/event/types";
import ActionButton from "@/features/button/components/action";
import LinkButton from "@/features/button/components/link";
-import TimeZoneSelector from "@/features/event/components/timezone-selector";
-import DateRangeSelector from "@/features/event/editor/date-range/selector";
-import DurationSelector from "@/features/event/editor/duration-selector";
-import TimeSelector from "@/features/event/editor/time-selector";
+import TimeSelector from "@/features/event/components/selectors/time";
+import AdvancedOptions from "@/features/event/editor/advanced-options";
+import DateRangeSelection from "@/features/event/editor/date-range/selector";
import { EventEditorType } from "@/features/event/editor/types";
import { validateEventData } from "@/features/event/editor/validate-data";
+import ScheduleGrid from "@/features/event/grid/grid";
import GridPreviewDialog from "@/features/event/grid/preview-dialog";
-import { useToast } from "@/features/toast/context";
-import { MESSAGES } from "@/lib/messages";
+import FormSelectorField from "@/features/selector/components/selector-field";
import submitEvent from "@/lib/utils/api/submit-event";
import { cn } from "@/lib/utils/classname";
type EventEditorProps = {
type: EventEditorType;
- initialData?: {
- title: string;
- code: string;
- eventRange: EventRange;
- };
+ initialData?: EventInformation;
};
+type SegmentedControlOption = "details" | "preview";
+
+const MemoizedGridPreview = memo(GridPreviewDialog);
+const MemoizedScheduleGrid = memo(ScheduleGrid);
+
export default function EventEditor({ type, initialData }: EventEditorProps) {
+ return (
+
+
+
+ );
+}
+
+function EventEditorContent({ type, initialData }: EventEditorProps) {
const {
state,
setTitle,
- setEventType,
- setCustomCode,
- setTimezone,
- setDuration,
- setTimeRange,
- setDateRange,
- setWeekdayRange,
- } = useEventInfo(initialData);
+ setStartTime,
+ setEndTime,
+ errors,
+ handleError,
+ clearAllErrors,
+ handleGenericError,
+ batchHandleErrors,
+ } = useEventContext();
const { title, customCode, eventRange } = state;
const router = useRouter();
- // TOASTS AND ERROR STATES
- const { addToast } = useToast();
- const [errors, setErrors] = useState>({});
-
- const handleNameChange = (e: string) => {
- if (errors.title) setErrors((prev) => ({ ...prev, title: "" }));
- else if (e === "") {
- setErrors((prev) => ({
- ...prev,
- title: MESSAGES.ERROR_EVENT_NAME_MISSING,
- }));
- }
- setTitle(e);
- };
-
- const handleTimeRangeChange = (from: number, to: number) => {
- if (errors.timeRange) setErrors((prev) => ({ ...prev, timeRange: "" }));
-
- if (from >= to) {
- setErrors((prev) => ({
- ...prev,
- timeRange: MESSAGES.ERROR_EVENT_RANGE_INVALID,
- }));
- }
-
- setTimeRange({ from, to });
- };
-
- const handleCustomCodeChange = useDebouncedCallback(async (customCode) => {
- if (type === "edit") return;
-
- if (errors.customCode) setErrors((prev) => ({ ...prev, customCode: "" }));
- if (customCode === "") {
- return;
- }
-
- try {
- const response = await fetch("/api/event/check-code/", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ custom_code: customCode }),
- });
-
- if (!response.ok) {
- setErrors((prev) => ({
- ...prev,
- customCode: MESSAGES.ERROR_EVENT_CODE_TAKEN,
- }));
- } else {
- setErrors((prev) => ({ ...prev, customCode: "" }));
- }
- } catch (error) {
- console.error("Error checking custom code availability:", error);
- addToast("error", MESSAGES.ERROR_GENERIC);
- }
- }, 300);
+ const [mobileTab, setMobileTab] = useState("details");
// SUBMIT EVENT INFO
const submitEventInfo = async () => {
- setErrors({}); // reset errors
+ clearAllErrors();
try {
const validationErrors = await validateEventData(type, state);
if (Object.keys(validationErrors).length > 0) {
- setErrors(validationErrors);
- Object.values(validationErrors).forEach((error) =>
- addToast("error", error),
- );
+ batchHandleErrors(validationErrors);
return false;
}
@@ -126,13 +76,13 @@ export default function EventEditor({ type, initialData }: EventEditorProps) {
type,
eventRange.type,
(code: string) => router.push(`/${code}`),
- addToast,
- setErrors,
+ handleError,
);
+
return success;
} catch (error) {
console.error("Submission failed:", error);
- addToast("error", MESSAGES.ERROR_GENERIC);
+ handleGenericError();
return false;
}
};
@@ -142,7 +92,7 @@ export default function EventEditor({ type, initialData }: EventEditorProps) {
);
const submitButton = (
@@ -154,10 +104,6 @@ export default function EventEditor({ type, initialData }: EventEditorProps) {
/>
);
- const earliestCalendarDate = new Date(
- (initialData?.eventRange as SpecificDateRange)?.dateRange?.from,
- );
-
return (
@@ -174,7 +120,7 @@ export default function EventEditor({ type, initialData }: EventEditorProps) {
type="text"
label="Event Name"
value={title}
- onChange={handleNameChange}
+ onChange={setTitle}
error={errors.title || errors.api}
classname="text-2xl font-semibold"
/>
@@ -185,168 +131,73 @@ export default function EventEditor({ type, initialData }: EventEditorProps) {
-
- {/* Date range picker */}
-
-
-
+
+
+
- {/* From/To */}
-
-
-
-
- handleTimeRangeChange(value, eventRange.timeRange.to)
- }
- />
-
-
-
-
- handleTimeRangeChange(eventRange.timeRange.from, value)
- }
- />
-
-
- {/* Timezone & Duration */}
-
- {/* Desktop: show all options */}
-
-
-
-
-
-
-
-
-
-
-
+
+
+
void;
-};
-
-export default function EventTypeSelect({
- id,
- eventType,
- disabled = false,
- onEventTypeChange,
-}: EventTypeSelectProps) {
- return (
-
- onEventTypeChange?.(value === "specific" ? "specific" : "weekday")
- }
- className="min-h-9 min-w-[100px] border-none"
- />
- );
-}
diff --git a/src/features/event/editor/time-range/selector.tsx b/src/features/event/editor/time-range/selector.tsx
new file mode 100644
index 00000000..bb71d30f
--- /dev/null
+++ b/src/features/event/editor/time-range/selector.tsx
@@ -0,0 +1,63 @@
+"use client";
+
+import { useState } from "react";
+
+import {
+ TimePickerRoot,
+ TimePickerWheel,
+ TimePickerSeparator,
+} from "@poursha98/react-ios-time-picker";
+
+export default function TimeRangeSelection() {
+ const [time, setTime] = useState("02:30 PM");
+
+ const wheelStyle = {
+ root: { display: "flex", width: "fit-content", padding: "0 8px" },
+ item: { fontSize: "16px" },
+ overlayTop: {
+ background:
+ "linear-gradient(to bottom, color-mix(in srgb, var(--color-background), transparent 20%) 5%, transparent)",
+ },
+ overlayBottom: {
+ background:
+ "linear-gradient(to top, color-mix(in srgb, var(--color-background), transparent 20%) 5%, transparent)",
+ },
+ };
+
+ return (
+
+
+
+ );
+}
diff --git a/src/features/event/editor/time-selector.tsx b/src/features/event/editor/time-selector.tsx
deleted file mode 100644
index d1f39d91..00000000
--- a/src/features/event/editor/time-selector.tsx
+++ /dev/null
@@ -1,137 +0,0 @@
-import { useState, useEffect, useRef } from "react";
-
-import * as Dialog from "@radix-ui/react-dialog";
-import { ChevronDownIcon } from "@radix-ui/react-icons";
-
-import CustomSelect from "@/components/select";
-import useCheckMobile from "@/lib/hooks/use-check-mobile";
-import { cn } from "@/lib/utils/classname";
-
-type TimeSelectorProps = {
- id: string;
- onChange: (time: number) => void;
- value: number;
-};
-
-export default function TimeSelector({
- id,
- onChange,
- value,
-}: TimeSelectorProps) {
- const isMobile = useCheckMobile();
-
- const options = Array.from({ length: 24 }, (_, i) => {
- const hour = i % 12 === 0 ? 12 : i % 12;
- const period = i < 12 ? "am" : "pm";
- return { label: `${hour}:00 ${period}`, value: i };
- });
-
- options.push({ label: "12:00 am", value: 24 });
-
- const handleValueChange = (selectedValue: string | number) => {
- const hour = Number(selectedValue);
- onChange(hour);
- };
-
- if (!isMobile) {
- return (
-
- );
- } else {
- return (
-
- );
- }
-}
-
-function TimeDrawer({
- id,
- value,
- options,
- onChange,
-}: {
- id: string;
- value: number;
- options: { label: string; value: number }[];
- onChange: (time: number) => void;
-}) {
- const valueLabel = options.find((opt) => opt.value === value)?.label || "";
-
- const [open, setOpen] = useState(false);
- const selectedItemRef = useRef(null);
-
- useEffect(() => {
- if (open) {
- const timer = setTimeout(() => {
- selectedItemRef.current?.scrollIntoView({
- block: "center",
- behavior: "auto",
- });
- }, 0);
-
- return () => clearTimeout(timer);
- }
- }, [open]);
-
- return (
-
-
-
- {valueLabel}
-
-
-
-
-
-
-
-
-
-
- Select Time
-
-
- {options.map((option) => {
- const isSelected = option.value === value;
- return (
-
{
- onChange(option.value);
- setOpen(false);
- }}
- className={cn(
- "p-4 text-center",
- isSelected && "bg-accent rounded-full text-white",
- )}
- >
- {option.label}
-
- );
- })}
-
-
-
-
- );
-}
diff --git a/src/features/event/grid/preview-dialog.tsx b/src/features/event/grid/preview-dialog.tsx
index 98f9eb43..5e75f20e 100644
--- a/src/features/event/grid/preview-dialog.tsx
+++ b/src/features/event/grid/preview-dialog.tsx
@@ -1,12 +1,12 @@
"use client";
-import { useState } from "react";
+import { useCallback, useEffect, useState } from "react";
import { EnterFullScreenIcon, Cross2Icon } from "@radix-ui/react-icons";
import { motion } from "framer-motion";
import { EventRange } from "@/core/event/types";
-import TimeZoneSelector from "@/features/event/components/timezone-selector";
+import TimeZoneSelector from "@/features/event/components/selectors/timezone";
import ScheduleGrid from "@/features/event/grid/grid";
import { cn } from "@/lib/utils/classname";
@@ -20,10 +20,31 @@ export default function GridPreviewDialog({
const [isOpen, setIsOpen] = useState(false);
const [timezone, setTimezone] = useState(eventRange.timezone);
+ useEffect(() => {
+ setTimezone(eventRange.timezone);
+ }, [eventRange.timezone]);
+
const handleTZChange = (newTZ: string | number) => {
setTimezone(newTZ.toString());
};
+ // Close dialog on Escape key
+ const closeDialog = useCallback(() => {
+ setIsOpen(false);
+ setTimezone(eventRange.timezone);
+ }, [eventRange.timezone]);
+
+ useEffect(() => {
+ const handleEscape = (e: KeyboardEvent) => {
+ if (e.key === "Escape" && isOpen) {
+ closeDialog();
+ }
+ };
+
+ window.addEventListener("keydown", handleEscape);
+ return () => window.removeEventListener("keydown", handleEscape);
+ }, [isOpen, eventRange.timezone, closeDialog]);
+
return (
{isOpen && (
@@ -48,24 +69,21 @@ export default function GridPreviewDialog({
layout
className="mr-4 flex items-center justify-end space-x-2"
>
-
+
Grid Preview
{isOpen ? (
{
- setIsOpen(!isOpen);
- setTimezone(eventRange.timezone);
- }}
+ className="hover:text-accent hover:bg-accent/25 active:bg-accent/40 h-6 w-6 cursor-pointer rounded-full p-1"
+ onClick={() => closeDialog()}
/>
) : (
setIsOpen(!isOpen)}
/>
)}
{isOpen ? (
-
+
({
+ id,
+ value,
+ options,
+ onChange,
+ dialogTitle,
+ dialogDescription,
+}: SelectorProps) {
+ const [open, setOpen] = useState(false);
+ const selectedItemRef = useRef(null);
+
+ const selectLabel = options.find((opt) => opt.value === value)?.label || "";
+
+ const handleClose = () => {
+ setOpen(false);
+ return true;
+ };
+
+ useEffect(() => {
+ if (open) {
+ const timer = setTimeout(() => {
+ // Scroll the selected item into view when the drawer opens
+ selectedItemRef.current?.scrollIntoView({
+ block: "center",
+ behavior: "auto",
+ });
+ }, 0);
+
+ return () => clearTimeout(timer);
+ }
+ }, [open]);
+
+ return (
+
+
+ {selectLabel}
+
+
+
+
+
+
+
+
}
+ label="Close Drawer"
+ shrinkOnMobile
+ onClick={handleClose}
+ />
+
+
+ {dialogTitle}
+
+
+
+
+ {options.map((option) => {
+ const isSelected = option.value === value;
+ return (
+
+ );
+ })}
+
+
+
+
+
+ );
+}
diff --git a/src/features/selector/components/dropdown.tsx b/src/features/selector/components/dropdown.tsx
new file mode 100644
index 00000000..1d5fc74c
--- /dev/null
+++ b/src/features/selector/components/dropdown.tsx
@@ -0,0 +1,83 @@
+import { forwardRef } from "react";
+
+import { CheckIcon, ChevronDownIcon } from "@radix-ui/react-icons";
+import * as Select from "@radix-ui/react-select";
+
+import { SelectorProps } from "@/features/selector/types";
+import { cn } from "@/lib/utils/classname";
+
+export default function Dropdown({
+ id,
+ onChange,
+ value,
+ options,
+ disabled,
+ className,
+}: SelectorProps) {
+ return (
+ {
+ // parse value back to number when possible, then cast to TValue
+ const parsed = isNaN(Number(v)) ? v : Number(v);
+ onChange(parsed as unknown as TValue);
+ }}
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {options.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+
+
+ );
+}
+
+type DropdownItemProps = {
+ value: string | number;
+ children: React.ReactNode;
+};
+
+const DropdownItem = forwardRef(
+ ({ children, value }, ref) => {
+ return (
+
+ {children}
+
+
+
+
+ );
+ },
+);
+DropdownItem.displayName = "DropdownItem";
diff --git a/src/features/selector/components/selector-field.tsx b/src/features/selector/components/selector-field.tsx
new file mode 100644
index 00000000..429e0966
--- /dev/null
+++ b/src/features/selector/components/selector-field.tsx
@@ -0,0 +1,32 @@
+import { cn } from "@/lib/utils/classname";
+
+type FormSelectorFieldProps = {
+ label: string;
+ htmlFor: string;
+ children: React.ReactNode;
+ isVertical?: boolean;
+ classname?: string;
+};
+
+export default function FormSelectorField({
+ label,
+ htmlFor,
+ children,
+ isVertical = false,
+ classname,
+}: FormSelectorFieldProps) {
+ return (
+
+
+ {children}
+
+ );
+}
diff --git a/src/features/selector/components/selector.tsx b/src/features/selector/components/selector.tsx
new file mode 100644
index 00000000..b749d417
--- /dev/null
+++ b/src/features/selector/components/selector.tsx
@@ -0,0 +1,47 @@
+import SelectorDrawer from "@/features/selector/components/drawer";
+import Dropdown from "@/features/selector/components/dropdown";
+import { SelectorProps } from "@/features/selector/types";
+import useCheckMobile from "@/lib/hooks/use-check-mobile";
+import { cn } from "@/lib/utils/classname";
+
+export default function Selector({
+ id,
+ onChange,
+ value,
+ options,
+ dialogTitle,
+ dialogDescription,
+ className,
+}: SelectorProps) {
+ const isMobile = useCheckMobile();
+
+ // converts the value to the format needed by CustomSelect (string or number)
+ const handleValueChange = (selectedValue: string | number) => {
+ onChange(selectedValue as TValue);
+ };
+
+ if (!isMobile) {
+ return (
+
+
+
+ );
+ } else {
+ return (
+
+ );
+ }
+}
diff --git a/src/features/selector/types.ts b/src/features/selector/types.ts
new file mode 100644
index 00000000..2193cd71
--- /dev/null
+++ b/src/features/selector/types.ts
@@ -0,0 +1,17 @@
+export type Option = {
+ label: string;
+ value: TValue;
+};
+
+export type SelectorProps = {
+ id: string;
+ onChange: (value: TValue) => void;
+ value: TValue;
+ options: Option[];
+ disabled?: boolean;
+ className?: string;
+
+ // for mobile drawer
+ dialogTitle?: string;
+ dialogDescription?: string;
+};
diff --git a/src/lib/hooks/use-form-errors.ts b/src/lib/hooks/use-form-errors.ts
index 2b637ef5..7d0373f8 100644
--- a/src/lib/hooks/use-form-errors.ts
+++ b/src/lib/hooks/use-form-errors.ts
@@ -1,4 +1,4 @@
-import { useState } from "react";
+import { useState, useMemo, useCallback } from "react";
import { useToast } from "@/features/toast/context";
import { MESSAGES } from "@/lib/messages";
@@ -7,37 +7,63 @@ export function useFormErrors() {
const [errors, setErrors] = useState>({});
const { addToast } = useToast();
- /*
- * Sets an error message for a specific field.
- * If the field is "api", also shows a toast notification.
- * If the field is "rate_limit" and no message is provided,
- * sets a default rate limit message.
- */
- const handleError = (field: string, message: string) => {
- if (field === "api") {
- addToast("error", message);
- } else if (field === "rate_limit" && !message) {
- message = MESSAGES.ERROR_RATE_LIMIT;
- }
-
- setErrors((prev) => ({
- ...prev,
- [field]: message,
- }));
- };
-
- // Clears all error messages
- const clearAllErrors = () => setErrors({});
-
- // Helper for generic try/catch blocks
- const handleGenericError = () => {
+ const clearAllErrors = useCallback(() => setErrors({}), []);
+
+ const handleError = useCallback(
+ (field: string, message: string) => {
+ // clear error if message is empty
+ if (!message) {
+ setErrors((prev) => {
+ const newErrors = { ...prev };
+ delete newErrors[field];
+ return newErrors;
+ });
+ return;
+ }
+
+ // show toast for api and toast errors
+ if (field === "api" || field === "toast") {
+ addToast("error", message);
+ } else if (field === "rate_limit" && !message) {
+ message = MESSAGES.ERROR_RATE_LIMIT;
+ }
+
+ setErrors((prev) => ({
+ ...prev,
+ [field]: message,
+ }));
+ },
+ [addToast],
+ );
+
+ const handleGenericError = useCallback(() => {
addToast("error", MESSAGES.ERROR_GENERIC);
- };
-
- return {
- errors,
- handleError,
- clearAllErrors,
- handleGenericError,
- };
+ }, [addToast]);
+
+ const batchHandleErrors = useCallback(
+ (newErrors: Record) => {
+ setErrors((prev) => ({ ...prev, ...newErrors }));
+ for (const message of Object.values(newErrors)) {
+ addToast("error", message);
+ }
+ },
+ [addToast],
+ );
+
+ return useMemo(
+ () => ({
+ errors,
+ handleError,
+ clearAllErrors,
+ handleGenericError,
+ batchHandleErrors,
+ }),
+ [
+ errors,
+ handleError,
+ clearAllErrors,
+ handleGenericError,
+ batchHandleErrors,
+ ],
+ );
}
diff --git a/src/lib/utils/api/submit-event.ts b/src/lib/utils/api/submit-event.ts
index 02b0f646..adb0f509 100644
--- a/src/lib/utils/api/submit-event.ts
+++ b/src/lib/utils/api/submit-event.ts
@@ -5,7 +5,6 @@ import {
} from "@/core/event/types";
import { findRangeFromWeekdayMap } from "@/core/event/weekday-utils";
import { EventEditorType } from "@/features/event/editor/types";
-import { ToastType } from "@/features/toast/type";
import { MESSAGES } from "@/lib/messages";
import { formatApiError } from "@/lib/utils/api/handle-api-error";
@@ -41,8 +40,7 @@ export default async function submitEvent(
type: EventEditorType,
eventType: "specific" | "weekday",
onSuccess: (code: string) => void,
- addToast: (type: ToastType, message: string) => void,
- setErrors: React.Dispatch>>,
+ handleError: (field: string, message: string) => void,
): Promise {
let apiRoute = "";
let jsonBody: EventSubmitJsonBody;
@@ -122,19 +120,16 @@ export default async function submitEvent(
const errorMessage = formatApiError(body);
if (res.status === 429) {
- setErrors((prev: Record) => ({
- ...prev,
- rate_limit: errorMessage || MESSAGES.ERROR_RATE_LIMIT,
- }));
+ handleError("rate_limit", errorMessage);
} else {
- addToast("error", formatApiError(body));
+ handleError("toast", formatApiError(body));
}
return false;
}
} catch (err) {
console.error("Fetch error:", err);
- addToast("error", MESSAGES.ERROR_GENERIC);
+ handleError("toast", MESSAGES.ERROR_GENERIC);
return false;
}
}
diff --git a/src/styles/globals.css b/src/styles/globals.css
index c4fa875f..0104a0f4 100644
--- a/src/styles/globals.css
+++ b/src/styles/globals.css
@@ -1,11 +1,13 @@
@import "tailwindcss";
@import "react-day-picker/style.css";
+@import "@poursha98/react-ios-time-picker/styles.css";
@import "./tailwind.css";
@import "./text.css";
@import "./frosted-glass.css";
@import "./month-calendar.css";
@import "./centered-absolute.css";
+@import "./radix.css";
/* GLOBAL STYLES */
@@ -14,3 +16,11 @@ body {
color: var(--foreground);
font-family: var(--font-nunito);
}
+
+:root {
+ --time-picker-bg: var(--background);
+ --time-picker-text: var(--foreground);
+ --time-picker-text-secondary: #9ca3af;
+ --time-picker-primary: var(--accent);
+ --time-picker-primary-light: rgba(59, 130, 246, 0.1);
+}
diff --git a/src/styles/radix.css b/src/styles/radix.css
new file mode 100644
index 00000000..ef637f18
--- /dev/null
+++ b/src/styles/radix.css
@@ -0,0 +1,29 @@
+/* Collapsible animations */
+
+.collapsible-content {
+ overflow: hidden;
+}
+.collapsible-content[data-state="open"] {
+ animation: slideDown_collpasible 300ms ease-out;
+}
+.collapsible-content[data-state="closed"] {
+ animation: slideUp_collpasible 300ms ease-out;
+}
+
+@keyframes slideDown_collpasible {
+ from {
+ height: 0;
+ }
+ to {
+ height: var(--radix-collapsible-content-height);
+ }
+}
+
+@keyframes slideUp_collpasible {
+ from {
+ height: var(--radix-collapsible-content-height);
+ }
+ to {
+ height: 0;
+ }
+}