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

- 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 ( -
- -
+ + {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 ( +
+ +
+ ); +} 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 ( -