# kj_affiliate_widget

Embeddable widget that lets third-party affiliate sites show
karriere-jura.de (KJ) content from a single `<script>` tag.

Four installation modes:

| Mode      | Element                  | What renders on the affiliate page                  |
|-----------|--------------------------|-----------------------------------------------------|
| `overlay` | `<kj-embed mode="overlay">` | Floating FAB → opens a popup with the full KJ site |
| `inline`  | `<kj-embed mode="inline">`  | KJ site rendered inside a panel in the page        |
| `grid`    | legacy `<script data-kj-mode="grid">` | JSON-driven job grid + filters       |
| `box`     | legacy `<script data-kj-mode="box">`  | Compact job ticker                   |

`overlay` and `inline` show the **entire KJ site** — every page, every menu
item, search, apply, the lot — via server-rendered fragments injected into
an **open Shadow DOM** on the affiliate page. No iframe, no CSP fight, no
third-party-cookie problem. See [`PARITY.md`](./PARITY.md) for the
parity audit against the origin site.

## Install on an affiliate page

```html
<!-- Modern: web component -->
<script defer src="https://karriere-jura.de/api/widget/kj-widget.js"></script>
<kj-embed key="-UuRmeYaPyIkg4KuWbhqKw" mode="inline"></kj-embed>

<!-- Legacy: <script data-kj-key> — still works, internally wraps in <kj-embed> -->
<script src="https://karriere-jura.de/api/widget/kj-widget.js"
        data-kj-key="-UuRmeYaPyIkg4KuWbhqKw"
        data-kj-mode="overlay" defer></script>
```

The widget key is issued from the **Affiliate Widget Config** backend view
(Website → Affiliate widgets) — one per (user, mode).

## Architecture in one diagram

```
Affiliate page
  <kj-embed key="..." mode="inline|overlay">         ← Web Component
        │  open Shadow DOM + <link page-css> + :host{--kj-* palette}
        ▼
  /api/widget/config/<key>      → mode + palette + lang + show_* + page
  /api/widget/menu              → website.menu tree → widget nav
  /api/widget/fragment?path=... → chrome-less KJ page HTML → injected
        │
  click delegation:
    internal link / menu → fetch next fragment (in-shadow nav)
    job search          → /api/widget/autocomplete (ORM fallback)
                       OR /api/hr.job/search if Elasticsearch deployed
    apply               → POST /api/widget/job/<id>/apply (CV upload)
    login/signup        → window.open(KJ, "_blank")
```

Key choice: the `?embed=1` layout in `kj_custom_website_theme` strips
header, footer, menu, popups conditionally based on a `keep=` token
list. The widget JS computes that token list from the affiliate's
`affiliate.widget.config` row. So one affiliate gets a lean job board,
another gets the full KJ site inside their popup — same code path.

## Public API surface

All endpoints live in `controllers/widget_api.py`, all are
`auth="public"` + `cors="*"`. Same-origin XHR works too.

| Method | Path | Purpose |
|--------|------|---------|
| GET | `/api/widget/kj-widget.js` | The widget itself (palette-baked, BASE_URL templated). |
| GET | `/api/widget/config/<key>` | mode, palette, lang, show_*, page, footer_mode. |
| GET | `/api/widget/menu?key=<key>` | KJ's `website.menu` tree. |
| GET | `/api/widget/fragment?key=&path=&keep=` | Page HTML + title + CSS links. |
| GET | `/api/widget/jobs?key=&filters` | JSON job list (used by grid/box + filter chip-bar). |
| GET | `/api/widget/filters?key` | Available facet values. |
| GET | `/api/widget/autocomplete?key=&q=` | Title/employer/city suggestions. |
| GET | `/api/widget/job/<id>/info?key=` | Job header for the apply-modal sidebar. |
| POST | `/api/widget/job/<id>/apply?key=` | Submit application (multipart, CV). |
| POST | `/api/widget/event` | Impression + click tracking. |

## Apply submission

`POST /api/widget/job/<id>/apply` accepts a multipart form with:

| Field      | Required | Notes |
|------------|:--------:|-------|
| `key`      | ✓        | Affiliate widget key. |
| `firstname` / `lastname` | ✓ (either) | |
| `email`    | ✓        | RFC-validated. |
| `phone`, `linkedin`, `message` | — | |
| `cv`       | —        | PDF/DOC/DOCX/ODT/RTF/TXT, max 10 MB. |
| `consent`  | ✓ (UI)   | Browser-enforced; not stored. |
| `website`, `url` | — | **Honeypot** — non-empty → 400. |

Creates `hr.candidate` + `hr.applicant` (multi-company guard satisfied by
pinning both to the job's company), attaches the CV as `ir.attachment`,
posts a chatter note with the affiliate widget key, logs a `click` event
in `affiliate.widget.event`. Per-key rate limit defaults to
`_WIDGET_RATE_LIMIT` per `_WIDGET_RATE_WINDOW`.

## Affiliate configuration

| Field on `affiliate.widget.config`      | Effect on the embed |
|-----------------------------------------|---------------------|
| `widget_key` / `widget_mode`            | Unique pair — identifies an embed. |
| `is_active`                             | Off → 403. |
| `filter_mode = all`                     | All published jobs are applyable. |
| `filter_mode = curated` + `filter_job_ids` | Only the curated set. |
| `theme_*_color`, `theme_btn_color`      | Flows into `--kj-*` palette tokens in the shadow root. |
| `inline_show_header` / `_topbar` / `_menu` | Add `header` / `topbar` / `menu` to the `keep` list. |
| `theme_footer_mode`                     | `full` / `impressum_only` → adds `footer`; `hidden` → strips. |
| `lang`                                  | Default fragment language; also feeds widget i18n (DE/EN). |
| `page`                                  | Landing path on first open (default `/job-offers`). |

## Tests

`tests/test_widget_apply.py` covers the public apply endpoint:

- Happy path: candidate + applicant + CV attachment + chatter + click event
- Honeypot (`website`, `url`)
- Rate-limit
- Missing key / invalid key
- Required-field validation
- CV format / size limits
- Filter-mode enforcement (`curated` jobs only)
- Job inactive → 404

Run them:

```bash
docker exec odoo18-web odoo -d kj \
  --workers=0 --http-port=8079 --gevent-port=8080 \
  --test-enable --test-tags=kj_affiliate_widget \
  --stop-after-init -u kj_affiliate_widget
```

`--workers=0` is **required** because `HttpCase` reads `httpd.server_port`
which only exists on the threaded server, not `PreforkServer`.

## Development

- **Edit JS/CSS** in `static/src/` — bump `WIDGET_CSS_VERSION` in
  `kj_widget.js` after a CSS change so browsers reload it.
- **Restart the container** after editing controllers/models:
  `docker restart odoo18-web`. Module update via `-u --stop-after-init`
  does *not* reload the live server.
- **Browser-test pages**:
  - `static/test_kj_embed.html` — lean + full-chrome panels side-by-side.
  - `static/test_all_modes.html` — overlay + inline + grid + box.
  - `static/test_widget_production.html` — production-style single embed.

## Known constraints

- **Shadow-DOM `@font-face` quirk** (Chromium): declarations inside the
  shadow root don't register at document level. The widget mirrors any
  font-bearing stylesheet (`font-awesome`, `fonts.googleapis.com`, KJ
  asset bundles) into `document.head` so the browser actually fetches
  the font files. De-duped via `window._kjLoadedFontSheets`.
- **`web.base.url` ICP** must include the public port
  (`http://localhost:8018`, not bare `http://localhost`). The embed JS
  is templated from it.

## License

LGPL-3.
