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 { 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<string>;
onLogout: () => void;
}) {
const appBackground =
themeMode === "dark"
@@ -200,6 +203,7 @@ function AppContent({
}}
userEmail={userEmail}
onUserEmailChange={onUserEmailChange}
onLogout={onLogout}
/>
<MemoAnnouncements announcements={announcements} />
@@ -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}
/>
<WelcomeOnboardingModal
open={isOnboardingOpen}
@@ -1,5 +1,5 @@
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 +23,7 @@ export default function WelcomeOnboardingModal({
const [form] = Form.useForm<{ email: string }>();
const [lookupState, setLookupState] = useState<EmailLookupState>("idle");
const [isSubmitting, setIsSubmitting] = useState(false);
const [hasSentEmail, setHasSentEmail] = useState(false);
const lookupTimerRef = useRef<number | null>(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}
>
<Input placeholder="alex@example.com" autoFocus maxLength={320} />
<Input
placeholder="alex@example.com"
autoFocus
maxLength={320}
disabled={hasSentEmail}
/>
</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
type="primary"
htmlType="submit"
block
loading={isSubmitting || lookupState === "checking"}
loading={!hasSentEmail && (isSubmitting || lookupState === "checking")}
disabled={hasSentEmail || !isLookupResolved}
>
{buttonLabel}
</Button>
@@ -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<string>;
onLogout: () => void;
}) {
const [form] = Form.useForm<{ email: string }>();
const watchedEmail = Form.useWatch("email", form) || "";
@@ -98,6 +101,25 @@ export default function AccountSettingsPopoverContent({
Discard
</Button>
</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>
</Space>
);
@@ -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<string>;
onLogout: () => void;
}) {
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
@@ -50,6 +52,10 @@ export default function TopNav({
<AccountSettingsPopoverContent
userEmail={userEmail}
onUserEmailChange={onUserEmailChange}
onLogout={() => {
setIsPopoverOpen(false);
onLogout();
}}
/>
}
>
@@ -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<EmailLookupState>("idle");
const [isSubmitting, setIsSubmitting] = useState(false);
const [hasSentEmail, setHasSentEmail] = useState(false);
const lookupTimerRef = useRef<number | null>(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 (
<Modal
@@ -116,17 +122,26 @@ export default function WelcomeOnboardingModal({
<Typography.Title level={4} style={{ margin: 0 }}>
Welcome to Lunchtime
</Typography.Title>
<ThemeModeToggle themeMode={themeMode} onThemeChange={onThemeChange} />
<ThemeModeToggle
themeMode={themeMode}
onThemeChange={onThemeChange}
/>
</Flex>
<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>
<Form
form={form}
layout="vertical"
layout="horizontal"
requiredMark={false}
onValuesChange={(_changedValues, values) => {
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);
}
}}
>
<Form.Item
label="Email"
name="email"
rules={[
{ 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} />
</Form.Item>
<Space direction="vertical" size={12} style={{ width: "100%" }}>
<Form.Item
label="Email"
name="email"
rules={[
{ required: true, message: "Email cannot be empty." },
{ type: "email", message: "Enter a valid email address." },
]}
>
<Input
placeholder="alex@example.com"
autoFocus
maxLength={320}
disabled={hasSentEmail}
/>
</Form.Item>
<Button
type="primary"
htmlType="submit"
block
loading={isSubmitting || lookupState === "checking"}
>
{buttonLabel}
</Button>
{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
type="primary"
htmlType="submit"
block
loading={
!hasSentEmail && (isSubmitting || lookupState === "checking")
}
disabled={hasSentEmail || !isLookupResolved}
>
{buttonLabel}
</Button>
</Space>
</Form>
</Space>
</Modal>
+5 -1
View File
@@ -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<string, string>();
@@ -72,3 +72,7 @@ export function updateUserEmail(nextUserEmail: string, userId: string) {
export function regenerateUserId() {
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 { 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<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 {
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.");
},
});