Onboarding improvements
Build and Push Lunchtime Images (Kaniko) / build-and-push (push) Successful in 1m14s
Build and Push Lunchtime Images (Kaniko) / build-and-push (push) Successful in 1m14s
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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.");
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user