# Vertex WiFi — Frontend Structure

> Deliverable 3 of 13. React layout, the design-token system and the data-fetching
> conventions.

---

## 1. Layout

```
frontend/src/
├── components/
│   ├── ui/                      the kit — every screen is built from these
│   │   ├── Button.tsx           5 variants, loading state
│   │   ├── Badge.tsx            + StatusBadge (renders an API enum envelope)
│   │   ├── Card.tsx             + CardHeader, EmptyState, Skeleton
│   │   ├── DataTable.tsx        + Pagination — server-side sort/paging
│   │   ├── Field.tsx            TextInput, SelectInput, TextArea, Toggle
│   │   ├── Modal.tsx            native <dialog> + ConfirmDialog
│   │   ├── StatCard.tsx         trend chip, optional deep link
│   │   └── Concerns/…
│   └── layout/
│       ├── AppLayout.tsx        sidebar + top bar + <Outlet>
│       ├── Sidebar.tsx          permission-filtered nav
│       ├── TopBar.tsx           search, notifications, theme, account menu
│       └── navigation.ts        the nav as data
│
├── hooks/
│   ├── useFilters.ts            URL-backed list filters + useDebounced
│   └── useApiForm.ts            Laravel 422 → react-hook-form errors
│
├── lib/
│   ├── api.ts                   axios instance, token store, ApiError, downloads
│   ├── permissions.ts           mirrors App\Enums\Permission
│   └── utils.ts                 cn(), money/date formatting, tone tokens
│
├── pages/                       13 route-level pages, all lazy-loaded
│   ├── Login.tsx  Dashboard.tsx  NotFound.tsx
│   ├── dashboard/charts.tsx
│   ├── customers/{CustomerList, CustomerForm, CustomerProfile}.tsx
│   ├── plans/PlanList.tsx
│   ├── billing/{InvoiceList, PaymentList}.tsx
│   ├── reports/Reports.tsx
│   ├── sms/SmsCentre.tsx
│   ├── settings/SettingsPage.tsx
│   ├── users/UserList.tsx
│   ├── admin/ActivityLog.tsx
│   └── account/ChangePassword.tsx
│
├── providers/
│   ├── AuthProvider.tsx         session + permission checks
│   ├── ThemeProvider.tsx        light / dark / system
│   └── ToastProvider.tsx
│
├── routes/guards.tsx            RequireAuth, RequireGuest, RequirePermission
└── types/api.ts                 types mirroring the API Resources
```

TypeScript runs with `strict` **and** `noUncheckedIndexedAccess`. The Vite template
ships without `strict`; it is enabled here because this codebase leans on the API
types to catch a renamed field at compile time, and without `strictNullChecks` an
optional relation (`current_subscription`, genuinely absent for a pending subscriber)
type-checks as always-present and then throws at runtime.

---

## 2. The design language

The look is an **operational tool**, not a dashboard concept: flat surfaces,
hairline borders, small radii, neutral greys, and colour reserved for meaning.

That is a deliberate correction. The first pass had the full set of
generated-template tells — blue-to-navy gradients, glassmorphism, 16 px pill radii,
soft coloured drop shadows, a pastel icon chip on every stat card, and Tailwind's
default slate-navy dark mode. Individually each is fine; together they are
instantly recognisable, and none of them help somebody read a due date.

| Rule | Why |
| --- | --- |
| **Radii 4–8 px** | 16 px cards with fully-round pills is the loudest tell there is. A corner should be a finish, not a feature. |
| **Borders, not shadows** | A page of drop-shadowed cards reads as a pile of floating tiles rather than a layout. Only genuinely overlapping surfaces (menus, dialogs) get elevation. |
| **No gradients** | The brand mark, the sidebar, the avatars and the login panel are all flat. |
| **Neutral greys** | Tailwind's `slate` is faintly navy; a whole app built from it has a default-template signature. The greys here are near-achromatic, so the one blue actually reads as blue. |
| **Colour means something** | Blue marks what is interactive or selected. Status colours (emerald/amber/rose) appear only where they carry state. A figure turns red only when it needs action. |
| **Dense** | 32 px table rows, 16 px card padding, 12 px grid gaps. A customer list shows ~13 rows per screen instead of ~8. |
| **Type carries hierarchy** | Weight and size do the work, not coloured chips and uppercase micro-labels. |

Glassmorphism was in the original brief and has been **removed** — see §12.

### Tokens

Two layers, in `src/index.css`.

**Layer 1 — the palette.** Fixed values that do not change with the theme: the
Vertex blue ramp, radii, shadows, the font stack.

**Layer 2 — semantic tokens.** These flip between light and dark:

```css
:root { --surface: #ffffff; --content: #14161a; --edge: #e3e5ea; }
.dark { --surface: #15171c; --content: #e8eaee; --edge: #262a32; }

@theme inline {
  --color-surface: var(--surface);
  --color-content: var(--content);
  --color-edge:    var(--edge);
}
```

Components reference **only** the semantic names — `bg-surface`, `text-muted`,
`border-edge`. A component is therefore written once and is correct in both themes,
with no `dark:` variant on every element. `dark:` is used only where a genuinely
different treatment is wanted (a tinted alert panel, a chart mark).

### Dark mode is class-driven

```css
@custom-variant dark (&:where(.dark, .dark *));
```

Tailwind v4 defaults `dark:` to `prefers-color-scheme`, which would make the theme
toggle a no-op. This points it at a `.dark` class that `ThemeProvider` owns.

`index.html` applies the stored theme **before first paint** in a tiny inline script.
Without it the app renders light and then flips — a white flash on every load for
anyone using dark mode.

Toggling picks the opposite of what is currently *rendered*, then pins it: a user on
`system` who clicks the toggle wants the other appearance, not to be put back onto
whatever the OS says.

### Translucency, in exactly one place

`.sticky-surface` on the top bar is the only translucent surface, because it is the
only one content genuinely scrolls beneath. Even there the background is 88 % opaque
and the blur is 8 px. The sidebar is solid: nothing scrolls under a fixed panel, so
a blur there buys no depth cue and costs compositing on every scroll frame. There is
a `@supports not` opaque fallback.

### Fonts

**Poppins**, self-hosted via `@fontsource` — no request to Google Fonts, so an
office on a bad line never stares at invisible text.

Two narrowings that matter:

- **Latin subset only.** The bare `@fontsource/poppins/400.css` declares latin,
  latin-ext *and* devanagari, and the bundler emits all three — ~156 kB of glyphs a
  Philippine ISP will never render. The `latin-` prefixed imports cut the download
  from 206 kB to **31 kB** (4 weights, woff2).
- **Four weights.** Poppins ships nine. 400/500/600 carry the UI; 700 is for real
  emphasis.

Poppins is a wide geometric face with a large x-height, so at defaults it reads
looser and larger than a UI sans. Body is set at 14 px with `letter-spacing:
-0.006em`, and headings tighten to `-0.021em` — that is what lets it work in a dense
table. It has no tabular-figure feature, but its digits are geometrically
near-uniform, so `tabular-nums` plus right-alignment keeps money columns readable.

---

## 3. Data fetching

TanStack Query throughout. Defaults in `main.tsx`:

```ts
staleTime: 30_000,              // an ISP's customer list does not change per second
refetchOnWindowFocus: false,    // fires on every alt-tab; produces a spinner storm
retry: (count, error) =>
  error instanceof ApiError && error.status >= 400 && error.status < 500
    ? false                     // a 403 or 422 fails identically three times
    : count < 2,
mutations: { retry: false },    // recording the same payment twice is worse than an error
```

Query keys are `['resource', ...params]`, so changing a filter refetches and
invalidating `['customers']` clears every customer query at once.

### The API client

`src/lib/api.ts` is the single boundary.

**Token storage** is centralised in `tokenStore` — one place knows how the credential
is persisted, so moving to Sanctum's cookie mode later touches only that block. The
expiry is stored alongside the token and checked locally on boot, so an expired token
does not produce a pointless request that 401s after a visible flash of the app shell.

**`ApiError`** normalises every failure. The case that matters is the network failure:
axios reports "Network Error", which tells an operator nothing. On a system whose SMS
gateway is a phone on the office LAN, "can't reach the server" is a real and frequent
condition and gets a real sentence.

**A 401 tears down React state**, not just localStorage — via a handler
`AuthProvider` registers. Otherwise the shell keeps rendering as if signed in until
the next navigation. The login request itself is excluded: a failed login must surface
as a form error, not a session teardown.

**Downloads** are fetched as a Blob rather than by navigating the browser to the URL,
because the bearer token lives in JS and cannot be attached to a plain `<a href>`. The
filename comes from `Content-Disposition`, so the server stays in charge of naming.

> With `responseType: 'blob'`, an *error* body is also a Blob, so the usual
> `response.data.message` is unreadable. `http.download` reads it back as text first —
> otherwise a 403 on an export surfaces as `[object Blob]`.

---

## 4. Permissions in the UI

`AuthProvider` exposes `can`, `canAny`, `canAll`, backed by a `Set` — permission
checks run on nearly every render of every screen (each nav item, each action button),
and `Array.includes` over 38 strings on each is needless work.

The sidebar is data (`navigation.ts`); each item carries the permissions that reveal
it, and a section whose every item is hidden disappears heading and all — an empty
"Administration" label is worse than nothing.

Row-level actions come from the API. `PaymentResource` ships a `can` object per row,
so the void button appears only where voiding will actually succeed. The frontend
never re-implements a policy.

> **This is presentation, not security.** Everything is enforced again by a Laravel
> policy on the request. Hiding a button the user cannot use is a courtesy; the
> policy is the control.

---

## 5. Filters live in the URL

`useFilters` keeps list state in the query string rather than in component state.
That is what makes a filtered list shareable and bookmarkable, survives a refresh, and
lets a dashboard notification card deep-link to
`/invoices?due_from=…&outstanding=1`. Component state loses all three.

Changing a filter resets to page 1 — staying on page 7 of a result set that now has
2 pages shows an empty table and looks broken.

The search box keeps a local mirror so typing feels instant, with `useDebounced`
pushing the value into the URL after 350 ms — one request for "Dela Cruz" instead of
nine.

Sort columns are whitelisted **server-side** (`CustomerFilters::SORTABLE`); an
unchecked `orderBy` is an injection vector, and the smoke test fires
`sort_by=id;DROP TABLE customers--` at it.

---

## 6. Forms

React Hook Form + Zod. `useApiFormErrors` maps a Laravel 422 onto field errors —
Laravel returns `{"connection.pppoe_username": [...]}`, dot notation that happens to
match react-hook-form's nested paths exactly, so a nested error lands on the right
input with no translation. Anything that is not a validation error (403, 429, network)
has no field to attach to and goes to a toast rather than being swallowed.

Client validation deliberately **mirrors** the server rather than exceeding it. The PH
mobile rule accepts the same four formats `App\Support\PhoneNumber` does — a stricter
client rule would block numbers the API is happy with.

The customer form posts all three sections (client / WiFi / subscription) as one
nested payload, so a half-created record is impossible. Empty strings are stripped
before sending so the API's `nullable` rules see absent rather than `""`.

---

## 7. Components worth knowing

**`DataTable`** — sorting and pagination are server-side by design; the component
never sees more than one page, so it cannot and must not sort locally. Overflow is
confined to the table's own wrapper: a table that widens the document breaks the
sidebar layout on every other screen. `secondary: true` on a column hides it below
`lg`. Skeleton rows use varied widths so loading reads as content rather than as a
progress bar.

*Row selection* is opt-in via `selectable`, and three details in it are deliberate:

- **Select-all covers the current page only.** The table holds one page, so it
  cannot select what it cannot see — and a header checkbox that silently selects
  4,000 unseen records is how people delete their database.
- **`stopPropagation` sits on the cell, not the input.** The row is a navigation
  target, so without it a near-miss on the checkbox padding opens the subscriber's
  profile instead of ticking the box.
- **The header checkbox sets the DOM `indeterminate` property**, which has no HTML
  attribute and can only be assigned via a ref. It is not decoration: assistive
  technology announces "mixed" from it, which is exactly what a partly-selected
  page is.

`CustomerList` clears the selection whenever the result set changes (any filter,
search or page), because selection is by id — a row that scrolls out of view on a
filter change would otherwise stay silently selected and be deleted from a page
where it is not even visible.

**`Modal`** — built on the native `<dialog>`, which gives focus trapping, the top
layer (so it cannot be clipped by a parent's `overflow`), inert background and Escape
handling from the platform. A div-based modal reimplements all of that, usually
incompletely.

**`Field`** — each primitive wires label/input/error with a generated id and
`aria-describedby`, so an error is announced by a screen reader rather than only being
visible. Done once here instead of being re-remembered in a dozen forms.

**`StatusBadge`** — renders the API's `{value, label, color}` envelope directly, so
the frontend keeps no copy of any status list. Colour classes come from a **lookup
table**, not interpolation: Tailwind compiles by scanning source text, so
`bg-${color}-100` produces no CSS at all.

---

## 8. Charts

`src/pages/dashboard/charts.tsx`. Three decisions, each rejecting the obvious version:

**One hue, not a categorical palette.** Every chart plots a single measure, so colour
encodes magnitude and the *label* carries identity. Colouring five status slices by
hue was tried and measured out: the semantic set (amber / sky / green / orange / red)
fails colour-blind adjacent-pair separation, and `#f43f5e` against `#f97316` scores
ΔE 12.7 for **normal** vision — below the readable floor of 15, so two of the five
statuses are hard to tell apart even with full colour vision. Labelled bars have no
such failure mode, so "Active vs Inactive" is a labelled bar list rather than a donut.

**No dual axis on the growth chart.** Signups per month (0–20) and cumulative
subscribers (0–5,000) share no scale; two y-axes let the reader infer whatever
relationship the axis scaling happens to suggest. The cumulative line is plotted; the
monthly figure lives in the tooltip.

**Dark mode gets its own step, not an inverted one.** `#2563eb` clears 3:1 contrast on
white but not on the dark canvas, so dark mode steps up the same ramp to `#3b7cf6`.
Both were verified against their own surface.

Also applied: solid hairline gridlines (dashes read as a threshold or a projection),
2px strokes, 8px active dots with a surface ring, direct labels on the plan bars so
the count is readable without hovering, and proportional figures on stat tiles
(`tabular-nums` makes a standalone "121" look loosely spaced; it belongs in table rows
and axis ticks where digits align vertically).

**Mount animation is off** (`isAnimationActive={false}`). Recharts draws a series in
over ~1.5 s, which on an operational dashboard only delays the figure being readable —
and means the chart is wrong-but-plausible for a second and a half on every load.
It was also what made the first verification screenshot show a revenue line that
stopped in March.

---

## 9. Code splitting

Every page is `lazy()`-loaded, and vendor chunks are split by concern. The reports and
SMS screens pull in weight a Staff-role encoder will never open, so shipping it in the
initial bundle would slow the login screen for everyone.

```
charts    386 kB → 111 kB gzip   (recharts + d3; dashboard only)
react     218 kB →  70 kB
vendor     91 kB →  32 kB
forms      87 kB →  24 kB        (react-hook-form + zod)
query      36 kB →  11 kB
dates      24 kB →   7 kB
app entry  24 kB →   7 kB
CSS        55 kB →  10 kB
```

Individual pages are 4–24 kB. `manualChunks` matches on the module path rather than
listing packages, so transitive dependencies (recharts pulls in a dozen `d3-*`
packages) land in the right chunk.

---

## 10. Accessibility

- Semantic landmarks; the nav is a real `<nav aria-label>`, the table a real `<table>`
  with `<th scope="col">` and `aria-sort`.
- One visible focus style everywhere, keyboard-only (`:focus-visible`).
- Errors linked via `aria-describedby` + `aria-invalid`.
- Toasts in an `aria-live="polite"` region, so an outcome is announced without
  interrupting what a screen reader is reading.
- `aria-current="page"` on the active nav item and tab.
- `prefers-reduced-motion` disables every animation.
- Icons that duplicate adjacent text are `aria-hidden`; icon-only buttons carry
  `aria-label`.
- Status is never colour-alone — every badge pairs its colour with a written label.

---

## 11. Verification

```bash
npx tsc -b --noEmit    # clean under strict + noUncheckedIndexedAccess
npm run build          # clean
npx oxlint src         # 0 errors
```

The 3 remaining lint warnings are `only-export-components` on the three provider
files — the standard React pattern of exporting a context provider and its hook
together. They affect hot-reload granularity in dev, nothing else.

Verified end to end through the Vite proxy against the live API: login returns a token
and 32 permissions for the Admin role; the dashboard returns 17 customers with a
12-month revenue series; the customer list paginates with the plan relation
eager-loaded.

Every screen was also rendered in a headless browser and inspected in both themes,
which is how the chart-animation issue above was found. `document.fonts` confirms
Poppins 400/500/600 load and that the built CSS makes **zero** external font
requests.

---

## 12. Deviation from the original brief

The brief listed **glassmorphism** as a UI requirement, and it was built that way
first. It has since been removed at your request to make the design not read as
AI-generated — frosted panels over gradients are the single most recognisable tell.

What replaced it: solid surfaces, hairline borders, and one 88 %-opaque blurred strip
on the sticky top bar where content actually passes underneath. Everything else in
the brief's UI list — modern dashboard, responsive, dark mode, sidebar navigation,
statistics cards, data tables, charts, blue-and-white Vertex branding — is intact.

Restoring the original look is a small, contained change: the tokens in
`src/index.css` (`--radius-*`, `--shadow-*`, the `:root`/`.dark` blocks) plus the
`.card` and `.sticky-surface` utilities. No component would need touching.
