---
name: portal-ui-mockups
description: "Build HTML mockups for IT Pro Partner operations portal — admin/customer dual-view, service integrations page, customer onboarding, router onboarding, router detail, network dashboard, DNS/domain management, login page, and event-specific forms."
version: 1.10.0
author: ShoNuff
platforms: [linux]
metadata:
  hermes:
    tags: [portal, mockup, ui, rbac, html, tailwind, onboarding, mikrotik, dns, domain, login]
---

# Portal UI Mockups

Build single-file HTML mockups for the IT Pro Partner operations portal. All mockups are throwaway by design.

## Multi-role / RBAC mockups

When the user wants both admin and customer views, build one file with a toggle button. Admin-only elements get `class="admin-only"`, customer-only get `class="customer-only"`. The toggle applies `role-customer` to the root, hiding admin-only content via `display: none`.

### Sidebar by role

| Link | admin-only? |
|---|---|
| Dashboard | No (content differs) |
| Servers | Yes |
| Network Devices | Yes |
| DNS | Yes |
| Backups | Yes |
| Call Logs | No (customer sees own calls) |
| Reports | Yes |
| Customers | Yes |

Toggle button in sidebar footer switches role label, user name, and badge.

## Dashboard pages

| Page | Admin sees | Customer sees |
|---|---|---|
| Dashboard | Server grid, activity feed, stats | My Services, recent calls |
| Servers | Full server cards | Hidden |
| Network | Full device table | Hidden |
| DNS | Zone list with editing | Hidden |
| Backups | Status table, restore | Hidden |
| Call Logs | All customers filter | Own calls only |
| Reports | 4 report types | Hidden |
| Customers | Full table with billing | Hidden |

## Customer Onboarding Flow

Three-step flow in one mockup file:

1. **Enter domain** — search input + "create blank customer" link
2. **Review matches** — auto-detected services from Cloudflare, RingLogix, UniFi, UISP, MXroute, RunCloud. Each shown with checkbox + platform badge + detail line. Not-found services shown with dashed borders and "Link manually" action
3. **Link & create** — batch action to link selected services and create customer record

### Status badges

- Existing customer: bg-blue-100 text-blue-700
- DNS (Cloudflare): bg-amber-100 text-amber-700
- VoIP (RingLogix): bg-purple-100 text-purple-700
- Network (UniFi): bg-green-100 text-green-700
- WISP (UISP/CCR): bg-cyan-100 text-cyan-700

## Login Page (login.html)

Two-panel layout for unauthenticated visitors:

| Left panel (desktop only) | Right panel |
|---|---|
| Blue gradient background with brand mark | Existing Customer login form |
| Feature list (SSO, monitoring, RBAC) | Email + password fields |
| Minimal "IT" logo + tagline | Remember me + forgot password |
| | **OR** New Customer inquiry form |

**Two login paths side-by-side:**

- **Existing Customer (left card):** Blue accent. Email/password fields. Google SSO button. Forgot password link.
- **New Customer (right card):** Green accent. First name + last name (split grid), email, company fields. Service interest checkboxes in 2-column grid (Internet/WISP, Business VoIP, DNS/Domain, Email Hosting, Web Hosting, Network Setup, Backup/DR, Other). Cloudflare Turnstile placeholder. "Submit Inquiry" button with 1 business day response note.

Turnstile anti-bot placeholder: shield checkmark icon + "Protected by Cloudflare Turnstile" text. Invisible user experience when live.

The login page is the landing for BOTH paths — existing customers sign in, new customers submit a lead form. This replaces a traditional single-form login page.

Navigation links in top bar: Services, DNS (links to dns.html), Contact.

## DNS & Domains Management (dns.html)

Two-tab layout. Tab switching via JS — add `class="tab-pane"` to each content div, and use `document.querySelectorAll('.tab-pane')` in the click handler. The tab handler selects panes by DOM index, not by closest parent selector, to avoid depth-traversal bugs.

### Tab 1: DNS Zones
- Summary cards: Total Zones, Registered Domains, Expiring Soon (≤90 days), DNS Records
- Zone list table: Zone name, Status badge, Plan, Record count, Linked Customer, Actions
- DNS Record Editor (below zone list): A, AAAA, CNAME, MX, TXT, SRV records. Type badge colors: A=blue, CNAME=green, MX=purple, TXT=cyan, SRV=orange, AAAA=green. Edit/Del actions per record. "Back to zones" breadcrumb. "Add Record" button with inline form (type selector, name, content, TTL, proxy toggle).

### Tab 2: Registered Domains
- Cloudflare Registrar badge
- Search & Register button + Transfer button
- Domain table: Domain, Expires, Auto-Renew (ON/OFF badge), Days Left, Customer, Status, Actions
- Expiring domains (≤90 days): amber background row, red bold days count
- All auto-renew ON shown as green badges
- "Renew" button on each domain row
- **Register a New Domain** panel at bottom: search input + TLD dropdown (.com, .net, .org, .io, .co) + Search button. Availability result preview showing domain name, available badge (green), price, Add to Cart button, and fee breakdown (transfer, renewal, premium status).

### Domain pricing data
Cloudflare sells at cost. Verified pricing: .com = $10.46/yr registration, $10.46/yr renewal, $10.46 transfer. No premium markup for standard TLDs. The API endpoint is `GET /accounts/{id}/registrar/domains/{domain}/check` (returns availability, fees, premium status, TLD support).

### API token permissions required for full functionality

| Feature | Required Token Permission |
|---|---|
| Zone DNS view/edit | Zone → DNS → Edit |
| Domain list + expiry | Account → Registrar → Read |
| Domain renew | Account → Registrar → Admin |
| Domain registration/transfer | Account → Registrar → Admin |

## Router Onboarding (MikroTik)

Dedicated router onboarding mockup page. Left side: customer selector, generate button, generated ROS7 script with copy/download. Right side: 3-step explainer, router status card, recently onboarded list.

See the `mikrotik-onboarding` skill for the full ROS7 implementation pattern, SSH key deployment, and pitfalls.

### VoIPSimplicity Customer Portal Pages (voipsimplicity.com)

Build standalone HTML mockups of the VoIP customer portal. Layout: dark theme (#0f172a background, #1e293b cards, #f59e0b amber accent), 4-tab layout (Inventory, Call Handling, Features, Recent Calls), responsive for phone/tablet/desktop.

**Mockup convention:** Single file per portal concept. Real data placeholders (Acme Corp, extensions 101-500). No login/auth UI — assume customer is already logged in. Each customer sees only their own data.

### Customer Portal Dashboard

| Section | Content |
|---|---|
| **Stats bar** | Extensions, Devices, Phone Numbers (DIDs), Calls This Month — color-coded cards (cyan, green, amber, violet) |
| **📋 Inventory tab** | Device table (name, extension, model, MAC, online/offline status dots), Extensions list (name, ext, device type, Available/On Call/Offline), Phone numbers table (number, label, route target, SMS support) |
| **🔀 Call Handling tab** | Visual call flow (Caller → Auto Attendant → Sales/Support/Directory/Reception → Answer/Voicemail), Time-based routing rules, Call queues (members, strategy, waiting count), Ring groups |
| **⚙️ Features tab** | Toggle-grid of enabled/disabled features (Voicemail to Email, Simultaneous Ring, Business Hours, Call Recording, Call Forwarding, Analytics, Auto Attendant, SMS) |
| **📞 Recent Calls tab** | Activity feed with inbound/outbound/missed/voicemail items, timestamps, durations, transcription preview |

### Inbound Call Flow Diagram (separate page)

A customer-facing explanation page showing how inbound calls get routed. NOT an SVG architecture diagram — use pure HTML/CSS with flexbox flow boxes and arrows (→ and ↓). Three sections:

1. **Visual flow** — horizontal rows with colored bordered boxes (PSTN=rose, Network=cyan, Platform=amber, Decision=violet, Endpoint=emerald). Show three routing forks (Direct Extension / Ring Group / Call Queue) and three resolutions (Answered / Voicemail / Forwarded).
2. **Step-by-step** — 7 numbered steps explaining the call path
3. **Missed call scenarios** — 4-card grid (Voicemail to Email, Forward to Mobile, Simultaneous Ring, Time-Based Routing)
4. **Call Routing Features table** — 11 features with descriptions

**Style:** Same dark theme as portal. Boxes 120-160px wide, hover lift effect. Pure HTML/CSS/JS, no SVG dependencies.

### Auto-Provisioning Demo (interactive mock)

Two-panel layout: provisioning form on left, API transaction log on right. Bottom sections for CDR export and customer lookup.

**Left panel:** Form fields (Customer Name, Extension, Device Name, MAC Address, Phone Model dropdown), "Run Auto-Provision" button, 4-step indicator (Auth → Create Device → Verify → Done).

**Right panel:** Terminal-style log showing simulated API calls with timestamps:
- `POST /oauth2/token/` → token received
- `POST ?object=device&action=create` → device created
- `POST ?object=subscriber&action=read` → verified

**Bottom sections:** CDR Automation (scheduled nightly export, last export stats), Customer Lookup (search by name/extension/email with simulated results).

**Code pattern:** All JS in same file. `async/await` + `sleep()` for step timing. Log output via DOM appendChild. Step indicators via class swapping.

### Serving VoIP mockups

All three VoIP pages served from `/var/www/static/` via Caddy on `core.itpropartner.com`:
- `/portal` — Customer portal dashboard
- `/call-flow` — Inbound call flow diagram
- `/voip-demo` — Auto-provisioning interactive demo

Caddy config pattern for static files:
```
@portal path /portal
handle @portal {
    root * /var/www/static
    file_server
}
```

## Router Detail Drill-Down Page

Six-tabbed detail page for a specific router:

| Tab | Content |
|---|---|
| **DNS** (default tab, NEW) | Total queries count, unique domains, active clients, blocked count. Top domains widget (7d). Top clients widget. DNS query log table: time, client IP, domain name, record type (A/AAAA badge), resolved IP, client hostname. Filter by client and time period dropdowns. Search bar. |
| DHCP | Full client table: hostname, IP, MAC, type (reserved/dynamic), status (active/offline/expired), lease time, last seen. Search bar. "Reserve" button converts dynamic leases to reservations. Inline "Add Reservation" form at bottom. |
| Network | DHCP leases + Interfaces combined on one tab |
| Bandwidth | Time period selector (hourly, daily, weekly, monthly, yearly). Sparkline chart area. Summary cards showing total up/down per period. |
| Firewall | NAT rules, filter rules, port forwards |
| Logs | Real-time system log feed |
| Config | Backup, restore, firmware update |

DHCP table row colors: Reserved = purple badge, Dynamic = blue badge. Status dots: green=active, gray=offline/expired.

Summary cards at top: uptime, CPU (with mini-bar), RAM (with mini-bar), firmware version (with update badge if outdated), temperature.

DNS tab is the default active tab (was DHCP before). This is the most-used router management function.

## Network Dashboard Page

Summary cards: total routers, online count + percentage, alerts count, average heartbeat latency.

Router table with columns: Status (color dot + label), Router name, Customer, Model, Uptime, Tunnel latency, **Installed firmware**, **Latest firmware**, Last seen.

Firmware color coding: green badge = current, amber badge = update available, gray = pending.

Status colors: green=Online, amber=Alert (missed heartbeat), red=Offline (no heartbeat >60m), gray=Pending (script sent, not yet installed).

Right rail panels: Recent alerts (color-coded with timestamps), Top bandwidth usage (progress bars), Quick actions (run command, backup, reboot, firmware update, onboard new).

## Apex Vehicle Registration Form (Live JSON Source)

As of Jul 7, 2026, the vehicle database form fetches from a live JSON endpoint. The vehicle data is served at **`https://core.itpropartner.com/vehicles.json`** (HTTPS via Caddy, CORS-enabled, 47 makes, 373 models). The HTML snippet in WPForms should load this JSON URL dynamically instead of having data embedded inline.

This decouples the form from the data — when the exotic-vehicle-scout cron job discovers new 500+ HP vehicles, I update the JSON file on Core and the form picks it up on next load without any WPForms edits.

### Custom/homebuilt vehicle entry requirement (Jul 10, 2026)

The exotic-vehicle-scout cron identified that boutique manufacturers (Ruf, Zenvo, Hennessey) and prototype/one-off builds don't fit the JSON-driven dropdown model. The registration form MUST also support:

1. **A "Not Listed" / "Custom Build" toggle** at the bottom of the make dropdown (or as a separate radio option)
2. When toggled, show **free-text fields** for: Make, Model, Year (or approximate year), and HP (user-entered)
3. Make it clear that the vehicle must still meet the **500+ HP minimum** for Apex Track Experience eligibility — but shouldn't block the submit. Let validation happen server-side or in a confirmation step.

The canonical database (`/root/portal-mockup/vehicles.json`) captures production vehicles. Custom/homebuilt cars won't be in it. The registration form must handle this at the UI level, not the DB level.

**Real-world examples that need this:** Ruf B8 prototype (1,000+ HP flat-eight mule), Zenvo Aurora Tur (1,850 HP boutique hypercar), Hennessey Venom F5 (1,817 HP) — these exist in the DB already but next year's one-off builds likely won't.

See `references/vehicles-database-inventory.md` for the full inventory of all 47 makes, 373 models, and HP ranges.

### Vehicle data URL pattern (Caddy multi-domain)

The Caddy config on Core serves all three ITPP subdomains from a single `/etc/caddy/Caddyfile`:

```
sign.itpropartner.com  → reverse_proxy to DocuSeal (port 3000) + static files at /var/www/static
core.itpropartner.com  → static JSON (vehicles.json), health endpoint, capabilities page
app.itpropartner.com   → reverse_proxy to portal mockups (port 8081)
```

**Tailscale Serve conflict:** Caddy and Tailscale both want port 443. Before starting Caddy for the first time, run `tailscale serve off` to release port 443. `app1.tailc2f3b0.ts.net` and `vaultwarden.tailc2f3b0.ts.net` stop working after this — all HTTPS traffic goes through Caddy. Portal mockups move to `app.itpropartner.com`.

### Writing Caddyfiles

Due to my tool constraints, I cannot use `write_file` to create or update `/etc/caddy/Caddyfile` (refused as "sensitive system path"). Use terminal to write the file via heredoc or Python, then run `caddy fmt --overwrite` and `systemctl reload caddy`. After writing, verify with `curl -s http://localhost:2019/config/ | python3 -c "import sys,json; d=json.load(sys.stdin)"` to confirm all routes are loaded.

If a new host is added to the Caddyfile but only the old hosts appear when checking the config API, a **full restart** (`systemctl stop caddy && systemctl start caddy`) may be needed instead of `reload`. Reload may only apply changes to existing hosts, not add new ones.

Simplified to email + screenshot upload only. Vehicle/class data matched by email from external Google Sheet. N8N automation: screenshot -> GPT-4o vision -> Google Sheet -> auto-updating leaderboard.

## Vehicle database pattern (static JSON)

Pre-load make/model/year/HP data as a static JSON file. No external API. Cascading dropdowns with disabled-state chaining. Color-locked specs panel (specs hidden until color selected). Light theme variant available (#ffffff bg, #2563eb accent).

### WPForms integration for vehicle selector

When the vehicle selector is embedded inside a WPForms form (not Elementor Forms):

1. **Do NOT rely on hidden `<input>` fields** — WPForms only submits its own managed fields. Hidden inputs outside WPForms' `<form>` are invisible to the submission handler.
2. **Create a WPForms Single Line Text field** with CSS class `apex-vehicle-field` — this becomes the vehicle data carrier.
3. **The JS snippet** (embedded in a WPForms HTML Content field on the same page) finds this field via `document.querySelector('.apex-vehicle-field input, .apex-vehicle-field textarea')` and populates it with a string like `"2022 Red Ferrari F8 Tributo 710"` when the user selects a vehicle.
4. **Add this JS after the `vrColor` change handler** in the vehicle snippet — it must write to both the visual spec panel AND the WPForms field.
5. **Avoid inline `onchange` handlers** — WordPress/Elementor loaders can strip them. Use `.onchange = function() {...}` or `addEventListener()` inside an IIFE.
6. **Embed the vehicle data inline** as a JS object literal, not fetched via `fetch()` — Elementor HTML widgets may not execute async fetches reliably.
7. **Use the `w()` helper function to write to WPForms** — the `w()` function already runs on every selection change. Add the WPForms population there so it fires for make, model, year, AND color changes, not just the final color select. This prevents stale data.
8. **User setup steps:** User adds the Single Line Text field in WPForms builder → CSS Classes = `apex-vehicle-field` → replaces the old HTML snippet with the updated one → field auto-populates.
- `user setup steps:` — The user imports the snippet INTO a WPForms HTML Content field (not into a page builder widget). This is the canonical integration point.
- `wpforms notification gating issue` — If the form uses PayPal Commerce or another payment addon, notifications may be set to fire only after payment confirmation. When using "Pay Offline" as a payment option, check the notification JSON for `"paypal_commerce": "1"` or similar flags. Removing that flag from the notification settings (in the database, not the UI) allows notifications to fire immediately. The flag is stored in `wp_posts.post_content` under `settings.notifications.{id}.paypal_commerce`. Use `unset()` on it and update the post.

**Common JS failure when populating WPForms:**
- Variable scope: `hp` must be defined in the same function scope as the WPForms line. `d[mk][md][yr]` returns the HP value — assign it to a local variable first.
- The user sees the visual spec panel update but the WPForms field stays blank → likely scope issue. Debug by checking if `hp` is defined before the `wpf.value = ...` line.
- Test with a simple selector first: if the `wpf` variable is null, the CSS class name doesn't match. Verify by running the selector in browser dev tools.

## User interaction style

This user prefers a **"vibe coding"** workflow — describe, build, test, iterate. No project manager, no discovery call, no 3-week development cycle. When they say "build X" or "make it do Y", execute. Do not present multiple options or ask which approach they prefer unless the decision has meaningful trade-offs. Default to a reasonable approach and build it.

If the user says "this is too verbose" or "this is terrible", strip down immediately. Concise deliverables with tables and bullet points are preferred over prose paragraphs.

Fixed decisions (do not re-ask):
- Dark themes for all new pages
- Mobile-first responsive design (iPhone + iPad tested)
- Single-file HTML mockups (no React/build tools)
- System font stack, no external font dependencies
- Tailwind CDN for fast styling (but NOT @apply directives — see below)

## Preferred mockup style

Dark themes (background ~#0a0a0f, cards ~#111122, accent #f97316 orange). Inter font, clean card-based layouts. Generous padding, muted secondary text. Deliver via Tailscale Serve path, serve with `python3 -m http.server`.

## Security policy reference

This user has a binding security policy: read-only default, state change requires approval, no assumed consent between actions, operation log after changes. All portal mockups that involve destructive actions (delete customer, deprovision, firewall changes) should include a confirmation flow.

## Navigation link across mockups

When adding new pages, add a DNS link (`href="dns.html"`) in the nav bar of every other mockup that has a nav section. The user expects to navigate between all portal pages.

## Responsive design requirements (EXTENSIVE update from Jul 6-7 session)

All mockups must work on **phone** (portrait + landscape), **iPad**, and **desktop**. Use Tailwind responsive prefixes throughout. This is not optional — the user has explicitly tested on their iPhone and iPad.

### General rules

1. **Summary cards** always use `grid-cols-2 md:grid-cols-4` (2 on phone, 4 on desktop). Never `grid-cols-4` or `grid-cols-5` without mobile overrides. 5-card grids become `grid-cols-2 sm:grid-cols-3 md:grid-cols-5`.
2. **Navigation** uses `gap-2 sm:gap-5` and `text-xs sm:text-sm`. Main header padding: `px-2 sm:px-4 md:px-6 py-2 sm:py-3`.
3. **Tables with 6+ columns** must use responsive column hiding with Tailwind breakpoint classes:
   - `hidden sm:block` — Time, Client (hide on phones)
   - `col-span-2 sm:col-span-1` — Domain (wider on mobile)
   - `hidden md:block` — Type, Response (hide on phones + landscape phones)
   - `hidden lg:block` — Client Hostname, less critical data
   - Table headers must match their data columns — if a column is hidden at a breakpoint, the `th` must have the same class
4. **Bottom panels** (alerts, bandwidth, quick actions) should be `grid-cols-1 md:grid-cols-2 lg:grid-cols-3` not always 3 columns.
5. **Card spacing** tightens on mobile: reduce padding from `p-4`/`p-5` to `p-2 sm:p-3`/`p-3 sm:p-4`.
6. **Detail page summary cards** (5 cards, e.g. uptime/CPU/RAM/FW/temp): `grid-cols-2 sm:grid-cols-3 md:grid-cols-5`.
7. **Text sizes** should scale down: `text-xl md:text-2xl` for bold counts, `text-base md:text-xl` for h1s.
8. **Tab bars** use `overflow-x-auto` + `white-space:nowrap` to avoid wrapping on mobile.
9. **Buttons in form sections** should stack vertically on mobile using `flex-col sm:flex-row`.
10. **Dropdowns next to search inputs** need to be in a nested flex row on mobile, not full-width and stacked.
11. **Grid gaps** tighten on mobile: `gap-1 sm:gap-3 md:gap-4` instead of a single uniform gap.
12. **Header quick-action dropdowns** should use `self-start md:self-auto` to avoid growing full-width.
13. **Subtitle/description text**: `text-xs sm:text-sm` for subtitle lines under h1s.
14. **Page title font**: `text-lg md:text-xl font-bold` for h1.
15. **Page padding**: `px-4 md:px-6 py-6 md:py-8` for main content area.

**CRITICAL: When building tables, identify which columns are vs are NOT needed at each breakpoint early, at the design stage.** Assigning responsive classes (`hidden sm:table-cell`) retroactively requires patching every single `<td>` in the table. For tables with many rows, this is a high-effort, error-prone process. Make the column priority decision before writing the HTML, not after.

**PROVEN PATTERN for responsive tables (validated on 3 mockups, works reliably):**
```html
<div class="table-wrap">
  <table>
    <thead>
      <tr>
        <th class="px-2 py-2 text-left">Status</th>   <!-- always visible -->
        <th class="px-2 py-2 text-left">Router</th>    <!-- always visible -->
        <th class="px-2 py-2 text-left hidden sm:table-cell">Customer</th>  <!-- tablet+ -->
        <th class="px-2 py-2 text-left hidden md:table-cell">Model</th>     <!-- desktop+ -->
        <th class="px-2 py-2 text-left hidden xl:table-cell">Uptime</th>    <!-- wide desktop -->
      </tr>
    </thead>
    <tbody class="divide-y divide-gray-100">
      <tr>
        <td class="px-2 py-2">...</td>
        <td class="px-2 py-2">...</td>
        <td class="px-2 py-2 hidden sm:table-cell">...</td>
        <td class="px-2 py-2 hidden md:table-cell">...</td>
        <td class="px-2 py-2 hidden xl:table-cell">...</td>
      </tr>
    </tbody>
  </table>
</div>
```

**TESTING on real devices:** After building, open the page on the user's actual phone or shrink the browser window to phone width. The most common failure is `hidden sm:block` applied to a `<td>` that should be `hidden sm:table-cell` — these are NOT interchangeable. `block` changes the display model and breaks table layout. Always use `hidden sm:table-cell` for table cells.

### HTML tables vs `display: grid` for tabular data

**Tables (`<table>`) are preferred over `display: grid` for data tables.** Tables with `<thead>`/`<tbody>` handle column-width behavior more predictably across email clients and simpler renderers. Grid-based tables (using `grid grid-cols-N`) work for basic layouts but don't respond well when columns need to hide at breakpoints — you end up having to rewrite every row. Use `<table>` with `<th>` column headers and `<td>` cells, and apply responsive hiding via `hidden sm:table-cell` / `hidden md:table-cell` on both `<th>` and corresponding `<td>` elements.

**Table wrapper**: Always wrap tables in `<div class="table-wrap">` with `overflow-x:auto; -webkit-overflow-scrolling:touch;` CSS to enable horizontal swipe on narrow screens. This ensures columns that can't fit get scrollable rather than broken.

### Specific page patterns

| Page | Tablet columns | Phone columns |
|---|---|---|
| DNS summary | 4 | 2 |
| DNS zones table | 5 cols (responsive column hide) | 3 cols (zone + status + manage) |
| Domains table | 5 cols | 2 cols (domain + renew) |
| Network summary | 4 | 2 |
| Router table | 7 cols (responsive column hide) | 4-5 cols (router + model + status + firmware) |
| Router detail cards | 5 → 3 | 2 |
| Router detail DNS stats | 4 | 2 |
| Router detail top domains/clients | 2 | 1 (stacked) |
| Bandwidth periods | 4 → 2 | 2 |
## Debt Recovery Portal Pages (debtrecoveryexperts.com)

### Serving structure

| Subdomain | Root | Auth | Content |
|-----------|------|------|---------|
| `portal.debtrecoveryexperts.com` | `/var/www/capabilities` | Public (Turnstile) | debt-recovery.html (intake), login.html, fee-calculator.html |
| `internal.debtrecoveryexperts.com` | `/var/www/internal` | Cloudflare Access SSO | index.html, dre-dashboard.html, dre-case-aging.html, letter-queue.html, dre-client-dashboard.html |

### Cloudflare Access pattern
- Internal portal protected by Cloudflare Access (SSO + JWT)
- Redirects to `iamgmb.cloudflareaccess.com` for auth
- Policy `Team`: allow emails from `germainebrown.com` and `yahoo.com` domains
- Caddy config: `internal.debtrecoveryexperts.com { root * /var/www/internal; try_files {path} {path}.html /index.html; file_server }`
- **File permission gotcha:** `write_file` creates files as `600` (root-only). Caddy runs as non-root → 403. ALWAYS run `chmod 644` on new HTML files served by Caddy.

### File permission gotcha — Caddy 403s

`write_file` creates files with mode `600` (root-only). If Caddy is serving them, it runs as a non-root user and cannot read the file → **403 Forbidden**.

**Always run `chmod 644 /path/to/file` after creating any HTML/CSS/JS file that Caddy serves.**

When diagnosing a 403 from a Caddy-served page, check file permissions first. If the file is `-rw-------` (600), that's the cause.

### Tailwind `@apply` in inline `<style>` is silently broken

`@apply` is a Tailwind build-pipeline directive that the TailwindCDN CDN script does NOT process. The browser sees it as invalid CSS and falls back to defaults — no console error. Always write plain CSS in `<style>` tags.

Convert:
- `@apply p-4 rounded-lg` → `padding: 1rem; border-radius: 8px;`
- `@apply text-gray-400 hover:text-white` → `color: #9ca3af;` + `.class:hover { color: white; }`
- `@apply fixed inset-0 bg-black/60 z-50` → `position: fixed; inset: 0; background: rgba(0,0,0,0.6); z-index: 50;`

### Customer Intake Form — ScrapingAnt-inspired split-screen

The claim intake form (`debt-recovery.html`) uses a two-panel split-screen design inspired by scrapingant.com/signup:

**Left panel (desktop only):**
- Dark gradient background: `linear-gradient(135deg, #0f172a 0%, #1e3a5f 40%, #b45309 100%)`
- Logo + tagline: "Recover what's yours. Without the headache."
- Feature icons with descriptions (AI analysis, tiered recovery, security)
- Footer note: "Trusted by contractors and owner-operators across Texas"
- Abstract pattern overlay (SVG circles at 10% opacity)

**Right panel:**
- White background, amber accent color (#f59e0b / amber-500) on focus states
- Form sections in order: Your Information → Debtor Information → Claim Details → Document Upload → Terms → Turnstile → Submit
- Document upload: empty drop zone with real `<input type="file">` + drag-and-drop JS + file list preview
- Cloudflare Turnstile invisible anti-bot protection
- Responsive: collapses to single column on mobile, logo shifts inline above form

This is the preferred visual style for all new DRE customer-facing pages.

### Persistent Navigation Bar (multi-page portal)

When building a multi-page internal portal, use a persistent nav with links: Home, Claims, Aging, Letters, Inbox, Analytics. Always update the nav on ALL existing pages when adding a new page — the user will notice inconsistencies.

Use a **4-link persistent nav** on every page (no "Clients" link — DRE team doesn't use it in daily work):

| Link | Path |
|------|------|
| Home | `/index.html` |
| Claims | `/dre-dashboard.html` |
| Aging | `/dre-case-aging.html` |
| Letters | `/letter-queue.html` |
| Inbox | `/inbox.html` |

When adding a new page to a multi-page portal, update the nav on **every** existing page. The user will notice if navs are inconsistent. Always add/remove the same links on all pages in a single batch.

### Missing `.hidden` CSS class

Every standalone HTML page that uses `classList.toggle('hidden')` for overlay/modals must define:
```css
.hidden { display: none !important; }
```
Pages like `index.html` usually have it; sibling pages (`draft-room.html`, `league.html`) commonly don't. This causes overlays to stay permanently visible even though JS correctly toggled the class. Add it to every standalone page's `<style>` block — it is NOT inherited across pages.

A subagent's task description saying a new page "copies nav from other pages" must be verified by checking at least the index page and one sibling page after any bulk change.

### Inbox page pattern

Email inbox data from `/data/dre-mails.json` (JSON array). Each email: id, mailbox (dre/collections), from, to, subject, body_preview, body, date, claim_match, is_read.

Stats bar with 3 cards (total, unread dre, unread collections). Expandable card rows with status dot (amber=unread, gray=read). Mailbox badge colors: dre=blue, collections=amber. Search filters by sender/subject/body. Mailbox filter toggle. Claim match badges linking to claims dashboard.

CSS:
```css
.nav-link { font-size:13px; color:#94a3b8; text-decoration:none; transition:color 0.15s; }
.nav-link:hover { color:#f59e0b; }
.nav-link.active { color:#f59e0b; font-weight:500; }
```

**Dark theme header:** `<header class="bg-[#0f172a] border-b border-[#1e293b] px-4 md:px-6 py-3 flex items-center justify-between">`
**Light theme header (client view):** `<header class="bg-white border-b border-gray-200 px-4 md:px-6 py-3 flex items-center justify-between">`

Light theme nav-link colors: `#64748b` default, `#d97706` active/hover.

Each page sets `class="nav-link active"` on its own link. The nav should be the **only** top-level navigation — no duplicate links in the content area.

### DRE Concept C Logo

```html
<div class="w-8 h-8 rounded-lg flex items-center justify-center font-bold text-base tracking-tight bg-[#1e293b]">
  <span class="text-amber-500">D</span><span class="text-gray-400">|</span><span class="text-slate-400">R</span><span class="text-gray-400">|</span><span class="text-amber-500">E</span>
</div>
```
- Dark theme: `bg-[#1e293b]`, amber-500 D and E, slate-400 R, gray-400 pipes
- Light theme: `bg-white`, amber-600 D and E, slate-500 R, gray-400 pipes
- Full wordmark beside logo: "DEBT Recovery Experts"
- Brand text always uses "DEBT" (all caps) for the D, not "Debt"

### Cloudflare Access + Internal Portal pattern
`login.html` served from public portal root. Two paths:
1. **SSO** button → links to `internal.debtrecoveryexperts.com/` → Cloudflare Access prompt → auth → internal portal landing
2. **New customer** link → back to intake form

### Cloudflare Access + Internal Portal pattern
- Clean intake form with 3 sections: Debtor Information, Claim Details, Document Upload
- Multi-document upload with drag-and-drop support + file list preview
- No AI analysis visible to customer
- Submit button + terms acknowledgment

### Internal DRE Dashboard: dre-dashboard.html (dark theme)
- Dark theme (#0f172a background, #1e293b cards)
- Claims queue table with AI scores per claim
- **AI Claim Analysis panel** (internal only, NOT in customer view):
  - Overall score gauge (78/100 example, green/yellow/red thresholds)
  - Factor breakdown bars: Documentation Quality, Debtor Profile, Legal Standing, Amount Reasonable
  - **Case Comparison** subpanel: matches against similar TX civil cases, shows avg recovery rate, median time, avg amount, and how current claim compares
  - AI summary paragraph and risk flags (green = strength, amber = concern)
  - Model attribution (e.g. Claude Opus 4.7) and doc count
- Action buttons: Approve & Continue / Request More Docs / Reject
- Progress timeline (intake -> AI analysis -> legal review -> notarization -> debtor served -> collection -> disbursement)

### Change History Card in Internal Dashboard

The DRE internal dashboard (`dre-dashboard.html`) includes a **Change History** card under the Case Timeline showing:

| Who | Change | Example |
|-----|--------|---------|
| DRE team member | Field edit (old→new) + reason | "changed Amount: $12,000 → $12,450 \"Corrected from invoice\"" |
| System | Auto-status change | "changed Status: Submitted → AI Review" |
| Client | Initial submission or document upload | "created claim" |

Timestamps are color-coded amber for DRE team, blue for System.

### API Keys & Capabilities Table Page

The capabilities page (`capabilities.html`) includes a full API key inventory table. Pattern:

**Columns:** Vendor, Key (masked — first 4 + last 3 chars), Used By, Usage, Status badge.

| Status colors | Meaning |
|---------------|---------|
| bg-green-100 text-green-700 | Active, within limits |
| bg-amber-100 text-amber-700 | Active (watch) — approaching limits or had issues |

Each row has `data-tip` hover tooltips with credit limits, what the key does, and flags.

Keys stored in ~/.hermes/.env (chmod 600). Usage tracked via cron logs.

This is a live inventory, refreshed from config — not hardcoded stats.

### Tailwind `@apply` in inline `<style>` is silently broken

`@apply` is a Tailwind build-pipeline directive that the TailwindCDN script does NOT process. The browser sees it as invalid CSS and falls back to defaults — no console error. Always write plain CSS in `<style>` tags.

Convert:
- `@apply p-4 rounded-lg` → `padding: 1rem; border-radius: 8px;`
- `@apply text-gray-400 hover:text-white` → `color: #9ca3af;` + `.class:hover { color: white; }`
- `@apply fixed inset-0 bg-black/60 z-50` → `position: fixed; inset: 0; background: rgba(0,0,0,0.6); z-index: 50;`

### File permission gotcha — Caddy 403s

`write_file` creates files with mode `600` (root-only). If Caddy is serving them, it runs as a non-root user and cannot read the file → **403 Forbidden**.

**Always run `chmod 644 /path/to/file` after creating any HTML/CSS/JS file that Caddy serves.**

### New Letter Template Selector (stage-gated)

The letter queue page uses a **stage-to-template mapping** pattern:

```javascript
const stageTemplates = {
  'soft-touch':     ['formal-demand'],       // Tier 1 → can skip to Tier 2
  'formal-demand':  ['lien-threat', 'escalation'],
  'lien-threat':    ['escalation', 'legal-action'],
  'escalation':     ['legal-action'],
  'legal-action':   []  // handled by counsel
};
```

Templates pre-fill the composer with placeholders: `[CLAIM]`, `[DEBTOR]`, `[AMOUNT]`, `[CLIENT]`. Each template includes `Payment can be made at: https://pay.debtrecoveryexperts.com`.

Modal UI: searchable client list, stage badge per row, template selection buttons on click. Dark theme overlay.

### Approval-Gated Outbound Workflow (Items 4-8)

Letters 4-8 (Soft Touch, Formal Demand, Lien Threat, Escalation, Legal Action) go through:
1. Queue appears as "Draft"
2. Anita authors/edits
3. Tony + Germaine must both approve
4. "Ready to Send" status
5. Sent via collections@debtrecoveryexperts.com

Items 1-3 (Onboarding, Under Review, Accepted + LPOA) are fully automatic from dre@.

## Servers Inventory Page (servers.html)

A standalone page at `/var/www/ops/servers.html` displaying server inventory from the ops data collector (`/data/ops-status.json`).

### Page sections

| Section | Detail |
|---|---|
| **Header** | Title + count badge ("N total") + "X Online / Y Total" summary + last updated + refresh button |
| **Quick Specs Comparison** | 4 cards: Total Servers, Total vCPUs, Total RAM (GB), Providers count |
| **netcup Server** | Dedicated card with name/template/IP, status dot, resource usage bars (disk % + memory %) |
| **Hetzner Servers** | Full sortable table: Name, Type (monospace badge), IP (monospace), Location, Status (with dot), Provider badge |

### Data flow

Fetches `/data/ops-status.json` (same endpoint as the dashboard) on load and auto-refresh every 30s. Renders `netcup_server` (object) and `hetzner_servers` (array) fields. Resource percentages from `overall.disk_used_pct` / `overall.memory_used_pct`.

### Spec lookup table

JavaScript object mapping plan types to vCPU/RAM:

```javascript
var SPEC_MAP = {
  'rs 2000 g12': { vcpus: 4, ram: 16 },
  'cpx11':        { vcpus: 2, ram: 4  },
  'cpx21':        { vcpus: 3, ram: 8  },
  'cpx31':        { vcpus: 4, ram: 16 },
  'cpx41':        { vcpus: 4, ram: 16 },
  'cpx51':        { vcpus: 8, ram: 32 },
  // ... expand as needed
};
```

### Sortable table pattern

Each sortable `<th>` has `onclick="sortTable('colName')"`, a `data-col` attribute, and a `sort-arrow` `<span>` with id `sort-colName`. Global `sortState = { col, dir }` tracks current sort. Toggle direction on re-click. Arrow icons: `▴` asc / `▾` desc with highlight via `var(--accent)` color.

Styled as `th.sortable { cursor: pointer; }` with hover → `var(--accent)`.

### Resource usage bars (netcup card)

Color-coded fill bars with thresholds:
- `< 70%` → green (`fill-green`)
- `70-89%` → amber (`fill-amber`)
- `≥ 90%` → red (`fill-red`)

Track: `height: 8px; background: rgba(15,23,42,0.6); border-radius: 4px; overflow: hidden;`

## Ops Portal Production Asset Architecture

The `/var/www/ops/` directory contains the IT Pro Partner operations portal — a dark-theme, mobile-first, self-contained static site served at `ops.itpropartner.com` via Caddy.

### Filesystem layout

```
/var/www/ops/
├── index.html            # Dashboard (main page)
├── services.html         # Services status (demo of architecture)
├── servers.html          # Server inventory
├── backups.html          # Backup status
├── logs.html             # Logs & Events — timeline, service events, cron errors, failure summary with filters
├── network.html          # Network infrastructure page
├── nav.html              # Shared navigation partial (fetched by all pages)
├── template.html         # Base page template for new pages
├── css/
│   └── ops.css           # Shared stylesheet (355 lines, ~10 KB)
├── js/
│   └── utils.js          # Shared utility functions (196 lines, ~6 KB)
└── data/
    └── ops-status.json   # Live data from ops-data-collector
```

### Shared stylesheet (`/css/ops.css`)

One canonical CSS file referenced by all pages. Contains everything the dashboard used to have inline: CSS custom properties (`--bg-body`, `--accent`, `--green`, `--red`, etc.), card/table/badge/grid/health-bar/status-dot styles, responsive breakpoints at 640px and 1024px, and a `.content-area` wrapper.

**Do NOT duplicate these styles inline.** All new ops portal pages must only reference `<link rel="stylesheet" href="/css/ops.css">`.

### Shared utilities (`/js/utils.js`)

IIFE-wrapped library exposing these globals on `window`:

| Function | Purpose |
|----------|---------|
| `fetchStatus([url])` | Fetches `/data/ops-status.json`, returns parsed Promise |
| `formatTime(iso)` | Formats ISO timestamp → "Jan 8, 10:45 AM" |
| `statusBadge(status)` | Returns `<span class="badge badge-*">` with ✓✗⚠ icons |
| `ageColor(iso)` | Returns color class green/amber/red by age |
| `escapeHtml(str)` | HTML entity escaping |
| `valOrDash(v)` | Returns '—' for null/undefined/empty |
| `getNested(obj, path, fallback)` | Dot-notation safe nested property access |
| `fmtAge(iso)` | Relative time string: "3h 12m ago" |
| `dotColor(status)` | Returns dot-green/dot-red/dot-amber/dot-gray |
| `colorClass(pct)` | Returns color-green (≥90), color-amber (≥70), color-red (<70) |

### Base template (`/template.html`)

New pages can start from `template.html`. It provides:
- `<link rel="stylesheet" href="/css/ops.css">`
- `<div id="nav"></div>` for nav injection
- `<script src="/js/utils.js">` for shared helpers
- `<div class="content-area" id="mainContent">` for page-specific content
- `<footer class="page-footer">` with auto-stamped "Last updated" timestamp (30s refresh)
- JS that fetches `/nav.html` on load

### Shared Navigation Partial (nav.html)

A standalone HTML fragment at `/var/www/ops/nav.html` fetched by all portal pages via browser JS:

```html
<div id="nav"></div>
<script>
fetch('/nav.html')
  .then(r => r.text())
  .then(html => { document.getElementById('nav').innerHTML = html; })
  .catch(() => { /* continue without nav */ });
</script>
```

#### Structure

- `<nav class="ops-nav">` with brand link + `<ul class="nav-links">` containing `<li><a class="nav-link" data-path="/services.html">Services</a>`
- Active page auto-detected via inline `<script>` in `nav.html`:
  ```javascript
  var currentPath = window.location.pathname;
  var links = document.querySelectorAll('#opsNav .nav-link');
  for (var i = 0; i < links.length; i++) {
    var link = links[i];
    var targetPath = link.getAttribute('data-path');
    if (currentPath === targetPath || (targetPath !== '/' && currentPath.indexOf(targetPath) === 0)) {
      link.classList.add('active');
    }
  }
  ```

#### Responsive behavior

- **Mobile (<768px):** Hamburger toggle button. Nav links hidden by default, shown when `.open` class toggled. Click-outside-to-close behavior via document click listener.
- **Tablet+ (≥768px):** Horizontal bar with all links visible. Active link gets bottom-border accent (#f59e0b).
- **Desktop (≥1024px):** Wider padding, larger font.
- Scoped CSS inline in nav.html (no external dependency — nav is a standalone injectable fragment).

#### Nav links (8 total)

Dashboard, Services, Servers, Network, Backups, Cron Jobs, Config, Logs

### How to add a new ops portal page

1. Start from `template.html` (copy it as a starting point) OR write a new page referencing the shared assets
2. Add `<link rel="stylesheet" href="/css/ops.css">` in `<head>`
3. Add `<div id="nav"></div>` + the fetch/nav-injection JS
4. Add `<script src="/js/utils.js">` for shared helpers
5. Populate `<div class="content-area">` with page-specific cards/tables/forms
6. Add the new page's link to `nav.html` and update it on ALL existing pages
7. Run `chmod 644 /var/www/ops/nav.html` (and the new page) — `write_file` creates files as `600`

### Pattern for pages that fetch live data

```javascript
fetchStatus()
  .then(function(data) {
    // Render sections using utils functions
    document.getElementById('lastUpdated').textContent = 'Updated ' + formatTime(data.timestamp);
  })
  .catch(function(err) {
    document.getElementById('errorBanner').classList.add('show');
  });
```

Auto-refresh with `setInterval(loadData, 30000)`.

### CSS file mode gotcha

`write_file` creates all files with mode `600` (root-only). Caddy runs as a non-root user → reading these files yields **403 Forbidden**. Always `chmod 644` any new HTML/CSS/JS file under `/var/www/ops/`.

## Shared Navigation Partial (nav.html) — Legacy reference

*(This section is preserved as historical reference. The current pattern is documented in the "Ops Portal Production Asset Architecture" section above.)*

A standalone HTML fragment at `/var/www/ops/nav.html` fetched by all portal pages via browser JS:

```html
<div id="nav"></div>
<script>
fetch('/nav.html')
  .then(r => r.text())
  .then(html => { document.getElementById('nav').innerHTML = html; })
  .catch(() => { /* continue without nav */ });
</script>
```

### Structure

- `<nav class="shared-nav">` with brand link + `.nav-links` containing `<a class="nav-link" data-page="pageName">` items
- Active page auto-detected via an inline `<script>` in `nav.html`:

```javascript
var page = location.pathname.replace(/\/$/, '') || '/';
var links = document.querySelectorAll('.nav-link');
for (var i = 0; i < links.length; i++) {
  if (links[i].getAttribute('href') === page) {
    links[i].classList.add('nav-link-active');
  }
}
```

### Caveats
- `nav.html` must be served by the same web server, not fetched cross-origin
- File permission: `write_file` creates at `600` — run `chmod 644 /var/www/ops/nav.html` or Caddy will 403
- Add new nav links to ALL existing pages' nav partial after adding a new portal page

## Related reference files

- `references/onboarding-pages.md` — Details of customer and router onboarding mockups built this session
- `references/mikrotik-portal-onboarding.md` — Router onboarding mockup page, WireGuard tunnel UI, one-shot registration script display pattern
- `references/login-pattern.md` — Login page two-panel layout, SSO options, mobile responsive pattern
- `references/leaflet-integration-pattern.md` — Leaflet.js + OpenStreetMap map integration: replace SVG maps, dark theme overrides, marker state management, view-switching lifecycle, programmatic popups
| `references/debt-recovery-mockups.md` | Debt recovery customer intake and internal DRE dashboard pages |
- `references/network-pages.md` — Network dashboard and router detail drill-down pages with DNS tab, DHCP, interfaces, bandwidth
- `references/dns-domains-management.md` — DNS zone management and domain registration pages, Cloudflare Registrar integration, pricing data
- `references/apex-vehicle-embed.md` — Apex vehicle registration snippet
- `references/apex-vehicle-registration-snippet.md` — Complete self-contained HTML snippet for embedding
- `references/servers-inventory-page.md` — Live server inventory page with netcup + Hetzner data, sortable table, spec lookup tables, resource usage bars, and shared nav partial pattern
- `references/cloudflare-api-reference.md` — Cloudflare API endpoint reference for DNS and Registrar operations, token permissions, and domain pricing data
- `references/network-ops-page.md` — Ops portal network page: fallback data pattern, placeholder card pattern, S3 cross-section, plain CSS alternative to Tailwind, data-source-agnostic health grid
- `references/logs-events-page-pattern.md` — Logs & Events page: timeline component, filter bar with counts, multi-source failure aggregation, red/amber card variants
- `references/config-page.md` — Config page pattern: static embedded data + live API fallback, file inventory table, environment grid, infra diagram link card
- `references/fleet-tracker-dashboard.md` — FleetTracker360 live GPS tracking dashboard concept: layout grid, mock data scenario, pure CSS for animation-heavy dashboards, backend API mapping
