diff --git a/frontend/src/app/(auth)/forgot-password/page.tsx b/frontend/src/app/(auth)/forgot-password/page.tsx index e52f7923f..4575adf91 100644 --- a/frontend/src/app/(auth)/forgot-password/page.tsx +++ b/frontend/src/app/(auth)/forgot-password/page.tsx @@ -80,7 +80,7 @@ export default function Page() { label="Email*" value={email} onChange={handleEmailChange} - outlined + style="outlined" error={errors.email || errors.api} />, ]} diff --git a/frontend/src/app/(auth)/login/page.tsx b/frontend/src/app/(auth)/login/page.tsx index 0d90179a9..d95350698 100644 --- a/frontend/src/app/(auth)/login/page.tsx +++ b/frontend/src/app/(auth)/login/page.tsx @@ -93,7 +93,7 @@ export default function Page() { label="Email*" value={email} onChange={handleEmailChange} - outlined + style="outlined" error={errors.email || errors.api} />, @@ -105,7 +105,7 @@ export default function Page() { label="Password*" value={password} onChange={handlePasswordChange} - outlined + style="outlined" error={errors.password || errors.api} />, ]} diff --git a/frontend/src/app/(auth)/register/page.tsx b/frontend/src/app/(auth)/register/page.tsx index 6e911c4fc..79c214c77 100644 --- a/frontend/src/app/(auth)/register/page.tsx +++ b/frontend/src/app/(auth)/register/page.tsx @@ -104,7 +104,7 @@ export default function Page() { label="Email*" value={email} onChange={handleEmailChange} - outlined + style="outlined" error={errors.email || errors.api} />, @@ -124,7 +124,7 @@ export default function Page() { setShowPasswordCriteria(false); } }} - outlined + style="outlined" error={errors.password || errors.api} showPasswordCriteria={showPasswordCriteria} passwordCriteria={passwordCriteria} @@ -138,7 +138,7 @@ export default function Page() { label="Retype Password*" value={confirmPassword} onChange={handleConfirmPasswordChange} - outlined + style="outlined" error={errors.confirmPassword || errors.api} />, ]} diff --git a/frontend/src/app/(auth)/reset-password/page.tsx b/frontend/src/app/(auth)/reset-password/page.tsx index 600c3768c..2d7c05e20 100644 --- a/frontend/src/app/(auth)/reset-password/page.tsx +++ b/frontend/src/app/(auth)/reset-password/page.tsx @@ -101,7 +101,7 @@ export default function Page() { setShowPasswordCriteria(false); } }} - outlined + style="outlined" error={errors.password || errors.api} showPasswordCriteria={showPasswordCriteria} passwordCriteria={passwordCriteria} @@ -115,7 +115,7 @@ export default function Page() { label="Retype Password*" value={confirmPassword} onChange={handleConfirmPasswordChange} - outlined + style="outlined" error={errors.confirmPassword || errors.api} />, ]} diff --git a/frontend/src/app/(event)/[event-code]/page-client.tsx b/frontend/src/app/(event)/[event-code]/page-client.tsx index 15b4ef6d0..a9bbf1362 100644 --- a/frontend/src/app/(event)/[event-code]/page-client.tsx +++ b/frontend/src/app/(event)/[event-code]/page-client.tsx @@ -18,9 +18,8 @@ import { useResultsContext, } from "@/features/event/results/context"; import { ResultsInformation } from "@/features/event/results/lib/types"; -import ShareMenu from "@/features/event/results/share-menu"; import HeaderSpacer from "@/features/header/components/header-spacer"; -import BaseDialog from "@/features/system-feedback/dialog/components/base"; +import ShareMenu from "@/features/share-menu/menu"; import { cn } from "@/lib/utils/classname"; export default function ClientPage({ @@ -107,9 +106,9 @@ function EventResults({ eventData }: { eventData: EventInformation }) { ); const shareButton = ( - } - showCloseButton - > - - + /> ); return ( diff --git a/frontend/src/app/(event)/[event-code]/painting/page-client.tsx b/frontend/src/app/(event)/[event-code]/painting/page-client.tsx index ffda22256..c9b5d0222 100644 --- a/frontend/src/app/(event)/[event-code]/painting/page-client.tsx +++ b/frontend/src/app/(event)/[event-code]/painting/page-client.tsx @@ -8,10 +8,12 @@ import { useDebouncedCallback } from "use-debounce"; import Checkbox from "@/components/checkbox"; import MobileFooterIsland from "@/components/mobile-footer-island"; +import TextInputField from "@/components/text-input-field"; import { useAvailability } from "@/core/availability/use-availability"; import { EventRange } from "@/core/event/types"; import ActionButton from "@/features/button/components/action"; import LinkButton from "@/features/button/components/link"; +import { MAX_DISPLAY_NAME_LENGTH } from "@/features/event/availability/constants"; import { validateAvailabilityData } from "@/features/event/availability/validate-data"; import TimeZoneSelector from "@/features/event/components/selectors/timezone"; import { ScheduleGrid } from "@/features/event/grid"; @@ -87,23 +89,12 @@ export default function ClientPage({ // return () => removeToast(toastId); // }, [addToast, removeToast]); - const handleNameChange = useDebouncedCallback(async (displayName) => { - if (errors.displayName) setErrors((prev) => ({ ...prev, displayName: "" })); - - if (displayName === "") { - setErrors((prev) => ({ - ...prev, - displayName: MESSAGES.ERROR_NAME_MISSING, - })); - return; - } - + const checkNameAvailability = useDebouncedCallback(async (displayName) => { try { await clientPost(ROUTES.availability.checkDisplayName, { event_code: eventCode, display_name: displayName, }); - setErrors((prev) => ({ ...prev, displayName: "" })); } catch (e) { const error = e as ApiErrorResponse; if (error.badRequest) { @@ -117,6 +108,26 @@ export default function ClientPage({ } }, 300); + const handleNameChange = (value: string) => { + setDisplayName(value); + if (value === "") { + checkNameAvailability.cancel(); + setErrors((prev) => ({ + ...prev, + displayName: MESSAGES.ERROR_NAME_MISSING, + })); + } else if (value.length > MAX_DISPLAY_NAME_LENGTH) { + checkNameAvailability.cancel(); + setErrors((prev) => ({ + ...prev, + displayName: MESSAGES.ERROR_NAME_LENGTH, + })); + } else { + setErrors((prev) => ({ ...prev, displayName: "" })); + checkNameAvailability(value); + } + }; + // DEFAULT NAME SETTING const [saveDefaultName, setSaveDefaultName] = useState(false); @@ -138,12 +149,12 @@ export default function ClientPage({ // If the user has a default name, use it to autofill the name field const newName = session.user.defaultName; setDisplayName(newName); - handleNameChange(newName); + checkNameAvailability(newName); addToast("success", MESSAGES.INFO_NAME_AUTOFILLED, { title: "NAME AUTOFILLED", }); nameInitialized.current = true; - }, [session, setDisplayName, addToast, handleNameChange]); + }, [session, setDisplayName, addToast, checkNameAvailability]); // SUBMIT AVAILABILITY const handleSubmitAvailability = async () => { @@ -266,7 +277,6 @@ export default function ClientPage({ errors={errors} session={session} displayName={displayName} - setDisplayName={setDisplayName} handleNameChange={handleNameChange} saveDefaultName={saveDefaultName} setSaveDefaultName={setSaveDefaultName} @@ -319,7 +329,6 @@ export default function ClientPage({ errors={errors} session={session} displayName={displayName} - setDisplayName={setDisplayName} handleNameChange={handleNameChange} saveDefaultName={saveDefaultName} setSaveDefaultName={setSaveDefaultName} @@ -362,7 +371,6 @@ function DisplayNameInput({ errors, session, displayName, - setDisplayName, handleNameChange, saveDefaultName, setSaveDefaultName, @@ -370,7 +378,6 @@ function DisplayNameInput({ errors: Record; session: Session; displayName: string; - setDisplayName: (name: string) => void; handleNameChange: (name: string) => void; saveDefaultName: boolean; setSaveDefaultName: (save: boolean) => void; @@ -379,26 +386,20 @@ function DisplayNameInput({
-

- {errors.displayName ? errors.displayName : "Error Placeholder"} -

Hi,{" "} - { - setDisplayName(e.target.value); - handleNameChange(e.target.value); - }} + onChange={handleNameChange} placeholder="add your name" - className={`inline-block w-auto border-b bg-transparent px-1 focus:outline-none ${ - errors.displayName - ? "border-error placeholder:text-error" - : "border-gray-400" - }`} + error={errors.displayName} + maxLength={{ + length: MAX_DISPLAY_NAME_LENGTH, + error: MESSAGES.ERROR_NAME_LENGTH, + }} />
add your availabilities here diff --git a/frontend/src/app/settings/(submenus)/page.tsx b/frontend/src/app/settings/(submenus)/page.tsx index c03811864..77849e6c9 100644 --- a/frontend/src/app/settings/(submenus)/page.tsx +++ b/frontend/src/app/settings/(submenus)/page.tsx @@ -62,11 +62,11 @@ export default function Page() { }; const handleDefaultNameChange = (value: string) => { + setDefaultName(value); if (value.length > MAX_DEFAULT_NAME_LENGTH) { setDefaultNameError(MESSAGES.ERROR_DEFAULT_NAME_LENGTH); } else { setDefaultNameError(""); - setDefaultName(value); } }; @@ -100,7 +100,11 @@ export default function Page() { type="text" onChange={handleDefaultNameChange} error={defaultNameError} - outlined + style="outlined" + maxLength={{ + length: MAX_DEFAULT_NAME_LENGTH, + error: MESSAGES.ERROR_DEFAULT_NAME_LENGTH, + }} />
void; onFocus?: () => void; onBlur?: () => void; - outlined?: boolean; + style?: FieldStyle; error?: string; className?: string; showPasswordCriteria?: boolean; passwordCriteria?: { [key: string]: boolean }; + placeholder?: string; + maxLength?: { + length: number; + error: string; + }; }; export default function TextInputField(props: TextInputFieldProps) { @@ -32,10 +38,12 @@ export default function TextInputField(props: TextInputFieldProps) { onFocus, onBlur, error, - outlined, + style, className, showPasswordCriteria = false, passwordCriteria = {}, + placeholder, + maxLength, } = props; const [showPassword, setShowPassword] = useState(false); @@ -43,10 +51,21 @@ export default function TextInputField(props: TextInputFieldProps) { const isPassword = type === "password"; const inputType = isPassword ? (showPassword ? "text" : "password") : type; + const isOutlined = style === "outlined"; + const isInline = style === "inline"; + + // Character limit checking + const isOverLimit = maxLength ? value.length > maxLength.length : false; + const activeError = isOverLimit ? maxLength?.error : error; + + const errorId = `${id}-error`; + // ref for placeholder size const labelRef = useRef(null); const [labelWidth, setLabelWidth] = useState(0); useEffect(() => { + if (!isOutlined) return; + const measureLabel = () => { if (labelRef.current) { // This accounts for the label being scaled down when floated @@ -63,19 +82,44 @@ export default function TextInputField(props: TextInputFieldProps) { return () => { window.removeEventListener("resize", measureLabel); }; - }, [label, error]); + }, [label, activeError, isOutlined]); const labelStartPos = "1rem"; // Equal to left-4 // border element classes const borderClasses = cn( "pointer-events-none absolute inset-0 rounded-full", "border peer-focus:border-2", - error ? "border-error" : "border-foreground", + activeError ? "border-error" : "border-foreground", ); return ( -
-
+
+ {/* --- inline error layout --- */} + {isInline && ( +

+ {activeError ? activeError : "Error Placeholder"} +

+ )} + + {isInline && ( + + )} + +
{/* --- input field --- */} onChange(e.target.value)} onFocus={onFocus} onBlur={onBlur} - placeholder=" " // triggers placeholder-shown state for floating label + placeholder={isInline ? placeholder || label : " "} // triggers placeholder-shown state for floating label + aria-invalid={!!activeError} + aria-describedby={activeError ? errorId : undefined} className={cn( - "peer w-full bg-transparent py-2", + "peer bg-transparent", "focus:outline-none", - outlined - ? // Transparent border for proper spacing, actual border handled separately - "rounded-full border border-transparent px-4" - : "border-b-1 px-2", + isInline ? "inline-block w-auto border-b px-1" : "w-full py-2", + isInline + ? activeError + ? "border-error placeholder:text-error" + : "border-gray-400" + : isOutlined + ? // Transparent border for proper spacing, actual border handled separately + "rounded-full border border-transparent px-4" + : "border-b-1 px-2", isPassword && "pr-10", )} /> @@ -104,7 +155,7 @@ export default function TextInputField(props: TextInputFieldProps) { * * The size of the cutout is determined by the label width, measured with a ref. */} - {outlined && ( + {isOutlined && ( <>
- {error ? ( - - - ) : ( - label - )} - + {!isInline && ( + + )} {/* --- trailing icon --- */} {isPassword && ( diff --git a/frontend/src/features/account/setting-dialogs/change-password/steps/change.tsx b/frontend/src/features/account/setting-dialogs/change-password/steps/change.tsx index 7227174a7..7be8d96ae 100644 --- a/frontend/src/features/account/setting-dialogs/change-password/steps/change.tsx +++ b/frontend/src/features/account/setting-dialogs/change-password/steps/change.tsx @@ -20,7 +20,7 @@ export default function ChangeStep({ flow }: ChangePasswordStepProps) { onChange={(value) => { flow.updateForm("currentPassword", value); }} - outlined + style="outlined" error={flow.errors.currentPassword || flow.errors.api} /> @@ -41,7 +41,7 @@ export default function ChangeStep({ flow }: ChangePasswordStepProps) { } }, 0); }} - outlined + style="outlined" error={flow.errors.newPassword || flow.errors.api} showPasswordCriteria={flow.showCriteria} passwordCriteria={flow.criteria} @@ -54,7 +54,7 @@ export default function ChangeStep({ flow }: ChangePasswordStepProps) { label="Retype Password*" value={flow.form.confirmPassword} onChange={(value) => flow.updateForm("confirmPassword", value)} - outlined + style="outlined" error={flow.errors.confirmPassword || flow.errors.api} />
diff --git a/frontend/src/features/account/setting-dialogs/change-password/steps/reset.tsx b/frontend/src/features/account/setting-dialogs/change-password/steps/reset.tsx index 175f16cc5..ac85c19ca 100644 --- a/frontend/src/features/account/setting-dialogs/change-password/steps/reset.tsx +++ b/frontend/src/features/account/setting-dialogs/change-password/steps/reset.tsx @@ -24,7 +24,7 @@ export default function ResetStep({ flow }: ChangePasswordStepProps) { } }, 0); }} - outlined + style="outlined" error={flow.errors.newPassword || flow.errors.api} showPasswordCriteria={flow.showCriteria} passwordCriteria={flow.criteria} @@ -37,7 +37,7 @@ export default function ResetStep({ flow }: ChangePasswordStepProps) { label="Retype Password*" value={flow.form.confirmPassword} onChange={(value) => flow.updateForm("confirmPassword", value)} - outlined + style="outlined" error={flow.errors.confirmPassword || flow.errors.api} />
diff --git a/frontend/src/features/account/setting-dialogs/delete-account.tsx b/frontend/src/features/account/setting-dialogs/delete-account.tsx index a4630eb3d..76ea81e50 100644 --- a/frontend/src/features/account/setting-dialogs/delete-account.tsx +++ b/frontend/src/features/account/setting-dialogs/delete-account.tsx @@ -92,7 +92,7 @@ export default function DeleteAccountDialog() { onChange={(value) => { setCurrentPassword(value); }} - outlined + style="outlined" error={errors.currentPassword || errors.api} /> diff --git a/frontend/src/features/dashboard/components/copy-button.tsx b/frontend/src/features/dashboard/components/copy-button.tsx deleted file mode 100644 index d5bc3f569..000000000 --- a/frontend/src/features/dashboard/components/copy-button.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { MouseEvent } from "react"; - -import { CopyIcon } from "lucide-react"; - -import { useToast } from "@/features/system-feedback"; -import { MESSAGES } from "@/lib/messages"; -import { cn } from "@/lib/utils/classname"; - -export type DashboardCopyButtonProps = { - code: string; -}; - -export default function DashboardCopyButton({ - code, -}: DashboardCopyButtonProps) { - const { addToast } = useToast(); - const eventUrl = - typeof window !== "undefined" ? `${window.location.origin}/${code}` : ""; - - const copyToClipboard = async (e: MouseEvent) => { - e.preventDefault(); // avoid triggering the parent link - - try { - await navigator.clipboard.writeText(eventUrl); - addToast("copy", MESSAGES.COPY_LINK_SUCCESS); - } catch (err) { - console.error("Failed to copy: ", err); - addToast("error", MESSAGES.COPY_LINK_FAILURE); - } - }; - - return ( - - ); -} diff --git a/frontend/src/features/dashboard/components/event.tsx b/frontend/src/features/dashboard/components/event.tsx index a180ef55a..88a144a90 100644 --- a/frontend/src/features/dashboard/components/event.tsx +++ b/frontend/src/features/dashboard/components/event.tsx @@ -5,9 +5,9 @@ import Link from "next/link"; import { useRouter } from "next/navigation"; import { EventType } from "@/core/event/types"; -import DashboardCopyButton from "@/features/dashboard/components/copy-button"; import DateRangeRow from "@/features/dashboard/components/date-range-row"; import ParticipantRow from "@/features/dashboard/components/participant-row"; +import DashboardShareButton from "@/features/dashboard/components/share-button"; import WeekdayRow from "@/features/dashboard/components/weekday-row"; import { cn } from "@/lib/utils/classname"; import { @@ -119,7 +119,7 @@ export default function DashboardEvent({
- + {myEvent && ( <> + } + eventTitle={title} + eventCode={code} + open={isOpen} + onOpenChange={setIsOpen} + /> + ); +} diff --git a/frontend/src/features/drawer/components/base.tsx b/frontend/src/features/drawer/components/base.tsx index 04d03d6ce..c4bdeb543 100644 --- a/frontend/src/features/drawer/components/base.tsx +++ b/frontend/src/features/drawer/components/base.tsx @@ -138,7 +138,7 @@ export default function BaseDrawer({ {showOverlay && ( onOpenChange?.(false)} + onClick={(e) => e.stopPropagation()} className={cn( "fixed inset-0", frostedGlass ? "bg-black/1" : "bg-black/30", @@ -149,6 +149,7 @@ export default function BaseDrawer({ e.stopPropagation()} className={cn( "fixed bottom-0 left-0 right-0 flex outline-none", _type !== "floating" && "h-[100dvh]", diff --git a/frontend/src/features/event/availability/constants.ts b/frontend/src/features/event/availability/constants.ts new file mode 100644 index 000000000..cf623a7c6 --- /dev/null +++ b/frontend/src/features/event/availability/constants.ts @@ -0,0 +1 @@ +export const MAX_DISPLAY_NAME_LENGTH = 25; diff --git a/frontend/src/features/event/availability/validate-data.ts b/frontend/src/features/event/availability/validate-data.ts index d0009a0de..443d7e993 100644 --- a/frontend/src/features/event/availability/validate-data.ts +++ b/frontend/src/features/event/availability/validate-data.ts @@ -1,4 +1,5 @@ import { AvailabilityState } from "@/core/availability/reducers/reducer"; +import { MAX_DISPLAY_NAME_LENGTH } from "@/features/event/availability/constants"; import { MESSAGES } from "@/lib/messages"; export async function validateAvailabilityData( @@ -9,6 +10,8 @@ export async function validateAvailabilityData( if (!displayName?.trim()) { errors.displayName = MESSAGES.ERROR_NAME_MISSING; + } else if (displayName.length > MAX_DISPLAY_NAME_LENGTH) { + errors.displayName = MESSAGES.ERROR_NAME_LENGTH; } if (!userAvailability || userAvailability.size === 0) { diff --git a/frontend/src/features/event/components/selectors/time.tsx b/frontend/src/features/event/components/selectors/time.tsx index 60f11274a..c41aa68d5 100644 --- a/frontend/src/features/event/components/selectors/time.tsx +++ b/frontend/src/features/event/components/selectors/time.tsx @@ -2,9 +2,10 @@ import Selector from "@/features/selector/components/selector"; import { BaseSelectorWrapperProps } from "@/features/selector/types"; import { convert12To24 } from "@/lib/utils/date-time-format"; -export default function TimeSelector( - props: BaseSelectorWrapperProps, -) { +export default function TimeSelector({ + dialogTitle, + ...props +}: { dialogTitle: string } & BaseSelectorWrapperProps) { const options = Array.from({ length: 24 }, (_, i) => { const hour = i % 12 === 0 ? 12 : i % 12; const period = i < 12 ? "am" : "pm"; @@ -21,7 +22,7 @@ export default function TimeSelector( ); diff --git a/frontend/src/features/event/editor/date-range/selector.tsx b/frontend/src/features/event/editor/date-range/selector.tsx index 9b758edbf..c0f5cdb26 100644 --- a/frontend/src/features/event/editor/date-range/selector.tsx +++ b/frontend/src/features/event/editor/date-range/selector.tsx @@ -26,7 +26,7 @@ export default function DateRangeSelection({
-
+

diff --git a/frontend/src/features/event/editor/editor.tsx b/frontend/src/features/event/editor/editor.tsx index e75d31af4..69d8af854 100644 --- a/frontend/src/features/event/editor/editor.tsx +++ b/frontend/src/features/event/editor/editor.tsx @@ -15,13 +15,15 @@ import ActionButton from "@/features/button/components/action"; import LinkButton from "@/features/button/components/link"; import TimeSelector from "@/features/event/components/selectors/time"; import AdvancedOptions from "@/features/event/editor/advanced-options"; +import { MAX_TITLE_LENGTH } from "@/features/event/editor/constants"; 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 { GridPreviewDialog, ScheduleGrid } from "@/features/event/grid"; +import { ScheduleGrid } from "@/features/event/grid"; import HeaderSpacer from "@/features/header/components/header-spacer"; import FormSelectorField from "@/features/selector/components/selector-field"; import { RateLimitBanner } from "@/features/system-feedback"; +import { MESSAGES } from "@/lib/messages"; import submitEvent from "@/lib/utils/api/submit-event"; import { cn } from "@/lib/utils/classname"; @@ -32,7 +34,6 @@ type EventEditorProps = { type SegmentedControlOption = "details" | "preview"; -const MemoizedGridPreview = memo(GridPreviewDialog); const MemoizedScheduleGrid = memo(ScheduleGrid); export default function EventEditor({ type, initialData }: EventEditorProps) { @@ -87,7 +88,7 @@ function EventEditorContent({ type, initialData }: EventEditorProps) { } }; - // BUTTONS + // REUSED COMPONENTS const cancelButton = ( ); + const grid = ( + + ); return (

@@ -123,6 +133,10 @@ function EventEditorContent({ type, initialData }: EventEditorProps) { onChange={setTitle} error={errors.title || errors.api} className="text-2xl font-semibold" + maxLength={{ + length: MAX_TITLE_LENGTH, + error: MESSAGES.ERROR_EVENT_NAME_LENGTH, + }} />
@@ -154,6 +168,7 @@ function EventEditorContent({ type, initialData }: EventEditorProps) { value={eventRange.timeRange.from} onChange={setStartTime} placeholder="Start Time" + dialogTitle="Select Start Time" /> @@ -163,6 +178,7 @@ function EventEditorContent({ type, initialData }: EventEditorProps) { value={eventRange.timeRange.to} onChange={setEndTime} placeholder="End Time" + dialogTitle="Select End Time" />
@@ -173,7 +189,11 @@ function EventEditorContent({ type, initialData }: EventEditorProps) {
- +
+
+
{grid}
+
+
@@ -183,14 +203,7 @@ function EventEditorContent({ type, initialData }: EventEditorProps) { mobileTab === "details" ? "hidden" : "block", )} > - + {grid}
{/* This z-index is necessary to avoid the time column overlapping */} diff --git a/frontend/src/features/event/grid/grid.tsx b/frontend/src/features/event/grid/grid.tsx index f7aca417d..207dd9f02 100644 --- a/frontend/src/features/event/grid/grid.tsx +++ b/frontend/src/features/event/grid/grid.tsx @@ -25,8 +25,6 @@ interface ScheduleGridProps { timezone: string; isWeekdayEvent?: boolean; - disableSelect?: boolean; - unselectedRange?: boolean; // for "view" mode diff --git a/frontend/src/features/event/grid/index.ts b/frontend/src/features/event/grid/index.ts index e1aa4984e..ab458d44b 100644 --- a/frontend/src/features/event/grid/index.ts +++ b/frontend/src/features/event/grid/index.ts @@ -1,3 +1,2 @@ // export components export { default as ScheduleGrid } from "@/features/event/grid/grid"; -export { default as GridPreviewDialog } from "@/features/event/grid/preview-dialog"; diff --git a/frontend/src/features/event/grid/preview-dialog.tsx b/frontend/src/features/event/grid/preview-dialog.tsx deleted file mode 100644 index 3a05412fc..000000000 --- a/frontend/src/features/event/grid/preview-dialog.tsx +++ /dev/null @@ -1,153 +0,0 @@ -"use client"; - -import { useCallback, useEffect, useState } from "react"; - -import { motion } from "framer-motion"; -import { MaximizeIcon, XIcon } from "lucide-react"; - -import checkUnselectedRange from "@/core/event/lib/unselected-range"; -import { EventRange } from "@/core/event/types"; -import ActionButton from "@/features/button/components/action"; -import TimeZoneSelector from "@/features/event/components/selectors/timezone"; -import ScheduleGrid from "@/features/event/grid/grid"; -import { cn } from "@/lib/utils/classname"; -import { findTimezoneLabel } from "@/lib/utils/date-time-format"; - -interface GridPreviewDialogProps { - eventRange: EventRange; - timeslots: Date[]; -} - -export default function GridPreviewDialog({ - eventRange, - timeslots, -}: GridPreviewDialogProps) { - 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]); - - const unselectedRange = checkUnselectedRange(eventRange); - - return ( -
- {isOpen && ( -
{ - setIsOpen(false); - setTimezone(eventRange.timezone); - }} - /> - )} - - {timeslots.length > 0 && ( - -

Grid Preview

- {isOpen ? ( -
- } - onClick={closeDialog} - className="bg-transparent p-1.5" - aria-label="Close Preview" - /> -
- ) : ( -
- } - onClick={() => { - setIsOpen(!isOpen); - }} - className="bg-transparent p-1.5" - aria-label="Open Preview" - /> -
- )} -
- )} - {isOpen ? ( - -
- -
-
- - -
-
- ) : ( - - - - )} -
-
- ); -} diff --git a/frontend/src/features/event/results/attendees/mobile-drawer.tsx b/frontend/src/features/event/results/attendees/mobile-drawer.tsx index e294bf98f..beb337dc3 100644 --- a/frontend/src/features/event/results/attendees/mobile-drawer.tsx +++ b/frontend/src/features/event/results/attendees/mobile-drawer.tsx @@ -4,14 +4,14 @@ import { ShareIcon, SquarePenIcon } from "lucide-react"; import EmptyButton from "@/features/button/components/empty"; import LinkButton from "@/features/button/components/link"; -import { FloatingDrawer, MorphingDrawer } from "@/features/drawer"; +import { MorphingDrawer } from "@/features/drawer"; import PanelHeader from "@/features/event/results/attendees/panel-header"; import ParticipantList from "@/features/event/results/attendees/participant-list"; import { RemoveParticipantDialog, useParticipantRemoval, } from "@/features/event/results/attendees/remove-participant"; -import ShareMenu from "@/features/event/results/share-menu"; +import ShareMenu from "@/features/share-menu/menu"; export default function AttendeesDrawer({ onSnapChange, @@ -61,9 +61,6 @@ export default function AttendeesDrawer({ /> ); - /* SHARE MENU */ - const [shareMenuOpen, setShareMenuOpen] = useState(false); - return ( - } - nested={true} - > - - + eventTitle={eventTitle} + eventCode={eventCode} + isNested + /> {paintingButton}
} diff --git a/frontend/src/features/event/results/share-menu.tsx b/frontend/src/features/share-menu/content.tsx similarity index 95% rename from frontend/src/features/event/results/share-menu.tsx rename to frontend/src/features/share-menu/content.tsx index 77f3e2b1d..55403603d 100644 --- a/frontend/src/features/event/results/share-menu.tsx +++ b/frontend/src/features/share-menu/content.tsx @@ -9,7 +9,7 @@ import { useToast } from "@/features/system-feedback"; import { MESSAGES } from "@/lib/messages"; import { cn } from "@/lib/utils/classname"; -export default function ShareMenu({ +export default function ShareMenuContent({ eventTitle, eventCode, }: { @@ -63,6 +63,7 @@ export default function ShareMenu({
+
{eventTitle}
- Anyone can join the event using this link + Anyone can join the event using this link.
void; +}) { + const isMobile = useCheckMobile(); + + /* OPEN STATE MANAGEMENT */ + const [internalOpen, setInternalOpen] = useState(false); + const isControlled = controlledOpen !== undefined; + const open = isControlled ? controlledOpen : internalOpen; + + const handleOpenChange = useCallback( + (newOpen: boolean) => { + if (!isControlled) { + setInternalOpen(newOpen); + } + onOpenChange?.(newOpen); + }, + [isControlled, onOpenChange], + ); + + return isMobile ? ( + + + + ) : ( + + + + ); +} diff --git a/frontend/src/features/system-feedback/dialog/components/base.tsx b/frontend/src/features/system-feedback/dialog/components/base.tsx index 8dd0d5d40..b9bce261e 100644 --- a/frontend/src/features/system-feedback/dialog/components/base.tsx +++ b/frontend/src/features/system-feedback/dialog/components/base.tsx @@ -77,6 +77,7 @@ export default function BaseDialog({ e.stopPropagation()} className={cn( "dialog-overlay fixed inset-0 z-40 bg-gray-700/40 transition-opacity", overlayClassName, @@ -84,6 +85,7 @@ export default function BaseDialog({ />
e.stopPropagation()} className={cn( "dialog-content fixed inset-0 z-40 m-auto flex flex-col gap-2 overflow-hidden", "bg-panel rounded-3xl p-6 shadow-md focus:outline-none", diff --git a/frontend/src/features/version-history/data.ts b/frontend/src/features/version-history/data.ts index d4f833d97..f48cd6dd8 100644 --- a/frontend/src/features/version-history/data.ts +++ b/frontend/src/features/version-history/data.ts @@ -194,6 +194,17 @@ export function getVersionHistoryData(): VersionHistoryData { "Fixed an issue where the mobile results page drawer would not display the footer when opened", ], }, + { + version: "v0.4.6", + releaseDate: { year: 2026, month: 6, day: 20 }, + changes: [ + "Added error messages to length-limited text fields", + "Added the share menu to dashboard events", + "Updated time selector drawer titles", + "Fixed the trigger area of the event date selector", + "Removed the grid preview dialog from the event editor", + ], + }, ], }, ]; diff --git a/frontend/src/lib/messages.ts b/frontend/src/lib/messages.ts index f7f4c9b84..e298bfb1d 100644 --- a/frontend/src/lib/messages.ts +++ b/frontend/src/lib/messages.ts @@ -1,4 +1,5 @@ import { MAX_DEFAULT_NAME_LENGTH } from "@/features/account/constants"; +import { MAX_DISPLAY_NAME_LENGTH } from "@/features/event/availability/constants"; import { MAX_DURATION, MAX_TITLE_LENGTH, @@ -19,13 +20,14 @@ export const MESSAGES = { ERROR_RESET_TOKEN_INVALID: "Invalid or expired reset token.", // availability errors + ERROR_NAME_LENGTH: `Name must be ${MAX_DISPLAY_NAME_LENGTH} characters or less.`, ERROR_NAME_MISSING: "Missing name.", - ERROR_NAME_TAKEN: "This name is unavailable. Please choose another.", + ERROR_NAME_TAKEN: "This name is unavailable.", ERROR_AVAILABILITY_MISSING: "Please select your availability on the grid.", // event errors ERROR_EVENT_NAME_MISSING: "Missing event name.", - ERROR_EVENT_NAME_LENGTH: `Event name must be under ${MAX_TITLE_LENGTH} characters.`, + ERROR_EVENT_NAME_LENGTH: `Event name must be ${MAX_TITLE_LENGTH} characters or less.`, ERROR_EVENT_CODE_TAKEN: "This code is unavailable. Please choose another.", ERROR_EVENT_DATES_MISSING: "Please select possible dates for this event.", ERROR_EVENT_WEEKDAYS_MISSING: "Please select possible days for this event.",