+
);
}
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..2a2f7b6
--- /dev/null
+++ b/frontend/tests/e2e/account-modal.spec.ts
@@ -0,0 +1,98 @@
+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();
+ await expect(page).toHaveURL(/\/account/);
+ 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);
+ });
+});
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();