Onboarding improvements
Build and Push Lunchtime Images (Kaniko) / build-and-push (push) Successful in 1m14s

This commit is contained in:
Simon Gruber
2026-03-29 16:00:46 +02:00
parent 94839443ae
commit 86ac1b2205
7 changed files with 198 additions and 44 deletions
+14
View File
@@ -22,6 +22,7 @@ import WelcomeOnboardingModal from "./components/modals/WelcomeOnboardingModal";
import { navigateTo, parseRoute, subscribeToRouteChange } from "./lib/routing"; import { navigateTo, parseRoute, subscribeToRouteChange } from "./lib/routing";
import { apiService } from "./lib/services"; import { apiService } from "./lib/services";
import { import {
clearUserId,
ensureUserId, ensureUserId,
hasStoredUserId, hasStoredUserId,
updateUserId, updateUserId,
@@ -163,6 +164,7 @@ function AppContent({
userId, userId,
userEmail, userEmail,
onUserEmailChange, onUserEmailChange,
onLogout,
}: { }: {
themeMode: ThemeMode; themeMode: ThemeMode;
setThemeMode: (mode: ThemeMode) => void; setThemeMode: (mode: ThemeMode) => void;
@@ -171,6 +173,7 @@ function AppContent({
userId: string; userId: string;
userEmail: string; userEmail: string;
onUserEmailChange: (nextUserEmail: string) => void | Promise<string>; onUserEmailChange: (nextUserEmail: string) => void | Promise<string>;
onLogout: () => void;
}) { }) {
const appBackground = const appBackground =
themeMode === "dark" themeMode === "dark"
@@ -200,6 +203,7 @@ function AppContent({
}} }}
userEmail={userEmail} userEmail={userEmail}
onUserEmailChange={onUserEmailChange} onUserEmailChange={onUserEmailChange}
onLogout={onLogout}
/> />
<MemoAnnouncements announcements={announcements} /> <MemoAnnouncements announcements={announcements} />
@@ -371,6 +375,15 @@ export default function App() {
return userEmail; return userEmail;
}; };
const handleLogout = () => {
clearUserId();
setUserId("");
setUserEmail("");
setIsOnboardingOpen(true);
navigateTo("/");
message.success("Logged out");
};
const completeCreateAccount = async (email: string) => { const completeCreateAccount = async (email: string) => {
try { try {
await apiService.account.register(email); await apiService.account.register(email);
@@ -406,6 +419,7 @@ export default function App() {
userId={userId} userId={userId}
userEmail={userEmail} userEmail={userEmail}
onUserEmailChange={handleUserEmailChange} onUserEmailChange={handleUserEmailChange}
onLogout={handleLogout}
/> />
<WelcomeOnboardingModal <WelcomeOnboardingModal
open={isOnboardingOpen} open={isOnboardingOpen}
@@ -1,5 +1,5 @@
import React, { useEffect, useMemo, useRef, useState } from "react"; 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"; import ThemeModeToggle from "./utils/ThemeModeToggle";
type ThemeMode = "light" | "dark"; type ThemeMode = "light" | "dark";
@@ -23,6 +23,7 @@ export default function WelcomeOnboardingModal({
const [form] = Form.useForm<{ email: string }>(); const [form] = Form.useForm<{ email: string }>();
const [lookupState, setLookupState] = useState<EmailLookupState>("idle"); const [lookupState, setLookupState] = useState<EmailLookupState>("idle");
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [hasSentEmail, setHasSentEmail] = useState(false);
const lookupTimerRef = useRef<number | null>(null); const lookupTimerRef = useRef<number | null>(null);
const lookupRequestIdRef = useRef(0); const lookupRequestIdRef = useRef(0);
@@ -62,6 +63,7 @@ export default function WelcomeOnboardingModal({
form.resetFields(); form.resetFields();
setLookupState("idle"); setLookupState("idle");
setIsSubmitting(false); setIsSubmitting(false);
setHasSentEmail(false);
lookupRequestIdRef.current += 1; lookupRequestIdRef.current += 1;
if (lookupTimerRef.current) { if (lookupTimerRef.current) {
window.clearTimeout(lookupTimerRef.current); window.clearTimeout(lookupTimerRef.current);
@@ -80,6 +82,9 @@ export default function WelcomeOnboardingModal({
); );
const buttonLabel = useMemo(() => { const buttonLabel = useMemo(() => {
if (hasSentEmail) {
return "Email sent";
}
if (lookupState === "exists") { if (lookupState === "exists") {
return "Migrate account"; return "Migrate account";
} }
@@ -90,14 +95,16 @@ export default function WelcomeOnboardingModal({
return "Create new account"; return "Create new account";
} }
return "Continue"; return "Continue";
}, [lookupState]); }, [hasSentEmail, lookupState]);
const isLookupResolved = lookupState === "exists" || lookupState === "new";
const helperText = useMemo(() => { const helperText = useMemo(() => {
if (lookupState === "exists") { 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") { if (lookupState === "new") {
return "No account found for this email. We'll create a new account."; return "Register using this email.";
} }
return ""; return "";
}, [lookupState]); }, [lookupState]);
@@ -127,6 +134,10 @@ export default function WelcomeOnboardingModal({
form={form} form={form}
layout="vertical" layout="vertical"
onValuesChange={(_changedValues, values) => { onValuesChange={(_changedValues, values) => {
if (hasSentEmail) {
return;
}
const currentEmail = String(values.email || ""); const currentEmail = String(values.email || "");
if (lookupTimerRef.current) { if (lookupTimerRef.current) {
window.clearTimeout(lookupTimerRef.current); window.clearTimeout(lookupTimerRef.current);
@@ -138,6 +149,10 @@ export default function WelcomeOnboardingModal({
}, 350); }, 350);
}} }}
onFinish={async (values) => { onFinish={async (values) => {
if (hasSentEmail) {
return;
}
const normalizedEmail = normalizeEmail(values.email); const normalizedEmail = normalizeEmail(values.email);
setIsSubmitting(true); setIsSubmitting(true);
try { try {
@@ -152,6 +167,8 @@ export default function WelcomeOnboardingModal({
} else { } else {
await onCreateAccount(normalizedEmail); await onCreateAccount(normalizedEmail);
} }
setHasSentEmail(true);
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
} }
@@ -166,14 +183,41 @@ export default function WelcomeOnboardingModal({
]} ]}
extra={helperText || undefined} extra={helperText || undefined}
> >
<Input placeholder="alex@example.com" autoFocus maxLength={320} /> <Input
placeholder="alex@example.com"
autoFocus
maxLength={320}
disabled={hasSentEmail}
/>
</Form.Item> </Form.Item>
{hasSentEmail ? (
<Alert
type="info"
showIcon
description="Email sent! Please check your inbox and spam folder for the confirmation email."
/>
) : null}
{hasSentEmail ? (
<Button
type="link"
onClick={() => {
setHasSentEmail(false);
setLookupState("idle");
}}
style={{ paddingInline: 0 }}
>
Change email
</Button>
) : null}
<Button <Button
type="primary" type="primary"
htmlType="submit" htmlType="submit"
block block
loading={isSubmitting || lookupState === "checking"} loading={!hasSentEmail && (isSubmitting || lookupState === "checking")}
disabled={hasSentEmail || !isLookupResolved}
> >
{buttonLabel} {buttonLabel}
</Button> </Button>
@@ -1,11 +1,12 @@
import React, { useEffect } from "react"; import React, { useEffect } from "react";
import { CheckOutlined } from "@ant-design/icons"; import { CheckOutlined, LogoutOutlined } from "@ant-design/icons";
import { import {
Alert, Alert,
Button, Button,
Flex, Flex,
Form, Form,
Input, Input,
Popconfirm,
Space, Space,
Typography, Typography,
message, message,
@@ -14,9 +15,11 @@ import {
export default function AccountSettingsPopoverContent({ export default function AccountSettingsPopoverContent({
userEmail, userEmail,
onUserEmailChange, onUserEmailChange,
onLogout,
}: { }: {
userEmail: string; userEmail: string;
onUserEmailChange: (nextUserEmail: string) => void | Promise<string>; onUserEmailChange: (nextUserEmail: string) => void | Promise<string>;
onLogout: () => void;
}) { }) {
const [form] = Form.useForm<{ email: string }>(); const [form] = Form.useForm<{ email: string }>();
const watchedEmail = Form.useWatch("email", form) || ""; const watchedEmail = Form.useWatch("email", form) || "";
@@ -98,6 +101,25 @@ export default function AccountSettingsPopoverContent({
Discard Discard
</Button> </Button>
</Flex> </Flex>
<Popconfirm
title="Log out?"
description="This clears your local user token on this device."
okText="Log out"
cancelText="Cancel"
okButtonProps={{ danger: true }}
onConfirm={onLogout}
>
<Button
size="large"
icon={<LogoutOutlined />}
danger
block
style={{ marginTop: 8 }}
>
Logout
</Button>
</Popconfirm>
</Form> </Form>
</Space> </Space>
); );
@@ -12,12 +12,14 @@ export default function TopNav({
onHome, onHome,
userEmail, userEmail,
onUserEmailChange, onUserEmailChange,
onLogout,
}: { }: {
themeMode: "light" | "dark"; themeMode: "light" | "dark";
onThemeChange: (mode: "light" | "dark") => void; onThemeChange: (mode: "light" | "dark") => void;
onHome: () => void; onHome: () => void;
userEmail: string; userEmail: string;
onUserEmailChange: (nextUserEmail: string) => void | Promise<string>; onUserEmailChange: (nextUserEmail: string) => void | Promise<string>;
onLogout: () => void;
}) { }) {
const [isPopoverOpen, setIsPopoverOpen] = useState(false); const [isPopoverOpen, setIsPopoverOpen] = useState(false);
@@ -50,6 +52,10 @@ export default function TopNav({
<AccountSettingsPopoverContent <AccountSettingsPopoverContent
userEmail={userEmail} userEmail={userEmail}
onUserEmailChange={onUserEmailChange} onUserEmailChange={onUserEmailChange}
onLogout={() => {
setIsPopoverOpen(false);
onLogout();
}}
/> />
} }
> >
@@ -1,5 +1,14 @@
import React, { useEffect, useMemo, useRef, useState } from "react"; 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"; import ThemeModeToggle from "../utils/ThemeModeToggle";
type ThemeMode = "light" | "dark"; type ThemeMode = "light" | "dark";
@@ -23,6 +32,7 @@ export default function WelcomeOnboardingModal({
const [form] = Form.useForm<{ email: string }>(); const [form] = Form.useForm<{ email: string }>();
const [lookupState, setLookupState] = useState<EmailLookupState>("idle"); const [lookupState, setLookupState] = useState<EmailLookupState>("idle");
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [hasSentEmail, setHasSentEmail] = useState(false);
const lookupTimerRef = useRef<number | null>(null); const lookupTimerRef = useRef<number | null>(null);
const lookupRequestIdRef = useRef(0); const lookupRequestIdRef = useRef(0);
@@ -62,6 +72,7 @@ export default function WelcomeOnboardingModal({
form.resetFields(); form.resetFields();
setLookupState("idle"); setLookupState("idle");
setIsSubmitting(false); setIsSubmitting(false);
setHasSentEmail(false);
lookupRequestIdRef.current += 1; lookupRequestIdRef.current += 1;
if (lookupTimerRef.current) { if (lookupTimerRef.current) {
window.clearTimeout(lookupTimerRef.current); window.clearTimeout(lookupTimerRef.current);
@@ -80,27 +91,22 @@ export default function WelcomeOnboardingModal({
); );
const buttonLabel = useMemo(() => { const buttonLabel = useMemo(() => {
if (hasSentEmail) {
return "Email sent";
}
if (lookupState === "exists") { if (lookupState === "exists") {
return "Migrate account"; return "Log In";
} }
if (lookupState === "checking") { if (lookupState === "checking") {
return "Checking account..."; return "Checking account...";
} }
if (lookupState === "new") { if (lookupState === "new") {
return "Create new account"; return "Register";
} }
return "Continue"; return "Continue";
}, [lookupState]); }, [hasSentEmail, lookupState]);
const helperText = useMemo(() => { const isLookupResolved = lookupState === "exists" || lookupState === "new";
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]);
return ( return (
<Modal <Modal
@@ -116,17 +122,26 @@ export default function WelcomeOnboardingModal({
<Typography.Title level={4} style={{ margin: 0 }}> <Typography.Title level={4} style={{ margin: 0 }}>
Welcome to Lunchtime Welcome to Lunchtime
</Typography.Title> </Typography.Title>
<ThemeModeToggle themeMode={themeMode} onThemeChange={onThemeChange} /> <ThemeModeToggle
themeMode={themeMode}
onThemeChange={onThemeChange}
/>
</Flex> </Flex>
<Typography.Paragraph style={{ marginBottom: 6 }}> <Typography.Paragraph style={{ marginBottom: 6 }}>
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.
</Typography.Paragraph> </Typography.Paragraph>
<Form <Form
form={form} form={form}
layout="vertical" layout="horizontal"
requiredMark={false}
onValuesChange={(_changedValues, values) => { onValuesChange={(_changedValues, values) => {
if (hasSentEmail) {
return;
}
const currentEmail = String(values.email || ""); const currentEmail = String(values.email || "");
if (lookupTimerRef.current) { if (lookupTimerRef.current) {
window.clearTimeout(lookupTimerRef.current); window.clearTimeout(lookupTimerRef.current);
@@ -138,6 +153,10 @@ export default function WelcomeOnboardingModal({
}, 350); }, 350);
}} }}
onFinish={async (values) => { onFinish={async (values) => {
if (hasSentEmail) {
return;
}
const normalizedEmail = normalizeEmail(values.email); const normalizedEmail = normalizeEmail(values.email);
setIsSubmitting(true); setIsSubmitting(true);
try { try {
@@ -152,31 +171,62 @@ export default function WelcomeOnboardingModal({
} else { } else {
await onCreateAccount(normalizedEmail); await onCreateAccount(normalizedEmail);
} }
setHasSentEmail(true);
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
} }
}} }}
> >
<Form.Item <Space direction="vertical" size={12} style={{ width: "100%" }}>
label="Email" <Form.Item
name="email" label="Email"
rules={[ name="email"
{ required: true, message: "Email cannot be empty." }, rules={[
{ type: "email", message: "Enter a valid email address." }, { required: true, message: "Email cannot be empty." },
]} { type: "email", message: "Enter a valid email address." },
extra={helperText || undefined} ]}
> >
<Input placeholder="alex@example.com" autoFocus maxLength={320} /> <Input
</Form.Item> placeholder="alex@example.com"
autoFocus
maxLength={320}
disabled={hasSentEmail}
/>
</Form.Item>
<Button {hasSentEmail ? (
type="primary" <Alert
htmlType="submit" type="info"
block showIcon
loading={isSubmitting || lookupState === "checking"} description="Email sent! Please check your inbox and spam folder for the confirmation email."
> />
{buttonLabel} ) : null}
</Button>
{hasSentEmail ? (
<Button
type="link"
onClick={() => {
setHasSentEmail(false);
setLookupState("idle");
}}
style={{ paddingInline: 0 }}
>
Change email
</Button>
) : null}
<Button
type="primary"
htmlType="submit"
block
loading={
!hasSentEmail && (isSubmitting || lookupState === "checking")
}
disabled={hasSentEmail || !isLookupResolved}
>
{buttonLabel}
</Button>
</Space>
</Form> </Form>
</Space> </Space>
</Modal> </Modal>
+5 -1
View File
@@ -1,5 +1,5 @@
import { USER_TOKEN_KEY } from "./constants"; import { USER_TOKEN_KEY } from "./constants";
import { getStoredValue, setStoredValue } from "./storage"; import { getStoredValue, removeStoredValue, setStoredValue } from "./storage";
const emailByUserId = new Map<string, string>(); const emailByUserId = new Map<string, string>();
@@ -72,3 +72,7 @@ export function updateUserEmail(nextUserEmail: string, userId: string) {
export function regenerateUserId() { export function regenerateUserId() {
return createUserId(); return createUserId();
} }
export function clearUserId() {
removeStoredValue(USER_TOKEN_KEY);
}
+15 -1
View File
@@ -7,8 +7,9 @@ import AsyncContent from "../components/utils/AsyncContent";
import ReadOnlyOrderOverviewCard from "../components/orders/ReadOnlyOrderOverviewCard"; import ReadOnlyOrderOverviewCard from "../components/orders/ReadOnlyOrderOverviewCard";
import { useApiRequest } from "../hooks/useApiRequest"; import { useApiRequest } from "../hooks/useApiRequest";
import { apiService } from "../lib/services"; import { apiService } from "../lib/services";
import { navigateTo } from "../lib/routing"; import { navigateTo, replaceRoute } from "../lib/routing";
import type { import type {
ApiError,
GetAdminViewResponse, GetAdminViewResponse,
Order, Order,
OrderFormConfig, OrderFormConfig,
@@ -25,6 +26,14 @@ type AdminViewData = Order & {
config: OrderFormConfig; config: OrderFormConfig;
}; };
function shouldRedirectToParticipant(error: unknown): boolean {
const apiError = error as Partial<ApiError> | 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 { function getEstimatedTotalNumber(rawValue: unknown): number | null {
if (typeof rawValue === "number" && Number.isFinite(rawValue)) { if (typeof rawValue === "number" && Number.isFinite(rawValue)) {
return rawValue; return rawValue;
@@ -100,6 +109,11 @@ export default function AdminView({ orderId }: { orderId: string }) {
setSelectedRowKeys([]); setSelectedRowKeys([]);
}, },
onError: (requestError) => { onError: (requestError) => {
if (shouldRedirectToParticipant(requestError)) {
replaceRoute(`/order/${orderId}`);
return;
}
message.error(requestError?.message || "Admin view could not be loaded."); message.error(requestError?.message || "Admin view could not be loaded.");
}, },
}); });