-
+
+ {/* --- inline error layout --- */}
+ {isInline && (
+
+ {activeError ? activeError : "Error Placeholder"}
+
+ )}
+
+ {isInline && (
+
+ {label}
+
+ )}
+
+
{/* --- 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 ? (
-
-
- {error}
-
- ) : (
- label
- )}
-
+ {!isInline && (
+
+ {activeError ? (
+
+
+ {activeError}
+
+ ) : (
+ label
+ )}
+
+ )}
{/* --- 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 (
-
-
- Copy Link
-
- );
-}
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 && (
<>
diff --git a/frontend/src/features/dashboard/components/share-button.tsx b/frontend/src/features/dashboard/components/share-button.tsx
new file mode 100644
index 000000000..00135df1b
--- /dev/null
+++ b/frontend/src/features/dashboard/components/share-button.tsx
@@ -0,0 +1,44 @@
+"use client";
+
+import { useState } from "react";
+
+import { ShareIcon } from "lucide-react";
+
+import ShareMenu from "@/features/share-menu/menu";
+import { cn } from "@/lib/utils/classname";
+
+export type DashboardShareButtonProps = {
+ title: string;
+ code: string;
+};
+
+export default function DashboardShareButton({
+ title,
+ code,
+}: DashboardShareButtonProps) {
+ const [isOpen, setIsOpen] = useState(false);
+
+ return (
+ {
+ e.preventDefault(); // prevent the link behind it triggering
+ setIsOpen(true);
+ }}
+ className={cn(
+ "flex cursor-pointer items-center gap-0.5 rounded-full px-2 py-1.5",
+ "border-foreground hover:bg-foreground/20 active:bg-foreground/10 border",
+ )}
+ >
+
+ Share
+
+ }
+ 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) {
@@ -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 ? (
-
-
-
-
-
-
- See event in{" "}
-
-
-
- Original Event in{" "}
-
- {findTimezoneLabel(eventRange.timezone)}
-
-
-
-
- ) : (
-
-
-
- )}
-
-
- );
-}
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.",