Merge branch 'main' into feature/123-consolidate-test-workflows
Linting / lint (pull_request) Successful in 1m49s
SonarQube Analysis / sonarqube (pull_request) Failing after 15m15s

This commit is contained in:
2026-08-22 11:12:52 -05:00
7 changed files with 529 additions and 29 deletions
+2
View File
@@ -10,6 +10,8 @@ playwright-report/
test-results/
.env
.superpowers/
# Logs, pids and uploads written by scripts/start-local.ps1
.local/
.scannerwork/
.nyc_output/
+6 -27
View File
@@ -1,19 +1,18 @@
import { useEffect, useState } from 'react';
import { useEffect } from 'react';
import Typography from 'antd/es/typography';
import Switch from 'antd/es/switch';
import Button from 'antd/es/button';
import Table from 'antd/es/table';
import Modal from 'antd/es/modal';
import message from 'antd/es/message';
import Space from 'antd/es/space';
import Divider from 'antd/es/divider';
import { useNavigate } from 'react-router-dom';
import { fetchMyOrders, OrderHistoryItem, updateConsent, exportMyData, deleteMyAccount } from './customerApi';
import { updateConsent, exportMyData, deleteMyAccount } from './customerApi';
import { setFavoriteAlerts } from './favoritesApi';
import { useCustomerAuth } from './CustomerAuthContext';
import AccountDetails from './AccountDetails';
const { Title, Text } = Typography;
const { Text } = Typography;
interface Props {
// Supplied by the route, which decides where closing lands: back to the page
@@ -23,13 +22,8 @@ interface Props {
export default function Account({ onClose }: Props) {
const { customer, loading, refresh, logout } = useCustomerAuth();
const [orders, setOrders] = useState<OrderHistoryItem[]>([]);
const navigate = useNavigate();
useEffect(() => {
if (customer) void fetchMyOrders().then(setOrders).catch(() => message.error('Could not load your orders'));
}, [customer]);
useEffect(() => {
if (!loading && !customer) navigate('/login');
}, [loading, customer, navigate]);
@@ -129,26 +123,11 @@ export default function Account({ onClose }: Props) {
</Space>
</div>
<Divider />
<Title level={5}>Order History</Title>
<Table
rowKey="id"
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)}` },
{ title: 'Processor', dataIndex: 'processor' },
{ title: 'Date', dataIndex: 'created_at', render: (v: string) => new Date(v).toLocaleDateString() }
]}
/>
<Divider />
<Space wrap>
{/* Order history is a page of its own now. The link stays here because
this is where a customer looks for it. */}
<Button onClick={() => navigate('/orders')}>View order history</Button>
<Button onClick={exportMyData}>Download my data</Button>
<Button onClick={handleLogout}>Log out</Button>
<Button danger onClick={handleDelete}>Delete my account</Button>
+141
View File
@@ -0,0 +1,141 @@
import { useCallback, useEffect, useState } from 'react';
import Layout from 'antd/es/layout';
import Typography from 'antd/es/typography';
import Table from 'antd/es/table';
import Button from 'antd/es/button';
import Empty from 'antd/es/empty';
import Alert from 'antd/es/alert';
import Spin from 'antd/es/spin';
import Space from 'antd/es/space';
import Tag from 'antd/es/tag';
import theme from 'antd/es/theme';
import { ArrowLeftOutlined } from '@ant-design/icons';
import { useNavigate, Link } from 'react-router-dom';
import { fetchMyOrders, OrderHistoryItem } from './customerApi';
import { useCustomerAuth } from './CustomerAuthContext';
const { Header, Content } = Layout;
const { Title } = Typography;
// Refunded is the one a customer needs to pick out of a column at a glance.
// Anything unrecognised falls through to a plain tag rather than disappearing.
const STATUS_COLORS: Record<string, string> = {
paid: 'green',
refunded: 'orange',
failed: 'red'
};
const COLUMNS = [
{ title: 'Item', dataIndex: 'item_name' },
{
title: 'Amount',
dataIndex: 'amount_cents',
align: 'right' as const,
render: (v: number) => `$${(v / 100).toFixed(2)}`
},
{
title: 'Status',
dataIndex: 'status',
render: (v: string) => <Tag color={STATUS_COLORS[v]}>{v}</Tag>
},
{ title: 'Processor', dataIndex: 'processor' },
{ title: 'Date', dataIndex: 'created_at', render: (v: string) => new Date(v).toLocaleDateString() }
];
type BodyProps = Readonly<{
loading: boolean;
error: string | null;
orders: OrderHistoryItem[];
onRetry: () => void;
}>;
// At module level rather than nested in Orders(). A function declared inside a
// component counts toward that component's cognitive complexity, which is what
// made Customers() hard to bring back under the threshold in #81.
function OrdersBody({ loading, error, orders, onRetry }: BodyProps) {
if (loading) return <Spin style={{ margin: 48 }} />;
// A retry rather than an alert alone: a transient failure would otherwise
// strand the customer on a page that needs a full reload to recover.
if (error) {
return (
<Alert
type="error"
showIcon
message="Could not load your orders"
description={error}
action={<Button onClick={onRetry}>Retry</Button>}
/>
);
}
if (orders.length === 0) {
return (
<Space direction="vertical" align="center" style={{ width: '100%', marginTop: 24 }}>
<Empty description="No orders yet" />
<Link to="/"><Button type="primary">Continue Shopping</Button></Link>
</Space>
);
}
return (
<Table
rowKey="id"
dataSource={orders}
pagination={false}
// Still correct on a phone. It is no longer compensating for being in a
// modal narrower than its own content.
scroll={{ x: 'max-content' }}
columns={COLUMNS}
/>
);
}
export default function Orders() {
const { customer, loading: authLoading } = useCustomerAuth();
const [orders, setOrders] = useState<OrderHistoryItem[]>([]);
const [loading, setLoading] = useState(true);
// Held separately from an empty list, because the two used to be
// indistinguishable: a failed load left an empty table behind a toast that
// faded, so the page went on telling the customer they had never ordered
// anything.
const [error, setError] = useState<string | null>(null);
const navigate = useNavigate();
const { token } = theme.useToken();
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
setOrders(await fetchMyOrders());
} catch (err) {
setError((err as Error).message);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (customer) void load();
}, [customer, load]);
useEffect(() => {
if (!authLoading && !customer) navigate('/login');
}, [authLoading, customer, navigate]);
return (
<Layout style={{ minHeight: '100vh' }}>
<Header style={{ background: token.colorBgContainer, borderBottom: `1px solid ${token.colorBorderSecondary}`, display: 'flex', alignItems: 'center', gap: 16 }}>
<Link to="/">
<Button icon={<ArrowLeftOutlined />}>Back to Shop</Button>
</Link>
<Title level={3} style={{ color: token.colorText, margin: 0 }}>Order History</Title>
</Header>
{/* 960 rather than the account modal's 700: four columns of which one is a
free-text item name, with room to add a fifth without another rethink. */}
<Content style={{ padding: 24, maxWidth: 960, margin: '0 auto', width: '100%' }}>
<OrdersBody loading={authLoading || loading} error={error} orders={orders} onRetry={load} />
</Content>
</Layout>
);
}
+5
View File
@@ -19,6 +19,7 @@ import VerifyEmail from './customer/VerifyEmail';
import ForgotPassword from './customer/ForgotPassword';
import ResetPassword from './customer/ResetPassword';
import Cart from './cart/Cart';
import Orders from './customer/Orders';
import { CustomerAuthProvider } from './customer/CustomerAuthContext';
import { CartProvider } from './cart/CartContext';
import { FavoritesProvider } from './customer/FavoritesContext';
@@ -99,6 +100,10 @@ function AppRoutes() {
<Route path="/" element={<App />} />
<Route path="/admin" element={<Admin />} />
<Route path="/cart" element={<Cart />} />
{/* A page rather than a modal route, deliberately: order history is a
list you read, like the cart, not a dialog you dismiss. Adding it to
MODAL_ROUTES would put it back in the 700px box it just left. */}
<Route path="/orders" element={<Orders />} />
<Route path="/privacy" element={<PrivacyPolicy />} />
<Route path="/verify-email" element={<VerifyEmail />} />
</Routes>
+4 -2
View File
@@ -95,7 +95,9 @@ test.describe('My Account opens as a modal', () => {
const modal = accountModal(page);
await expect(modal).toContainText(email);
await expect(modal).toContainText('Order History');
// The orders table lives at /orders now. What the account view still owes
// the customer is a way to reach it.
await expect(modal.getByRole('button', { name: 'View order history' })).toBeVisible();
// 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);
@@ -126,7 +128,7 @@ test.describe('My Account opens as a modal', () => {
// 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 expect(modal.getByRole('button', { name: 'View order history' })).toBeVisible();
await closeAccount(page);
await expect(page).toHaveURL(/\/$/);
+71
View File
@@ -0,0 +1,71 @@
import { test, expect, Page } from './fixtures';
const PASSWORD = 'supersecret123';
const uniqueEmail = () => `orders-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}@example.com`;
// The generous wait is the same one the other account specs use: registration is
// a bcrypt round-trip rather than a render, and runs past Playwright's 5s
// default when the suite's workers all register at once.
async function registerCustomer(page: Page): Promise<string> {
const email = uniqueEmail();
await page.goto('/register');
await page.getByRole('textbox', { name: 'Email' }).fill(email);
await page.getByRole('textbox', { name: 'First name' }).fill('Test');
await page.getByRole('textbox', { name: 'Last name' }).fill('Customer');
await page.getByLabel('Password').fill(PASSWORD);
await page.getByRole('button', { name: 'Create account' }).click();
await expect(page.getByRole('button', { name: 'My Account' })).toBeVisible({ timeout: 20000 });
return email;
}
test.describe('Order history has a page of its own', () => {
test('a signed-out visitor is sent to sign in', async ({ page }) => {
await page.goto('/orders');
await expect(page).toHaveURL(/\/login/, { timeout: 20000 });
});
// A page, not a modal: no dialog, and the storefront is not rendered behind
// it. Putting /orders in MODAL_ROUTES would quietly undo the whole change,
// and this is what would catch it.
test('renders as a page rather than a modal over the storefront', async ({ page }) => {
await registerCustomer(page);
await page.goto('/orders');
await expect(page.getByRole('heading', { name: 'Order History' })).toBeVisible();
await expect(page.getByRole('dialog')).toHaveCount(0);
await expect(page.getByRole('heading', { name: 'Redefined Designs' })).toBeHidden();
});
test('says so when there are no orders, rather than showing an empty table', async ({ page }) => {
await registerCustomer(page);
await page.goto('/orders');
await expect(page.getByText('No orders yet')).toBeVisible();
await expect(page.getByRole('button', { name: 'Continue Shopping' })).toBeVisible();
});
test('Back to Shop returns to the storefront', async ({ page }) => {
await registerCustomer(page);
await page.goto('/orders');
await page.getByRole('button', { name: 'Back to Shop' }).click();
await expect(page).toHaveURL(/\/$/);
await expect(page.getByRole('heading', { name: 'Redefined Designs' })).toBeVisible();
});
// The account view is where a customer looks for their orders, so the route
// out of it is the part that has to keep working now the table has gone.
test('My Account links to it', async ({ page }) => {
await registerCustomer(page);
await page.goto('/account');
await page.getByRole('dialog', { name: 'My Account' })
.getByRole('button', { name: 'View order history' }).click();
await expect(page).toHaveURL(/\/orders/);
await expect(page.getByRole('heading', { name: 'Order History' })).toBeVisible();
});
});
+300
View File
@@ -0,0 +1,300 @@
#Requires -Version 7
<#
.SYNOPSIS
Starts the Redefined Designs stack locally for testing and review.
.DESCRIPTION
Brings up a Postgres container, runs migrations, builds and starts the
backend, and starts the Vite dev server. Safe to run repeatedly: an
existing container is reused rather than recreated, and processes already
listening are left alone.
Logs and process ids go to .local/ at the repository root.
.PARAMETER Fresh
Drop the database container and its data before starting, so migrations
run against an empty database.
.PARAMETER Stop
Stop the backend, the dev server and the database container, then exit.
.PARAMETER DbPort
Host port for Postgres. Change it if something already holds the default.
.EXAMPLE
.\scripts\start-local.ps1
.\scripts\start-local.ps1 -Fresh
.\scripts\start-local.ps1 -Stop
#>
[CmdletBinding()]
param(
# Deliberately not 55432. That port is reserved by Hyper-V on at least one
# machine here, and Docker's failure when it cannot bind does not mention
# reservations, which has cost time more than once.
[int]$DbPort = 55500,
[int]$ApiPort = 3000,
[int]$WebPort = 5173,
[switch]$Fresh,
[switch]$Stop
)
$ErrorActionPreference = 'Stop'
$RepoRoot = Split-Path -Parent $PSScriptRoot
$StateDir = Join-Path $RepoRoot '.local'
$PidFile = Join-Path $StateDir 'pids.json'
$Container = 'redefined-designs-local-db'
$DbUser = 'redefined_local'
$DbPassword = 'redefined_local'
$DbName = 'redefined_local'
function Write-Step { param([string]$Message) Write-Host "==> $Message" -ForegroundColor Cyan }
function Write-Note { param([string]$Message) Write-Host " $Message" -ForegroundColor DarkGray }
function Write-Good { param([string]$Message) Write-Host " $Message" -ForegroundColor Green }
# $ErrorActionPreference = 'Stop' does NOT stop the script when a native
# executable exits non-zero, only when a cmdlet throws. Everything here is
# node, npm or docker, so without this wrapper a failed migration is a line of
# red text the script prints and then carries straight past. It did exactly
# that once, and reported a healthy stack sitting on an empty database.
function Invoke-Checked {
param([scriptblock]$Command, [string]$What)
& $Command
if ($LASTEXITCODE -ne 0) {
throw "$What failed (exit code $LASTEXITCODE)."
}
}
function Assert-Docker {
docker info *>$null
if ($LASTEXITCODE -ne 0) {
throw "Docker is not running. Start Docker Desktop and try again."
}
}
# node-pg-migrate pulls in an lru-cache that calls
# diagnostics_channel.tracingChannel, which does not exist before Node 20. On
# Node 18 the migration dies in minified library code with "(0 , U.tracingChannel)
# is not a function", which says nothing about versions. CI runs Node 20.
function Assert-NodeVersion {
$raw = (node --version)
$major = [int](($raw -replace '^v', '') -split '\.')[0]
if ($major -lt 20) {
throw @"
Node $raw is too old. This needs Node 20 or newer.
If you use nvm-windows:
nvm use 24.13.1
Then run this script again in a new shell.
"@
}
Write-Note "node $raw"
}
# Reads back only the ids this script wrote. Killing by port would be shorter
# and would also kill whatever else happened to be listening.
function Get-TrackedProcesses {
if (-not (Test-Path $PidFile)) { return @{} }
try { return (Get-Content $PidFile -Raw | ConvertFrom-Json -AsHashtable) }
catch { return @{} }
}
function Stop-Tracked {
param([string]$Name)
$tracked = Get-TrackedProcesses
if (-not $tracked.ContainsKey($Name)) { return }
$process = Get-Process -Id $tracked[$Name] -ErrorAction SilentlyContinue
if ($process) {
Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue
Write-Good "stopped $Name (pid $($process.Id))"
}
}
function Set-Tracked {
param([string]$Name, [int]$ProcessId)
$tracked = Get-TrackedProcesses
$tracked[$Name] = $ProcessId
$tracked | ConvertTo-Json | Set-Content $PidFile
}
function Test-Listening {
param([int]$Port)
return [bool](Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue)
}
function Wait-For {
param(
[scriptblock]$Condition,
[string]$What,
[int]$TimeoutSeconds = 60
)
for ($i = 1; $i -le $TimeoutSeconds; $i++) {
if (& $Condition) {
Write-Good "$What ready after ${i}s"
return
}
Start-Sleep -Seconds 1
}
throw "$What did not become ready within ${TimeoutSeconds}s."
}
function Stop-Environment {
Write-Step 'Stopping'
Stop-Tracked 'backend'
Stop-Tracked 'frontend'
docker stop $Container *>$null
if ($LASTEXITCODE -eq 0) { Write-Good "stopped container $Container" }
else { Write-Note "container $Container was not running" }
Remove-Item $PidFile -ErrorAction SilentlyContinue
Write-Host ''
Write-Host 'Stopped.' -ForegroundColor Green
}
function Start-Database {
if ($Fresh) {
Write-Step 'Removing the existing database (-Fresh)'
docker rm -f $Container *>$null
Write-Good 'removed'
}
$existing = (docker ps -a --filter "name=^/$Container$" --format '{{.Names}}')
if ($existing -eq $Container) {
Write-Step "Reusing the database container"
docker start $Container *>$null
}
else {
Write-Step "Creating the database container on port $DbPort"
docker run -d --name $Container `
-e "POSTGRES_USER=$DbUser" `
-e "POSTGRES_PASSWORD=$DbPassword" `
-e "POSTGRES_DB=$DbName" `
-p "${DbPort}:5432" `
postgres:16 *>$null
if ($LASTEXITCODE -ne 0) {
# The message Docker gives for a reserved port does not say
# "reserved", so name the likely cause and the way out of it.
throw @"
Could not start Postgres on port $DbPort.
If the port is in use, or reserved by Hyper-V (which silently claims ranges on
Windows), pick another one:
.\scripts\start-local.ps1 -DbPort 55600
Reserved ranges: netsh interface ipv4 show excludedportrange protocol=tcp
"@
}
}
Wait-For -What 'Postgres' -Condition {
docker exec $Container pg_isready -U $DbUser -d $DbName *>$null
$LASTEXITCODE -eq 0
}
}
function Install-IfMissing {
param([string]$Directory)
$name = Split-Path -Leaf $Directory
if (Test-Path (Join-Path $Directory 'node_modules')) {
Write-Note "$name dependencies already installed"
return
}
Write-Step "Installing $name dependencies"
Push-Location $Directory
try { Invoke-Checked { npm install } "$name npm install" } finally { Pop-Location }
}
function Start-Backend {
$backend = Join-Path $RepoRoot 'backend'
# The six the backend refuses to boot without, plus the two that make a
# local run behave. Set in this session so the child process inherits them.
$env:PGHOST = 'localhost'
$env:PGPORT = "$DbPort"
$env:PGUSER = $DbUser
$env:PGPASSWORD = $DbPassword
$env:PGDATABASE = $DbName
$env:UPLOADS_DIR = (Join-Path $StateDir 'uploads')
$env:PORT = "$ApiPort"
# No PayPal credentials locally. DEMO_MODE lets the whole cart and checkout
# path run without them and with no way to reach live PayPal.
$env:DEMO_MODE = 'true'
New-Item -ItemType Directory -Force -Path $env:UPLOADS_DIR *>$null
Write-Step 'Running migrations'
Push-Location $backend
try {
Invoke-Checked { node migrate.js up } 'Migrations'
Write-Step 'Building the backend'
Invoke-Checked { npm run build } 'Backend build'
}
finally { Pop-Location }
if (Test-Listening -Port $ApiPort) {
Write-Note "something is already listening on $ApiPort; leaving it alone"
return
}
Write-Step "Starting the backend on $ApiPort"
# Separate files: Start-Process cannot redirect both streams to one path.
$process = Start-Process -FilePath 'node' -ArgumentList 'dist/server.js' `
-WorkingDirectory $backend `
-RedirectStandardOutput (Join-Path $StateDir 'backend.log') `
-RedirectStandardError (Join-Path $StateDir 'backend.err.log') `
-WindowStyle Hidden -PassThru
Set-Tracked 'backend' $process.Id
Wait-For -What 'Backend' -Condition {
try {
$null = Invoke-WebRequest "http://localhost:$ApiPort/api/config" -TimeoutSec 2 -UseBasicParsing
$true
}
catch { $false }
}
}
function Start-Frontend {
if (Test-Listening -Port $WebPort) {
Write-Note "something is already listening on $WebPort; leaving it alone"
return
}
Write-Step "Starting the dev server on $WebPort"
$process = Start-Process -FilePath 'npm.cmd' -ArgumentList 'run', 'dev' `
-WorkingDirectory (Join-Path $RepoRoot 'frontend') `
-RedirectStandardOutput (Join-Path $StateDir 'frontend.log') `
-RedirectStandardError (Join-Path $StateDir 'frontend.err.log') `
-WindowStyle Hidden -PassThru
Set-Tracked 'frontend' $process.Id
Wait-For -What 'Dev server' -Condition { Test-Listening -Port $WebPort }
}
New-Item -ItemType Directory -Force -Path $StateDir *>$null
if ($Stop) {
Stop-Environment
return
}
Assert-NodeVersion
Assert-Docker
Start-Database
Install-IfMissing (Join-Path $RepoRoot 'backend')
Install-IfMissing (Join-Path $RepoRoot 'frontend')
Start-Backend
Start-Frontend
Write-Host ''
Write-Host 'Running.' -ForegroundColor Green
Write-Host " Storefront http://localhost:$WebPort"
Write-Host " Admin http://localhost:$WebPort/admin"
Write-Host " API http://localhost:$ApiPort/api/config"
Write-Host " Postgres localhost:$DbPort ($DbUser / $DbPassword / $DbName)"
Write-Host ''
Write-Host " Logs $StateDir"
Write-Host " Stop .\scripts\start-local.ps1 -Stop"
Write-Host ''