diff --git a/.gitignore b/.gitignore index dc20b0f..3b6eac3 100755 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,8 @@ playwright-report/ test-results/ .env .superpowers/ +# Logs, pids and uploads written by scripts/start-local.ps1 +.local/ .scannerwork/ .nyc_output/ diff --git a/frontend/src/customer/Account.tsx b/frontend/src/customer/Account.tsx index 281c9f3..9156c7f 100755 --- a/frontend/src/customer/Account.tsx +++ b/frontend/src/customer/Account.tsx @@ -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([]); 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) { - - Order History - `$${(v / 100).toFixed(2)}` }, - { title: 'Processor', dataIndex: 'processor' }, - { title: 'Date', dataIndex: 'created_at', render: (v: string) => new Date(v).toLocaleDateString() } - ]} - /> - + {/* Order history is a page of its own now. The link stays here because + this is where a customer looks for it. */} + diff --git a/frontend/src/customer/Orders.tsx b/frontend/src/customer/Orders.tsx new file mode 100644 index 0000000..8322bbf --- /dev/null +++ b/frontend/src/customer/Orders.tsx @@ -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 = { + 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) => {v} + }, + { 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 ; + + // 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 ( + Retry} + /> + ); + } + + if (orders.length === 0) { + return ( + + + + + ); + } + + return ( +
+ ); +} + +export default function Orders() { + const { customer, loading: authLoading } = useCustomerAuth(); + const [orders, setOrders] = useState([]); + 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(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 ( + +
+ + + + Order History +
+ {/* 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. */} + + + +
+ ); +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 273675d..d760310 100755 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -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() { } /> } /> } /> + {/* 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. */} + } /> } /> } /> diff --git a/frontend/tests/e2e/account-modal.spec.ts b/frontend/tests/e2e/account-modal.spec.ts index f490bb5..0b277cb 100644 --- a/frontend/tests/e2e/account-modal.spec.ts +++ b/frontend/tests/e2e/account-modal.spec.ts @@ -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(/\/$/); diff --git a/frontend/tests/e2e/orders.spec.ts b/frontend/tests/e2e/orders.spec.ts new file mode 100644 index 0000000..81a3b4d --- /dev/null +++ b/frontend/tests/e2e/orders.spec.ts @@ -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 { + 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(); + }); +}); diff --git a/scripts/start-local.ps1 b/scripts/start-local.ps1 new file mode 100644 index 0000000..6aac5dd --- /dev/null +++ b/scripts/start-local.ps1 @@ -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 ''