From 86ac1b220578f2b0a958fae483f42d598ec0e395 Mon Sep 17 00:00:00 2001 From: Simon Gruber Date: Sun, 29 Mar 2026 16:00:46 +0200 Subject: [PATCH] Onboarding improvements --- src/frontend/src/App.tsx | 14 ++ .../src/components/WelcomeOnboardingModal.tsx | 56 +++++++- .../account/AccountSettingsPopoverContent.tsx | 24 +++- src/frontend/src/components/common/TopNav.tsx | 6 + .../modals/WelcomeOnboardingModal.tsx | 120 +++++++++++++----- src/frontend/src/lib/userIdentity.ts | 6 +- src/frontend/src/views/AdminView.tsx | 16 ++- 7 files changed, 198 insertions(+), 44 deletions(-) diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index 775e430..e042cc8 100644 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -22,6 +22,7 @@ import WelcomeOnboardingModal from "./components/modals/WelcomeOnboardingModal"; import { navigateTo, parseRoute, subscribeToRouteChange } from "./lib/routing"; import { apiService } from "./lib/services"; import { + clearUserId, ensureUserId, hasStoredUserId, updateUserId, @@ -163,6 +164,7 @@ function AppContent({ userId, userEmail, onUserEmailChange, + onLogout, }: { themeMode: ThemeMode; setThemeMode: (mode: ThemeMode) => void; @@ -171,6 +173,7 @@ function AppContent({ userId: string; userEmail: string; onUserEmailChange: (nextUserEmail: string) => void | Promise; + onLogout: () => void; }) { const appBackground = themeMode === "dark" @@ -200,6 +203,7 @@ function AppContent({ }} userEmail={userEmail} onUserEmailChange={onUserEmailChange} + onLogout={onLogout} /> @@ -371,6 +375,15 @@ export default function App() { return userEmail; }; + const handleLogout = () => { + clearUserId(); + setUserId(""); + setUserEmail(""); + setIsOnboardingOpen(true); + navigateTo("/"); + message.success("Logged out"); + }; + const completeCreateAccount = async (email: string) => { try { await apiService.account.register(email); @@ -406,6 +419,7 @@ export default function App() { userId={userId} userEmail={userEmail} onUserEmailChange={handleUserEmailChange} + onLogout={handleLogout} /> (); const [lookupState, setLookupState] = useState("idle"); const [isSubmitting, setIsSubmitting] = useState(false); + const [hasSentEmail, setHasSentEmail] = useState(false); const lookupTimerRef = useRef(null); const lookupRequestIdRef = useRef(0); @@ -62,6 +63,7 @@ export default function WelcomeOnboardingModal({ form.resetFields(); setLookupState("idle"); setIsSubmitting(false); + setHasSentEmail(false); lookupRequestIdRef.current += 1; if (lookupTimerRef.current) { window.clearTimeout(lookupTimerRef.current); @@ -80,6 +82,9 @@ export default function WelcomeOnboardingModal({ ); const buttonLabel = useMemo(() => { + if (hasSentEmail) { + return "Email sent"; + } if (lookupState === "exists") { return "Migrate account"; } @@ -90,14 +95,16 @@ export default function WelcomeOnboardingModal({ return "Create new account"; } return "Continue"; - }, [lookupState]); + }, [hasSentEmail, lookupState]); + + const isLookupResolved = lookupState === "exists" || lookupState === "new"; const helperText = useMemo(() => { if (lookupState === "exists") { - return "An account was found for this email. We'll send a migration confirmation link."; + return "Log in using this email."; } if (lookupState === "new") { - return "No account found for this email. We'll create a new account."; + return "Register using this email."; } return ""; }, [lookupState]); @@ -127,6 +134,10 @@ export default function WelcomeOnboardingModal({ form={form} layout="vertical" onValuesChange={(_changedValues, values) => { + if (hasSentEmail) { + return; + } + const currentEmail = String(values.email || ""); if (lookupTimerRef.current) { window.clearTimeout(lookupTimerRef.current); @@ -138,6 +149,10 @@ export default function WelcomeOnboardingModal({ }, 350); }} onFinish={async (values) => { + if (hasSentEmail) { + return; + } + const normalizedEmail = normalizeEmail(values.email); setIsSubmitting(true); try { @@ -152,6 +167,8 @@ export default function WelcomeOnboardingModal({ } else { await onCreateAccount(normalizedEmail); } + + setHasSentEmail(true); } finally { setIsSubmitting(false); } @@ -166,14 +183,41 @@ export default function WelcomeOnboardingModal({ ]} extra={helperText || undefined} > - + + {hasSentEmail ? ( + + ) : null} + + {hasSentEmail ? ( + + ) : null} + diff --git a/src/frontend/src/components/account/AccountSettingsPopoverContent.tsx b/src/frontend/src/components/account/AccountSettingsPopoverContent.tsx index ae51d27..63ae121 100644 --- a/src/frontend/src/components/account/AccountSettingsPopoverContent.tsx +++ b/src/frontend/src/components/account/AccountSettingsPopoverContent.tsx @@ -1,11 +1,12 @@ import React, { useEffect } from "react"; -import { CheckOutlined } from "@ant-design/icons"; +import { CheckOutlined, LogoutOutlined } from "@ant-design/icons"; import { Alert, Button, Flex, Form, Input, + Popconfirm, Space, Typography, message, @@ -14,9 +15,11 @@ import { export default function AccountSettingsPopoverContent({ userEmail, onUserEmailChange, + onLogout, }: { userEmail: string; onUserEmailChange: (nextUserEmail: string) => void | Promise; + onLogout: () => void; }) { const [form] = Form.useForm<{ email: string }>(); const watchedEmail = Form.useWatch("email", form) || ""; @@ -98,6 +101,25 @@ export default function AccountSettingsPopoverContent({ Discard + + + + ); diff --git a/src/frontend/src/components/common/TopNav.tsx b/src/frontend/src/components/common/TopNav.tsx index 50affbc..ce040bd 100644 --- a/src/frontend/src/components/common/TopNav.tsx +++ b/src/frontend/src/components/common/TopNav.tsx @@ -12,12 +12,14 @@ export default function TopNav({ onHome, userEmail, onUserEmailChange, + onLogout, }: { themeMode: "light" | "dark"; onThemeChange: (mode: "light" | "dark") => void; onHome: () => void; userEmail: string; onUserEmailChange: (nextUserEmail: string) => void | Promise; + onLogout: () => void; }) { const [isPopoverOpen, setIsPopoverOpen] = useState(false); @@ -50,6 +52,10 @@ export default function TopNav({ { + setIsPopoverOpen(false); + onLogout(); + }} /> } > diff --git a/src/frontend/src/components/modals/WelcomeOnboardingModal.tsx b/src/frontend/src/components/modals/WelcomeOnboardingModal.tsx index 2aa407f..e8e945d 100644 --- a/src/frontend/src/components/modals/WelcomeOnboardingModal.tsx +++ b/src/frontend/src/components/modals/WelcomeOnboardingModal.tsx @@ -1,5 +1,14 @@ import React, { useEffect, useMemo, useRef, useState } from "react"; -import { Button, Flex, Form, Input, Modal, Space, Typography } from "antd"; +import { + Alert, + Button, + Flex, + Form, + Input, + Modal, + Space, + Typography, +} from "antd"; import ThemeModeToggle from "../utils/ThemeModeToggle"; type ThemeMode = "light" | "dark"; @@ -23,6 +32,7 @@ export default function WelcomeOnboardingModal({ const [form] = Form.useForm<{ email: string }>(); const [lookupState, setLookupState] = useState("idle"); const [isSubmitting, setIsSubmitting] = useState(false); + const [hasSentEmail, setHasSentEmail] = useState(false); const lookupTimerRef = useRef(null); const lookupRequestIdRef = useRef(0); @@ -62,6 +72,7 @@ export default function WelcomeOnboardingModal({ form.resetFields(); setLookupState("idle"); setIsSubmitting(false); + setHasSentEmail(false); lookupRequestIdRef.current += 1; if (lookupTimerRef.current) { window.clearTimeout(lookupTimerRef.current); @@ -80,27 +91,22 @@ export default function WelcomeOnboardingModal({ ); const buttonLabel = useMemo(() => { + if (hasSentEmail) { + return "Email sent"; + } if (lookupState === "exists") { - return "Migrate account"; + return "Log In"; } if (lookupState === "checking") { return "Checking account..."; } if (lookupState === "new") { - return "Create new account"; + return "Register"; } return "Continue"; - }, [lookupState]); + }, [hasSentEmail, lookupState]); - const helperText = useMemo(() => { - if (lookupState === "exists") { - return "An account was found for this email. We'll send a migration confirmation link."; - } - if (lookupState === "new") { - return "No account found for this email. We'll create a new account."; - } - return ""; - }, [lookupState]); + const isLookupResolved = lookupState === "exists" || lookupState === "new"; return ( Welcome to Lunchtime - + - Enter your account email to continue. We'll automatically detect whether to create a new account or migrate an existing one. + Enter your account email to continue. We'll send you a confirmation + link to log in or create an account.
{ + if (hasSentEmail) { + return; + } + const currentEmail = String(values.email || ""); if (lookupTimerRef.current) { window.clearTimeout(lookupTimerRef.current); @@ -138,6 +153,10 @@ export default function WelcomeOnboardingModal({ }, 350); }} onFinish={async (values) => { + if (hasSentEmail) { + return; + } + const normalizedEmail = normalizeEmail(values.email); setIsSubmitting(true); try { @@ -152,31 +171,62 @@ export default function WelcomeOnboardingModal({ } else { await onCreateAccount(normalizedEmail); } + + setHasSentEmail(true); } finally { setIsSubmitting(false); } }} > - - - + + + + - + {hasSentEmail ? ( + + ) : null} + + {hasSentEmail ? ( + + ) : null} + +
diff --git a/src/frontend/src/lib/userIdentity.ts b/src/frontend/src/lib/userIdentity.ts index 002805b..fbd52f2 100644 --- a/src/frontend/src/lib/userIdentity.ts +++ b/src/frontend/src/lib/userIdentity.ts @@ -1,5 +1,5 @@ import { USER_TOKEN_KEY } from "./constants"; -import { getStoredValue, setStoredValue } from "./storage"; +import { getStoredValue, removeStoredValue, setStoredValue } from "./storage"; const emailByUserId = new Map(); @@ -72,3 +72,7 @@ export function updateUserEmail(nextUserEmail: string, userId: string) { export function regenerateUserId() { return createUserId(); } + +export function clearUserId() { + removeStoredValue(USER_TOKEN_KEY); +} diff --git a/src/frontend/src/views/AdminView.tsx b/src/frontend/src/views/AdminView.tsx index 65016bf..71c7144 100644 --- a/src/frontend/src/views/AdminView.tsx +++ b/src/frontend/src/views/AdminView.tsx @@ -7,8 +7,9 @@ import AsyncContent from "../components/utils/AsyncContent"; import ReadOnlyOrderOverviewCard from "../components/orders/ReadOnlyOrderOverviewCard"; import { useApiRequest } from "../hooks/useApiRequest"; import { apiService } from "../lib/services"; -import { navigateTo } from "../lib/routing"; +import { navigateTo, replaceRoute } from "../lib/routing"; import type { + ApiError, GetAdminViewResponse, Order, OrderFormConfig, @@ -25,6 +26,14 @@ type AdminViewData = Order & { config: OrderFormConfig; }; +function shouldRedirectToParticipant(error: unknown): boolean { + const apiError = error as Partial | null; + const status = apiError?.status; + const message = error instanceof Error ? error.message : ""; + + return status === 404 && message === "Admin order view not found"; +} + function getEstimatedTotalNumber(rawValue: unknown): number | null { if (typeof rawValue === "number" && Number.isFinite(rawValue)) { return rawValue; @@ -100,6 +109,11 @@ export default function AdminView({ orderId }: { orderId: string }) { setSelectedRowKeys([]); }, onError: (requestError) => { + if (shouldRedirectToParticipant(requestError)) { + replaceRoute(`/order/${orderId}`); + return; + } + message.error(requestError?.message || "Admin view could not be loaded."); }, });