From 9bb3cc86b6c748cf9de52fd7b772f4a0f857738d Mon Sep 17 00:00:00 2001 From: Thom Lamb Date: Sat, 22 Aug 2026 10:53:40 -0500 Subject: [PATCH 1/2] feat(frontend): give order history a page of its own (#121) The account modal had accumulated: a profile line, a name form, two collapsed panels for changing email and password, two consent switches, an order table and four controls. The table was the piece that fitted worst, being the only tabular data in a 700px dialog whose body is capped at 70vh. The scroll={{ x: 'max-content' }} already on it was a workaround for being in the wrong container rather than a layout choice. It moves to /orders, an ordinary page in the same Routes block as /cart and /privacy, rather than another entry in MODAL_ROUTES. Order history is a list you read, like the cart, not a dialog you dismiss. A modal at /account/orders would have been the smaller change and was rejected: it inherits the same width and the same scroll cap, so it moves the table without giving it anything. The page shell follows Cart.tsx, which is the established shape here: a Layout with a Header carrying Back to Shop and the title, and the same guard sending a signed-out visitor to /login. The account modal keeps a View order history button where the table used to be, because that is where a customer looks for it. One thing changes rather than moves. The old effect caught a failed load with a toast and left orders as an empty array. The toast faded and the empty table did not, so from then on a customer whose request failed saw exactly what a customer with no orders saw, and the page asserted something false. Loading, failed and empty are now three distinct states, and the failed one carries a Retry: a transient failure would otherwise strand someone on a page that needs a full reload to recover. OrdersBody sits at module level rather than nested inside 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. The two assertions in account-modal.spec.ts that looked for the text "Order History" inside the modal are updated to look for the link, not deleted. They were the only coverage that the account view still offers any route to the orders, which is exactly what this change could have silently broken. Verification, against a real backend and database: five new tests covering the signed-out redirect, the empty state, Back to Shop, the link from My Account, and that the page renders as a page rather than a modal over the storefront - that last one is what would catch /orders being added to MODAL_ROUTES and quietly undoing the change. The full suite goes from 100 to 105 passing with no new failures; the three that fail did so before this branch and fail identically on main. tsc and the production build are clean, ESLint reports no errors. Closes #121 Co-Authored-By: Claude Opus 5 --- frontend/src/customer/Account.tsx | 33 +----- frontend/src/customer/Orders.tsx | 141 +++++++++++++++++++++++ frontend/src/main.tsx | 5 + frontend/tests/e2e/account-modal.spec.ts | 6 +- frontend/tests/e2e/orders.spec.ts | 71 ++++++++++++ 5 files changed, 227 insertions(+), 29 deletions(-) create mode 100644 frontend/src/customer/Orders.tsx create mode 100644 frontend/tests/e2e/orders.spec.ts 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(); + }); +}); From 40b483fc3057363e46764112eb92da46a9cd4345 Mon Sep 17 00:00:00 2001 From: Thom Lamb Date: Sat, 22 Aug 2026 11:07:06 -0500 Subject: [PATCH 2/2] feat(scripts): a PowerShell script to start the local environment (#125) Bringing the app up locally was seven or eight commands in a particular order: a Postgres container on a port that is not blocked, the six environment variables the backend refuses to boot without, migrations, a TypeScript build, the backend, then Vite. None of it hard, all of it tedious, and documented nowhere outside CI workflows written for a Linux runner. scripts/start-local.ps1 does the sequence. It reuses an existing container rather than recreating one, skips npm install when node_modules is already there, leaves alone anything already listening on a port it wanted, and waits on pg_isready and a 200 from /api/config rather than sleeping a fixed number of seconds. -Fresh recreates the database, -Stop tears everything down by the process ids it recorded rather than by port, since killing by port would also kill whatever else happened to be listening. Two things this got wrong first time round, both found by running it rather than by reading it. $ErrorActionPreference = 'Stop' does not stop a PowerShell script when a native executable exits non-zero, only when a cmdlet throws. Every command here is node, npm or docker, so the first run printed a stack trace from a failed migration, carried straight on, and reported a healthy stack sitting on a database with no tables in it. That is the worst kind of wrong: a green summary over a broken environment. Native calls now go through Invoke-Checked, which tests $LASTEXITCODE and throws. The migration failed because node on the PATH was v18.16.1. node-pg-migrate pulls in an lru-cache that calls diagnostics_channel.tracingChannel, which does not exist before Node 20, and the failure surfaces as "(0 , U.tracingChannel) is not a function" from a minified file - which says nothing whatsoever about Node versions. The script now checks the major version first and says what to do about it, so the confusing crash becomes one clear line before anything else runs. The port default is 55500 rather than anything near 55432, which is reserved by Hyper-V on this machine. Docker's message when it cannot bind a reserved port does not mention reservations, so the failure path names the likely cause and prints the netsh command that lists the reserved ranges. Verification, all observed rather than assumed: the version guard was made to fire on Node 18 and produced the intended message. A -Fresh run on Node 24 applied all six migrations, and psql then showed thirteen tables where the broken run had none. /api/config and the storefront both answer 200. -Stop stopped both tracked processes and the container. A second run with the dev server already up detected it and left it alone rather than failing. .local/ holds the logs, pids and uploads, and is gitignored. Closes #125 Co-Authored-By: Claude Opus 5 --- .gitignore | 2 + scripts/start-local.ps1 | 300 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 302 insertions(+) create mode 100644 scripts/start-local.ps1 diff --git a/.gitignore b/.gitignore index a7f8048..abf3d47 100755 --- a/.gitignore +++ b/.gitignore @@ -10,5 +10,7 @@ playwright-report/ test-results/ .env .superpowers/ +# Logs, pids and uploads written by scripts/start-local.ps1 +.local/ .scannerwork/ .nyc_output/ 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 ''