Frontend Coding Standards
Last updated: 2026-04-30 Stack: React 19, TypeScript, Vite, Tailwind v4, TanStack Query v5, Zustand, MSAL, Phosphor Icons
This document is the authoritative reference for coding standards, architectural patterns, and component conventions used in the RoundTripWeb repository. All new code — and all AI-assisted code — must follow these patterns. When in doubt, look at an existing file that does the same thing and match its structure.
1. Project Structure
src/
├── components/
│ └── common/ # Shared UI primitives (SurfacePanel, MetaRow, etc.)
├── features/ # Feature-based folders — one per domain
│ ├── dispatch/ # DispatchBoardPage.tsx
│ ├── tickets/ # TicketListPage.tsx, TicketDetailPage.tsx, CreateTicketModal.tsx
│ ├── clients/ # ClientListPage.tsx, ClientDetailPage.tsx
│ ├── inventory/ # InventoryListPage.tsx, InventoryDetailPage.tsx
│ ├── billing/ # InvoiceListPage.tsx, InvoiceDetailPage.tsx
│ └── tech/ # TodayPage.tsx, TechTicketDetailPage.tsx, RecordPartsPage.tsx, TechProfilePage.tsx
├── layouts/
│ ├── DispatchLayout.tsx # Shell for /dispatch/* routes
│ └── TechnicianLayout.tsx # Shell for /tech/* routes
├── lib/
│ ├── api/ # One file per domain
│ │ ├── axiosInstance.ts
│ │ ├── types.ts # Shared cross-domain types (DispatchBoardData, PagedResult)
│ │ ├── ticketApi.ts
│ │ ├── ticketDetailApi.ts
│ │ ├── createTicketApi.ts
│ │ ├── dispatchApi.ts
│ │ ├── clientApi.ts
│ │ ├── inventoryApi.ts
│ │ ├── billingApi.ts
│ │ └── techApi.ts
│ ├── auth/
│ │ ├── msalConfig.ts # MSAL configuration + loginRequest + apiScopes
│ │ └── msalInstance.ts # Single PublicClientApplication instance
│ ├── offlineQueue.ts # IndexedDB mutation queue for PWA
│ └── queryClient.ts # TanStack QueryClient + IndexedDB persister
├── stores/
│ └── authStore.ts # Zustand store — auth state, role, tenantId
├── features/*/hooks/ # TanStack Query hooks, co-located with their feature
├── AuthProvider.tsx # MSAL initialization + AuthSync
├── RoleRedirect.tsx # Post-login role-based routing
├── router.tsx # createBrowserRouter — all routes
└── sw.ts # Custom Workbox service worker
Key structural rules
- Pages and components live in
features/, notpages/ - Each feature folder has its own
hooks/subfolder - Auth infrastructure lives in
lib/auth/— never inline MSAL config in a component - The single
msalInstanceis created inmsalInstance.tsand imported wherever needed outside React context (e.g.axiosInstance.ts) - Shared cross-domain types (e.g.
DispatchBoardData,PagedResult) live inlib/api/types.ts
2. Separation of Concerns — The Golden Rule
Every file has exactly one responsibility.
| Layer | What lives here | Example |
|---|---|---|
src/lib/api/ | API calls + response types. No React, no hooks. | ticketDetailApi.ts |
src/features/*/hooks/ | TanStack Query hooks wrapping the API. No JSX. | useTicketActions.ts |
src/features/*.tsx | Pure UI. No useMutation, no apiClient calls. | TicketDetailPage.tsx |
src/components/common/ | Reusable UI primitives. No business logic, no API calls. | SurfacePanel.tsx |
src/stores/ | Zustand global state. No JSX, no API calls. | authStore.ts |
src/lib/auth/ | MSAL config and instance only. | msalConfig.ts |
Never put API calls directly in a page or component. Never put useMutation or useQuery directly in a page — they belong in a hook file.
3. API Layer (src/lib/api/)
One file per domain
axiosInstance.ts — configured Axios instance with auth interceptor
types.ts — shared cross-domain types (DispatchBoardData, PagedResult)
ticketApi.ts — ticket list + TicketSummary types
ticketDetailApi.ts — ticket detail + all action methods (assign, start, complete, hold, cancel, addNote)
createTicketApi.ts — client search, address selection, create ticket
dispatchApi.ts — dispatch board data
clientApi.ts — client list, detail, create, update, addresses, service history
inventoryApi.ts — inventory list, detail, stock adjustment
billingApi.ts — invoice list, detail, generate, send, record payment, cancel
techApi.ts — technician-specific endpoints (me, tickets, status transitions, location, notes, parts)
File structure
import { apiClient } from './axiosInstance'
// ── Types ──────────────────────────────────────────────────────────────────
export interface TicketDetail { ... }
export interface AssignTicketResponse { ... }
// ── API ────────────────────────────────────────────────────────────────────
export const ticketDetailApi = {
getDetail: async (id: string): Promise<TicketDetail> => {
const { data } = await apiClient.get<TicketDetail>(`/v1/tickets/${id}`)
return data
},
assign: async (ticketId: string, technicianId: string): Promise<AssignTicketResponse> => {
const { data } = await apiClient.patch<AssignTicketResponse>(
`/v1/tickets/${ticketId}/assign`,
{ technicianId }
)
return data
},
}
Rules
- Always use
apiClientfrom./axiosInstance— never importaxiosInstancedirectly - Export all types from the API file — hooks and pages import types from the API layer
- Keep methods thin — no business logic, just the HTTP call and return
- No
console.logstatements in production code
4. Hook Layer (src/features/*/hooks/)
Hooks live in a hooks/ subfolder co-located with their feature, not in a global hooks folder.
Query hook pattern
import { useQuery } from '@tanstack/react-query'
import { ticketDetailApi } from '@/lib/api/ticketDetailApi'
export const TICKET_DETAIL_KEY = (id: string) =>
['tickets', 'detail', id] as const
export function useTicketDetail(id: string) {
return useQuery({
queryKey: TICKET_DETAIL_KEY(id),
queryFn: () => ticketDetailApi.getDetail(id),
enabled: !!id,
staleTime: 30_000,
})
}
Mutation hook pattern
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useMsal } from '@azure/msal-react'
import { ticketDetailApi } from '@/lib/api/ticketDetailApi'
import { TICKET_DETAIL_KEY } from './useTicketDetail'
export function useTicketActions(ticketId: string) {
const queryClient = useQueryClient()
const { accounts } = useMsal()
const account = accounts[0]
const authorUserId = account?.localAccountId ?? '00000000-0000-0000-0000-000000000000'
const authorName = account?.name ?? account?.username ?? 'Dispatcher'
const invalidate = () =>
queryClient.invalidateQueries({ queryKey: TICKET_DETAIL_KEY(ticketId) })
const assignMutation = useMutation({
mutationFn: (technicianId: string) =>
ticketDetailApi.assign(ticketId, technicianId),
onSuccess: invalidate,
})
return { assignMutation, ... }
}
Rules
- Always export query keys as named constants (
TICKET_DETAIL_KEY,TICKET_LIST_KEY) - Mutation hooks own
onSuccessinvalidation — pages never callqueryClient.invalidateQueriesdirectly - MSAL account extraction lives in the hook, not the page
5. Page / Feature Layer (src/features/*.tsx)
Pages are pure UI. They import hooks and common components only. Pages do not import apiClient, call useMutation/useQuery directly, or import msalInstance.
6. Common Components (src/components/common/)
Always use shared components — never recreate inline.
| Component | Use for |
|---|---|
SurfacePanel | Every card/panel surface |
SectionHeader | Titled header row inside a SurfacePanel |
MetaRow | Labelled metadata rows in detail sidebars |
StatusBadge | Ticket status pill |
PriorityBadge | Ticket priority pill |
PageHeader | Page title + subtitle + optional action slot |
StatusBadge and PriorityBadge — spread ...rest props
StatusBadge and PriorityBadge extend React.HTMLAttributes<HTMLSpanElement> and spread ...rest. This means they forward arbitrary HTML attributes including data-testid:
// ✅ Correct — data-testid is forwarded to the underlying <span>
<StatusBadge status={ticket.status} data-testid="status-badge" />
Any new shared component must follow the same pattern — accept and spread ...rest so testids and ARIA attributes can be passed without modifying the component.
7. Authentication Architecture
Role and TenantId come from GET /v1/me — NOT from JWT claims
⚠️ Critical. The JWT tid claim is the Entra External ID directory GUID — it is NOT the RoundTrip TenantId. Role and TenantId are resolved by calling GET /v1/me after login, which does an OID lookup in the TenantUsers table.
App load
└── AuthProvider initializes msalInstance
└── handleRedirectPromise() processes redirect
└── setMsalInstance() wires MSAL into axiosInstance
└── AuthSync: LOGIN_SUCCESS → setAccount() called
└── setAccount() is ASYNC — calls GET /v1/me
└── /v1/me returns { role, tenantId, displayName, ... }
└── authStore updated with real role + tenantId
└── RoleRedirect reads authStore.role → navigates to /dispatch or /tech
setAccount() in authStore.ts is an async function. It calls GET /v1/me to get the role and RoundTrip TenantId from the database — not from JWT claims. Role-dependent UI must not render before this resolves.
MSAL cache — localStorage
MSAL is configured to use localStorage (not sessionStorage). This was changed from the initial implementation to support Playwright E2E auth state persistence across browser contexts.
// msalConfig.ts
cache: {
cacheLocation: 'localStorage', // ← not sessionStorage
}
MSAL transient errors — do not log out (TRA-181)
⚠️ Critical. When acquireTokenSilent throws BrowserAuthError with codes like block_iframe_reload, timed_out, or monitor_window_timeout, this is a transient error caused by stale MSAL interaction state in localStorage (e.g. after a deployment while a user was mid-auth-flow). Do NOT clear auth on these errors.
authStore.ts uses a typed FetchResult discriminated union to handle this:
type FetchResult =
| { status: 'ok'; user: CurrentUserResponse }
| { status: 'token_error' } // transient — don't log out
| { status: 'not_found' } // user not provisioned — do log out
| { status: 'api_error' } // network failure — don't log out
Key files
| File | Purpose |
|---|---|
lib/auth/msalConfig.ts | MSAL config, apiScopes, loginRequest. Cache is localStorage. |
lib/auth/msalInstance.ts | Single PublicClientApplication instance |
AuthProvider.tsx | Initializes MSAL, handles redirect, wraps app in MsalProvider |
RoleRedirect.tsx | Post-login: reads role from authStore, redirects to the right shell |
stores/authStore.ts | Zustand store: isAuthenticated, role, userId, tenantId, displayName |
MSAL instance rule
msalInstance is created once in msalInstance.ts. Import it wherever you need MSAL outside React context. Never create a second PublicClientApplication.
8. Design Tokens
All colors use CSS variables. Never hardcode hex values except in RoundTripLogo.
Brand colors
var(--rt-navy) /* #0F2D44 */
var(--rt-blue) /* #1D6FA4 */
var(--rt-blue-muted) /* icon tiles, selected states */
var(--rt-blue-hover) /* button hover */
var(--rt-sky) /* #7EC8E3 — accent */
Semantic colors
var(--color-text) var(--color-text-muted)
var(--color-surface) var(--color-surface-2)
var(--color-border) var(--color-border-strong)
var(--color-red) var(--color-red-muted) var(--color-red-text)
var(--color-amber) var(--color-amber-muted) var(--color-amber-text)
var(--color-green) var(--color-green-muted) var(--color-green-text)
var(--color-steel) var(--color-steel-muted)
Typography
var(--font-display) /* Barlow Condensed 700 — page titles */
var(--font-ui) /* Inter — labels, buttons, badges, nav */
var(--font-body) /* Inter — body text, descriptions */
var(--font-mono) /* JetBrains Mono — ticket numbers, dates, IDs */
9. Service Worker (src/sw.ts)
Built via vite-plugin-pwa in injectManifest mode.
Critical routing rules
// MUST check url.origin BEFORE url.pathname — prevents intercepting cross-origin API calls
registerRoute(
({ url }) => url.origin === 'https://api.roundtrips.app',
new NetworkOnly()
)
registerRoute(
({ request, url }) =>
request.method === 'GET' &&
url.origin === self.location.origin && // ← origin guard mandatory
url.pathname.startsWith('/v1/'),
new NetworkFirst({ cacheName: 'api-responses', networkTimeoutSeconds: 10 })
)
Without the origin guard, Workbox silently returns no-response for cross-origin fetches.
Deploying SW changes — stale service worker warning
After deploying a new build:
- DevTools → Application → Storage → Clear site data
- Close the tab completely — a page reload is NOT sufficient
- Open a fresh tab
10. Real-Time (SignalR)
WebSockets are disabled by default on Azure App Service Linux. Always enable via CLI:
az webapp config set \
--name app-roundtrip-prod \
--resource-group rg-roundtrip-prod \
--web-sockets-enabled true
This is not available in the Azure Portal UI for Linux App Service — CLI only.
11. E2E Test Patterns (src/e2e/)
State sharing between tests
Use src/e2e/fixtures/e2e-state.ts for cross-test state. Never use process.env directly — it doesn't survive across CI pipeline steps.
import { setTicketUrl, getTicketUrl, setTicketNumber, getTicketNumber } from '../fixtures/e2e-state'
// After creating a ticket (test 02)
setTicketUrl(page.url())
setTicketNumber(ticketNum)
// In downstream tests (03-06)
const ticketUrl = getTicketUrl()
State is persisted to src/e2e/test-results/.e2e-state.json (gitignored).
Navigation pattern — always go through the app root first
Direct page.goto(deepUrl) triggers MSAL re-auth. Always load from the dispatch board first:
await dispatchBoard.goto() // loads from app root — MSAL state already in localStorage
await page.goto(ticketUrl) // now navigate to deep link
Hold reason values must match the DB CHECK constraint
// ✅ Correct
await ticketDetail.putOnHold('AwaitingParts')
// ❌ Wrong — doesn't match DB constraint
await ticketDetail.putOnHold('WaitingForParts')
12. Naming Conventions
| Thing | Convention | Example |
|---|---|---|
| Feature folders | lowercase | features/tickets/ |
| API files | camelCase + Api suffix | ticketDetailApi.ts |
| Hook files | camelCase + use prefix | useTicketActions.ts |
| Query key exports | UPPER_SNAKE + _KEY | TICKET_DETAIL_KEY |
| Page components | PascalCase + Page | TicketDetailPage |
| Modal components | PascalCase + Modal | CreateTicketModal |
| Layout components | PascalCase + Layout | DispatchLayout |
| Common components | PascalCase, descriptive noun | SurfacePanel, MetaRow |
| Store files | camelCase + Store | authStore.ts |
| API object exports | camelCase + Api | ticketDetailApi |
13. Known Issues & TODOs
| Location | Issue | Ticket |
|---|---|---|
lib/api/ | PagedResult<T> duplicated across API files — consolidate into types.ts | — |
14. Things to Never Do
| Never | Why |
|---|---|
| Derive role or tenantId from JWT claims | Role/tenantId come from GET /v1/me — JWT tid is Entra directory GUID |
Import axiosInstance directly in a page or component | All API access goes through a domain API file |
Call useMutation or useQuery directly in a page | Wrap them in a hooks/ file |
Use sessionStorage for MSAL cache | Cache is localStorage — changing this breaks Playwright E2E |
| Clear auth on MSAL transient errors | block_iframe_reload, timed_out are transient — use FetchResult pattern in authStore |
Use url.pathname alone in SW route matchers | Always check url.origin first |
| Navigate directly to a deep URL in E2E tests | Load from dispatch board root first to avoid MSAL re-auth |
| Use hold reason values not in the DB constraint | AwaitingParts | AwaitingApproval | Rescheduled | CustomerNotHome only |
Leave console.log statements in production code | Remove all before beta |
Create a second PublicClientApplication | One instance only, in msalInstance.ts |
| Use Lucide or any other icon library | Phosphor only |
| Use hardcoded hex colors | Use CSS variables |
| Use the offline queue in dispatcher features | Technician PWA only |