Merge branch 'main' into feature/123-consolidate-test-workflows
This commit is contained in:
@@ -10,6 +10,8 @@ playwright-report/
|
||||
test-results/
|
||||
.env
|
||||
.superpowers/
|
||||
# Logs, pids and uploads written by scripts/start-local.ps1
|
||||
.local/
|
||||
.scannerwork/
|
||||
.nyc_output/
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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(/\/$/);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 ''
|
||||
Reference in New Issue
Block a user