# Frontend Architecture (resources/js/)

## Stack

- **React 18** + **TypeScript** (no `any` — ESLint pre-commit blocks it)
- **Inertia.js v2** — server-driven SPA. No client-side routing. No frontend REST API consumption (except a few `/api/*` JSON endpoints — see "fetch vs router" below).
- **Tailwind CSS 4** — `@import "tailwindcss"` syntax (never `@tailwind`)
- **shadcn/ui** primitives in `Components/ui/` — DO NOT modify directly. Regenerate via `npx shadcn-ui@latest add --overwrite ComponentName`
- **Vitest** + **Testing Library** for component tests
- **TipTap** for the content editor

## Directory Layout (top-level only)

```
resources/js/
├── Components/   Domain-grouped React components. ui/ holds shadcn primitives (do not edit).
├── Layouts/      DashboardLayout, AdminLayout, AuthLayout
├── Pages/        Inertia pages — 1:1 with backend routes (Inertia::render('X/Y') ↔ Pages/X/Y.tsx)
├── hooks/        Custom React hooks (see catalog below)
├── lib/          Utilities — format.ts, sanitize.ts, utils.ts, ical.ts, errorMessages.ts
├── types/        TypeScript types (index.ts exports PageProps + shared shapes)
├── config/       navigation.ts, admin-navigation.ts, billing-constants.ts, icons.ts
├── test/         Vitest setup.ts + MSW handlers
└── ssr.tsx       SSR entry point — eagerly imports all pages (no dynamic imports)
```

To enumerate: `ls resources/js/Components/`, `find resources/js/Pages -name '*.tsx' -not -name '*.test.*'`, `ls resources/js/hooks/`.

## Custom Hooks Catalog (hooks/)

| Hook | Purpose |
|------|---------|
| `useFlashToasts()` | Reads `flash.success`/`flash.error` from Inertia props, fires `toast.success()`/`toast.error()` via Sonner. De-dupes by JSON-stringifying the flash key. |
| `useFormValidation(schema)` | Client-side **Zod v4** validation. Returns `{ errors, validateField, validateAll, clearError, setErrors }`. Uses `error.issues` (NOT `error.errors` — Zod v4 breaking change). |
| `useUnsavedChanges(isDirty)` | Adds `beforeunload` handler when `isDirty=true`. Use for settings forms. |
| `useNavigationState()` | Returns `boolean` — true while Inertia navigation is in-flight. |
| `useAdminFilters({ route, filters, debounce? })` | Debounced search + filter navigation for admin list pages. Uses `preserveState: true, replace: true`. |
| `useAdminKeyboardShortcuts({ onSearch?, onNextPage?, onPrevPage? })` | `/` focuses search, `n`/`p` navigate pages. Ignored when typing in inputs. |
| `useSiteKeyboardShortcuts({ onSearch?, onNextPage?, onPrevPage? })` | Site-scoped keyboard shortcuts. `/` focuses search, `n`/`p` navigate pages. Same API as admin variant but for site list pages. |
| `use-mobile()` | Returns `boolean` — true when viewport is below mobile breakpoint. shadcn responsive hook. |
| `useAdminAction()` | Manages confirm dialogs for admin user actions (toggleAdmin, toggleActive, impersonate). |
| `useTimezone({ initialTimezone? })` | Saves timezone via `POST /api/settings`. |
| `useAutoSave(saveFn, data, delay?)` | Auto-saves form data after debounce delay. Use for content editor and brief editing. |
| `usePolling(callback, intervalMs, enabled?)` | Polls callback at interval while enabled. Used for batch job progress and active job tracking. |
| `useQualityScore(content)` | Computes content quality score (readability, word count, heading structure). Used in content editor. |
| `useContentScore({ content, siteId, siteUrl, serpSnapshotId, nlpTerms, debounceMs })` | Real-time content scoring against SERP competitors. Debounced server calls with client-side term matching for instant feedback. Returns `{ score, isLoading, error, terms }`. |
| `useSerpAnalysis({ siteId, pollingIntervalMs? })` | Manages SERP analysis lifecycle: trigger fetch job, poll status, handle timeout (120s), cancel. Returns `{ analyze, cancel, status, serpData, isAnalyzing, error }`. |
| `useWebhooksState()` | Manages webhook endpoint list state with CRUD operations. |

For each hook's full signature + state shape, read the source — these are well-typed:
`cat resources/js/hooks/<hookName>.ts`

## Utilities Catalog (lib/)

### format.ts

| Function | Signature | Returns |
|----------|----------|---------|
| `formatFileSize` | `(bytes: number)` | `"1.2 KB"`, `"3.4 MB"` |
| `formatDateOnly` | `(dateString: string \| null, fallback?)` | `"Apr 19, 2026"` (short month) |
| `formatDateTime` | `(dateString: string \| null, fallback?)` | `"Apr 19, 2026, 2:30 PM"` |
| `formatRelativeTime` | `(dateString: string \| null)` | `"5m ago"`, `"2h ago"`, `"3d ago"` |
| `formatNumber` | `(value: number)` | `"1,234"` (thousands separator, `"—"` for non-finite) |
| `formatNumberShort` | `(value: number, decimals?)` | `"1.2K"`, `"3.4M"` |
| `formatPercent` | `(value: number, decimals?)` | `"45.5%"` (multiplies 0–1 ratio by 100) |
| `formatPercentRaw` | `(value: number, decimals?)` | `"5.2%"` (value already a percentage) |
| `formatDecimal` | `(value: number, decimals?)` | `"3.14"` |
| `formatCurrency` | `(value: number, locale?, currency?)` | `"$51.25"` (Intl.NumberFormat) |
| `truncateUrl` | `(url: string, maxLen?)` | `"/path/to/page…"` |
| `capitalize` | `(str: string)` | `"Hello"` |
| `formatProviderName` | `(provider: string)` | `"github"` → `"GitHub"` |
| ~~`formatDate`~~ | *@deprecated* | Use `formatDateTime` or `formatDateOnly` |

### utils.ts

- `cn(...inputs)` — Tailwind class name merger (clsx + tailwind-merge)

### ical.ts

- iCalendar (`.ics`) format generation for SEO calendar export. Converts `SeoCalendarEntry` data to RFC 5545 format for import into Google Calendar, Outlook, etc.

### sanitize.ts

- `sanitizeHtml(html: string)` — DOMPurify-based sanitization with explicit `ALLOWED_TAGS` and `ALLOWED_ATTR` allowlists. **Canonical pattern** — reuse this in every `dangerouslySetInnerHTML` usage.

### errorMessages.ts

- User-friendly error message conversion. `getAnalysisErrorMessage()` and similar functions translate raw API errors for display.

## Vitest Setup

`vitest.config.ts` — `globals: true`, `jsdom`, `@` alias → `./resources/js`.

`test/setup.ts` globally mocks `@inertiajs/react` (`router`, `usePage`), `ziggy-js` `route()` with a static route map, `window.matchMedia`, `ResizeObserver`, `IntersectionObserver`, Radix pointer-capture APIs, `scrollIntoView`.

**When adding a new backend route, you MUST add it to the `mockRoutes` map in `test/setup.ts`** — otherwise component tests calling `route('your.new.route')` throw.

MSW handlers in `test/mocks/handlers.ts` — override per test with `server.use(http.post(...))`.

## Key Patterns

### Inertia Shared Props

All pages receive these via `HandleInertiaRequests` middleware:

```typescript
interface PageProps {
  auth: Auth;                    // Current user + impersonation state
  flash: { success?: string; error?: string };
  errors: Record<string, string>;
  features: Features;            // Feature flags (billing, socialAuth, 2FA, etc.)
  notifications_unread_count: number;
  sites: SiteSummary[];          // All user's sites (for SiteSwitcher)
  limits: Limits | null;         // Usage limits + current counts
  active_jobs: ActiveJob[];      // Up to 5 active AiJob/AnalysisRun records with progress
  ai_max_batch_size: number;
  ai_defaults: AiDefaults;       // AI temperature/model defaults
  polling_interval_ms: number;   // Interval for polling active job progress
}

// Additional key types in types/index.ts:
// Branding — company_name, logo_url, colors, custom_domain, remove_rankwiz_branding
// ActiveJob — type: 'ai_draft' | 'analysis', site info, status, progress
// OnboardingStep, OnboardingWizardProgress, OnboardingWizardState
// TopicQuery, Topic, TopicClusteringResult
```

Access with: `const { auth, features, sites } = usePage<PageProps>().props;`

### Form Handling

Use Inertia's `useForm` hook — validation is server-side via Laravel Form Requests:

```tsx
const { data, setData, post, processing, errors } = useForm({ name: '', domain: '' });
const submit = (e: FormEvent) => { e.preventDefault(); post('/sites'); };
```

- `errors` object maps field names to error messages from server
- Use `<InputError message={errors.name} />` to display
- Use `LoadingButton` component for submit buttons (shows spinner during `processing`)

### Navigation

- **Client-side**: `<Link href="/sites/1/analyze">` (Inertia Link component)
- **Programmatic**: `router.visit('/path')`, `router.post('/path', data)`
- **Inertia router calls are fire-and-forget** — they do NOT return Promises. Don't `await` them.

### When to use fetch() vs Inertia router

**Use `router.*` (Inertia)** for all navigation and mutations that update Inertia page props (form submissions, filter changes, status updates, CRUD operations). Inertia handles the full page-props cycle including flash messages, validation errors, and shared-prop refreshes.

**Use raw `fetch()`** only for Sanctum API endpoints (`/api/*`) that return JSON and are consumed client-side without a page reload — for example:

- `POST /api/settings` (timezone, notification preferences)
- `GET /api/notifications` (notification dropdown polling)
- Any endpoint that returns structured JSON to drive a React component, not an Inertia response

**Never use `fetch()`** to call Inertia routes (those that return `Inertia::render(...)`) — use `router.visit()` or `router.reload()` instead.

| Scenario | Correct tool |
|----------|-------------|
| Form submission with redirect | `router.post()` |
| Filter/sort navigation | `router.visit()` with `preserveState` |
| Reload page props | `router.reload({ only: [...] })` |
| Sanctum API endpoint returning JSON | `fetch('/api/...')` |
| Polling active job progress | `router.reload({ only: [...] })` via `usePolling` |

### Component Conventions

- Functional components only (no class components)
- Props destructured in function signature
- Use `cn()` utility from `lib/utils.ts` for conditional class names
- Semantic color tokens for dark mode support: `text-foreground`, `bg-card`, `border-border`, `text-muted-foreground`
- Handle all three states: **loading**, **empty**, **error** (never show raw errors to users)
- Config values come as Inertia props — never hardcode

### Testing

```bash
npx vitest run                              # All frontend tests
npx vitest run resources/js/Pages/SomePage.test.tsx  # Single file
```

- Mock Inertia's `usePage` with a typed helper (see `Billing/Index.test.tsx` for pattern)
- Mock `@inertiajs/react` router for navigation assertions
- Use `@testing-library/react` with `screen` queries
- Prefer `getByRole`, `getByText` over test IDs

### Mock Pattern for Inertia Pages

```tsx
// Create typed mock page helper
const mockPage = (props: ReturnType<typeof createMockProps>) =>
  ({ props }) as unknown as ReturnType<typeof import("@inertiajs/react").usePage<PageProps>>;

// Use in tests
const { usePage } = await import("@inertiajs/react");
vi.mocked(usePage).mockReturnValue(mockPage(createMockProps({ ... })));
```

## Gotchas

1. **Zod v4**: `useFormValidation` uses `error.issues` not `error.errors`. Any new Zod schemas should use `.issues[0]?.message`.
2. **SSR**: `ssr.tsx` eagerly imports all pages (no dynamic imports). `npm run build` runs both client and SSR builds.
3. **Route mocks**: `test/setup.ts` has a static route map for `ziggy-js`. New routes need manual addition.

## Adding New Pages

1. Create page component in `Pages/DomainName/PageName.tsx`
2. Add route in Laravel `routes/web.php` pointing to a controller
3. Controller returns `Inertia::render('DomainName/PageName', $props)`
4. Add TypeScript types for page-specific props
5. Wrap in appropriate layout (`DashboardLayout` for authenticated pages)
6. Add navigation entry in `config/navigation.ts` if it should appear in sidebar
7. Write tests after implementation (test-after for UI is acceptable)

## Styling Rules

- Tailwind CSS 4 syntax: `@import "tailwindcss"` in CSS files
- Use `className` (not `style`), compose with `cn()` utility
- Dark mode: use semantic tokens (`bg-background`, `text-foreground`), NOT hardcoded colors
- Responsive: mobile-first with `sm:`, `md:`, `lg:` breakpoints
- shadcn/ui components handle their own dark mode — don't override
