diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 8150b1c8..e08d97bf 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -26,6 +26,7 @@
"next": "15.4.10",
"next-themes": "^0.4.6",
"patch-package": "^8.0.1",
+ "qr-code-styling": "^1.9.2",
"react": "^19.0.0",
"react-day-picker": "^9.6.7",
"react-dom": "^19.0.0",
@@ -7374,6 +7375,24 @@
"node": ">=6"
}
},
+ "node_modules/qr-code-styling": {
+ "version": "1.9.2",
+ "resolved": "https://registry.npmjs.org/qr-code-styling/-/qr-code-styling-1.9.2.tgz",
+ "integrity": "sha512-RgJaZJ1/RrXJ6N0j7a+pdw3zMBmzZU4VN2dtAZf8ZggCfRB5stEQ3IoDNGaNhYY3nnZKYlYSLl5YkfWN5dPutg==",
+ "license": "MIT",
+ "dependencies": {
+ "qrcode-generator": "^1.4.4"
+ },
+ "engines": {
+ "node": ">=18.18.0"
+ }
+ },
+ "node_modules/qrcode-generator": {
+ "version": "1.5.2",
+ "resolved": "https://registry.npmjs.org/qrcode-generator/-/qrcode-generator-1.5.2.tgz",
+ "integrity": "sha512-pItrW0Z9HnDBnFmgiNrY1uxRdri32Uh9EjNYLPVC2zZ3ZRIIEqBoDgm4DkvDwNNDHTK7FNkmr8zAa77BYc9xNw==",
+ "license": "MIT"
+ },
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 0ade39b4..2b46d32c 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -30,6 +30,7 @@
"next": "15.4.10",
"next-themes": "^0.4.6",
"patch-package": "^8.0.1",
+ "qr-code-styling": "^1.9.2",
"react": "^19.0.0",
"react-day-picker": "^9.6.7",
"react-dom": "^19.0.0",
diff --git a/frontend/src/app/(auth)/layout.tsx b/frontend/src/app/(auth)/layout.tsx
index 3f1baa5a..77352b8e 100644
--- a/frontend/src/app/(auth)/layout.tsx
+++ b/frontend/src/app/(auth)/layout.tsx
@@ -1,41 +1,16 @@
-"use client";
+import { redirect } from "next/navigation";
-import { useEffect, useRef } from "react";
+import { getSession } from "@/lib/utils/get-session";
-import { useRouter } from "next/navigation";
-
-import Loading from "@/app/loading";
-import { useAccount } from "@/features/account/context";
-import { useToast } from "@/features/system-feedback";
-import { MESSAGES } from "@/lib/messages";
-
-export default function AuthLayout({
+export default async function AuthLayout({
children,
}: {
children: React.ReactNode;
}) {
- const { loginState } = useAccount();
- const router = useRouter();
- const { addToast } = useToast();
- const hasBeenLoggedOutRef = useRef(false);
-
- useEffect(() => {
- if (loginState === "logged_out") {
- hasBeenLoggedOutRef.current = true;
- }
-
- if (loginState === "logged_in") {
- // Check if the user was logged out to avoid this triggering on login
- if (!hasBeenLoggedOutRef.current) {
- router.replace("/dashboard");
- addToast("info", MESSAGES.INFO_ALREADY_LOGGED_IN);
- }
- }
- }, [loginState, router, addToast]);
+ const session = await getSession();
- if (loginState === "logged_in") {
- // Logged in status is included to avoid flickering on redirect
- return ;
+ if (session.isLoggedIn) {
+ redirect("/dashboard?alreadyLoggedIn=true");
}
return <>{children}>;
diff --git a/frontend/src/app/(auth)/login/page.tsx b/frontend/src/app/(auth)/login/page.tsx
index b6946210..0d90179a 100644
--- a/frontend/src/app/(auth)/login/page.tsx
+++ b/frontend/src/app/(auth)/login/page.tsx
@@ -3,27 +3,29 @@
import { useState } from "react";
import Link from "next/link";
-import { useRouter } from "next/navigation";
+import { useRouter, useSearchParams } from "next/navigation";
import Checkbox from "@/components/checkbox";
import AuthPageLayout from "@/components/layout/auth-page";
import LinkText from "@/components/link-text";
import TextInputField from "@/components/text-input-field";
-import { useAccount } from "@/features/account/context";
import ActionButton from "@/features/button/components/action";
import { useFormErrors } from "@/lib/hooks/use-form-errors";
import { MESSAGES } from "@/lib/messages";
import { clientPost } from "@/lib/utils/api/client-fetch";
import { ROUTES } from "@/lib/utils/api/endpoints";
import { ApiErrorResponse } from "@/lib/utils/api/fetch-wrapper";
+import { getSafeRedirectUrl } from "@/lib/utils/url";
export default function Page() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [rememberMe, setRememberMe] = useState(false);
- const { login } = useAccount();
const router = useRouter();
+ const searchParams = useSearchParams();
+ const callbackUrl = getSafeRedirectUrl(searchParams.get("callbackUrl"));
+
// TOASTS AND ERROR STATES
const { errors, handleError, clearAllErrors } = useFormErrors();
@@ -52,16 +54,13 @@ export default function Page() {
}
try {
- const data = await clientPost(ROUTES.auth.login, {
+ await clientPost(ROUTES.auth.login, {
email,
password,
remember_me: rememberMe,
});
- login({
- email: data.email,
- defaultName: data.default_display_name,
- });
- router.push("/dashboard");
+ router.push(callbackUrl);
+ router.refresh();
return true;
} catch (e) {
const error = e as ApiErrorResponse;
diff --git a/frontend/src/app/(event)/[event-code]/page-client.tsx b/frontend/src/app/(event)/[event-code]/page-client.tsx
index 828c8a87..15b4ef6d 100644
--- a/frontend/src/app/(event)/[event-code]/page-client.tsx
+++ b/frontend/src/app/(event)/[event-code]/page-client.tsx
@@ -4,24 +4,23 @@ import { useState } from "react";
import { PencilIcon, ShareIcon, SquarePenIcon } from "lucide-react";
-import CopyToastButton from "@/components/copy-toast-button";
import KebabMenu from "@/components/kebab-menu";
import { EventInformation } from "@/core/event/types";
-import ActionButton from "@/features/button/components/action";
+import EmptyButton from "@/features/button/components/empty";
import LinkButton from "@/features/button/components/link";
import ScheduleGrid from "@/features/event/grid/grid";
-import AttendeesPanel from "@/features/event/results/attendee-panel/panel";
-import { getResultBanners } from "@/features/event/results/banners";
+import AttendeesPanel from "@/features/event/results/attendees/desktop-panel";
+import AttendeesDrawer from "@/features/event/results/attendees/mobile-drawer";
+import { getResultBanners } from "@/features/event/results/components/banners";
+import DisplaySettings from "@/features/event/results/components/display-settings";
import {
ResultsProvider,
useResultsContext,
} from "@/features/event/results/context";
-import DisplaySettings from "@/features/event/results/display-settings";
-import ResultsDrawer from "@/features/event/results/drawer";
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 { useToast } from "@/features/system-feedback";
-import { MESSAGES } from "@/lib/messages";
+import BaseDialog from "@/features/system-feedback/dialog/components/base";
import { cn } from "@/lib/utils/classname";
export default function ClientPage({
@@ -61,9 +60,6 @@ function EventResults({ eventData }: { eventData: EventInformation }) {
timeslots,
} = eventData;
- /* TOAST PROVIDER */
- const { addToast } = useToast();
-
/* TIMEZONE HANDLING */
const handleTZChange = (newTZ: string | number) => {
setTimezone(newTZ.toString());
@@ -110,44 +106,21 @@ function EventResults({ eventData }: { eventData: EventInformation }) {
/>
);
- const shareButton = (buttonStyle: HeaderButtonStyle) => {
- // Check if sharing is supported
- if (typeof navigator !== "undefined" && !navigator.share) {
- /* This condition means it will be rendered until mounted on the client, then it
- * disappears if not supported. There are more browsers that support the API than
- * don't, so this is a better trade-off than having the button appear after initial
- * mount on supported browsers.
- *
- * This also won't be visible on mobile anyway, since the buttons are hidden in the
- * kebab menu.
- */
- return null;
- } else {
- return (
- }
label="Share Event"
- onClick={async () => {
- try {
- await navigator.share({
- title: eventTitle,
- url: window.location.href,
- });
- } catch (error) {
- // An error is thrown if sharing is cancelled, ignore that
- if (error instanceof Error && error.name !== "AbortError") {
- addToast("error", MESSAGES.ERROR_GENERIC);
- }
- }
- }}
/>
- );
- }
- };
-
- const copyButton = (buttonStyle: HeaderButtonStyle) => (
-
+ }
+ showCloseButton
+ >
+
+
);
return (
@@ -158,18 +131,15 @@ function EventResults({ eventData }: { eventData: EventInformation }) {
{eventTitle}
-
-
- {isCreator && editButton("frosted glass inset")}
- {shareButton("frosted glass inset")}
- {copyButton("frosted glass inset")}
-
-
+ {isCreator && (
+
+ {editButton("frosted glass inset")}
+
+ )}
{isCreator && editButton("secondary")}
- {shareButton("secondary")}
- {copyButton("secondary")}
+ {shareButton}
{paintingButton}
@@ -188,16 +158,22 @@ function EventResults({ eventData }: { eventData: EventInformation }) {
timeslots={timeslots}
/>
+
+
+
+
{/* Mobile Spacer & Drawer */}
-
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 62583db2..ffda2225 100644
--- a/frontend/src/app/(event)/[event-code]/painting/page-client.tsx
+++ b/frontend/src/app/(event)/[event-code]/painting/page-client.tsx
@@ -7,10 +7,9 @@ import { useRouter } from "next/navigation";
import { useDebouncedCallback } from "use-debounce";
import Checkbox from "@/components/checkbox";
-import MobileFooterTray from "@/components/mobile-footer-tray";
+import MobileFooterIsland from "@/components/mobile-footer-island";
import { useAvailability } from "@/core/availability/use-availability";
import { EventRange } from "@/core/event/types";
-import { useAccount } from "@/features/account/context";
import ActionButton from "@/features/button/components/action";
import LinkButton from "@/features/button/components/link";
import { validateAvailabilityData } from "@/features/event/availability/validate-data";
@@ -28,14 +27,17 @@ import { ROUTES } from "@/lib/utils/api/endpoints";
import { ApiErrorResponse } from "@/lib/utils/api/fetch-wrapper";
import { SelfAvailability } from "@/lib/utils/api/types";
import { timeslotToISOString } from "@/lib/utils/date-time-format";
+import type { Session } from "@/lib/utils/get-session";
export default function ClientPage({
+ session,
eventCode,
eventName,
eventRange,
timeslots,
initialData,
}: {
+ session: Session;
eventCode: string;
eventName: string;
eventRange: EventRange;
@@ -119,27 +121,29 @@ export default function ClientPage({
const [saveDefaultName, setSaveDefaultName] = useState(false);
// DEFAULT NAME APPLICATION
- // This also accounts for the situation where a user directly opens the painting page
- // instead of coming from the results page.
- const { loginState, accountDetails, login } = useAccount();
// If editing, don't try to autofill the name
const nameInitialized = useRef(!!initialData);
useEffect(() => {
- if (nameInitialized.current) return;
- if (loginState !== "logged_in") return;
- if (!accountDetails || !accountDetails.defaultName) {
- nameInitialized.current = true; // don't try again after setting the name
+ // If the name is already initialized (either by user input or because we're
+ // editing), or if the user is not logged in, don't try to autofill the name
+ if (nameInitialized.current || !session.isLoggedIn) return;
+
+ // If the user doesn't have a default name, mark the name as initialized to
+ // avoid trying to autofill on every render
+ if (!session.user.defaultName) {
+ nameInitialized.current = true;
return;
}
- const newName = accountDetails.defaultName;
+ // If the user has a default name, use it to autofill the name field
+ const newName = session.user.defaultName;
setDisplayName(newName);
handleNameChange(newName);
addToast("success", MESSAGES.INFO_NAME_AUTOFILLED, {
title: "NAME AUTOFILLED",
});
nameInitialized.current = true;
- }, [loginState, accountDetails, setDisplayName, addToast, handleNameChange]);
+ }, [session, setDisplayName, addToast, handleNameChange]);
// SUBMIT AVAILABILITY
const handleSubmitAvailability = async () => {
@@ -168,15 +172,11 @@ export default function ClientPage({
// Save the default name if checkbox checked
if (saveDefaultName) {
- if (accountDetails) {
+ if (session.isLoggedIn) {
try {
await clientPost(ROUTES.account.setDefaultName, {
display_name: displayName,
});
- login({
- ...accountDetails,
- defaultName: displayName,
- });
addToast("success", MESSAGES.SUCCESS_DEFAULT_NAME_SAVED);
} catch (e) {
const error = e as ApiErrorResponse;
@@ -259,45 +259,18 @@ export default function ClientPage({
{/* Main Content */}
-
+
{/* Left Panel */}
-
-
-
-
- {errors.displayName ? errors.displayName : "Error Placeholder"}
-
- Hi,{" "}
-
{
- setDisplayName(e.target.value);
- handleNameChange(e.target.value);
- }}
- 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"
- }`}
- />
-
- add your availabilities here
-
- {loginState === "logged_in" && !accountDetails!.defaultName && (
-
- setSaveDefaultName(checked)}
- >
-
- )}
-
+
+
Displaying event in
@@ -324,11 +297,35 @@ export default function ClientPage({
}
}}
/>
+
+
+ Displaying event in
+
+
{/* This z-index is necessary to avoid the time column overlapping */}
);
}
+
+function DisplayNameInput({
+ errors,
+ session,
+ displayName,
+ setDisplayName,
+ handleNameChange,
+ saveDefaultName,
+ setSaveDefaultName,
+}: {
+ errors: Record;
+ session: Session;
+ displayName: string;
+ setDisplayName: (name: string) => void;
+ handleNameChange: (name: string) => void;
+ saveDefaultName: boolean;
+ setSaveDefaultName: (save: boolean) => void;
+}) {
+ return (
+
+
+
+
+ {errors.displayName ? errors.displayName : "Error Placeholder"}
+
+ Hi,{" "}
+
{
+ setDisplayName(e.target.value);
+ handleNameChange(e.target.value);
+ }}
+ 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"
+ }`}
+ />
+
+ add your availabilities here
+
+ {session.isLoggedIn && !session.user.defaultName && (
+
+ setSaveDefaultName(checked)}
+ >
+
+ )}
+
+
+ );
+}
diff --git a/frontend/src/app/(event)/[event-code]/painting/page.tsx b/frontend/src/app/(event)/[event-code]/painting/page.tsx
index e6221d5a..94f4cb2f 100644
--- a/frontend/src/app/(event)/[event-code]/painting/page.tsx
+++ b/frontend/src/app/(event)/[event-code]/painting/page.tsx
@@ -10,6 +10,7 @@ import handleErrorResponse from "@/lib/utils/api/handle-api-error";
import { processEventData } from "@/lib/utils/api/processors/process-event-data";
import { serverGet } from "@/lib/utils/api/server-fetch";
import { constructMetadata } from "@/lib/utils/construct-metadata";
+import { getSession } from "@/lib/utils/get-session";
export async function generateMetadata({
params,
@@ -40,6 +41,7 @@ export default async function Page({ params }: EventCodePageProps) {
notFound();
}
+ const session = await getSession();
const [eventData, initialAvailabilityData] = await Promise.all([
getCachedEventDetails(eventCode),
serverGet(
@@ -61,6 +63,7 @@ export default async function Page({ params }: EventCodePageProps) {
return (
Dashboard
-
+
+
+
+ {/* 2. Skeleton for the Event Grid */}
+
+
+ {[1, 2, 3].map((i) => (
+
+ ))}
+
+
+
);
}
diff --git a/frontend/src/app/dashboard/page-client.tsx b/frontend/src/app/dashboard/page-client.tsx
index 5ed7d651..aac3c7af 100644
--- a/frontend/src/app/dashboard/page-client.tsx
+++ b/frontend/src/app/dashboard/page-client.tsx
@@ -5,7 +5,6 @@ import { startTransition, useOptimistic, useRef, useState } from "react";
import Link from "next/link";
import SegmentedControl from "@/components/segmented-control";
-import { useAccount } from "@/features/account/context";
import { DashboardEventProps } from "@/features/dashboard/components/event";
import EventGrid from "@/features/dashboard/components/event-grid";
import { deleteEvent } from "@/features/dashboard/delete-event";
@@ -27,7 +26,8 @@ export type DashboardPageProps = {
export default function ClientPage({
created_events,
participated_events,
-}: DashboardPageProps) {
+ logged_in,
+}: DashboardPageProps & { logged_in: boolean }) {
const [optimisticCreatedEvents, deleteOptimisticCreatedEvent] = useOptimistic(
created_events,
(state, eventToDelete: string) => {
@@ -48,8 +48,6 @@ export default function ClientPage({
const eventToDelete = useRef
(null);
const { addToast } = useToast();
- const { loginState } = useAccount();
-
const currentTabEvents =
tab === "created" ? optimisticCreatedEvents : optimisticParticipatedEvents;
@@ -82,7 +80,7 @@ export default function ClientPage({
Dashboard
- {loginState === "logged_out" && (
+ {!logged_in && (
This data is only available from this browser.{" "}
@@ -100,34 +98,28 @@ export default function ClientPage({
)}
-
-
-
-
-
- {currentTabEvents.length ? (
-
- ) : (
-
-
- {tab === "created"
- ? "You haven't created any events yet."
- : "You haven't participated in any events yet."}
-
-
{`When you do, it'll show up here for quick access!`}
+
+
+ {currentTabEvents.length ? (
+
+ ) : (
+
+
+ {tab === "created"
+ ? "You haven't created any events yet."
+ : "You haven't participated in any events yet."}
- )}
-
+
{`When you do, it'll show up here for quick access!`}
+
+ )}
;
+ return
;
} catch (e) {
const error = e as ApiErrorResponse;
handleErrorResponse(error.status, error.data);
diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx
index 7379ab33..2f2df2a6 100644
--- a/frontend/src/app/layout.tsx
+++ b/frontend/src/app/layout.tsx
@@ -1,13 +1,13 @@
+import { Suspense } from "react";
+
import { Analytics } from "@vercel/analytics/next";
import type { Metadata } from "next";
import { Modak, Nunito } from "next/font/google";
-import { AccountDetails } from "@/features/account/type";
+import { CookieGuard } from "@/components/cookie-guard";
import Header from "@/features/header/components/header";
+import ToastListener from "@/features/system-feedback/toast/listener";
import { Providers } from "@/lib/providers";
-import { ROUTES } from "@/lib/utils/api/endpoints";
-import { ApiErrorResponse } from "@/lib/utils/api/fetch-wrapper";
-import { serverGet } from "@/lib/utils/api/server-fetch";
import "@/styles/globals.css";
const modak = Modak({
@@ -66,31 +66,11 @@ export function generateMetadata(): Metadata {
};
}
-async function checkLoginStatus(): Promise
{
- try {
- const data = await serverGet(ROUTES.auth.checkAccountAuth);
- return {
- email: data.email,
- defaultName: data.default_display_name,
- };
- } catch (e) {
- const error = e as ApiErrorResponse;
-
- if (error.status === 401 || error.status === 403) {
- return null;
- }
-
- throw error;
- }
-}
-
export default async function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
- const accountDetails = await checkLoginStatus();
-
return (
-
-
- {children}
+
+
+
+
+
+
+ {children}
+
diff --git a/frontend/src/app/settings/layout.tsx b/frontend/src/app/settings/layout.tsx
index 8673d1c8..8f16163f 100644
--- a/frontend/src/app/settings/layout.tsx
+++ b/frontend/src/app/settings/layout.tsx
@@ -1,48 +1,33 @@
-"use client";
+import { Metadata } from "next";
+import { headers } from "next/headers";
+import { redirect } from "next/navigation";
-import { useEffect, useRef } from "react";
-
-import Link from "next/link";
-import { usePathname, useRouter } from "next/navigation";
-
-import Loading from "@/app/loading";
-import { useAccount } from "@/features/account/context";
+import { SettingsProvider } from "@/features/account/settings/context";
+import SettingsNav from "@/features/account/settings/sidebar-nav";
import HeaderSpacer from "@/features/header/components/header-spacer";
-import { useToast } from "@/features/system-feedback";
-import { MESSAGES } from "@/lib/messages";
-import { cn } from "@/lib/utils/classname";
+import { constructMetadata } from "@/lib/utils/construct-metadata";
+import { getSession } from "@/lib/utils/get-session";
-const SETTINGS_TABS = [
- { href: "/settings", label: "General" },
- { href: "/settings/security", label: "Security" },
- { href: "/settings/remove", label: "Account Removal" },
-] as const;
+export function generateMetadata(): Metadata {
+ return constructMetadata(
+ "Account Settings",
+ "Manage your account settings and preferences on Plancake.",
+ );
+}
-export default function SettingsLayout({
+export default async function SettingsLayout({
children,
}: {
children: React.ReactNode;
}) {
- const pathname = usePathname();
- const router = useRouter();
- const { loginState } = useAccount();
- const { addToast } = useToast();
- const previousLoginState = useRef(loginState);
-
- useEffect(() => {
- if (loginState === "logged_out") {
- router.replace("/login");
+ const session = await getSession();
- // Only show the toast if they didn't JUST log out
- if (previousLoginState.current !== "logged_in") {
- addToast("info", MESSAGES.INFO_NOT_LOGGED_IN);
- }
- }
- previousLoginState.current = loginState;
- }, [addToast, loginState, router]);
+ if (!session.isLoggedIn) {
+ const headersList = await headers();
+ const currentPath = headersList.get("x-pathname") || "/settings";
- if (loginState === "logged_out") {
- return ;
+ const encodedCallback = encodeURIComponent(currentPath);
+ redirect(`/login?callbackUrl=${encodedCallback}&unauthorized=true`);
}
return (
@@ -58,29 +43,14 @@ export default function SettingsLayout({
-
- {SETTINGS_TABS.map((tab) => {
- const isActive = pathname === tab.href;
-
- return (
-
- {tab.label}
-
- );
- })}
-
+
-
{children}
+
+
+ {children}
+
+
);
diff --git a/frontend/src/app/settings/page.tsx b/frontend/src/app/settings/page.tsx
index 6c2b9f19..c0381186 100644
--- a/frontend/src/app/settings/page.tsx
+++ b/frontend/src/app/settings/page.tsx
@@ -3,10 +3,11 @@
import { useState } from "react";
import { CheckIcon } from "lucide-react";
+import { useRouter } from "next/navigation";
import TextInputField from "@/components/text-input-field";
import { MAX_DEFAULT_NAME_LENGTH } from "@/features/account/constants";
-import { useAccount } from "@/features/account/context";
+import { useSettingsAccount } from "@/features/account/settings/context";
import ActionButton from "@/features/button/components/action";
import { useToast } from "@/features/system-feedback/toast/context";
import { MESSAGES } from "@/lib/messages";
@@ -16,7 +17,8 @@ import { ApiErrorResponse } from "@/lib/utils/api/fetch-wrapper";
import { cn } from "@/lib/utils/classname";
export default function Page() {
- const { refreshAccount, accountDetails } = useAccount();
+ const router = useRouter();
+ const accountDetails = useSettingsAccount();
const { addToast } = useToast();
const [defaultName, setDefaultName] = useState(
@@ -36,8 +38,8 @@ export default function Page() {
await clientPost(ROUTES.account.setDefaultName, {
display_name: defaultName,
});
- await refreshAccount();
addToast("success", MESSAGES.SUCCESS_DEFAULT_NAME_SAVED);
+ router.refresh();
} catch (e) {
const error = e as ApiErrorResponse;
addToast("error", error.formattedMessage);
@@ -45,9 +47,9 @@ export default function Page() {
} else {
try {
await clientPost(ROUTES.account.removeDefaultName);
- await refreshAccount();
setDefaultName("");
addToast("success", MESSAGES.SUCCESS_DEFAULT_NAME_REMOVED);
+ router.refresh();
} catch (e) {
const error = e as ApiErrorResponse;
addToast("error", error.formattedMessage);
diff --git a/frontend/src/components/cookie-guard.tsx b/frontend/src/components/cookie-guard.tsx
new file mode 100644
index 00000000..2e9091db
--- /dev/null
+++ b/frontend/src/components/cookie-guard.tsx
@@ -0,0 +1,45 @@
+"use client";
+
+import { useEffect, useState } from "react";
+
+import { cn } from "@/lib/utils/classname";
+
+export function CookieGuard({ children }: { children: React.ReactNode }) {
+ const [cookiesEnabled, setCookiesEnabled] = useState
(null);
+
+ useEffect(() => {
+ const testKey = "plancake_test";
+ try {
+ document.cookie = `${testKey}=1; SameSite=Lax`;
+ const exists = document.cookie
+ .split(";")
+ .some((item) => item.trim().startsWith(`${testKey}=`));
+ document.cookie = `${testKey}=; Max-Age=0; SameSite=Lax`;
+ setCookiesEnabled(exists);
+ } catch {
+ setCookiesEnabled(false);
+ }
+ }, []);
+
+ if (cookiesEnabled === false) {
+ return (
+
+
Cookies Required
+
+ Plancake requires cookies for the site to work properly. Please
+ enable/unblock them in your browser settings and refresh.
+
+
+ );
+ }
+
+ // Show while still checking or enabled
+ // We prioritize the experience of users who have cookies enabled rather than strictly
+ // enforcing the guard while checking
+ return <>{children}>;
+}
diff --git a/frontend/src/components/mobile-footer-island.tsx b/frontend/src/components/mobile-footer-island.tsx
new file mode 100644
index 00000000..d7971c06
--- /dev/null
+++ b/frontend/src/components/mobile-footer-island.tsx
@@ -0,0 +1,60 @@
+import { useEffect, useRef, useState } from "react";
+
+import { ButtonArray } from "@/features/button/button-array";
+import { cn } from "@/lib/utils/classname";
+
+export default function MobileFooterIsland({
+ children,
+ leftButtons,
+ rightButtons,
+}: {
+ children?: React.ReactNode;
+ leftButtons?: ButtonArray;
+ rightButtons?: ButtonArray;
+}) {
+ const [islandHeight, setIslandHeight] = useState(0);
+ const islandRef = useRef(null);
+ const extraTopPadding = 48;
+
+ useEffect(() => {
+ if (!islandRef.current) return;
+ const observer = new ResizeObserver((entries) => {
+ for (const entry of entries) {
+ setIslandHeight(entry.contentRect.height + extraTopPadding);
+ }
+ });
+ observer.observe(islandRef.current);
+ return () => observer.disconnect();
+ }, []);
+
+ return (
+
+ {/* Placeholder div to avoid content overlap */}
+
+
+ {children}
+
+ {leftButtons ? (
+ leftButtons.map((button, index) =>
{button}
)
+ ) : (
+ // Placeholder to keep right buttons aligned
+
+ )}
+ {rightButtons &&
+ rightButtons.map((button, index) => (
+
{button}
+ ))}
+
+
+
+ );
+}
diff --git a/frontend/src/components/mobile-footer-tray.tsx b/frontend/src/components/mobile-footer-tray.tsx
deleted file mode 100644
index 885781f5..00000000
--- a/frontend/src/components/mobile-footer-tray.tsx
+++ /dev/null
@@ -1,17 +0,0 @@
-import { ButtonArray } from "@/features/button/button-array";
-
-export default function MobileFooterTray({
- buttons,
-}: {
- buttons: ButtonArray;
-}) {
- return (
-
-
- {buttons.map((button, index) => (
-
{button}
- ))}
-
-
- );
-}
diff --git a/frontend/src/components/segmented-control.tsx b/frontend/src/components/segmented-control.tsx
index d77ff513..84a231dc 100644
--- a/frontend/src/components/segmented-control.tsx
+++ b/frontend/src/components/segmented-control.tsx
@@ -8,6 +8,7 @@ type SegmentedControlProps = {
options: { label: React.ReactNode; value: T; ariaLabel?: string }[];
value: T;
onChange: (value: T) => void;
+ hidePadding?: boolean;
className?: string;
};
@@ -15,6 +16,7 @@ export default function SegmentedControl({
options,
value,
onChange,
+ hidePadding = false,
className,
}: SegmentedControlProps) {
const activeIndex = options.findIndex((opt) => opt.value === value);
@@ -22,19 +24,22 @@ export default function SegmentedControl({
// Calculate dynamic style for the sliding pill
const pillStyle = useMemo(() => {
- const pillWidth = `(100% - 16px) / ${count}`;
- const leftOffset = `calc(8px + (${pillWidth}) * ${activeIndex})`;
+ const baseOffset = hidePadding ? 0 : 8;
+
+ const pillWidth = `(100% - ${baseOffset * 2}px) / ${count}`;
+ const leftOffset = `calc(${baseOffset}px + (${pillWidth}) * ${activeIndex})`;
return {
width: `calc(${pillWidth})`,
- left: activeIndex === -1 ? "8px" : leftOffset,
+ left: activeIndex === -1 ? `${baseOffset}px` : leftOffset,
};
- }, [activeIndex, count]);
+ }, [hidePadding, activeIndex, count]);
return (
({
}}
>
@@ -55,10 +63,10 @@ export default function SegmentedControl
({
onClick={() => onChange(option.value)}
aria-label={option.ariaLabel}
className={cn(
- "z-10 flex w-full items-center justify-center rounded-full py-2 text-sm font-medium transition-colors duration-300 focus:outline-none",
+ "z-10 flex w-full items-center justify-center rounded-full py-2 focus:outline-none",
isSelected
- ? "text-white"
- : "text-foreground hover:bg-accent/25 cursor-pointer",
+ ? "text-accent-text font-bold"
+ : "text-foreground hover:bg-accent/15 cursor-pointer",
)}
>
{option.label}
diff --git a/frontend/src/core/event/lib/default-range.ts b/frontend/src/core/event/lib/default-range.ts
index df733d13..b89fde60 100644
--- a/frontend/src/core/event/lib/default-range.ts
+++ b/frontend/src/core/event/lib/default-range.ts
@@ -1,23 +1,17 @@
-import { format } from "date-fns";
-
import { SpecificDateRange, WeekdayRange } from "@/core/event/types";
-const defaultTimeRange = { from: "09:00", to: "17:00" };
+const emptyRange = { from: null, to: null };
export const DEFAULT_RANGE_SPECIFIC: SpecificDateRange = {
type: "specific" as const,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
- dateRange: {
- from: format(new Date(), "yyyy-MM-dd"),
- to: format(new Date(Date.now() + 2 * 24 * 60 * 60 * 1000), "yyyy-MM-dd"),
- },
-
- timeRange: defaultTimeRange,
+ dateRange: emptyRange,
+ timeRange: emptyRange,
};
export const DEFAULT_RANGE_WEEKDAY: WeekdayRange = {
type: "weekday" as const,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
- weekdays: ["Mon", "Tue", "Wed"],
- timeRange: defaultTimeRange,
+ weekdays: [],
+ timeRange: emptyRange,
};
diff --git a/frontend/src/core/event/lib/expand-event-range.ts b/frontend/src/core/event/lib/expand-event-range.ts
index c08f158b..655281dc 100644
--- a/frontend/src/core/event/lib/expand-event-range.ts
+++ b/frontend/src/core/event/lib/expand-event-range.ts
@@ -2,15 +2,16 @@ import {
addDays,
addMinutes,
eachDayOfInterval,
+ endOfWeek,
+ format,
+ getDay,
isBefore,
parseISO,
- format,
startOfWeek,
- endOfWeek,
- getDay,
} from "date-fns";
import { fromZonedTime } from "date-fns-tz";
+import checkUnselectedRange from "@/core/event/lib/unselected-range";
import {
ALL_WEEKDAYS,
EventRange,
@@ -76,13 +77,11 @@ export function expandEventRange(range: EventRange): Date[] {
}
function generateSlotsForSpecificRange(range: SpecificDateRange): Date[] {
- if (!range.dateRange.from || !range.dateRange.to) {
- return [];
- }
+ if (checkUnselectedRange(range)) return [];
// Validate Duration
- const startDate = parseISO(range.dateRange.from.split("T")[0]);
- const endDate = parseISO(range.dateRange.to.split("T")[0]);
+ const startDate = parseISO(range.dateRange.from!.split("T")[0]);
+ const endDate = parseISO(range.dateRange.to!.split("T")[0]);
if (checkDateRange(startDate, endDate)) {
return [];
@@ -95,13 +94,18 @@ function generateSlotsForSpecificRange(range: SpecificDateRange): Date[] {
end: endDate,
});
+ const nonNullTimeRange = {
+ from: range.timeRange.from!,
+ to: range.timeRange.to!,
+ };
+
for (const day of days) {
const dayStr = format(day, "yyyy-MM-dd");
const { startUTC, endUTC } = getDailyBoundariesInUTC(
dayStr,
range.timezone,
- range.timeRange,
+ nonNullTimeRange,
);
slots.push(...generateSlotsBetween(startUTC, endUTC));
@@ -112,6 +116,7 @@ function generateSlotsForSpecificRange(range: SpecificDateRange): Date[] {
function generateSlotsForWeekdayRange(range: WeekdayRange): Date[] {
if (range.type !== "weekday") return [];
+ if (checkUnselectedRange(range)) return [];
const slots: Date[] = [];
const referenceDate = new Date();
@@ -124,6 +129,11 @@ function generateSlotsForWeekdayRange(range: WeekdayRange): Date[] {
end: endOfCurrentWeek,
});
+ const nonNullTimeRange = {
+ from: range.timeRange.from!,
+ to: range.timeRange.to!,
+ };
+
for (const currentDay of days) {
const currentDayIndex = getDay(currentDay);
const dayName = ALL_WEEKDAYS[currentDayIndex];
@@ -134,7 +144,7 @@ function generateSlotsForWeekdayRange(range: WeekdayRange): Date[] {
const { startUTC, endUTC } = getDailyBoundariesInUTC(
dayStr,
range.timezone,
- range.timeRange,
+ nonNullTimeRange,
);
slots.push(...generateSlotsBetween(startUTC, endUTC));
diff --git a/frontend/src/core/event/lib/unselected-range.ts b/frontend/src/core/event/lib/unselected-range.ts
new file mode 100644
index 00000000..b0f3a656
--- /dev/null
+++ b/frontend/src/core/event/lib/unselected-range.ts
@@ -0,0 +1,26 @@
+import { EventRange } from "@/core/event/types";
+
+/**
+ * Checks if the given event range has unselected dates or times.
+ *
+ * @param eventRange The event range to check
+ * @returns `true` if the event range has unselected dates or times, `false` otherwise
+ */
+export default function checkUnselectedRange(eventRange: EventRange) {
+ if (eventRange.type === "specific") {
+ if (
+ eventRange.dateRange.from === null ||
+ eventRange.dateRange.to === null
+ ) {
+ return true;
+ }
+ } else {
+ if (eventRange.weekdays.length === 0) {
+ return true;
+ }
+ }
+ if (eventRange.timeRange.from === null || eventRange.timeRange.to === null) {
+ return true;
+ }
+ return false;
+}
diff --git a/frontend/src/core/event/reducers/range-reducer.ts b/frontend/src/core/event/reducers/range-reducer.ts
index f20481cf..7d254560 100644
--- a/frontend/src/core/event/reducers/range-reducer.ts
+++ b/frontend/src/core/event/reducers/range-reducer.ts
@@ -10,9 +10,12 @@ import { EventRange, EventType, Weekday } from "@/core/event/types";
export type EventRangeAction =
| { type: "SET_RANGE_INFO"; payload: EventRange }
| { type: "SET_RANGE_TYPE"; payload: EventType }
- | { type: "SET_DATE_RANGE"; payload: { from: string; to: string } }
- | { type: "SET_START_TIME"; payload: string }
- | { type: "SET_END_TIME"; payload: string }
+ | {
+ type: "SET_DATE_RANGE";
+ payload: { from: string | null; to: string | null };
+ }
+ | { type: "SET_START_TIME"; payload: string | null }
+ | { type: "SET_END_TIME"; payload: string | null }
| { type: "SET_WEEKDAYS"; payload: Weekday[] }
| { type: "SET_TIMEZONE"; payload: string }
| { type: "RESET" };
diff --git a/frontend/src/core/event/types.ts b/frontend/src/core/event/types.ts
index 5f7de583..f417e07e 100644
--- a/frontend/src/core/event/types.ts
+++ b/frontend/src/core/event/types.ts
@@ -12,10 +12,10 @@ type BaseEventRange = {
type: EventType;
timezone: string;
timeRange: {
- from: string;
- to: string;
- }
-}
+ from: string | null;
+ to: string | null;
+ };
+};
// discriminated union for event ranges - this is your single source of truth
export type EventRange = SpecificDateRange | WeekdayRange;
@@ -37,8 +37,8 @@ export const ALL_WEEKDAYS: Weekday[] = [
export type SpecificDateRange = BaseEventRange & {
type: "specific";
dateRange: {
- from: string;
- to: string;
+ from: string | null;
+ to: string | null;
};
};
diff --git a/frontend/src/core/event/use-event-info.ts b/frontend/src/core/event/use-event-info.ts
index 6dd7bb15..cbb1556a 100644
--- a/frontend/src/core/event/use-event-info.ts
+++ b/frontend/src/core/event/use-event-info.ts
@@ -6,7 +6,12 @@ import { DateRange } from "react-day-picker";
import { DEFAULT_RANGE_SPECIFIC } from "@/core/event/lib/default-range";
import { expandEventRange } from "@/core/event/lib/expand-event-range";
import { EventInfoReducer } from "@/core/event/reducers/info-reducer";
-import { EventInformation, EventRange, EventType, Weekday } from "@/core/event/types";
+import {
+ EventInformation,
+ EventRange,
+ EventType,
+ Weekday,
+} from "@/core/event/types";
import {
checkDateRange,
checkTimeRange,
@@ -75,10 +80,15 @@ export function useEventInfo(initialData?: EventInformation) {
}, []);
const setStartTime = useCallback(
- (time: string) => {
- if (checkTimeRange(time, state.eventRange.timeRange.to)) {
+ (time: string | null) => {
+ if (!time || !state.eventRange.timeRange.to) {
+ // Don't compare and clear errors if either of the times are missing
+ handleError("timeRange", "");
+ } else if (checkTimeRange(time, state.eventRange.timeRange.to)) {
handleError("timeRange", "");
- } else handleError("timeRange", MESSAGES.ERROR_EVENT_RANGE_INVALID);
+ } else {
+ handleError("timeRange", MESSAGES.ERROR_EVENT_TIMES_INVALID);
+ }
dispatch({ type: "SET_START_TIME", payload: time });
},
@@ -86,11 +96,14 @@ export function useEventInfo(initialData?: EventInformation) {
);
const setEndTime = useCallback(
- (time: string) => {
- if (checkTimeRange(state.eventRange.timeRange.from, time)) {
+ (time: string | null) => {
+ if (!time || !state.eventRange.timeRange.from) {
+ // Don't compare and clear errors if either of the times are missing
+ handleError("timeRange", "");
+ } else if (checkTimeRange(state.eventRange.timeRange.from, time)) {
handleError("timeRange", "");
} else {
- handleError("timeRange", MESSAGES.ERROR_EVENT_RANGE_INVALID);
+ handleError("timeRange", MESSAGES.ERROR_EVENT_TIMES_INVALID);
}
dispatch({ type: "SET_END_TIME", payload: time });
@@ -119,9 +132,13 @@ export function useEventInfo(initialData?: EventInformation) {
[handleError],
);
- const setWeekdayRange = useCallback((weekdays: Weekday[]) => {
- dispatch({ type: "SET_WEEKDAYS", payload: weekdays });
- }, []);
+ const setWeekdayRange = useCallback(
+ (weekdays: Weekday[]) => {
+ handleError("weekdayRange", "");
+ dispatch({ type: "SET_WEEKDAYS", payload: weekdays });
+ },
+ [handleError],
+ );
const resetEventInfo = useCallback(() => {
dispatch({ type: "RESET" });
diff --git a/frontend/src/features/account/context.ts b/frontend/src/features/account/context.ts
deleted file mode 100644
index fdd622ca..00000000
--- a/frontend/src/features/account/context.ts
+++ /dev/null
@@ -1,27 +0,0 @@
-import { createContext, useContext } from "react";
-
-import { AccountDetails, LoginState } from "@/features/account/type";
-
-export const AccountContext = createContext<{
- loginState: LoginState;
- accountDetails: AccountDetails | null;
- login: (accountDetails: AccountDetails) => void;
- logout: () => void;
- refreshAccount: () => Promise;
-}>({
- loginState: "logged_out",
- accountDetails: null,
- login: () => {},
- logout: () => {},
- refreshAccount: async () => {},
-});
-
-export function useAccount() {
- const context = useContext(AccountContext);
- if (!context) {
- throw new Error("useAccount must be used within an AccountProvider");
- }
- return context;
-}
-
-export default AccountContext;
diff --git a/frontend/src/features/account/provider.tsx b/frontend/src/features/account/provider.tsx
deleted file mode 100644
index e95f11dd..00000000
--- a/frontend/src/features/account/provider.tsx
+++ /dev/null
@@ -1,53 +0,0 @@
-"use client";
-
-import { useCallback, useState } from "react";
-
-import AccountContext from "@/features/account/context";
-import { AccountDetails, LoginState } from "@/features/account/type";
-import { clientGet } from "@/lib/utils/api/client-fetch";
-import { ROUTES } from "@/lib/utils/api/endpoints";
-
-export default function AccountProvider({
- children,
- accountDetails: initialAccountDetails,
-}: {
- children: React.ReactNode;
- accountDetails: AccountDetails | null;
-}) {
- const [accountDetails, setAccountDetails] = useState(initialAccountDetails);
- const [loginState, setLoginState] = useState(
- initialAccountDetails ? "logged_in" : "logged_out",
- );
-
- const login = useCallback((accountDetails: AccountDetails) => {
- setAccountDetails(accountDetails);
- setLoginState("logged_in");
- }, []);
-
- const logout = useCallback(() => {
- setAccountDetails(null);
- setLoginState("logged_out");
- }, []);
-
- const refreshAccount = useCallback(async () => {
- try {
- const data = await clientGet(ROUTES.auth.checkAccountAuth);
- setAccountDetails({
- email: data.email,
- defaultName: data.default_display_name,
- });
- setLoginState("logged_in");
- } catch {
- setAccountDetails(null);
- setLoginState("logged_out");
- }
- }, []);
-
- return (
-
- {children}
-
- );
-}
diff --git a/frontend/src/features/account/setting-dialogs/change-password/main-dialog.tsx b/frontend/src/features/account/setting-dialogs/change-password/main-dialog.tsx
index 5bed79e1..63826de1 100644
--- a/frontend/src/features/account/setting-dialogs/change-password/main-dialog.tsx
+++ b/frontend/src/features/account/setting-dialogs/change-password/main-dialog.tsx
@@ -7,7 +7,7 @@ import OtpStep from "@/features/account/setting-dialogs/change-password/steps/ot
import ResetStep from "@/features/account/setting-dialogs/change-password/steps/reset";
import { useChangePasswordFlow } from "@/features/account/setting-dialogs/change-password/use-change-password";
import EmptyButton from "@/features/button/components/empty";
-import { ConfirmationDialog } from "@/features/system-feedback";
+import { FormDialog } from "@/features/system-feedback";
import useCheckMobile from "@/lib/hooks/use-check-mobile";
export default function ChangePasswordDialog() {
@@ -27,35 +27,45 @@ export default function ChangePasswordDialog() {
const displayStep = flow.open ? flow.step : renderedStep;
let dialogTitle = "";
- let dialogDescription = null;
+ let dialogDescriptionText = "";
+ let submitLabel = "Save";
let onConfirmHandler = async () => false;
+ let dialogContent = null;
- // Use `displayStep` instead of `flow.step` for the if/else blocks
+ // Configure dynamic content based on the current step
if (displayStep === "CHANGE") {
dialogTitle = "Change Your Password";
+ dialogDescriptionText = "Secure your account with a new password.";
+ submitLabel = "Change Password";
onConfirmHandler = flow.handleChangePassword;
- dialogDescription = ;
+ dialogContent = ;
} else if (displayStep === "OTP") {
dialogTitle = "Enter Reset Code";
- onConfirmHandler = flow.handleVerifyOTP;
- dialogDescription = ;
+ dialogDescriptionText = "Please enter the code sent to your email.";
+ submitLabel = "Verify Code";
+ onConfirmHandler = () => flow.handleVerifyOTP();
+ dialogContent = ;
} else if (displayStep === "RESET") {
dialogTitle = "Reset Password";
+ dialogDescriptionText = "Create a new secure password.";
+ submitLabel = "Reset Password";
onConfirmHandler = flow.handleAuthedReset;
- dialogDescription = ;
+ dialogContent = ;
}
return (
- }
open={flow.open}
onOpenChange={flow.handleOpenChange}
- onConfirm={onConfirmHandler}
+ onSubmit={onConfirmHandler}
>
-
-
+ {dialogContent}
+
);
}
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 27748dde..7227174a 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
@@ -5,12 +5,12 @@ import { ChangePasswordStepProps } from "@/features/account/setting-dialogs/chan
export default function ChangeStep({ flow }: ChangePasswordStepProps) {
return (
-
+
Enter your current password and the new one you would like to replace it
with.
-
+
- We sent a password reset code to your email. Enter the code below!
+
+
Check your email for the password reset code!
+
Enter your new password!
-
+
-
-
- Are you absolutely sure you
- want to delete your account?
-
-
- This action cannot be undone.
-
-
-
- {
- setCurrentPassword(value);
- }}
- outlined
- error={errors.currentPassword || errors.api}
- />
-
- }
+ description={"Delete Account Confirmation"}
+ trigger={
}
open={confirmationOpen}
onOpenChange={handleOpenChange}
- onConfirm={handleDeleteAccount}
+ onSubmit={handleDeleteAccount}
+ submitLabel="Delete Account"
>
-
-
+
+
+ Are you absolutely sure you want to
+ delete your account?
+
+
+ This action cannot be undone.
+
+
+
+
{
+ setCurrentPassword(value);
+ }}
+ outlined
+ error={errors.currentPassword || errors.api}
+ />
+
);
}
diff --git a/frontend/src/features/account/settings/context.tsx b/frontend/src/features/account/settings/context.tsx
new file mode 100644
index 00000000..bafb6be7
--- /dev/null
+++ b/frontend/src/features/account/settings/context.tsx
@@ -0,0 +1,31 @@
+"use client";
+
+import { createContext, useContext } from "react";
+
+import { AccountDetails } from "@/features/account/type";
+
+const SettingsContext = createContext(null);
+
+export function SettingsProvider({
+ children,
+ accountDetails,
+}: {
+ children: React.ReactNode;
+ accountDetails: AccountDetails;
+}) {
+ return (
+
+ {children}
+
+ );
+}
+
+export function useSettingsAccount() {
+ const context = useContext(SettingsContext);
+ if (!context) {
+ throw new Error(
+ "useSettingsAccount must be used within a SettingsProvider",
+ );
+ }
+ return context;
+}
diff --git a/frontend/src/features/account/settings/sidebar-nav.tsx b/frontend/src/features/account/settings/sidebar-nav.tsx
new file mode 100644
index 00000000..2961dcc2
--- /dev/null
+++ b/frontend/src/features/account/settings/sidebar-nav.tsx
@@ -0,0 +1,38 @@
+"use client";
+
+import Link from "next/link";
+import { usePathname } from "next/navigation";
+
+import { cn } from "@/lib/utils/classname";
+
+const SETTINGS_TABS = [
+ { href: "/settings", label: "General" },
+ { href: "/settings/security", label: "Security" },
+ { href: "/settings/remove", label: "Account Removal" },
+] as const;
+
+export default function SettingsNav() {
+ const pathname = usePathname();
+
+ return (
+
+ {SETTINGS_TABS.map((tab) => {
+ const isActive = pathname === tab.href;
+ return (
+
+ {tab.label}
+
+ );
+ })}
+
+ );
+}
diff --git a/frontend/src/features/account/type.ts b/frontend/src/features/account/type.ts
index 5ffbc2ce..c6b2979c 100644
--- a/frontend/src/features/account/type.ts
+++ b/frontend/src/features/account/type.ts
@@ -1,5 +1,3 @@
-export type LoginState = "logged_in" | "logged_out";
-
export type AccountDetails = {
email: string;
defaultName: string | null;
diff --git a/frontend/src/features/drawer/components/base.tsx b/frontend/src/features/drawer/components/base.tsx
index 391cf03c..d323b5f8 100644
--- a/frontend/src/features/drawer/components/base.tsx
+++ b/frontend/src/features/drawer/components/base.tsx
@@ -27,6 +27,7 @@ export default function BaseDrawer({
modal = true,
showOverlay = !frostedGlass && modal,
nested = false,
+ hideCloseButton = false,
...rest
}: DrawerProps) {
useDrawerResize();
@@ -235,7 +236,7 @@ export default function BaseDrawer({
- {_type !== "morphing" && (
+ {_type !== "morphing" && !hideCloseButton && (
) {
+export default function TimeSelector(
+ props: BaseSelectorWrapperProps,
+) {
const options = Array.from({ length: 24 }, (_, i) => {
const hour = i % 12 === 0 ? 12 : i % 12;
const period = i < 12 ? "am" : "pm";
diff --git a/frontend/src/features/event/editor/advanced-options.tsx b/frontend/src/features/event/editor/advanced-options.tsx
index 2d76df46..a29dfb05 100644
--- a/frontend/src/features/event/editor/advanced-options.tsx
+++ b/frontend/src/features/event/editor/advanced-options.tsx
@@ -6,7 +6,6 @@ import { useDebouncedCallback } from "use-debounce";
import { useEventContext } from "@/core/event/context";
import TimeZoneSelector from "@/features/event/components/selectors/timezone";
-import FormSelectorField from "@/features/selector/components/selector-field";
import { MESSAGES } from "@/lib/messages";
import { clientPost } from "@/lib/utils/api/client-fetch";
import { ROUTES } from "@/lib/utils/api/endpoints";
@@ -25,7 +24,8 @@ export default function AdvancedOptions(props: AdvancedOptionsProps) {
-
- Advanced Options
-
+
Advanced Options
-
+
@@ -80,37 +78,44 @@ function Options({ isEditing = false, errors }: AdvancedOptionsProps) {
return (
<>
-
-
-
+
-
- {!isEditing && "Custom"} Event Code
- {errors.customCode && (
-
- )}
-
-
+
+
+ {!isEditing && "Custom"} Event Code
+ {errors.customCode && (
+
+ )}
+
+
+
>
);
}
diff --git a/frontend/src/features/event/editor/date-range/calendars/month.tsx b/frontend/src/features/event/editor/date-range/calendars/month.tsx
index 1f0f07d8..e5e2f48d 100644
--- a/frontend/src/features/event/editor/date-range/calendars/month.tsx
+++ b/frontend/src/features/event/editor/date-range/calendars/month.tsx
@@ -103,8 +103,8 @@ export const Calendar = forwardRef(
* range. Otherwise, it continues the current range.
*/
const handleSelect = (range: DateRange | undefined, selectedDay: Date) => {
- // full range already exists, start new range
- if (localRange?.from && localRange?.to) {
+ // full range already exists or nothing selected, start new range
+ if ((localRange?.from && localRange?.to) || !localRange?.from) {
const newRange = { from: selectedDay, to: undefined };
setLocalRange(newRange);
return;
diff --git a/frontend/src/features/event/editor/date-range/calendars/weekday.tsx b/frontend/src/features/event/editor/date-range/calendars/weekday.tsx
index f8ec3e97..efe65941 100644
--- a/frontend/src/features/event/editor/date-range/calendars/weekday.tsx
+++ b/frontend/src/features/event/editor/date-range/calendars/weekday.tsx
@@ -15,6 +15,7 @@ export default function WeekdayCalendar({
onChange,
}: WeekdayCalendarProps) {
const [anchorIndex, setAnchorIndex] = useState(null);
+ const [hoverIndex, setHoverIndex] = useState(null);
const handleDayClick = (index: number) => {
if (anchorIndex === null) {
@@ -35,41 +36,50 @@ export default function WeekdayCalendar({
.map((d) => ALL_WEEKDAYS.indexOf(d))
.filter((i) => i !== -1);
- const start = selectedIndices.length > 0 ? Math.min(...selectedIndices) : -1;
- const end = selectedIndices.length > 0 ? Math.max(...selectedIndices) : -1;
+ let highlightStart = -1;
+ let highlightEnd = -1;
+ if (selectedIndices.length > 0 && anchorIndex === null) {
+ highlightStart = Math.min(...selectedIndices);
+ highlightEnd = Math.max(...selectedIndices);
+ } else if (anchorIndex !== null) {
+ highlightStart = Math.min(anchorIndex, hoverIndex ?? anchorIndex);
+ highlightEnd = Math.max(anchorIndex, hoverIndex ?? anchorIndex);
+ }
return (
{ALL_WEEKDAYS.map((day, index) => {
- const isActive = index >= start && index <= end;
- const isRangeStart = index === start;
- const isRangeEnd = index === end;
+ const isHighlighted = index >= highlightStart && index <= highlightEnd;
+ const isRangeStart = index === highlightStart;
+ const isRangeEnd = index === highlightEnd;
return (
- handleDayClick(index)}
- className={cn(
- "flex h-8 w-10 items-center justify-center px-6",
- "hover:bg-accent/25 active:bg-accent/40",
-
- // Inactive State
- !isActive && "text-foreground/50 rounded-full",
+ onPointerEnter={() => setHoverIndex(index)}
+ onPointerLeave={() => setHoverIndex(null)}
+ >
+
- {day}
-
+ // Contiguous Rounding Logic
+ isRangeStart && "rounded-l-full",
+ isRangeEnd && "rounded-r-full",
+ )}
+ >
+ {day}
+
+
);
})}
diff --git a/frontend/src/features/event/editor/date-range/date-range-props.ts b/frontend/src/features/event/editor/date-range/date-range-props.ts
index ee0a68da..b035485a 100644
--- a/frontend/src/features/event/editor/date-range/date-range-props.ts
+++ b/frontend/src/features/event/editor/date-range/date-range-props.ts
@@ -4,6 +4,6 @@ export type DateRangeProps = {
export type SpecificDateRangeDisplayProps = {
earliestDate: Date;
- startDate: Date;
- endDate: Date;
+ startDate: Date | null;
+ endDate: Date | null;
};
diff --git a/frontend/src/features/event/editor/date-range/event-type-select.tsx b/frontend/src/features/event/editor/date-range/event-type-select.tsx
index fa0d02f6..b37cce43 100644
--- a/frontend/src/features/event/editor/date-range/event-type-select.tsx
+++ b/frontend/src/features/event/editor/date-range/event-type-select.tsx
@@ -1,6 +1,6 @@
import { useEventContext } from "@/core/event/context";
import { EventType } from "@/core/event/types";
-import Dropdown from "@/features/selector/components/dropdown";
+import Selector from "@/features/selector/components/selector";
type EventTypeSelectProps = {
id: string;
@@ -15,12 +15,14 @@ export default function EventTypeSelect({
const rangeType = state.eventRange?.type || "specific";
return (
-
setEventType(value)}
diff --git a/frontend/src/features/event/editor/date-range/selector.tsx b/frontend/src/features/event/editor/date-range/selector.tsx
index 90ddc369..9b758edb 100644
--- a/frontend/src/features/event/editor/date-range/selector.tsx
+++ b/frontend/src/features/event/editor/date-range/selector.tsx
@@ -1,7 +1,6 @@
import { parseISO } from "date-fns";
import { TriangleAlertIcon } from "lucide-react";
-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";
@@ -9,48 +8,34 @@ import { DateRangeProps } from "@/features/event/editor/date-range/date-range-pr
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 FormSelectorField from "@/features/selector/components/selector-field";
import useCheckMobile from "@/lib/hooks/use-check-mobile";
export default function DateRangeSelection({
editing = false,
}: DateRangeProps) {
- const { state, setWeekdayRange, setEventType, errors } = useEventContext();
+ const { state, setWeekdayRange, errors } = useEventContext();
const { eventRange, originalEventRange } = state;
const rangeType = eventRange?.type ?? "specific";
return (
-
-
Type
+
+
+ Type
+
-
+
- Possible Dates
- {errors.dateRange && (
+ {rangeType === "specific" ? "Possible Dates" : "Possible Days"}
+ {(errors.dateRange || errors.weekdayRange) && (
)}
-
-
- setEventType(checked ? "weekday" : "specific")
- }
- disabled={editing}
- />
-
-
{eventRange?.type === "specific" ? (
- {/* Start Date */}
-
-
FROM
-
- {displayFrom}
-
-
+
- TO
+ TO
- {/* End Date */}
-
-
UNTIL
-
- {displayTo}
+
+
+ );
+}
+
+function SpecificDateDisplay({
+ mobileLabel,
+ date,
+ placeholder,
+ ariaLabel,
+ open = false,
+}: {
+ mobileLabel: string;
+ date: Date | null;
+ placeholder: string;
+ ariaLabel: string;
+ open?: boolean;
+}) {
+ const displayDate = date ? format(date, "EEE MMMM d, yyyy") : placeholder;
+
+ return (
+
+
{mobileLabel}
+
+
+ {displayDate}
+
-
+
);
}
diff --git a/frontend/src/features/event/editor/editor.tsx b/frontend/src/features/event/editor/editor.tsx
index 14907a89..e75d31af 100644
--- a/frontend/src/features/event/editor/editor.tsx
+++ b/frontend/src/features/event/editor/editor.tsx
@@ -5,10 +5,11 @@ import { memo, useState } from "react";
import { TriangleAlertIcon } from "lucide-react";
import { useRouter } from "next/navigation";
-import MobileFooterTray from "@/components/mobile-footer-tray";
+import MobileFooterIsland from "@/components/mobile-footer-island";
import SegmentedControl from "@/components/segmented-control";
import TextInputField from "@/components/text-input-field";
import { EventProvider, useEventContext } from "@/core/event/context";
+import checkUnselectedRange from "@/core/event/lib/unselected-range";
import { EventInformation } from "@/core/event/types";
import ActionButton from "@/features/button/components/action";
import LinkButton from "@/features/button/components/link";
@@ -113,7 +114,7 @@ function EventEditorContent({ type, initialData }: EventEditorProps) {
)}
-
+
-
-
-
-
-
- Possible Times
- {errors.timeRange && }
-
-
-
-
-
-
-
-
-
+
+
+ Possible Times
+ {errors.timeRange && }
+
+
+
+
+
+
+
+
+
+
@@ -185,7 +179,7 @@ function EventEditorContent({ type, initialData }: EventEditorProps) {
@@ -193,19 +187,28 @@ function EventEditorContent({ type, initialData }: EventEditorProps) {
mode="preview"
isWeekdayEvent={eventRange.type === "weekday"}
disableSelect={true}
+ unselectedRange={checkUnselectedRange(eventRange)}
timezone={eventRange.timezone}
timeslots={timeslots}
/>
-
{/* This z-index is necessary to avoid the time column overlapping */}
-
+
+
+
);
diff --git a/frontend/src/features/event/editor/validate-data.ts b/frontend/src/features/event/editor/validate-data.ts
index d1d8d79a..f201de55 100644
--- a/frontend/src/features/event/editor/validate-data.ts
+++ b/frontend/src/features/event/editor/validate-data.ts
@@ -26,7 +26,7 @@ export async function validateEventData(
// Validate event range
if (eventRange.type === "specific") {
if (!eventRange.dateRange?.from || !eventRange.dateRange?.to) {
- errors.dateRange = MESSAGES.ERROR_EVENT_RANGE_INVALID;
+ errors.dateRange = MESSAGES.ERROR_EVENT_DATES_MISSING;
} else {
// check if the date range is more than 64 days
const fromDate = new Date(eventRange.dateRange.from);
@@ -42,13 +42,17 @@ export async function validateEventData(
(data.eventRange as WeekdayRange).weekdays,
);
if (weekdayRange.startDay === null || weekdayRange.endDay === null) {
- errors.weekdayRange = MESSAGES.ERROR_EVENT_RANGE_INVALID;
+ errors.weekdayRange = MESSAGES.ERROR_EVENT_WEEKDAYS_MISSING;
}
}
// Validate time range
- if (!checkTimeRange(eventRange.timeRange.from, eventRange.timeRange.to)) {
- errors.timeRange = MESSAGES.ERROR_EVENT_RANGE_INVALID;
+ if (!eventRange.timeRange.from || !eventRange.timeRange.to) {
+ errors.timeRange = MESSAGES.ERROR_EVENT_TIMES_MISSING;
+ } else if (
+ !checkTimeRange(eventRange.timeRange.from, eventRange.timeRange.to)
+ ) {
+ errors.timeRange = MESSAGES.ERROR_EVENT_TIMES_INVALID;
}
return errors;
diff --git a/frontend/src/features/event/grid/grid.tsx b/frontend/src/features/event/grid/grid.tsx
index ee595f66..f7aca417 100644
--- a/frontend/src/features/event/grid/grid.tsx
+++ b/frontend/src/features/event/grid/grid.tsx
@@ -16,6 +16,7 @@ import PreviewTimeBlock from "@/features/event/grid/timeblocks/preview";
import ResultsTimeBlock from "@/features/event/grid/timeblocks/results";
import { getHighestMatchCount } from "@/features/event/results/lib/utils";
import useCheckMobile from "@/lib/hooks/use-check-mobile";
+import { MESSAGES } from "@/lib/messages";
import { cn } from "@/lib/utils/classname";
interface ScheduleGridProps {
@@ -26,6 +27,8 @@ interface ScheduleGridProps {
disableSelect?: boolean;
+ unselectedRange?: boolean;
+
// for "view" mode
availabilities?: ResultsAvailabilityMap;
numParticipants?: number;
@@ -62,6 +65,7 @@ export default function ScheduleGrid({
timezone,
mode = "preview",
isWeekdayEvent = false,
+ unselectedRange = false,
availabilities = {},
numParticipants = 0,
hoveredSlot,
@@ -112,15 +116,22 @@ export default function ScheduleGrid({
return () => resizeObserver.disconnect();
});
- if (error) return
;
+ if (unselectedRange)
+ return (
+
+ );
+
+ if (error) return
;
return (
-
+
(
-
-
+const GridMessage = ({
+ error,
+ message,
+}: {
+ error: boolean;
+ message: string;
+}) => (
+
+ {error && }
{message}
);
diff --git a/frontend/src/features/event/grid/preview-dialog.tsx b/frontend/src/features/event/grid/preview-dialog.tsx
index 35e68545..3a05412f 100644
--- a/frontend/src/features/event/grid/preview-dialog.tsx
+++ b/frontend/src/features/event/grid/preview-dialog.tsx
@@ -5,6 +5,7 @@ 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";
@@ -49,6 +50,8 @@ export default function GridPreviewDialog({
return () => window.removeEventListener("keydown", handleEscape);
}, [isOpen, eventRange.timezone, closeDialog]);
+ const unselectedRange = checkUnselectedRange(eventRange);
+
return (
{isOpen && (
@@ -69,41 +72,44 @@ export default function GridPreviewDialog({
: "absolute inset-0 h-full w-full pb-4 pl-2 pr-4 pt-4",
)}
>
-
- 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"
- />
-
- )}
-
+ {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 ? (
{hoursLabel[i]}
diff --git a/frontend/src/features/event/grid/time-slot.tsx b/frontend/src/features/event/grid/time-slot.tsx
index 2c0d60b7..58cba82d 100644
--- a/frontend/src/features/event/grid/time-slot.tsx
+++ b/frontend/src/features/event/grid/time-slot.tsx
@@ -56,7 +56,7 @@ function TimeSlot({
onContextMenu={(e) => e.preventDefault()}
className={cn(
"bg-background relative",
- disableSelect ? "bg-panel cursor-not-allowed" : "cursor-cell",
+ disableSelect ? "md:bg-panel cursor-not-allowed" : "cursor-cell",
cellClasses,
"select-none",
isHovered &&
diff --git a/frontend/src/features/event/results/attendee-panel/panel.tsx b/frontend/src/features/event/results/attendee-panel/panel.tsx
deleted file mode 100644
index 5e3c6a9c..00000000
--- a/frontend/src/features/event/results/attendee-panel/panel.tsx
+++ /dev/null
@@ -1,87 +0,0 @@
-"use client";
-
-import { useEffect, useState } from "react";
-
-import PanelHeader from "@/features/event/results/attendee-panel/panel-header";
-import ParticipantList from "@/features/event/results/attendee-panel/participant-list";
-import { useResultsContext } from "@/features/event/results/context";
-import { ConfirmationDialog } from "@/features/system-feedback";
-import { cn } from "@/lib/utils/classname";
-
-export default function AttendeesPanel() {
- const {
- participants,
- currentUser,
- clearSelectedParticipants,
- handleRemoveParticipant: onRemoveParticipant,
- } = useResultsContext();
-
- /* REMOVING STATES */
- const [isRemoving, setIsRemoving] = useState(false);
- const [personToRemove, setPersonToRemove] = useState(null);
- const [isConfirmationOpen, setIsConfirmationOpen] = useState(false);
-
- useEffect(() => {
- const handleEscape = (e: KeyboardEvent) => {
- if (e.key === "Escape") setIsRemoving(false);
- };
- window.addEventListener("keydown", handleEscape);
- return () => window.removeEventListener("keydown", handleEscape);
- }, []);
-
- const promptRemove = (person: string) => {
- setPersonToRemove(person);
- setIsConfirmationOpen(true);
- };
-
- const toggleRemoving = () => {
- setIsRemoving(!isRemoving);
- clearSelectedParticipants();
- };
-
- return (
-
-
-
-
-
-
- Are you sure you want to remove{" "}
- {personToRemove} ?
-
- )
- }
- // Controlled Props
- open={isConfirmationOpen}
- onOpenChange={setIsConfirmationOpen}
- onConfirm={async () => {
- if (!personToRemove) return false;
- const success = await onRemoveParticipant(personToRemove);
- if (success) {
- if (participants.length === 1) setIsRemoving(false);
- }
- return success;
- }}
- />
-
- );
-}
diff --git a/frontend/src/features/event/results/attendees/desktop-panel.tsx b/frontend/src/features/event/results/attendees/desktop-panel.tsx
new file mode 100644
index 00000000..176cac2e
--- /dev/null
+++ b/frontend/src/features/event/results/attendees/desktop-panel.tsx
@@ -0,0 +1,58 @@
+"use client";
+
+import { useEffect } from "react";
+
+import PanelHeader from "@/features/event/results/attendees/panel-header";
+import ParticipantList from "@/features/event/results/attendees/participant-list";
+import {
+ useParticipantRemoval,
+ RemoveParticipantDialog,
+} from "@/features/event/results/attendees/remove-participant";
+import { cn } from "@/lib/utils/classname";
+
+export default function AttendeesPanel() {
+ const {
+ isRemoving,
+ setIsRemoving,
+ personToRemove,
+ isConfirmationOpen,
+ setIsConfirmationOpen,
+ promptRemove,
+ toggleRemoving,
+ confirmRemove,
+ currentUser,
+ } = useParticipantRemoval();
+
+ useEffect(() => {
+ const handleEscape = (e: KeyboardEvent) => {
+ if (e.key === "Escape") setIsRemoving(false);
+ };
+ window.addEventListener("keydown", handleEscape);
+ return () => window.removeEventListener("keydown", handleEscape);
+ }, [setIsRemoving]);
+
+ return (
+
+ );
+}
diff --git a/frontend/src/features/event/results/attendees/mobile-drawer.tsx b/frontend/src/features/event/results/attendees/mobile-drawer.tsx
new file mode 100644
index 00000000..e294bf98
--- /dev/null
+++ b/frontend/src/features/event/results/attendees/mobile-drawer.tsx
@@ -0,0 +1,127 @@
+import { useEffect, useState } from "react";
+
+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 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";
+
+export default function AttendeesDrawer({
+ onSnapChange,
+ eventTitle,
+ eventCode,
+}: {
+ onSnapChange: (snap: number | string | null) => void;
+ eventTitle: string;
+ eventCode: string;
+}) {
+ const {
+ isRemoving,
+ setIsRemoving,
+ personToRemove,
+ isConfirmationOpen,
+ setIsConfirmationOpen,
+ promptRemove,
+ toggleRemoving,
+ confirmRemove,
+ currentUser,
+ clearSelectedParticipants,
+ } = useParticipantRemoval();
+
+ /* TABS */
+ const [activeSnap, setActiveSnap] = useState(0.22);
+
+ useEffect(() => {
+ onSnapChange(activeSnap);
+ }, [activeSnap, onSnapChange]);
+
+ const isCollapsed = activeSnap === 0.22;
+
+ useEffect(() => {
+ if (isCollapsed && isRemoving) {
+ setIsRemoving(false);
+ clearSelectedParticipants();
+ }
+ }, [isCollapsed, isRemoving, clearSelectedParticipants, setIsRemoving]);
+
+ /* BUTTONS */
+ const paintingButton = (
+ }
+ label={(currentUser ? "Edit" : "Add") + " Availability"}
+ href={`/${eventCode}/painting`}
+ />
+ );
+
+ /* SHARE MENU */
+ const [shareMenuOpen, setShareMenuOpen] = useState(false);
+
+ return (
+ {}}
+ contentClassName="h-full md:hidden"
+ activeSnapPoint={activeSnap}
+ setActiveSnapPoint={setActiveSnap}
+ title="Attendees List"
+ description="View attendees for this event"
+ snapPoints={[0.22, 0.37]}
+ modal={false}
+ floatingAtLowestSnap
+ scrollableBody
+ headerContent={
+
+ }
+ footerContent={
+
+ }
+ aria-label="Share Event"
+ />
+ }
+ nested={true}
+ >
+
+
+ {paintingButton}
+
+ }
+ >
+
+
+
+
+ );
+}
diff --git a/frontend/src/features/event/results/attendee-panel/panel-header.tsx b/frontend/src/features/event/results/attendees/panel-header.tsx
similarity index 100%
rename from frontend/src/features/event/results/attendee-panel/panel-header.tsx
rename to frontend/src/features/event/results/attendees/panel-header.tsx
diff --git a/frontend/src/features/event/results/attendee-panel/participant-chip.tsx b/frontend/src/features/event/results/attendees/participant-chip.tsx
similarity index 100%
rename from frontend/src/features/event/results/attendee-panel/participant-chip.tsx
rename to frontend/src/features/event/results/attendees/participant-chip.tsx
diff --git a/frontend/src/features/event/results/attendee-panel/participant-list.tsx b/frontend/src/features/event/results/attendees/participant-list.tsx
similarity index 94%
rename from frontend/src/features/event/results/attendee-panel/participant-list.tsx
rename to frontend/src/features/event/results/attendees/participant-list.tsx
index a013aada..11b0bed8 100644
--- a/frontend/src/features/event/results/attendee-panel/participant-list.tsx
+++ b/frontend/src/features/event/results/attendees/participant-list.tsx
@@ -1,4 +1,4 @@
-import ParticipantChip from "@/features/event/results/attendee-panel/participant-chip";
+import ParticipantChip from "@/features/event/results/attendees/participant-chip";
import { useResultsContext } from "@/features/event/results/context";
export default function ParticipantList({
diff --git a/frontend/src/features/event/results/attendees/remove-participant.tsx b/frontend/src/features/event/results/attendees/remove-participant.tsx
new file mode 100644
index 00000000..5c0c152f
--- /dev/null
+++ b/frontend/src/features/event/results/attendees/remove-participant.tsx
@@ -0,0 +1,99 @@
+import { useState, useCallback } from "react";
+
+import { useResultsContext } from "@/features/event/results/context";
+import { ConfirmationDialog } from "@/features/system-feedback";
+
+export function useParticipantRemoval() {
+ const {
+ participants,
+ currentUser,
+ clearSelectedParticipants,
+ handleRemoveParticipant,
+ } = useResultsContext();
+
+ const [isRemoving, setIsRemoving] = useState(false);
+ const [personToRemove, setPersonToRemove] = useState(null);
+ const [isConfirmationOpen, setIsConfirmationOpen] = useState(false);
+
+ const promptRemove = useCallback((person: string) => {
+ setPersonToRemove(person);
+ setIsConfirmationOpen(true);
+ }, []);
+
+ const toggleRemoving = useCallback(() => {
+ setIsRemoving((prev) => !prev);
+ clearSelectedParticipants();
+ }, [clearSelectedParticipants]);
+
+ const confirmRemove = useCallback(async () => {
+ if (!personToRemove) return false;
+ const success = await handleRemoveParticipant(personToRemove);
+ if (success && participants.length <= 1) {
+ setIsRemoving(false);
+ }
+ return success;
+ }, [personToRemove, handleRemoveParticipant, participants.length]);
+
+ return {
+ isRemoving,
+ setIsRemoving,
+ personToRemove,
+ isConfirmationOpen,
+ setIsConfirmationOpen,
+ promptRemove,
+ toggleRemoving,
+ confirmRemove,
+ currentUser,
+ clearSelectedParticipants,
+ };
+}
+
+interface RemoveParticipantDialogProps {
+ personToRemove: string | null;
+ currentUser: string | null | undefined;
+ isOpen: boolean;
+ onOpenChange: (open: boolean) => void;
+ onConfirm: () => Promise;
+ asNestedDrawer?: boolean;
+}
+
+export function RemoveParticipantDialog({
+ personToRemove,
+ currentUser,
+ isOpen,
+ onOpenChange,
+ onConfirm,
+ asNestedDrawer,
+}: RemoveParticipantDialogProps) {
+ return (
+
+
+ {personToRemove === currentUser ? (
+ "Are you sure you want to leave this event?"
+ ) : (
+
+ Are you sure you want to remove{" "}
+ {personToRemove} ?
+
+ )}
+
+
+ );
+}
diff --git a/frontend/src/features/event/results/banners.tsx b/frontend/src/features/event/results/components/banners.tsx
similarity index 100%
rename from frontend/src/features/event/results/banners.tsx
rename to frontend/src/features/event/results/components/banners.tsx
diff --git a/frontend/src/features/event/results/display-settings.tsx b/frontend/src/features/event/results/components/display-settings.tsx
similarity index 65%
rename from frontend/src/features/event/results/display-settings.tsx
rename to frontend/src/features/event/results/components/display-settings.tsx
index d8b9bf8e..4fc9a6df 100644
--- a/frontend/src/features/event/results/display-settings.tsx
+++ b/frontend/src/features/event/results/components/display-settings.tsx
@@ -5,14 +5,10 @@ import TimeZoneSelector from "@/features/event/components/selectors/timezone";
export default function DisplaySettings({
timezone,
onTimezoneChange,
- open = false,
- setOpen,
drawerNesting = 0,
}: {
timezone: string;
onTimezoneChange: (newTZ: string) => void;
- open?: boolean;
- setOpen?: React.Dispatch>;
drawerNesting?: number;
}) {
// const { showOnlyBestTimes, setShowOnlyBestTimes } = useResultsContext();
@@ -26,16 +22,12 @@ export default function DisplaySettings({
/> */}
{/* */}
Displaying event in
-
-
-
+
{/*
*/}
>
);
diff --git a/frontend/src/features/event/results/drawer.tsx b/frontend/src/features/event/results/drawer.tsx
deleted file mode 100644
index a9810a01..00000000
--- a/frontend/src/features/event/results/drawer.tsx
+++ /dev/null
@@ -1,159 +0,0 @@
-import { useEffect, useState } from "react";
-
-import { GlobeIcon, SquarePenIcon } from "lucide-react";
-
-import EmptyButton from "@/features/button/components/empty";
-import LinkButton from "@/features/button/components/link";
-import { MorphingDrawer } from "@/features/drawer";
-import TimeZoneSelector from "@/features/event/components/selectors/timezone";
-import PanelHeader from "@/features/event/results/attendee-panel/panel-header";
-import ParticipantList from "@/features/event/results/attendee-panel/participant-list";
-import { useResultsContext } from "@/features/event/results/context";
-import ConfirmationDialog from "@/features/system-feedback/confirmation/base";
-import { tzEqual } from "@/lib/utils/date-time-format";
-
-export default function ResultsDrawer({
- timezone,
- onTimezoneChange,
- onSnapChange,
- eventCode,
-}: {
- timezone: string;
- onTimezoneChange: (newTZ: string) => void;
- onSnapChange: (snap: number | string | null) => void;
- eventCode: string;
-}) {
- const {
- participants,
- currentUser,
- clearSelectedParticipants,
- handleRemoveParticipant: onRemoveParticipant,
- } = useResultsContext();
-
- const promptRemove = (person: string) => {
- setPersonToRemove(person);
- setIsConfirmationOpen(true);
- };
-
- /* REMOVING STATES */
- const [isRemoving, setIsRemoving] = useState(false);
- const [personToRemove, setPersonToRemove] = useState(null);
- const [isConfirmationOpen, setIsConfirmationOpen] = useState(false);
-
- /* TABS */
- const [activeSnap, setActiveSnap] = useState(0.22);
-
- useEffect(() => {
- onSnapChange(activeSnap);
- }, [activeSnap, onSnapChange]);
-
- const isCollapsed = activeSnap === 0.22;
-
- useEffect(() => {
- if (isCollapsed && isRemoving) {
- setIsRemoving(false);
- clearSelectedParticipants();
- }
- }, [isCollapsed, isRemoving, clearSelectedParticipants]);
-
- const toggleRemoving = () => {
- setIsRemoving(!isRemoving);
- clearSelectedParticipants();
- };
-
- /* TIMEZONE HANDLING */
- const tzChanged = !tzEqual(
- timezone,
- Intl.DateTimeFormat().resolvedOptions().timeZone,
- );
-
- /* BUTTONS */
- const paintingButton = (
- }
- label={(currentUser ? "Edit" : "Add") + " Availability"}
- href={`/${eventCode}/painting`}
- />
- );
-
- return (
- {}}
- contentClassName="h-full md:hidden"
- activeSnapPoint={activeSnap}
- setActiveSnapPoint={setActiveSnap}
- title="Attendees List"
- description="View attendees for this event"
- snapPoints={[0.22, 0.37]}
- modal={false}
- floatingAtLowestSnap
- scrollableBody
- headerContent={
-
- }
- footerContent={
-
- }
- aria-label="Change Timezone"
- />
- }
- />
- {paintingButton}
-
- }
- >
-
-
-
- Are you sure you want to remove{" "}
- {personToRemove} ?
-
- )
- }
- open={isConfirmationOpen}
- onOpenChange={setIsConfirmationOpen}
- onConfirm={async () => {
- if (!personToRemove) return false;
- const success = await onRemoveParticipant(personToRemove);
- if (success) {
- if (participants.length === 1) setIsRemoving(false);
- }
- return success;
- }}
- />
-
- );
-}
diff --git a/frontend/src/features/event/results/share-menu.tsx b/frontend/src/features/event/results/share-menu.tsx
new file mode 100644
index 00000000..77f3e2b1
--- /dev/null
+++ b/frontend/src/features/event/results/share-menu.tsx
@@ -0,0 +1,131 @@
+import { useEffect, useMemo, useRef } from "react";
+
+import { ShareIcon } from "lucide-react";
+import QRCodeStyling from "qr-code-styling";
+
+import CopyToastButton from "@/components/copy-toast-button";
+import ActionButton from "@/features/button/components/action";
+import { useToast } from "@/features/system-feedback";
+import { MESSAGES } from "@/lib/messages";
+import { cn } from "@/lib/utils/classname";
+
+export default function ShareMenu({
+ eventTitle,
+ eventCode,
+}: {
+ eventTitle: string;
+ eventCode: string;
+}) {
+ const { addToast } = useToast();
+
+ const shareButton: React.ReactNode =
+ typeof navigator !== "undefined" && navigator.share ? (
+ }
+ label="Share"
+ onClick={async () => {
+ try {
+ await navigator.share({
+ title: eventTitle,
+ url: window.location.href,
+ });
+ } catch (error) {
+ // An error is thrown if sharing is cancelled, ignore that
+ if (error instanceof Error && error.name !== "AbortError") {
+ addToast("error", MESSAGES.ERROR_GENERIC);
+ }
+ }
+ }}
+ />
+ ) : null;
+
+ const copyButton = (
+
+ );
+
+ const currentURL =
+ typeof window !== "undefined"
+ ? `${window.location.origin}/${eventCode}`
+ : "";
+
+ const copyToClipboard = async () => {
+ try {
+ await navigator.clipboard.writeText(currentURL);
+ addToast("copy", MESSAGES.COPY_LINK_SUCCESS);
+ } catch (err) {
+ console.error("Failed to copy: ", err);
+ addToast("error", MESSAGES.COPY_LINK_FAILURE);
+ }
+ };
+
+ return (
+
+
+
+
+ {currentURL ? currentURL.replace(/^https?:\/\//, "") : "Loading..."}
+
+
+ Anyone can join the event using this link
+
+
+
+ {copyButton}
+ {shareButton}
+
+
+ );
+}
+
+function QRCode({ url }: { url: string }) {
+ const ref = useRef(null);
+
+ const qrCode = useMemo(() => {
+ return new QRCodeStyling({
+ width: 256,
+ height: 256,
+ type: "svg",
+ data: url,
+ dotsOptions: {
+ color: "var(--color-foreground)",
+ type: "rounded",
+ },
+ cornersSquareOptions: {
+ type: "extra-rounded",
+ },
+ cornersDotOptions: {
+ type: "extra-rounded",
+ },
+ backgroundOptions: {
+ color: "var(--color-panel)",
+ },
+ });
+ }, [url]);
+
+ useEffect(() => {
+ if (ref.current) {
+ qrCode.append(ref.current);
+ }
+ }, [qrCode]);
+
+ useEffect(() => {
+ qrCode.update({
+ data: url,
+ });
+ }, [qrCode, url]);
+
+ return
;
+}
diff --git a/frontend/src/features/header/components/account-button.tsx b/frontend/src/features/header/components/buttons/account.tsx
similarity index 56%
rename from frontend/src/features/header/components/account-button.tsx
rename to frontend/src/features/header/components/buttons/account.tsx
index 094fc593..be214a8b 100644
--- a/frontend/src/features/header/components/account-button.tsx
+++ b/frontend/src/features/header/components/buttons/account.tsx
@@ -4,11 +4,11 @@ import { LogOutIcon, UserIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import KebabMenu from "@/components/kebab-menu";
-import { useAccount } from "@/features/account/context";
+import { AccountDetails } from "@/features/account/type";
import ActionButton from "@/features/button/components/action";
import EmptyButton from "@/features/button/components/empty";
import LinkButton from "@/features/button/components/link";
-import ShrinkingHeaderButton from "@/features/header/components/shrinking-header-button";
+import ShrinkingHeaderButton from "@/features/header/components/buttons/shrinking-header";
import { useHeaderSize } from "@/features/header/context";
import { useToast } from "@/features/system-feedback";
import { MESSAGES } from "@/lib/messages";
@@ -16,9 +16,12 @@ import { clientPost } from "@/lib/utils/api/client-fetch";
import { ROUTES } from "@/lib/utils/api/endpoints";
import { ApiErrorResponse } from "@/lib/utils/api/fetch-wrapper";
-export default function AccountButton() {
+export default function AccountButton({
+ accountDetails,
+}: {
+ accountDetails: AccountDetails;
+}) {
const { activeMenu, setActiveMenu } = useHeaderSize();
- const { loginState, logout, accountDetails } = useAccount();
const router = useRouter();
const { addToast } = useToast();
@@ -27,14 +30,14 @@ export default function AccountButton() {
const signOut = async () => {
try {
await clientPost(ROUTES.auth.logout);
- logout();
- router.push("/login");
addToast("success", MESSAGES.SUCCESS_LOGOUT);
- return true;
+
+ router.push("/login");
+ router.refresh();
} catch (e) {
const error = e as ApiErrorResponse;
+ console.error("Logout error:", error);
addToast("error", error.formattedMessage);
- return false;
}
};
@@ -56,43 +59,30 @@ export default function AccountButton() {
/>
);
- if (loginState === "logged_in") {
- return (
- }
- >
- setActiveMenu(isOpen ? "account" : null)}
- trigger={
- }
- aria-label="Account settings"
- />
- }
- >
-
- {accountDetails?.email}
-
- {accountSettingsButton}
- {signOutButton}
-
-
- );
- }
-
return (
-
-
+ }
+ >
+ setActiveMenu(isOpen ? "account" : null)}
+ trigger={
+ }
+ aria-label="Account settings"
+ />
+ }
+ >
+
+ {accountDetails?.email}
+
+ {accountSettingsButton}
+ {signOutButton}
+
);
}
diff --git a/frontend/src/features/header/components/dashboard-button.tsx b/frontend/src/features/header/components/buttons/dashboard.tsx
similarity index 95%
rename from frontend/src/features/header/components/dashboard-button.tsx
rename to frontend/src/features/header/components/buttons/dashboard.tsx
index 741f3332..592fa738 100644
--- a/frontend/src/features/header/components/dashboard-button.tsx
+++ b/frontend/src/features/header/components/buttons/dashboard.tsx
@@ -1,7 +1,7 @@
import { LayoutDashboardIcon } from "lucide-react";
import LinkButton from "@/features/button/components/link";
-import ShrinkingHeaderButton from "@/features/header/components/shrinking-header-button";
+import ShrinkingHeaderButton from "@/features/header/components/buttons/shrinking-header";
export default function DashboardButton() {
return (
diff --git a/frontend/src/features/header/components/buttons/login.tsx b/frontend/src/features/header/components/buttons/login.tsx
new file mode 100644
index 00000000..11f997d9
--- /dev/null
+++ b/frontend/src/features/header/components/buttons/login.tsx
@@ -0,0 +1,15 @@
+import LinkButton from "@/features/button/components/link";
+import ShrinkingHeaderButton from "@/features/header/components/buttons/shrinking-header";
+
+export default function LoginButton() {
+ return (
+
+
+
+ );
+}
diff --git a/frontend/src/features/header/components/new-event-button.tsx b/frontend/src/features/header/components/buttons/new-event.tsx
similarity index 95%
rename from frontend/src/features/header/components/new-event-button.tsx
rename to frontend/src/features/header/components/buttons/new-event.tsx
index f44f4bf3..b9e8129d 100644
--- a/frontend/src/features/header/components/new-event-button.tsx
+++ b/frontend/src/features/header/components/buttons/new-event.tsx
@@ -1,11 +1,9 @@
-"use client";
-
import { PlusIcon } from "lucide-react";
import { usePathname } from "next/navigation";
import LinkButton from "@/features/button/components/link";
import { ButtonStyle } from "@/features/button/props";
-import ShrinkingHeaderButton from "@/features/header/components/shrinking-header-button";
+import ShrinkingHeaderButton from "@/features/header/components/buttons/shrinking-header";
export default function NewEventButton() {
const pathname = usePathname();
diff --git a/frontend/src/features/header/components/shrinking-header-button.tsx b/frontend/src/features/header/components/buttons/shrinking-header.tsx
similarity index 97%
rename from frontend/src/features/header/components/shrinking-header-button.tsx
rename to frontend/src/features/header/components/buttons/shrinking-header.tsx
index 54215071..b9f59617 100644
--- a/frontend/src/features/header/components/shrinking-header-button.tsx
+++ b/frontend/src/features/header/components/buttons/shrinking-header.tsx
@@ -18,10 +18,9 @@ export default function ShrinkingHeaderButton({
label?: string;
children: React.ReactNode;
}) {
- const [showButton, setShowButton] = useState(false);
- const buttonShowTimeout = useRef(null);
-
const { isShrunk } = useHeaderSize();
+ const [showButton, setShowButton] = useState(!isShrunk);
+ const buttonShowTimeout = useRef(null);
useEffect(() => {
if (buttonShowTimeout.current) {
diff --git a/frontend/src/features/header/components/header.tsx b/frontend/src/features/header/components/header.tsx
index cf6ee716..5e4b94af 100644
--- a/frontend/src/features/header/components/header.tsx
+++ b/frontend/src/features/header/components/header.tsx
@@ -1,126 +1,18 @@
-"use client";
+import AccountButton from "@/features/header/components/buttons/account";
+import LoginButton from "@/features/header/components/buttons/login";
+import ShrinkingHeader from "@/features/header/components/shrinking-header";
+import { getSession } from "@/lib/utils/get-session";
-import { useEffect, useRef, useState } from "react";
-
-import { motion } from "framer-motion";
-
-import AccountButton from "@/features/header/components/account-button";
-import DashboardButton from "@/features/header/components/dashboard-button";
-import LogoArea from "@/features/header/components/logo-area";
-import NewEventButton from "@/features/header/components/new-event-button";
-import ThemePicker from "@/features/header/components/theme-picker";
-import { useHeaderSize } from "@/features/header/context";
-import useCheckMobile from "@/lib/hooks/use-check-mobile";
-import { cn } from "@/lib/utils/classname";
-
-const SCROLL_THRESHOLD = 50;
-
-export default function Header() {
- const [mounted, setMounted] = useState(false);
-
- const isMobile = useCheckMobile();
- const lastScrollPoint = useRef(0);
- const scrollCheckpoint = useRef(0);
-
- const { isShrunk, heightClass, shrink, expand, activeMenu } = useHeaderSize();
-
- useEffect(() => {
- setMounted(true);
-
- if (!isMobile) {
- expand();
- return;
- }
-
- const handleScroll = () => {
- const currentScrollPoint = Math.min(
- Math.max(window.scrollY, 0),
- document.documentElement.scrollHeight - window.innerHeight,
- );
- const scrollingDown = currentScrollPoint > lastScrollPoint.current;
- lastScrollPoint.current = currentScrollPoint;
-
- if (currentScrollPoint <= 0) {
- expand();
- return;
- }
-
- if (isShrunk) {
- if (scrollingDown) {
- scrollCheckpoint.current = currentScrollPoint;
- } else if (
- currentScrollPoint <
- scrollCheckpoint.current - SCROLL_THRESHOLD
- ) {
- expand();
- }
- } else {
- if (!scrollingDown) {
- scrollCheckpoint.current = currentScrollPoint;
- } else if (
- currentScrollPoint >
- scrollCheckpoint.current + SCROLL_THRESHOLD
- ) {
- shrink();
- }
- }
- };
-
- window.addEventListener("scroll", handleScroll);
- return () => {
- window.removeEventListener("scroll", handleScroll);
- };
- }, [isMobile, isShrunk, shrink, expand]);
-
- if (!mounted) {
- return null;
- }
+export default async function Header() {
+ const session = await getSession();
return (
-
- {
- if (!isShrunk) return;
- e.preventDefault();
- e.stopPropagation();
- expand();
- }}
- >
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+ {session.isLoggedIn ? (
+
+ ) : (
+
+ )}
+
);
}
diff --git a/frontend/src/features/header/components/logo-area.tsx b/frontend/src/features/header/components/logo-area.tsx
index 50acc181..d0440c00 100644
--- a/frontend/src/features/header/components/logo-area.tsx
+++ b/frontend/src/features/header/components/logo-area.tsx
@@ -1,7 +1,3 @@
-"use client";
-
-import { useEffect, useState } from "react";
-
import Link from "next/link";
import LinkText from "@/components/link-text";
@@ -10,19 +6,8 @@ import { getCurrentVersion } from "@/features/version-history/data";
import { cn } from "@/lib/utils/classname";
export default function LogoArea({ isShrunk = false }: { isShrunk?: boolean }) {
- const [mounted, setMounted] = useState(false);
-
- useEffect(() => {
- setMounted(true);
- }, []);
-
- if (!mounted) {
- return null;
- }
-
return (
- {/* Text Container */}
diff --git a/frontend/src/features/header/components/shrinking-header.tsx b/frontend/src/features/header/components/shrinking-header.tsx
new file mode 100644
index 00000000..da40639a
--- /dev/null
+++ b/frontend/src/features/header/components/shrinking-header.tsx
@@ -0,0 +1,128 @@
+"use client";
+
+import { useEffect, useRef, useState } from "react";
+
+import { motion } from "framer-motion";
+
+import DashboardButton from "@/features/header/components/buttons/dashboard";
+import NewEventButton from "@/features/header/components/buttons/new-event";
+import LogoArea from "@/features/header/components/logo-area";
+import ThemePicker from "@/features/header/components/theme-picker";
+import { useHeaderSize } from "@/features/header/context";
+import useCheckMobile from "@/lib/hooks/use-check-mobile";
+import { cn } from "@/lib/utils/classname";
+
+const SCROLL_THRESHOLD = 50;
+
+export default function ShrinkingHeader({
+ children: accountButton,
+}: {
+ children: React.ReactNode;
+}) {
+ const [mounted, setMounted] = useState(false);
+
+ const isMobile = useCheckMobile();
+ const lastScrollPoint = useRef(0);
+ const scrollCheckpoint = useRef(0);
+
+ const { isShrunk, heightClass, shrink, expand, activeMenu } = useHeaderSize();
+
+ useEffect(() => {
+ setMounted(true);
+
+ if (!isMobile) {
+ expand();
+ return;
+ }
+
+ const handleScroll = () => {
+ const currentScrollPoint = Math.min(
+ Math.max(window.scrollY, 0),
+ document.documentElement.scrollHeight - window.innerHeight,
+ );
+ const scrollingDown = currentScrollPoint > lastScrollPoint.current;
+ lastScrollPoint.current = currentScrollPoint;
+
+ if (currentScrollPoint <= 0) {
+ expand();
+ return;
+ }
+
+ if (isShrunk) {
+ if (scrollingDown) {
+ scrollCheckpoint.current = currentScrollPoint;
+ } else if (
+ currentScrollPoint <
+ scrollCheckpoint.current - SCROLL_THRESHOLD
+ ) {
+ expand();
+ }
+ } else {
+ if (!scrollingDown) {
+ scrollCheckpoint.current = currentScrollPoint;
+ } else if (
+ currentScrollPoint >
+ scrollCheckpoint.current + SCROLL_THRESHOLD
+ ) {
+ shrink();
+ }
+ }
+ };
+
+ window.addEventListener("scroll", handleScroll);
+ return () => {
+ window.removeEventListener("scroll", handleScroll);
+ };
+ }, [isMobile, isShrunk, shrink, expand]);
+
+ if (!mounted) return null;
+
+ return (
+
+ {
+ if (!isShrunk) return;
+ e.preventDefault();
+ e.stopPropagation();
+ expand();
+ }}
+ >
+
+
+
+
+
+
+
+
+
+ {accountButton}
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/features/header/components/theme-picker.tsx b/frontend/src/features/header/components/theme-picker.tsx
index 8a82b843..a3c1c51f 100644
--- a/frontend/src/features/header/components/theme-picker.tsx
+++ b/frontend/src/features/header/components/theme-picker.tsx
@@ -6,7 +6,7 @@ import { useTheme } from "next-themes";
import KebabMenu from "@/components/kebab-menu";
import SegmentedControl from "@/components/segmented-control";
import EmptyButton from "@/features/button/components/empty";
-import ShrinkingHeaderButton from "@/features/header/components/shrinking-header-button";
+import ShrinkingHeaderButton from "@/features/header/components/buttons/shrinking-header";
import { useHeaderSize } from "@/features/header/context";
export default function ThemePicker() {
diff --git a/frontend/src/features/selector/components/drawer.tsx b/frontend/src/features/selector/components/drawer.tsx
index aee0cf3b..685f2b8e 100644
--- a/frontend/src/features/selector/components/drawer.tsx
+++ b/frontend/src/features/selector/components/drawer.tsx
@@ -1,13 +1,16 @@
import { cloneElement, useEffect, useRef, useState } from "react";
+import { ChevronDownIcon } from "lucide-react";
+
import { FloatingDrawer, StandardDrawer } from "@/features/drawer";
import { DrawerProps } from "@/features/selector/types";
import { cn } from "@/lib/utils/classname";
-export default function SelectorDrawer
({
+export default function SelectorDrawer({
id,
value,
options,
+ placeholder,
onChange,
dialogTitle,
dialogDescription,
@@ -78,7 +81,7 @@ export default function SelectorDrawer({
aria-disabled={disabled}
className={cn(
"relative flex items-center rounded-2xl text-start focus:outline-none",
- "bg-accent/15 text-accent-text px-3 py-1",
+ "bg-accent/15 text-accent-text gap-2 px-3 py-1",
open && !disabled && "ring-accent ring-1",
// Interactive states only when enabled
!disabled &&
@@ -88,7 +91,12 @@ export default function SelectorDrawer({
"bg-foreground/20 text-foreground hover:bg-foreground/20 active:bg-foreground/20 cursor-not-allowed opacity-50 hover:cursor-not-allowed",
)}
>
- {selectLabel}
+
+ {selectLabel || placeholder}
+
+
)
}
diff --git a/frontend/src/features/selector/components/dropdown.tsx b/frontend/src/features/selector/components/dropdown.tsx
index 14a8008e..2ed78683 100644
--- a/frontend/src/features/selector/components/dropdown.tsx
+++ b/frontend/src/features/selector/components/dropdown.tsx
@@ -6,11 +6,12 @@ import { CheckIcon, ChevronDownIcon } from "lucide-react";
import { DropdownProps } from "@/features/selector/types";
import { cn } from "@/lib/utils/classname";
-export default function Dropdown({
+export default function Dropdown({
id,
onChange,
value,
options,
+ placeholder,
disabled,
className,
}: DropdownProps) {
@@ -19,6 +20,7 @@ export default function Dropdown({
value={value?.toString()}
onValueChange={(v) => {
// parse value back to number when possible, then cast to TValue
+ if (v === "") return onChange(null as TValue);
const parsed = isNaN(Number(v)) ? v : Number(v);
onChange(parsed as unknown as TValue);
}}
@@ -37,8 +39,13 @@ export default function Dropdown({
aria-label="Custom select"
disabled={disabled}
>
-
-
+
+
@@ -50,7 +57,10 @@ export default function Dropdown({
{options.map((option) => (
-
+
{option.label}
))}
@@ -62,7 +72,7 @@ export default function Dropdown({
}
type DropdownItemProps = {
- value: string | number;
+ value: string | number | null;
children: React.ReactNode;
};
@@ -71,10 +81,10 @@ const DropdownItem = forwardRef(
return (
-
+
{label}
{children}
diff --git a/frontend/src/features/selector/components/selector.tsx b/frontend/src/features/selector/components/selector.tsx
index 57b5cc13..f9af2449 100644
--- a/frontend/src/features/selector/components/selector.tsx
+++ b/frontend/src/features/selector/components/selector.tsx
@@ -4,13 +4,13 @@ import { SelectorProps } from "@/features/selector/types";
import useCheckMobile from "@/lib/hooks/use-check-mobile";
import { cn } from "@/lib/utils/classname";
-export default function Selector(
+export default function Selector(
props: SelectorProps,
) {
const isMobile = useCheckMobile();
// converts the value to the format needed by CustomSelect (string or number)
- const handleValueChange = (selectedValue: string | number) => {
+ const handleValueChange = (selectedValue: string | number | null) => {
props.onChange(selectedValue as TValue);
};
@@ -23,6 +23,7 @@ export default function Selector(
value={props.value}
options={props.options}
onChange={handleValueChange}
+ placeholder={props.placeholder}
disabled={props.disabled}
className={cn("w-full", !props.className && "h-fit w-fit")}
/>
diff --git a/frontend/src/features/selector/types.ts b/frontend/src/features/selector/types.ts
index 6da30c0a..1c7ba093 100644
--- a/frontend/src/features/selector/types.ts
+++ b/frontend/src/features/selector/types.ts
@@ -1,9 +1,9 @@
-export type Option = {
+export type Option = {
label: string;
value: TValue;
};
-export type BaseSelectorProps = {
+export type BaseSelectorProps = {
/**
* Unique identifier for the selector, used for accessibility and testing purposes
*
@@ -19,7 +19,7 @@ export type BaseSelectorProps = {
/**
* The currently selected value.
*
- * @type string | number
+ * @type string | number | null
*/
value: TValue;
/**
@@ -29,6 +29,12 @@ export type BaseSelectorProps = {
* @example [{ label: "Option 1", value: "option1" }, { label: "Option 2", value: "option2" }]
*/
options: Option[];
+ /**
+ * Placeholder text to display when no value is selected.
+ *
+ * @type string
+ */
+ placeholder?: string;
/**
* Whether the selector is disabled.
*
@@ -43,10 +49,10 @@ export type BaseSelectorProps = {
/* EXTENDED FROM BASE PROPS */
-export type DropdownProps =
+export type DropdownProps =
BaseSelectorProps;
-export type DrawerProps =
+export type DrawerProps =
BaseSelectorProps & {
/**
* Title to display at the top of the drawer when the selector is open.
@@ -87,7 +93,7 @@ export type DrawerProps =
onOpenChange?: (open: boolean) => void;
/**
* Optional custom trigger element to open the drawer.
- *
+ *
* If not provided, the default will be used.
*/
trigger?: React.ReactElement;
@@ -100,7 +106,8 @@ export type DrawerProps =
* same core functionality and only differ in presentation. Based off of DrawerProps
* since it is the more complex variant.
*/
-export type SelectorProps = DrawerProps;
+export type SelectorProps =
+ DrawerProps;
/**
* Base props for the selector components.
@@ -108,7 +115,5 @@ export type SelectorProps = DrawerProps;
* This type omits options, dialogTitle, and dialogDescription since they should be
* provided by the parent component (e.g. TimeZoneSelector).
*/
-export type BaseSelectorWrapperProps = Omit<
- SelectorProps,
- "options" | "dialogTitle" | "dialogDescription"
->;
+export type BaseSelectorWrapperProps =
+ Omit, "options" | "dialogTitle" | "dialogDescription">;
diff --git a/frontend/src/features/system-feedback/confirmation/base.tsx b/frontend/src/features/system-feedback/confirmation/base.tsx
deleted file mode 100644
index a8a801b4..00000000
--- a/frontend/src/features/system-feedback/confirmation/base.tsx
+++ /dev/null
@@ -1,229 +0,0 @@
-import React, { useState, useCallback, useEffect } from "react";
-
-import * as Dialog from "@radix-ui/react-dialog";
-
-import ActionButton from "@/features/button/components/action";
-import { FloatingDrawer } from "@/features/drawer";
-import { DIALOG_CONFIG } from "@/features/system-feedback/confirmation/config";
-import { ConfirmationDialogType } from "@/features/system-feedback/type";
-import { cn } from "@/lib/utils/classname";
-
-type ConfirmationDialogProps = {
- type: ConfirmationDialogType;
- title: string;
- description: React.ReactNode;
- onConfirm: () => boolean | Promise;
- children?: React.ReactNode;
- disabled?: boolean;
- showIcon?: boolean;
- autoClose?: boolean;
- asNestedDrawer?: boolean;
-
- // controlled props
- // (for when the dialog needs to be controlled by the parent component)
- open?: boolean;
- onOpenChange?: (open: boolean) => void;
-};
-
-export default function ConfirmationDialog({
- type,
- title,
- description,
- onConfirm,
- children: triggerElement,
- disabled = false,
- showIcon = false,
- autoClose = false,
- asNestedDrawer = false,
- open: controlledOpen,
- onOpenChange,
-}: ConfirmationDialogProps) {
- const [internalOpen, setInternalOpen] = useState(false);
-
- // use controlled state if provided, otherwise local state
- const isControlled = controlledOpen !== undefined;
- const open = isControlled ? controlledOpen : internalOpen;
-
- const handleOpenChange = useCallback(
- (newOpen: boolean) => {
- if (!isControlled) {
- setInternalOpen(newOpen);
- }
- onOpenChange?.(newOpen);
- },
- [isControlled, onOpenChange],
- );
-
- const handleClose = useCallback(() => {
- handleOpenChange(false);
- }, [handleOpenChange]);
-
- const handleConfirm = useCallback(async () => {
- // If autoClose is enabled, we optimistically close
- // the dialog before calling onConfirm
- if (autoClose) {
- handleOpenChange(false);
- onConfirm();
- return true;
- }
-
- const success = await onConfirm();
- if (success) {
- handleOpenChange(false);
- }
- return success;
- }, [autoClose, onConfirm, handleOpenChange]);
-
- useEffect(() => {
- // If the dialog is not open, then don't add the keydown listener
- if (!open) return;
-
- // Add keydown listener for Enter and Escape keys to trigger confirm or cancel
- // actions.
- const handleKeyDown = (e: KeyboardEvent) => {
- // Ignore key events coming from text inputs or editable elements
- const target = e.target as HTMLElement | null;
- const tagName = target?.tagName;
- const isMultilineText =
- tagName === "TEXTAREA" || target?.isContentEditable;
- if (isMultilineText) {
- return;
- }
-
- if (e.key === "Escape") handleClose();
- else if (e.key === "Enter") handleConfirm();
- };
-
- window.addEventListener("keydown", handleKeyDown);
-
- return () => window.removeEventListener("keydown", handleKeyDown);
- }, [handleClose, handleConfirm, open]);
-
- const config = DIALOG_CONFIG[type] || DIALOG_CONFIG.info;
- const Icon = config.icon;
-
- const renderIcon = () => {
- if (config.isTextIcon) {
- return (
-
- );
- }
- return (
-
- {Icon && }
-
- );
- };
-
- if (asNestedDrawer) {
- return (
- }
- >
-
-
- {showIcon && renderIcon()}
-
{title}
-
-
- {description}
-
-
-
-
- );
- }
-
- return (
-
- {triggerElement && (
- {
- if (disabled) {
- e.preventDefault();
- e.stopPropagation();
- }
- }}
- aria-disabled={disabled}
- >
- {triggerElement}
-
- )}
-
-
-
-
-
-
-
- {showIcon && renderIcon()}
-
{title}
-
-
-
-
- {description}
-
-
-
-
-
-
-
- );
-}
diff --git a/frontend/src/features/system-feedback/confirmation/config.ts b/frontend/src/features/system-feedback/confirmation/config.ts
deleted file mode 100644
index c45ed448..00000000
--- a/frontend/src/features/system-feedback/confirmation/config.ts
+++ /dev/null
@@ -1,38 +0,0 @@
-import {
- CheckIcon,
- InfoIcon,
- TriangleAlertIcon,
-} from "lucide-react";
-
-export const DIALOG_CONFIG = {
- warning: {
- icon: null, // special case for the text "!"
- bgClass: "bg-lion",
- btnStyle: "primary",
- isTextIcon: true,
- },
- delete: {
- icon: TriangleAlertIcon,
- bgClass: "bg-error/40",
- btnStyle: "danger",
- isTextIcon: false,
- },
- success: {
- icon: CheckIcon,
- bgClass: "bg-foreground/40",
- btnStyle: "primary",
- isTextIcon: false,
- },
- error: {
- icon: TriangleAlertIcon,
- bgClass: "bg-error/40",
- btnStyle: "danger",
- isTextIcon: false,
- },
- info: {
- icon: InfoIcon,
- bgClass: "bg-blue/40",
- btnStyle: "primary",
- isTextIcon: false,
- },
-} as const;
diff --git a/frontend/src/features/system-feedback/dialog/components/base.tsx b/frontend/src/features/system-feedback/dialog/components/base.tsx
new file mode 100644
index 00000000..8dd0d5d4
--- /dev/null
+++ b/frontend/src/features/system-feedback/dialog/components/base.tsx
@@ -0,0 +1,123 @@
+import { useCallback, useState } from "react";
+
+import * as Dialog from "@radix-ui/react-dialog";
+import { XIcon } from "lucide-react";
+
+import ActionButton from "@/features/button/components/action";
+import { FloatingDrawer } from "@/features/drawer";
+import { BaseDialogProps } from "@/features/system-feedback/dialog/props";
+import { cn } from "@/lib/utils/classname";
+
+export default function BaseDialog({
+ title,
+ description,
+ trigger,
+ children,
+ open: controlledOpen,
+ onOpenChange,
+ asNestedDrawer = false,
+ triggerDisabled = false,
+ overlayClassName,
+ showCloseButton = false,
+}: BaseDialogProps) {
+ /* 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],
+ );
+
+ if (asNestedDrawer) {
+ return (
+ }
+ hideCloseButton={!showCloseButton}
+ >
+
+
+ );
+ }
+
+ return (
+
+ {trigger && (
+ {
+ if (triggerDisabled) {
+ e.preventDefault();
+ e.stopPropagation();
+ }
+ }}
+ aria-disabled={triggerDisabled}
+ >
+ {trigger}
+
+ )}
+
+
+
+
+
+
+
+
+
+
+ {description}
+
+
+ {children}
+
+ {showCloseButton && (
+
+
}
+ aria-label="Close dialog"
+ onClick={(e) => {
+ e?.stopPropagation();
+ handleOpenChange(false);
+ }}
+ />
+
+ )}
+
+
+
+
+ );
+}
diff --git a/frontend/src/features/system-feedback/dialog/components/confirmation.tsx b/frontend/src/features/system-feedback/dialog/components/confirmation.tsx
new file mode 100644
index 00000000..68fb0cb7
--- /dev/null
+++ b/frontend/src/features/system-feedback/dialog/components/confirmation.tsx
@@ -0,0 +1,116 @@
+import { useCallback, useState } from "react";
+
+import ActionButton from "@/features/button/components/action";
+import BaseModal from "@/features/system-feedback/dialog/components/base";
+import { DIALOG_CONFIG } from "@/features/system-feedback/dialog/config";
+import { ConfirmationDialogProps } from "@/features/system-feedback/dialog/props";
+import { cn } from "@/lib/utils/classname";
+
+export default function ConfirmationDialog({
+ type,
+ title,
+ description,
+ onConfirm,
+ children,
+ trigger,
+ triggerDisabled = false,
+ autoClose = false,
+ asNestedDrawer = false,
+ showCloseButton = false,
+ open: controlledOpen,
+ onOpenChange,
+}: ConfirmationDialogProps) {
+ const config = DIALOG_CONFIG[type] || DIALOG_CONFIG.info;
+
+ const [uncontrolledOpen, setUncontrolledOpen] = useState(false);
+ const isControlled = controlledOpen !== undefined;
+ const open = isControlled ? controlledOpen : uncontrolledOpen;
+
+ const handleOpenChange = useCallback(
+ (newOpen: boolean) => {
+ if (!isControlled) {
+ setUncontrolledOpen(newOpen);
+ }
+ onOpenChange?.(newOpen);
+ },
+ [isControlled, onOpenChange],
+ );
+
+ const handleConfirm = useCallback(async () => {
+ if (autoClose) {
+ handleOpenChange(false);
+ onConfirm();
+ return true;
+ }
+ const success = await onConfirm();
+ if (success) {
+ handleOpenChange(false);
+ }
+ return success;
+ }, [autoClose, onConfirm, handleOpenChange]);
+
+ const handleKeyDown = useCallback(
+ (e: React.KeyboardEvent) => {
+ if (e.key === "Enter") {
+ const target = e.target as HTMLElement;
+
+ const isTextInput =
+ target.tagName === "INPUT" ||
+ target.tagName === "TEXTAREA" ||
+ target.isContentEditable;
+
+ const isActionable =
+ target.tagName === "BUTTON" || target.tagName === "A";
+
+ if (isTextInput || isActionable) {
+ return;
+ }
+
+ e.preventDefault();
+ handleConfirm();
+ }
+ },
+ [handleConfirm],
+ );
+
+ return (
+
+
+ {description && !children && (
+
+ {description}
+
+ )}
+
+ {children}
+
+
+
handleOpenChange(false)}
+ />
+
+
+
+
+ );
+}
diff --git a/frontend/src/features/system-feedback/dialog/components/form.tsx b/frontend/src/features/system-feedback/dialog/components/form.tsx
new file mode 100644
index 00000000..babb8bf5
--- /dev/null
+++ b/frontend/src/features/system-feedback/dialog/components/form.tsx
@@ -0,0 +1,87 @@
+import { useCallback, useState } from "react";
+
+import ActionButton from "@/features/button/components/action";
+import EmptyButton from "@/features/button/components/empty";
+import BaseModal from "@/features/system-feedback/dialog/components/base";
+import { DIALOG_CONFIG } from "@/features/system-feedback/dialog/config";
+import { FormDialogProps } from "@/features/system-feedback/dialog/props";
+import { cn } from "@/lib/utils/classname";
+
+export default function FormDialog({
+ type,
+ title,
+ description,
+ onSubmit,
+ submitLabel = "Save",
+ cancelLabel = "Cancel",
+ children,
+ trigger,
+ asNestedDrawer = false,
+ showCloseButton = false,
+ open: controlledOpen,
+ onOpenChange,
+}: FormDialogProps) {
+ const config = DIALOG_CONFIG[type] || DIALOG_CONFIG.info;
+
+ const [uncontrolledOpen, setUncontrolledOpen] = useState(false);
+ const isControlled = controlledOpen !== undefined;
+ const open = isControlled ? controlledOpen : uncontrolledOpen;
+
+ const handleOpenChange = useCallback(
+ (newOpen: boolean) => {
+ if (!isControlled) {
+ setUncontrolledOpen(newOpen);
+ }
+ onOpenChange?.(newOpen);
+ },
+ [isControlled, onOpenChange],
+ );
+
+ const handleSubmit = useCallback(
+ async (e: React.FormEvent) => {
+ e.preventDefault();
+ const success = await onSubmit();
+ if (success) {
+ handleOpenChange(false);
+ }
+ },
+ [onSubmit, handleOpenChange],
+ );
+
+ return (
+
+
+
+ );
+}
diff --git a/frontend/src/features/system-feedback/dialog/config.ts b/frontend/src/features/system-feedback/dialog/config.ts
new file mode 100644
index 00000000..bcd2fea0
--- /dev/null
+++ b/frontend/src/features/system-feedback/dialog/config.ts
@@ -0,0 +1,24 @@
+import { ButtonStyle } from "@/features/button/props";
+import { DialogType } from "@/features/system-feedback/type";
+
+export type DialogConfig = {
+ buttonStyle: ButtonStyle;
+};
+
+export const DIALOG_CONFIG = {
+ warning: {
+ buttonStyle: "primary",
+ },
+ delete: {
+ buttonStyle: "danger",
+ },
+ success: {
+ buttonStyle: "primary",
+ },
+ error: {
+ buttonStyle: "danger",
+ },
+ info: {
+ buttonStyle: "primary",
+ },
+} satisfies Record;
diff --git a/frontend/src/features/system-feedback/dialog/props.ts b/frontend/src/features/system-feedback/dialog/props.ts
new file mode 100644
index 00000000..08349db8
--- /dev/null
+++ b/frontend/src/features/system-feedback/dialog/props.ts
@@ -0,0 +1,109 @@
+import type { DialogType } from "@/features/system-feedback";
+
+type CommonDialogProps = {
+ /**
+ * Title of the dialog
+ * Required for accessibility (used in Title components and aria attributes)
+ *
+ * @type {string}
+ */
+ title: string;
+ /**
+ * Description of the dialog
+ * Required for accessibility (used in Description components and aria attributes)
+ *
+ * @type {string}
+ */
+ description: string;
+ /**
+ * Dialog open state (for controlled dialogs)
+ * If not provided, the dialog will manage its own open state internally.
+ *
+ * @type {boolean}
+ * @default undefined
+ */
+ open?: boolean;
+ /**
+ * Callback when the open state changes (for controlled dialogs)
+ * If not provided, the dialog will manage its own open state internally.
+ *
+ * @type {(open: boolean) => void}
+ * @default undefined
+ */
+ onOpenChange?: (open: boolean) => void;
+ /**
+ * The content of the dialog, which can be any valid React node.
+ *
+ * @type {React.ReactNode}
+ * @default undefined
+ */
+ children?: React.ReactNode;
+ /**
+ * Trigger element to open the dialog. If not provided, the dialog can only be opened
+ * programmatically via the `open` prop.
+ *
+ * @type {React.ReactNode}
+ * @default undefined
+ */
+ trigger?: React.ReactNode;
+ /**
+ * Whether to disable the trigger element (if provided).
+ *
+ * @type {boolean}
+ * @default false
+ */
+ triggerDisabled?: boolean;
+ /**
+ * Indicates whether or not the drawer version of the dialog should be displayed
+ * as a nested drawer or not. This is to ensure the correct styling and behavior
+ * when the dialog is used inside another drawer.
+ *
+ * @type {boolean}
+ * @default false
+ */
+ asNestedDrawer?: boolean;
+ /**
+ * Additional className to apply to the dialog overlay, for further customization.
+ *
+ * @type {string}
+ * @default undefined
+ */
+ overlayClassName?: string;
+ /**
+ * Whether to show a close button in the top-right corner of the dialog.
+ *
+ * @type {boolean}
+ * @default false
+ */
+ showCloseButton?: boolean;
+};
+
+export type BaseDialogProps = CommonDialogProps;
+
+export type ConfirmationDialogProps = CommonDialogProps & {
+ type: DialogType;
+ /**
+ * Enforced strictly as a string for semantic standard confirmations.
+ */
+ description: string;
+
+ /** Callback function that is called when the user confirms the action */
+ onConfirm: () => boolean | Promise;
+
+ /** Whether to automatically close the dialog after confirming */
+ autoClose?: boolean;
+};
+
+export type FormDialogProps = CommonDialogProps & {
+ type: DialogType;
+ /** * Callback function called when the native