# Error pages (404) and Insights routing

**Last Updated:** 2026-03-24

## Purpose

- **Consistent German UX** for missing blog posts, bad Insights URLs, and generic 404s.
- **Avoid WordPress** for stray `/insights/*` paths that are not valid app routes (previously fell through to `index.php` and the theme 404).

## URL matrix (expected behaviour)

| URL pattern | HTTP | Served by |
|-------------|------|-----------|
| `/insights/` | 200 | [`v2/pages/blog/index.php`](../../../v2/pages/blog/index.php) |
| `/insights/page/2/` | 200 | [`v2/pages/blog/index.php`](../../../v2/pages/blog/index.php) |
| `/insights/lexikon/{slug}/` (valid JSON) | 200 | [`v2/pages/blog/post.php`](../../../v2/pages/blog/post.php) |
| `/insights/lexikon/{slug}/` (missing post) | 404 | [`v2/pages/errors/render-blog-error-page.php`](../../../v2/pages/errors/render-blog-error-page.php) (`article_not_found`) |
| `/insights/{invalid-cat}/…` from `post.php` | 404 | `render-blog-error-page.php` (`category_not_found`) |
| `/insights/hello/` (unknown single segment) | 404 | [`v2/pages/blog/insights-error-router.php`](../../../v2/pages/blog/insights-error-router.php) |
| `/insights/foo/bar/` (unknown namespace) | 404 | `insights-error-router.php` |
| `/insights/lexikon/a/b/` (extra path segment) | 404 | `insights-error-router.php` |
| `/insights/page` (no page number) | 404 | `insights-error-router.php` |
| `/insights/topics/{missing}/` | 404 | `render-blog-error-page.php` (`topic_not_found`) |
| `/insights/bilder/{file}` | 200 / file | Earlier [`.htaccess`](../../../.htaccess) image rules (Apache); **PHP built-in `router.php` may differ** for local `/insights/bilder/*` |
| Comparison invalid slug | 404 | [`v2/pages/errors/render-marketing-error-page.php`](../../../v2/pages/errors/render-marketing-error-page.php) via [`v2/pages/404.php`](../../../v2/pages/404.php) |
| Other URLs hitting WordPress `index.php` | 404 | [`wp-content/themes/ordio/404.php`](../../../wp-content/themes/ordio/404.php) (residual) |

## Key files

| File | Role |
|------|------|
| [`v2/pages/errors/render-blog-error-page.php`](../../../v2/pages/errors/render-blog-error-page.php) | Insights shell: `head.php`, header, footer, German copy, skip link, `noindex` |
| [`v2/pages/errors/render-marketing-error-page.php`](../../../v2/pages/errors/render-marketing-error-page.php) | Marketing shell for generic 404 |
| [`v2/pages/blog/insights-error-router.php`](../../../v2/pages/blog/insights-error-router.php) | Entry from `.htaccess` for unknown `/insights/*` |
| [`.htaccess`](../../../.htaccess) | Rules **after** pillar routes, **before** global `index.php` catch-all |
| [`router.php`](../../../router.php) | Parity for `php -S` (does not read `.htaccess`) |
| [`wp-content/themes/ordio/404.php`](../../../wp-content/themes/ordio/404.php) | Residual WordPress 404 |
| [`wp-content/themes/ordio/functions.php`](../../../wp-content/themes/ordio/functions.php) | Skip link text: “Zum Inhalt springen” |

## Changing routes

When adding new top-level segments under `/insights/`, update **both**:

1. [`.htaccess`](../../../.htaccess) — whitelist in the “Unknown /insights paths” conditions (single- and two-segment rules).
2. [`router.php`](../../../router.php) — add an explicit match **before** the final `/insights/` catch-all.

Otherwise new paths may incorrectly return the Insights 404 or hit WordPress.

## CTAs and modal (Insights 404 vs marketing 404)

**Insights/blog error** ([`render-blog-error-page.php`](../../../v2/pages/errors/render-blog-error-page.php)): primary **Kostenlos testen** (opens conversion modal) plus a **Zur Insights-Übersicht** link to `/insights/` (secondary, outlined style). No extra modal CTAs on this template.

**Marketing/generic error** ([`render-marketing-error-page.php`](../../../v2/pages/errors/render-marketing-error-page.php)): may still include **Demo vereinbaren** and other actions per that layout.

Both error shells use `<body x-data="{}">` so Alpine compiles `@click` on modal buttons. Modal buttons include an `onclick` fallback calling `Alpine.store('modal').open(...)` (same pattern as the footer) so the modal opens after deferred Alpine loads.

## Testing

```bash
# Optional: set base URL when Docker/stack is running (no trailing slash)
export ORDIO_ERROR_TEST_BASE=http://localhost:8003
python3 v2/scripts/dev-helpers/test-error-endpoints.py
```

Without `ORDIO_ERROR_TEST_BASE`, the script still validates that repo wiring (`.htaccess` markers and PHP files) is present.

## Documentation inventory

Regenerate after adding docs:

```bash
python3 scripts/documentation/inventory-documentation.py
python3 scripts/documentation/check-redundancy.py
```
