diff --git a/package-lock.json b/package-lock.json index 50526b9e..8ae195a8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,11 +8,14 @@ "name": "frontend", "version": "0.1.0", "dependencies": { + "@poursha98/react-ios-time-picker": "^2.0.3", + "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.13", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-icons": "^1.3.2", "@radix-ui/react-popover": "^1.1.13", "@radix-ui/react-select": "^2.2.4", + "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-toast": "^1.2.15", "clsx": "^2.1.1", "date-fns-tz": "^3.2.0", @@ -1418,6 +1421,19 @@ "node": ">=12.4.0" } }, + "node_modules/@poursha98/react-ios-time-picker": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@poursha98/react-ios-time-picker/-/react-ios-time-picker-2.0.3.tgz", + "integrity": "sha512-r/8l0Tbsdh7BT6YEDG7HyvRKcOc5TisSLVARjg9f+57W9H/zCBXT6Ux+NhZZJ+x2R1pqOo6GLzLrHFPc7L13HA==", + "license": "MIT", + "dependencies": { + "clsx": "^2.1.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, "node_modules/@radix-ui/number": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", @@ -1450,6 +1466,107 @@ } } }, + "node_modules/@radix-ui/react-collapsible": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz", + "integrity": "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-collection": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.6.tgz", @@ -2298,6 +2415,82 @@ } } }, + "node_modules/@radix-ui/react-switch": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.2.6.tgz", + "integrity": "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-switch/node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-toast": { "version": "1.2.15", "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.15.tgz", diff --git a/package.json b/package.json index b8f22134..068be9c3 100644 --- a/package.json +++ b/package.json @@ -12,11 +12,14 @@ "check-format": "prettier --check ." }, "dependencies": { + "@poursha98/react-ios-time-picker": "^2.0.3", + "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.13", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-icons": "^1.3.2", "@radix-ui/react-popover": "^1.1.13", "@radix-ui/react-select": "^2.2.4", + "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-toast": "^1.2.15", "clsx": "^2.1.1", "date-fns-tz": "^3.2.0", diff --git a/src/app/(event)/[event-code]/edit/page.tsx b/src/app/(event)/[event-code]/edit/page.tsx index 38e9a63f..8ffb0024 100644 --- a/src/app/(event)/[event-code]/edit/page.tsx +++ b/src/app/(event)/[event-code]/edit/page.tsx @@ -17,7 +17,7 @@ export default async function Page({ params }: EventCodePageProps) { return ( ); } diff --git a/src/app/(event)/[event-code]/page-client.tsx b/src/app/(event)/[event-code]/page-client.tsx index 38221e09..cbbb07c7 100644 --- a/src/app/(event)/[event-code]/page-client.tsx +++ b/src/app/(event)/[event-code]/page-client.tsx @@ -10,7 +10,7 @@ import { ResultsAvailabilityMap } from "@/core/availability/types"; import { EventRange } from "@/core/event/types"; import LinkButton from "@/features/button/components/link"; import { AvailabilityDataResponse } from "@/features/event/availability/fetch-data"; -import TimeZoneSelector from "@/features/event/components/timezone-selector"; +import TimeZoneSelector from "@/features/event/components/selectors/timezone"; import ScheduleGrid from "@/features/event/grid/grid"; import EventInfoDrawer, { EventInfo } from "@/features/event/info-drawer"; diff --git a/src/app/(event)/[event-code]/painting/page-client.tsx b/src/app/(event)/[event-code]/painting/page-client.tsx index d1eb472a..3ff95656 100644 --- a/src/app/(event)/[event-code]/painting/page-client.tsx +++ b/src/app/(event)/[event-code]/painting/page-client.tsx @@ -15,7 +15,7 @@ import ActionButton from "@/features/button/components/action"; import LinkButton from "@/features/button/components/link"; import { SelfAvailabilityResponse } from "@/features/event/availability/fetch-data"; import { validateAvailabilityData } from "@/features/event/availability/validate-data"; -import TimeZoneSelector from "@/features/event/components/timezone-selector"; +import TimeZoneSelector from "@/features/event/components/selectors/timezone"; import ScheduleGrid from "@/features/event/grid/grid"; import EventInfoDrawer, { EventInfo } from "@/features/event/info-drawer"; import { useToast } from "@/features/toast/context"; diff --git a/src/components/segmented-control.tsx b/src/components/segmented-control.tsx new file mode 100644 index 00000000..5f77e89a --- /dev/null +++ b/src/components/segmented-control.tsx @@ -0,0 +1,69 @@ +"use client"; + +import { useMemo } from "react"; + +import { cn } from "@/lib/utils/classname"; + +type SegmentedControlProps = { + options: { label: string; value: T }[]; + value: T; + onChange: (value: T) => void; + className?: string; +}; + +export default function SegmentedControl({ + options, + value, + onChange, + className, +}: SegmentedControlProps) { + const activeIndex = options.findIndex((opt) => opt.value === value); + const count = options.length; + + // Calculate dynamic style for the sliding pill + const pillStyle = useMemo(() => { + const pillWidth = `(100% - 16px) / ${count}`; + const leftOffset = `calc(8px + (${pillWidth}) * ${activeIndex})`; + + return { + width: `calc(${pillWidth})`, + left: activeIndex === -1 ? "8px" : leftOffset, + }; + }, [activeIndex, count]); + + return ( +
+
+ + {options.map((option) => { + const isSelected = option.value === value; + return ( + + ); + })} +
+ ); +} diff --git a/src/components/select.tsx b/src/components/select.tsx deleted file mode 100644 index 9386142b..00000000 --- a/src/components/select.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import { forwardRef } from "react"; - -import { CheckIcon, ChevronDownIcon } from "@radix-ui/react-icons"; -import * as Select from "@radix-ui/react-select"; - -import { cn } from "@/lib/utils/classname"; - -// --- Simplified Types --- -type Option = { - label: string; - value: string | number; -}; - -type CustomSelectProps = { - id: string; - value: string | number; - options: Option[]; - disabled?: boolean; - onValueChange: (value: string | number) => void; - placeholder?: string; - className?: string; -}; - -// --- Refactored Component --- -export default function CustomSelect({ - id, - value, - options, - disabled, - onValueChange, - placeholder, - className, -}: CustomSelectProps) { - return ( - onValueChange(isNaN(Number(v)) ? v : Number(v))} - > - - - - - {disabled ? null : ( - - - - )} - - - - - - {options.map((option) => ( - - {option.label} - - ))} - - - - - ); -} - -// --- SelectItem (Unchanged) --- -type SelectItemProps = { - value: string | number; - children: React.ReactNode; -}; - -const SelectItem = forwardRef( - ({ children, value }, ref) => { - return ( - - {children} - - - - - ); - }, -); -SelectItem.displayName = "SelectItem"; diff --git a/src/components/switch.tsx b/src/components/switch.tsx new file mode 100644 index 00000000..d036a627 --- /dev/null +++ b/src/components/switch.tsx @@ -0,0 +1,39 @@ +import * as RadixSwitch from "@radix-ui/react-switch"; + +import { cn } from "@/lib/utils/classname"; + +type SwitchProps = { + id: string; + checked: boolean; + onCheckedChange: (checked: boolean) => void; + disabled?: boolean; +}; + +export default function Switch({ + id, + checked, + onCheckedChange, + disabled = false, +}: SwitchProps) { + return ( + + + + ); +} diff --git a/src/core/event/context.tsx b/src/core/event/context.tsx new file mode 100644 index 00000000..33153699 --- /dev/null +++ b/src/core/event/context.tsx @@ -0,0 +1,31 @@ +"use client"; + +import { createContext, useContext, ReactNode } from "react"; + +import { EventInformation } from "@/core/event/types"; +import { useEventInfo } from "@/core/event/use-event-info"; + +type EventContextType = ReturnType; + +const EventContext = createContext(null); + +type EventProviderProps = { + children: ReactNode; + initialData?: EventInformation; +}; + +export function EventProvider({ children, initialData }: EventProviderProps) { + const eventInfo = useEventInfo(initialData); + + return ( + {children} + ); +} + +export function useEventContext() { + const context = useContext(EventContext); + if (!context) { + throw new Error("useEventContext must be used within an EventProvider"); + } + return context; +} diff --git a/src/core/event/reducers/range-reducer.ts b/src/core/event/reducers/range-reducer.ts index 9ba686c0..ae541e7a 100644 --- a/src/core/event/reducers/range-reducer.ts +++ b/src/core/event/reducers/range-reducer.ts @@ -4,7 +4,8 @@ export type EventRangeAction = | { type: "SET_RANGE_INFO"; payload: EventRange } | { type: "SET_RANGE_TYPE"; payload: "specific" | "weekday" } | { type: "SET_DATE_RANGE"; payload: { from: string; to: string } } - | { type: "SET_TIME_RANGE"; payload: { from: number; to: number } } + | { type: "SET_START_TIME"; payload: number } + | { type: "SET_END_TIME"; payload: number } | { type: "SET_WEEKDAYS"; payload: { weekdays: Partial> }; @@ -67,12 +68,22 @@ export function EventRangeReducer( }; } - case "SET_TIME_RANGE": { + case "SET_START_TIME": { return { ...state, timeRange: { - from: action.payload.from, - to: action.payload.to, + from: action.payload, + to: state.timeRange.to, + }, + }; + } + + case "SET_END_TIME": { + return { + ...state, + timeRange: { + from: state.timeRange.from, + to: action.payload, }, }; } diff --git a/src/core/event/use-event-info.ts b/src/core/event/use-event-info.ts index 5418c00c..9982bc53 100644 --- a/src/core/event/use-event-info.ts +++ b/src/core/event/use-event-info.ts @@ -1,18 +1,21 @@ -import { useReducer, useCallback } from "react"; +import { useMemo, useReducer, useCallback } from "react"; import { DateRange } from "react-day-picker"; import { EventInfoReducer } from "@/core/event/reducers/info-reducer"; import { EventInformation, EventRange, WeekdayMap } from "@/core/event/types"; +import { checkInvalidDateRangeLength } from "@/features/event/editor/validate-data"; +import { useFormErrors } from "@/lib/hooks/use-form-errors"; +import { MESSAGES } from "@/lib/messages"; -export function useEventInfo(initialData?: { - title: string; - code: string; - eventRange: EventRange; -}) { - const initialState: EventInformation = { +const checkTimeRange = (from: number, to: number): boolean => { + return to > from; +}; + +function createInitialState(initialData?: EventInformation): EventInformation { + return { title: initialData?.title || "", - customCode: initialData?.code || "", + customCode: initialData?.customCode || "", eventRange: initialData?.eventRange || { type: "specific", duration: 0, @@ -27,21 +30,43 @@ export function useEventInfo(initialData?: { }, }, }; +} - if (!initialData?.eventRange?.duration) { - initialState.eventRange.duration = 0; - } - - const [state, dispatch] = useReducer(EventInfoReducer, initialState); +export function useEventInfo(initialData?: EventInformation) { + const [state, dispatch] = useReducer( + EventInfoReducer, + initialData, + createInitialState, + ); - // DISPATCHERS - const setTitle = useCallback((title: string) => { - dispatch({ type: "SET_TITLE", payload: title }); - }, []); + const { + errors, + handleError, + handleGenericError, + clearAllErrors, + batchHandleErrors, + } = useFormErrors(); + + // DISPATCHERS (checks input, sets errors if needed) + const setTitle = useCallback( + (title: string): void => { + if (errors.title) handleError("title", ""); + else if (title === "") { + handleError("title", MESSAGES.ERROR_EVENT_NAME_MISSING); + } + + dispatch({ type: "SET_TITLE", payload: title }); + }, + [errors.title, handleError], + ); - const setCustomCode = useCallback((code: string) => { - dispatch({ type: "SET_CUSTOM_CODE", payload: code }); - }, []); + const setCustomCode = useCallback( + (code: string) => { + handleError("customCode", ""); + dispatch({ type: "SET_CUSTOM_CODE", payload: code }); + }, + [handleError], + ); const setEventRangeInfo = useCallback((info: EventRange) => { dispatch({ type: "SET_RANGE_INFO", payload: info }); @@ -59,23 +84,49 @@ export function useEventInfo(initialData?: { dispatch({ type: "SET_DURATION", payload: duration }); }, []); - const setTimeRange = useCallback( - (timeRange: { from: number; to: number }) => { - dispatch({ type: "SET_TIME_RANGE", payload: timeRange }); + const setStartTime = useCallback( + (time: number) => { + if (checkTimeRange(time, state.eventRange.timeRange.to)) { + handleError("timeRange", ""); + } else handleError("timeRange", MESSAGES.ERROR_EVENT_RANGE_INVALID); + + dispatch({ type: "SET_START_TIME", payload: time }); }, - [], + [state.eventRange.timeRange.to, handleError], ); - const setDateRange = useCallback((dateRange: DateRange | undefined) => { - if (dateRange?.from && dateRange?.to) { - const from = dateRange.from.toISOString(); - const to = dateRange.to.toISOString(); - dispatch({ - type: "SET_DATE_RANGE", - payload: { from, to }, - }); - } - }, []); + const setEndTime = useCallback( + (time: number) => { + if (checkTimeRange(state.eventRange.timeRange.from, time)) { + handleError("timeRange", ""); + } else { + handleError("timeRange", MESSAGES.ERROR_EVENT_RANGE_INVALID); + } + + dispatch({ type: "SET_END_TIME", payload: time }); + }, + [state.eventRange.timeRange.from, handleError], + ); + + const setDateRange = useCallback( + (dateRange: DateRange | undefined) => { + if (checkInvalidDateRangeLength(dateRange)) { + handleError("dateRange", MESSAGES.ERROR_EVENT_RANGE_TOO_LONG); + } else { + handleError("dateRange", ""); + } + + if (dateRange?.from && dateRange?.to) { + const from = dateRange.from.toISOString(); + const to = dateRange.to.toISOString(); + dispatch({ + type: "SET_DATE_RANGE", + payload: { from, to }, + }); + } + }, + [handleError], + ); const setWeekdayRange = useCallback((weekdays: WeekdayMap) => { dispatch({ type: "SET_WEEKDAYS", payload: { weekdays } }); @@ -85,17 +136,44 @@ export function useEventInfo(initialData?: { dispatch({ type: "RESET" }); }, []); - return { - state, - setTitle, - setEventType, - setCustomCode, - setEventRangeInfo, - setTimezone, - setDuration, - setTimeRange, - setDateRange, - setWeekdayRange, - resetEventInfo, - }; + return useMemo( + () => ({ + state, + setTitle, + setEventType, + setCustomCode, + setEventRangeInfo, + setTimezone, + setDuration, + setStartTime, + setEndTime, + setDateRange, + setWeekdayRange, + resetEventInfo, + errors, + handleError, + handleGenericError, + batchHandleErrors, + clearAllErrors, + }), + [ + state, + setTitle, + setEventType, + setCustomCode, + setEventRangeInfo, + setTimezone, + setDuration, + setStartTime, + setEndTime, + setDateRange, + setWeekdayRange, + resetEventInfo, + errors, + handleError, + handleGenericError, + batchHandleErrors, + clearAllErrors, + ], + ); } diff --git a/src/features/event/components/selectors/duration.tsx b/src/features/event/components/selectors/duration.tsx new file mode 100644 index 00000000..df5d859c --- /dev/null +++ b/src/features/event/components/selectors/duration.tsx @@ -0,0 +1,31 @@ +import Selector from "@/features/selector/components/selector"; + +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) { + return ( + + ); +} diff --git a/src/features/event/components/selectors/time.tsx b/src/features/event/components/selectors/time.tsx new file mode 100644 index 00000000..8d0c4f1e --- /dev/null +++ b/src/features/event/components/selectors/time.tsx @@ -0,0 +1,33 @@ +import Selector from "@/features/selector/components/selector"; + +type TimeSelectorProps = { + id: string; + onChange: (time: number) => void; + value: number; +}; + +export default function TimeSelector({ + id, + onChange, + value, +}: TimeSelectorProps) { + 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 }); + + return ( + + ); +} diff --git a/src/features/event/components/selectors/timezone.tsx b/src/features/event/components/selectors/timezone.tsx new file mode 100644 index 00000000..59265a12 --- /dev/null +++ b/src/features/event/components/selectors/timezone.tsx @@ -0,0 +1,39 @@ +import { useTimezoneSelect, allTimezones } from "react-timezone-select"; + +import Selector from "@/features/selector/components/selector"; + +const labelStyle = "original"; +const timezones = allTimezones; + +type TimeZoneSelectorProps = { + id: string; + onChange: (tz: string) => void; + value: string; + className?: string; +}; + +export default function TimeZoneSelector({ + id, + onChange, + value, + className, +}: TimeZoneSelectorProps) { + const { options, parseTimezone } = useTimezoneSelect({ + labelStyle, + timezones, + }); + + const parsedValue = parseTimezone(value)?.value || ""; + + return ( + + id={id} + onChange={onChange} + value={parsedValue} + options={options} + dialogTitle="Select Timezone" + dialogDescription="Select a timezone from the list" + className={className} + /> + ); +} diff --git a/src/features/event/components/timezone-selector.tsx b/src/features/event/components/timezone-selector.tsx deleted file mode 100644 index 8e6fea95..00000000 --- a/src/features/event/components/timezone-selector.tsx +++ /dev/null @@ -1,142 +0,0 @@ -import { useState, useEffect, useRef } from "react"; - -import * as Dialog from "@radix-ui/react-dialog"; -import { ChevronDownIcon } from "@radix-ui/react-icons"; -import { useTimezoneSelect, allTimezones } from "react-timezone-select"; - -import CustomSelect from "@/components/select"; -import useCheckMobile from "@/lib/hooks/use-check-mobile"; -import { cn } from "@/lib/utils/classname"; - -const labelStyle = "original"; -const timezones = allTimezones; - -type TimeZoneSelectorProps = { - id: string; - onChange: (tz: string) => void; - value: string; - className?: string; -}; - -export default function TimeZoneSelector({ - id, - onChange, - value, - className, -}: TimeZoneSelectorProps) { - const isMobile = useCheckMobile(); - - const { options, parseTimezone } = useTimezoneSelect({ - labelStyle, - timezones, - }); - - if (!isMobile) { - return ( -
- onChange(String(v))} - className="w-full" - /> -
- ); - } else { - return ( - - ); - } -} - -function TimeZoneDrawer({ - id, - value, - onChange, - options, -}: { - id: string; - value: string; - options: { label: string; value: string }[]; - onChange: (tz: string) => 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", - isSelected && "bg-accent rounded-full text-white", - )} - > - {option.label} -
- ); - })} -
- - - - ); -} diff --git a/src/features/event/editor/advanced-options.tsx b/src/features/event/editor/advanced-options.tsx new file mode 100644 index 00000000..fb5fb459 --- /dev/null +++ b/src/features/event/editor/advanced-options.tsx @@ -0,0 +1,130 @@ +import React, { useState } from "react"; + +import * as Collapsible from "@radix-ui/react-collapsible"; +import { + ChevronRightIcon, + ExclamationTriangleIcon, +} from "@radix-ui/react-icons"; +import { useDebouncedCallback } from "use-debounce"; + +import { useEventContext } from "@/core/event/context"; +import DurationSelector from "@/features/event/components/selectors/duration"; +import TimeZoneSelector from "@/features/event/components/selectors/timezone"; +import FormSelectorField from "@/features/selector/components/selector-field"; +import { MESSAGES } from "@/lib/messages"; +import { cn } from "@/lib/utils/classname"; + +type AdvancedOptionsProps = { + isEditing?: boolean; + errors: Record; +}; + +export default function AdvancedOptions(props: AdvancedOptionsProps) { + const [open, setOpen] = useState(false); + return ( + + +
+ + + Advanced Options + +
+
+ + + + +
+ ); +} + +function Options({ isEditing = false, errors }: AdvancedOptionsProps) { + const { + state: { customCode, eventRange }, + setTimezone, + setDuration, + setCustomCode, + handleError, + } = useEventContext(); + + const [localCode, setLocalCode] = useState(customCode); + + const checkCodeAvailability = useDebouncedCallback(async (code: string) => { + if (isEditing || !code) return; + + try { + const response = await fetch("/api/event/check-code/", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ custom_code: code }), + }); + if (!response.ok) + handleError("customCode", MESSAGES.ERROR_EVENT_CODE_TAKEN); + } catch { + handleError("api", MESSAGES.ERROR_GENERIC); + } + + setCustomCode(code); + }, 500); + + const handleCustomCodeChange = (e: React.ChangeEvent) => { + const newValue = e.target.value; + setLocalCode(newValue); + checkCodeAvailability(newValue); + }; + + return ( + <> + + + + + + setDuration((v as number) || 0)} + /> + + + + + + ); +} diff --git a/src/features/event/editor/month-calendar.tsx b/src/features/event/editor/date-range/calendars/month.tsx similarity index 74% rename from src/features/event/editor/month-calendar.tsx rename to src/features/event/editor/date-range/calendars/month.tsx index 9e721a22..4b88dc34 100644 --- a/src/features/event/editor/month-calendar.tsx +++ b/src/features/event/editor/date-range/calendars/month.tsx @@ -5,14 +5,15 @@ import { useState } from "react"; import { ExclamationTriangleIcon } from "@radix-ui/react-icons"; import { DateRange, DayPicker, getDefaultClassNames } from "react-day-picker"; -import { checkInvalidDateRangeLength } from "@/features/event/editor/validate-data"; import useCheckMobile from "@/lib/hooks/use-check-mobile"; +import { cn } from "@/lib/utils/classname"; type CalendarProps = { earliestDate?: Date; className?: string; selectedRange: DateRange; setDateRange: (range: DateRange | undefined) => void; + dateRangeError?: string; }; export function Calendar({ @@ -20,6 +21,7 @@ export function Calendar({ className, selectedRange, setDateRange, + dateRangeError, }: CalendarProps) { const defaultClassNames = getDefaultClassNames(); @@ -39,20 +41,12 @@ export function Calendar({ : today; const [month, setMonth] = useState(startDate); - const [tooManyDays, setTooManyDays] = useState(() => { - return checkInvalidDateRangeLength(selectedRange); - }); - - const checkDateRange = (range: DateRange | undefined) => { - setTooManyDays(checkInvalidDateRangeLength(range)); - setDateRange(range); - }; return ( -
+
{/* */} @@ -64,16 +58,16 @@ export function Calendar({ month={month} onMonthChange={setMonth} selected={selectedRange} - onSelect={checkDateRange} + onSelect={setDateRange} disabled={{ before: startDate }} classNames={{ root: `${defaultClassNames.root} flex justify-center items-center`, }} /> - {!isMobile && tooManyDays && ( + {!isMobile && dateRangeError && (
- Too many days selected. Max is 30 days. + {dateRangeError}
)}
diff --git a/src/features/event/editor/weekday-calendar.tsx b/src/features/event/editor/date-range/calendars/weekday.tsx similarity index 54% rename from src/features/event/editor/weekday-calendar.tsx rename to src/features/event/editor/date-range/calendars/weekday.tsx index a0ba9e5b..8e130787 100644 --- a/src/features/event/editor/weekday-calendar.tsx +++ b/src/features/event/editor/date-range/calendars/weekday.tsx @@ -8,17 +8,14 @@ import { cn } from "@/lib/utils/classname"; type WeekdayCalendarProps = { selectedDays: WeekdayMap; onChange: (map: WeekdayMap) => void; - inDrawer?: boolean; }; export default function WeekdayCalendar({ selectedDays, onChange, }: WeekdayCalendarProps) { - // const [startMonday, setStartMonday] = useState(false); - // const reorderedDays = startMonday ? [...days.slice(1), days[0]] : days; - const [startDay, setStartDay] = useState(null); + useEffect(() => { const hasSelection = Object.values(selectedDays).some((val) => val === 1); if (hasSelection) { @@ -35,33 +32,22 @@ export default function WeekdayCalendar({ onChange(newSelection); }, [selectedDays, onChange]); - // for toggling only one day at a time - // currently not in use - // const handleDayClick = (day: Weekday) => { - // const newSelectedDays = { ...selectedDays }; - // newSelectedDays[day] = newSelectedDays[day] === 1 ? 0 : 1; - // onChange(newSelectedDays); - // }; - const handleRangeSelect = (day: Weekday) => { if (!startDay) { - // set it as the start of the range setStartDay(day); - // clear previous selections and select this day const newSelection: WeekdayMap = { ...selectedDays }; days.forEach((d) => (newSelection[d] = 0)); newSelection[day] = 1; onChange(newSelection); } else { - // complete range + // End of selection (Complete Range) const newSelection: WeekdayMap = { ...selectedDays }; days.forEach((d) => (newSelection[d] = 0)); const startIndex = days.indexOf(startDay); const endIndex = days.indexOf(day); - // determine the range boundaries const [min, max] = [ Math.min(startIndex, endIndex), Math.max(startIndex, endIndex), @@ -76,32 +62,37 @@ export default function WeekdayCalendar({ } }; + const activeIndices = days + .map((day, i) => (selectedDays[day] === 1 ? i : -1)) + .filter((i) => i !== -1); + + const rangeStart = activeIndices.length ? Math.min(...activeIndices) : -1; + const rangeEnd = activeIndices.length ? Math.max(...activeIndices) : -1; + return ( -
-
- {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 ( -
- {/* Start Date */} -
- - - {displayFrom} - -
- - TO - - {/* End Date */} -
- - - {displayTo} - -
-
- ); -} 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 ( +
+ {/* Start Date */} +
+

FROM

+ + {displayFrom} + +
+ + TO + + {/* End Date */} +
+

UNTIL

+ + {displayTo} + +
+
+ ); +} 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 */} -