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() { + + + ) : ( <> diff --git a/frontend/src/customer/Account.tsx b/frontend/src/customer/Account.tsx index 3e5d68d..2ca9bca 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(); @@ -66,9 +72,18 @@ export default function Account() { } return ( -
- - My Account + +
{customer.email} {!customer.email_verified && (
@@ -112,7 +127,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..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();