feat(pos-web): add web baseline

Why: establish baseline web runtime scaffold before wave implementation.

What: add react app structure, docker runtime assets, docs runbooks, and ignore policy updates.

Rule: keep technical intent and repository workflow compliance.
This commit is contained in:
José René White Enciso 2026-03-08 15:53:49 -06:00
parent d4c0073872
commit f48bcb1a4d
28 changed files with 4664 additions and 0 deletions

8
.dockerignore Normal file
View File

@ -0,0 +1,8 @@
node_modules/
dist/
.vite/
.repo-tasks/
.repo-context/
.tasks/
.agile/
.git/

11
.gitignore vendored
View File

@ -2,3 +2,14 @@
.repo-context/
.tasks/
.agile/
node_modules/
dist/
.vite/
*.local
.env
.env.*
!.env.example
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*

17
Dockerfile Normal file
View File

@ -0,0 +1,17 @@
# syntax=docker/dockerfile:1.7
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install
COPY . .
ARG VITE_API_BASE_URL=http://localhost:8080
ENV VITE_API_BASE_URL=${VITE_API_BASE_URL}
RUN npm run build
FROM nginx:1.27-alpine AS runtime
WORKDIR /usr/share/nginx/html
COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
COPY docker/40-runtime-config.sh /docker-entrypoint.d/40-runtime-config.sh
RUN chmod +x /docker-entrypoint.d/40-runtime-config.sh
COPY --from=build /app/dist ./
EXPOSE 8080

View File

@ -0,0 +1,8 @@
#!/bin/sh
set -eu
cat > /usr/share/nginx/html/runtime-config.js <<EOT
window.__APP_CONFIG__ = {
API_BASE_URL: "${API_BASE_URL:-http://localhost:8080}"
};
EOT

9
docker/nginx.conf Normal file
View File

@ -0,0 +1,9 @@
server {
listen 8080;
server_name _;
root /usr/share/nginx/html;
location / {
try_files $uri $uri/ /index.html;
}
}

View File

@ -0,0 +1,6 @@
# Frontend Boundary
- This repository hosts a React edge application for a single BFF.
- Frontend data access flows through `src/api/*` adapter modules.
- The UI does not access DAL or internal services directly.
- API base URL is configured with `VITE_API_BASE_URL`.

View File

@ -0,0 +1,27 @@
# Containerization Runbook
## Image Build
```bash
docker build -t agilewebs/pos-transactions-web:dev .
```
## Local Run
```bash
docker run --rm -p 8080:8080 \
-e API_BASE_URL=http://host.docker.internal:8080 \
--name pos-transactions-web agilewebs/pos-transactions-web:dev
```
## Runtime Configuration Strategy
- Build-time fallback: `VITE_API_BASE_URL`
- Runtime override: container env `API_BASE_URL`
- Runtime file generated at startup: `/runtime-config.js`
## Health Check
```bash
curl -f http://localhost:8080/
```

View File

@ -0,0 +1,27 @@
# Local Development
## Install
```bash
npm install
```
## Run
```bash
VITE_API_BASE_URL=http://localhost:8080 npm run dev
```
## Build
```bash
npm run build
```
## Test
```bash
npm run test:ci
```
See also: `docs/runbooks/testing.md`

23
docs/runbooks/testing.md Normal file
View File

@ -0,0 +1,23 @@
# Testing Runbook
## Install
```bash
npm install
```
## Execute Tests
```bash
npm run test:ci
```
## Coverage Scope
- `src/api/client.test.ts`: runtime-config precedence and fallback behavior
- `src/api/dashboardApi.test.ts`: endpoint path/query contract generation
- `src/App.test.tsx`: render baseline and mocked load flow
## Notes
- Use containerized Node execution when host `npm` is unavailable.

13
index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>POS Transactions Web</title>
</head>
<body>
<div id="root"></div>
<script src="/runtime-config.js"></script>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

4224
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

28
package.json Normal file
View File

@ -0,0 +1,28 @@
{
"name": "pos-transactions-web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview --host 0.0.0.0 --port 4173",
"test": "vitest",
"test:ci": "vitest run --coverage=false"
},
"dependencies": {
"react": "^19.1.1",
"react-dom": "^19.1.1"
},
"devDependencies": {
"@types/react": "^19.1.10",
"@types/react-dom": "^19.1.7",
"@vitejs/plugin-react": "^5.1.0",
"typescript": "^5.9.2",
"vite": "^7.1.6",
"vitest": "^2.1.8",
"jsdom": "^25.0.1",
"@testing-library/react": "^16.1.0",
"@testing-library/jest-dom": "^6.6.3"
}
}

3
public/runtime-config.js Normal file
View File

@ -0,0 +1,3 @@
window.__APP_CONFIG__ = {
API_BASE_URL: 'http://localhost:8080'
};

31
src/App.test.tsx Normal file
View File

@ -0,0 +1,31 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
vi.mock('./api/dashboardApi', () => ({
loadDashboard: vi.fn()
}));
import { loadDashboard } from './api/dashboardApi';
import App from './App';
describe('App', () => {
it('renders baseline page', () => {
render(<App />);
expect(screen.getByRole('heading', { name: 'POS Transactions Web' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Load' })).toBeInTheDocument();
});
it('loads dashboard data when user clicks load', async () => {
vi.mocked(loadDashboard).mockResolvedValue({ summary: 'ok' });
render(<App />);
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'ctx stage28' } });
fireEvent.click(screen.getByRole('button', { name: 'Load' }));
await waitFor(() => {
expect(loadDashboard).toHaveBeenCalledWith('ctx stage28');
expect(screen.getByText(/summary/)).toBeInTheDocument();
});
});
});

41
src/App.tsx Normal file
View File

@ -0,0 +1,41 @@
import { useState } from 'react';
import { loadDashboard } from './api/dashboardApi';
function App() {
const [contextId, setContextId] = useState('demo-context');
const [payload, setPayload] = useState<unknown>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const onLoad = async (): Promise<void> => {
setLoading(true);
setError(null);
try {
const response = await loadDashboard(contextId);
setPayload(response);
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown request error';
setError(message);
setPayload(null);
} finally {
setLoading(false);
}
};
return (
<main className="app">
<h1>POS Transactions Web</h1>
<p>React baseline wired to its corresponding BFF via an API adapter module.</p>
<div className="row">
<input value={contextId} onChange={(event) => setContextId(event.target.value)} />
<button type="button" onClick={onLoad} disabled={loading}>
{loading ? 'Loading...' : 'Load'}
</button>
</div>
{error && <p>{error}</p>}
<pre>{JSON.stringify(payload, null, 2)}</pre>
</main>
);
}
export default App;

26
src/api/client.test.ts Normal file
View File

@ -0,0 +1,26 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { getApiBaseUrl } from './client';
describe('getApiBaseUrl', () => {
afterEach(() => {
delete window.__APP_CONFIG__;
vi.unstubAllEnvs();
});
it('uses runtime-config API base URL when present', () => {
window.__APP_CONFIG__ = { API_BASE_URL: 'http://runtime.example' };
vi.stubEnv('VITE_API_BASE_URL', 'http://env.example');
expect(getApiBaseUrl()).toBe('http://runtime.example');
});
it('falls back to VITE_API_BASE_URL when runtime-config is missing', () => {
vi.stubEnv('VITE_API_BASE_URL', 'http://env.example');
expect(getApiBaseUrl()).toBe('http://env.example');
});
it('falls back to localhost default when both runtime and env are missing', () => {
expect(getApiBaseUrl()).toBe('http://localhost:8080');
});
});

25
src/api/client.ts Normal file
View File

@ -0,0 +1,25 @@
declare global {
interface Window {
__APP_CONFIG__?: {
API_BASE_URL?: string;
};
}
}
export function getApiBaseUrl(): string {
const runtimeValue = window.__APP_CONFIG__?.API_BASE_URL;
if (runtimeValue && runtimeValue.length > 0) {
return runtimeValue;
}
return import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:8080';
}
export async function getJson<T>(path: string): Promise<T> {
const response = await fetch(`${getApiBaseUrl()}${path}`);
if (!response.ok) {
throw new Error(`GET ${path} failed with status ${response.status}`);
}
return (await response.json()) as T;
}

View File

@ -0,0 +1,18 @@
import { describe, expect, it, vi } from 'vitest';
vi.mock('./client', () => ({
getJson: vi.fn()
}));
import { getJson } from './client';
import { loadDashboard } from './dashboardApi';
describe('loadDashboard', () => {
it('builds encoded endpoint path and delegates to getJson', async () => {
vi.mocked(getJson).mockResolvedValue({ ok: true });
await loadDashboard('demo context/1');
expect(getJson).toHaveBeenCalledWith('/api/pos/transactions/summary?contextId=demo%20context%2F1');
});
});

5
src/api/dashboardApi.ts Normal file
View File

@ -0,0 +1,5 @@
import { getJson } from './client';
export async function loadDashboard(contextId: string): Promise<unknown> {
return getJson(`/api/pos/transactions/summary?contextId=${encodeURIComponent(contextId)}`);
}

10
src/main.tsx Normal file
View File

@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './styles.css';
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

56
src/styles.css Normal file
View File

@ -0,0 +1,56 @@
:root {
color-scheme: light;
font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
}
body {
margin: 0;
background: linear-gradient(140deg, #f5f8ff, #eef3f0);
color: #1d2230;
}
#root {
min-height: 100vh;
display: grid;
place-items: center;
padding: 24px;
}
.app {
width: min(760px, 100%);
background: #ffffff;
border: 1px solid #dce4f2;
border-radius: 14px;
padding: 20px;
box-shadow: 0 8px 30px rgba(37, 52, 92, 0.08);
}
.row {
display: flex;
gap: 10px;
align-items: center;
margin-bottom: 12px;
}
input,
button {
font: inherit;
border-radius: 8px;
border: 1px solid #c7d4ee;
padding: 8px 10px;
}
button {
background: #2158d6;
color: #fff;
border-color: #2158d6;
cursor: pointer;
}
pre {
overflow: auto;
background: #0f172a;
color: #dbeafe;
border-radius: 8px;
padding: 12px;
}

1
src/test/setup.ts Normal file
View File

@ -0,0 +1 @@
import '@testing-library/jest-dom/vitest';

1
src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

17
tsconfig.app.json Normal file
View File

@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "Bundler",
"allowImportingTsExtensions": false,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true
},
"include": ["src"]
}

1
tsconfig.app.tsbuildinfo Normal file
View File

@ -0,0 +1 @@
{"root":["./src/App.test.tsx","./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/client.test.ts","./src/api/client.ts","./src/api/dashboardApi.test.ts","./src/api/dashboardApi.ts","./src/test/setup.ts"],"version":"5.9.3"}

6
tsconfig.json Normal file
View File

@ -0,0 +1,6 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" }
]
}

10
vite.config.ts Normal file
View File

@ -0,0 +1,10 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
host: '0.0.0.0',
port: 5173
}
});

12
vitest.config.ts Normal file
View File

@ -0,0 +1,12 @@
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: './src/test/setup.ts',
include: ['src/**/*.test.ts', 'src/**/*.test.tsx']
}
});