feat(account): open My Account as a modal over the page behind it (#51)

/account had no header and no links of any kind, so once a customer opened it the only way back to the storefront was the browser's back button or editing the URL.

It is now a modal rendered over whatever the customer was looking at, while staying a real route. Opening it from the header pushes /account and names the current page as the backdrop, so closing returns there with filters intact, and the browser's Back button does the same thing as the close control. Entering /account directly — a bookmark, the link in a verification email, or the redirect after registering — has no page behind it and falls back to the storefront, so closing always lands somewhere real. Keeping it a route means the URL still works: bookmarkable, shareable, and refreshable with the view still open, which the header link and the four post-authentication redirects already depend on.

Deleting an account now clears the session as well. Previously it removed the account server-side and navigated home without touching the auth context, so the header went on offering "My Account" for an account that no longer existed until the next reload. That was always wrong, but the modal makes it visible rather than merely stale, because the storefront is rendered behind and the wrong header is on screen throughout.

The modal body is capped and scrolls, and the order history table scrolls within itself, so the view survives a phone without pushing its own title and close control off-screen.

Also scopes the account switch locator in the favorites spec to the modal, since the storefront now renders behind the account view and has a theme switch of its own, and gives the post-registration wait a realistic timeout — it waits on a bcrypt round-trip rather than a render, and the 5s default was surfacing as a flake on whichever test lost the race under parallel load.

Closes #51
This commit is contained in:
2026-08-18 16:36:20 -05:00
parent 0f04cd25cd
commit 91485b6ac1
5 changed files with 235 additions and 26 deletions
+133
View File
@@ -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<string> {
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(/\/$/);
});
});
+6 -3
View File
@@ -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();