diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index c48b21e..f908111 100755
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1,7 +1,7 @@
import { useEffect, useState, useCallback, useMemo } from 'react';
import { Layout, Typography, Switch, Row, Col, Spin, Button, theme, Badge, Empty, Alert } from 'antd';
import { ShoppingCartOutlined, FilterOutlined } from '@ant-design/icons';
-import { Link, useSearchParams } from 'react-router-dom';
+import { Link, useLocation, useSearchParams } from 'react-router-dom';
import { Item, FilterOptions, fetchItems, fetchFilterOptions } from './api';
import ItemCard from './components/ItemCard';
import FilterDrawer from './components/FilterDrawer';
@@ -33,6 +33,7 @@ export default function App() {
const [drawerOpen, setDrawerOpen] = useState(false);
const [authModalOpen, setAuthModalOpen] = useState(false);
const [searchParams, setSearchParams] = useSearchParams();
+ const location = useLocation();
const { mode, toggle } = useThemeMode();
const { customer, loading: authLoading } = useCustomerAuth();
const { items: cartItems } = useCart();
@@ -127,8 +128,13 @@ export default function App() {
} />
+ {/* The account link names the page to render behind the modal, so
+ closing it comes back here — filters and all — rather than to a
+ default. */}
{customer ? (
-
+
+
+
) : (
<>
diff --git a/frontend/src/customer/Account.tsx b/frontend/src/customer/Account.tsx
index 3e5d68d..59f7d07 100755
--- a/frontend/src/customer/Account.tsx
+++ b/frontend/src/customer/Account.tsx
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
-import { Card, Typography, Switch, Button, Table, Modal, message, Space, Divider } from 'antd';
+import { Typography, Switch, Button, Table, Modal, message, Space, Divider } from 'antd';
import { useNavigate } from 'react-router-dom';
import { fetchMyOrders, OrderHistoryItem, updateConsent, exportMyData, deleteMyAccount } from './customerApi';
import { setFavoriteAlerts } from './favoritesApi';
@@ -7,7 +7,13 @@ import { useCustomerAuth } from './CustomerAuthContext';
const { Title, Text } = Typography;
-export default function Account() {
+interface Props {
+ // Supplied by the route, which decides where closing lands: back to the page
+ // the customer came from, or to the storefront when they arrived directly.
+ onClose: () => void;
+}
+
+export default function Account({ onClose }: Props) {
const { customer, loading, refresh, logout } = useCustomerAuth();
const [orders, setOrders] = useState([]);
const navigate = useNavigate();
@@ -59,16 +65,37 @@ export default function Account() {
okButtonProps: { danger: true },
onOk: async () => {
await deleteMyAccount();
+ // The storefront is rendered behind this modal, so without clearing the
+ // session it goes on showing "My Account" and hiding Sign up for an
+ // account that no longer exists — visibly stale, not merely stale in
+ // state. replace, so Back cannot return to /account and bounce to
+ // /login.
+ refresh();
message.success('Account deleted');
- navigate('/');
+ navigate('/', { replace: true });
}
});
}
return (
-
-
- My Account
+
+
{customer.email}
{!customer.email_verified && (
@@ -98,6 +125,9 @@ export default function Account() {
size="small"
dataSource={orders}
pagination={false}
+ // Scrolls within itself rather than widening the modal past the
+ // viewport on a phone.
+ scroll={{ x: 'max-content' }}
columns={[
{ title: 'Item', dataIndex: 'item_name' },
{ title: 'Amount', dataIndex: 'amount_cents', render: (v: number) => `$${(v / 100).toFixed(2)}` },
@@ -112,7 +142,7 @@ export default function Account() {
-
-
+
+
);
}
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
index 8baad82..8ebbc7f 100755
--- a/frontend/src/main.tsx
+++ b/frontend/src/main.tsx
@@ -1,6 +1,7 @@
import React, { useEffect, useState } from 'react';
import ReactDOM from 'react-dom/client';
-import { BrowserRouter, Routes, Route } from 'react-router-dom';
+import { BrowserRouter, Routes, Route, useLocation, useNavigate } from 'react-router-dom';
+import type { Location } from 'react-router-dom';
import { ConfigProvider, theme as antdTheme } from 'antd';
import 'antd/dist/reset.css';
import App from './App';
@@ -26,6 +27,11 @@ const DARK_ACCENT = '#f0f0f0';
const REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)';
+// What /account renders over when it was entered directly — a bookmark, a link
+// in an email, or a redirect after signing in. There is no page behind in that
+// case, and a modal floating on nothing has nowhere to close back to.
+const STOREFRONT_BACKDROP: Partial = { pathname: '/', search: '', hash: '' };
+
// Respects the OS-level "reduce motion" accessibility setting by turning off
// antd's transitions. Beyond the accessibility win, animated popups are a
// standing source of flake in end-to-end tests, which drive the app with this
@@ -45,6 +51,48 @@ function usePrefersReducedMotion(): boolean {
return prefers;
}
+// /account is a route that renders as a modal over whatever the customer was
+// looking at, rather than a page of its own. It stays a real, linkable URL —
+// bookmarkable, refreshable, and closed by the browser's Back button — while
+// never being a place with no way out of it.
+function AppRoutes() {
+ const location = useLocation();
+ const navigate = useNavigate();
+ const state = location.state as { background?: Location } | null;
+ const isAccount = location.pathname === '/account';
+
+ // In-app navigation names the page to render behind. Anything else — a
+ // bookmark, an email link, the redirect after registering — falls back to the
+ // storefront, so closing always lands somewhere real.
+ const background = state?.background;
+ const backdrop = isAccount ? background ?? { ...location, ...STOREFRONT_BACKDROP } : location;
+
+ function closeAccount() {
+ // Back, when there is somewhere to go back to, so closing the modal and
+ // pressing Back do the same thing and neither leaves a dead entry behind.
+ if (background) navigate(-1);
+ else navigate('/', { replace: true });
+ }
+
+ return (
+ <>
+
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+ {/* Rendered outside the Routes above, which are showing the backdrop. */}
+ {isAccount && }
+ >
+ );
+}
+
function Root() {
const { mode } = useThemeMode();
const prefersReducedMotion = usePrefersReducedMotion();
@@ -66,18 +114,7 @@ function Root() {
}}
>
-
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
-
+
);
diff --git a/frontend/tests/e2e/account-modal.spec.ts b/frontend/tests/e2e/account-modal.spec.ts
new file mode 100644
index 0000000..b3a096c
--- /dev/null
+++ b/frontend/tests/e2e/account-modal.spec.ts
@@ -0,0 +1,133 @@
+import { test, expect, Page } from '@playwright/test';
+
+const PASSWORD = 'supersecret123';
+
+const uniqueEmail = () => `account-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}@example.com`;
+
+// Registering lands on /account, which is now the modal over the storefront.
+// Returns the address so a test can assert the right account is shown.
+async function registerAndCloseAccount(page: Page): Promise {
+ const email = uniqueEmail();
+ await page.goto('/register');
+ await page.getByRole('textbox', { name: 'Email' }).fill(email);
+ await page.getByLabel('Password').fill(PASSWORD);
+ await page.getByRole('button', { name: 'Create account' }).click();
+ // Registration is a bcrypt round-trip, not a render. Unloaded it takes about
+ // half a second; with the suite's workers all registering at once it can pass
+ // Playwright's 5s default, which shows up as a failure on whichever test lost
+ // the race rather than as the load problem it is.
+ await expect(page).toHaveURL(/\/account/, { timeout: 20000 });
+ await closeAccount(page);
+ return email;
+}
+
+const accountModal = (page: Page) => page.getByRole('dialog', { name: 'My Account' });
+
+async function closeAccount(page: Page) {
+ await accountModal(page).getByRole('button', { name: 'Close' }).click();
+ await expect(accountModal(page)).toBeHidden();
+}
+
+test.describe('My Account opens as a modal', () => {
+ test('opens over the storefront and closes back to it, filters and all', async ({ page }) => {
+ await registerAndCloseAccount(page);
+
+ // A filtered view, to prove closing restores where the customer actually
+ // was rather than a bare storefront.
+ await page.goto('/?max_price=50000');
+ await expect(page.getByRole('button', { name: 'My Account' })).toBeVisible();
+
+ await page.getByRole('button', { name: 'My Account' }).click();
+ await expect(accountModal(page)).toBeVisible();
+ // Still a real, linkable URL rather than hidden view state.
+ await expect(page).toHaveURL(/\/account/);
+
+ await closeAccount(page);
+ await expect(page).toHaveURL(/max_price=50000/);
+ });
+
+ test('the browser back button closes it, the same as the close control', async ({ page }) => {
+ await registerAndCloseAccount(page);
+
+ await page.goto('/?max_price=50000');
+ await page.getByRole('button', { name: 'My Account' }).click();
+ await expect(accountModal(page)).toBeVisible();
+
+ await page.goBack();
+
+ await expect(accountModal(page)).toBeHidden();
+ await expect(page).toHaveURL(/max_price=50000/);
+ });
+
+ test('a direct visit renders the storefront behind it, so closing lands somewhere real', async ({ page }) => {
+ await registerAndCloseAccount(page);
+
+ // A bookmark, or the link in a verification email. There is no page behind
+ // in this case, which is what used to make /account a dead end.
+ await page.goto('/account');
+
+ await expect(accountModal(page)).toBeVisible();
+ await expect(page.getByRole('heading', { name: 'Redefined Designs' })).toBeVisible();
+
+ await closeAccount(page);
+ await expect(page).toHaveURL(/\/$/);
+ await expect(page.getByRole('button', { name: 'My Account' })).toBeVisible();
+ });
+
+ test('survives a reload, since it is a route rather than view state', async ({ page }) => {
+ const email = await registerAndCloseAccount(page);
+
+ await page.goto('/?max_price=50000');
+ await page.getByRole('button', { name: 'My Account' }).click();
+ await expect(accountModal(page)).toBeVisible();
+
+ await page.reload();
+
+ await expect(accountModal(page)).toBeVisible();
+ await expect(accountModal(page)).toContainText(email);
+ });
+
+ test('shows the signed-in account and its settings', async ({ page }) => {
+ const email = await registerAndCloseAccount(page);
+
+ await page.goto('/account');
+
+ const modal = accountModal(page);
+ await expect(modal).toContainText(email);
+ await expect(modal).toContainText('Order History');
+ // Scoped to the modal: the storefront behind it has a theme switch of its
+ // own, so an unscoped switch locator would be ambiguous.
+ await expect(modal.getByRole('switch')).toHaveCount(2);
+ });
+
+ test('deleting the account does not leave the page behind it looking signed in', async ({ page }) => {
+ await registerAndCloseAccount(page);
+ await page.goto('/account');
+
+ await accountModal(page).getByRole('button', { name: 'Delete my account' }).click();
+ await page.getByRole('dialog', { name: 'Delete your account?' })
+ .getByRole('button', { name: 'Delete my account' }).click();
+
+ // The storefront is rendered behind the modal, so a session left in place
+ // would visibly go on offering My Account for an account that is gone.
+ await expect(page.getByRole('button', { name: 'Sign up' })).toBeVisible();
+ await expect(page.getByRole('button', { name: 'My Account' })).toBeHidden();
+ await expect(page).toHaveURL(/\/$/);
+ });
+
+ test('stays usable on a phone, with the close control in reach', async ({ page }) => {
+ await registerAndCloseAccount(page);
+ await page.setViewportSize({ width: 390, height: 664 });
+ await page.goto('/account');
+
+ const modal = accountModal(page);
+ await expect(modal).toBeVisible();
+ // The view is taller than the viewport, so the body scrolls rather than
+ // pushing the title and close control off-screen.
+ await expect(modal.getByRole('button', { name: 'Close' })).toBeInViewport();
+ await expect(modal.getByText('Order History')).toBeVisible();
+
+ await closeAccount(page);
+ await expect(page).toHaveURL(/\/$/);
+ });
+});
diff --git a/frontend/tests/e2e/favorites.spec.ts b/frontend/tests/e2e/favorites.spec.ts
index 7108839..a680492 100644
--- a/frontend/tests/e2e/favorites.spec.ts
+++ b/frontend/tests/e2e/favorites.spec.ts
@@ -130,10 +130,13 @@ test.describe('Favoriting items', () => {
await page.request.put('/api/customers/me/favorite-alerts', { data: { enabled: true } });
await page.goto('/account');
- const toggle = page.getByText('Email me when an item I favorited is sold');
- await expect(toggle).toBeVisible();
+ // Scoped to the account modal. The storefront renders behind it and has a
+ // theme switch of its own, so an unscoped switch locator only picks the
+ // right control by DOM accident.
+ const account = page.getByRole('dialog', { name: 'My Account' });
+ await expect(account.getByText('Email me when an item I favorited is sold')).toBeVisible();
- await page.getByRole('switch').last().click();
+ await account.getByRole('switch').last().click();
await expect(page.getByText('Turned off')).toBeVisible();
const me = await (await page.request.get('/api/customers/me')).json();