> For the complete documentation index, see [llms.txt](https://docs.ipcheck.ing/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.ipcheck.ing/developer/architecture/frontend.md).

# Frontend

Everything under `frontend/` is a Vue 3 SPA using `<script setup>`, Pinia, vue-router in HTML5 history mode, vue-i18n, and Tailwind CSS v4 over copied-in shadcn-vue primitives. No TypeScript.

`App.vue` is a thin shell — tooltip provider, toast host, PWA install prompt, theme, and `<router-view>`. It is also where the two event-driven pipelines are initialized exactly once.

## Routing

`frontend/router/index.js` declares four real routes and a catch-all:

| Path               | Component               | Notes                                           |
| ------------------ | ----------------------- | ----------------------------------------------- |
| `/`                | `Home.vue`              | Eagerly imported — the default landing page     |
| `/tools/:slug`     | `StandaloneTool.vue`    | Full page for one tool, shareable and indexable |
| `/privacy`         | `PrivacyPolicy.vue`     |                                                 |
| `/r/:id`           | `report/ReportPage.vue` | Read-only shared diagnostic report, noindex     |
| `/:pathMatch(.*)*` | —                       | Redirects to `/`                                |

Everything except `Home` is lazily imported so it stays out of the homepage bundle. `scrollBehavior` deliberately does **not** scroll when only the query changes on the same path — that is what opening and closing the tools drawer does.

Advanced tools have a second entry point: on the homepage, `?tool=<slug>` opens the same component inside a bottom drawer. Both entry points render the same `.vue` file; only the wrapper differs.

## The tools registry

`frontend/data/tools.js` is the single source of truth for advanced tools. It exports one ordered array and a lookup map:

```js
export const ADVANCED_TOOLS = [
  { slug: 'whois', emoji: '📓', titleKey: 'whois.Title',
    noteKey: 'advancedtools.Whois',
    component: () => import('@/components/advanced-tools/Whois.vue') },
  // …
];

export const TOOL_BY_SLUG = new Map(ADVANCED_TOOLS.map((t) => [t.slug, t]));
```

Entry fields:

| Field                  | Meaning                                                          |
| ---------------------- | ---------------------------------------------------------------- |
| `slug`                 | Stable identifier used by both `?tool=<slug>` and `/tools/:slug` |
| `emoji`                | Glyph on the card and in the drawer header                       |
| `titleKey` / `noteKey` | i18n keys for the title and the one-line description             |
| `component`            | Lazy `import()` of the tool's `.vue` file                        |
| `requiresOriginalSite` | Optional gate; omitted means the tool is public                  |

Three consumers derive from it, which is why adding an entry is usually the whole job:

* **`Advanced.vue`** maps the array into the card grid (`cards`), filters it against `configs.originalSite` (`enabledCards`), and resolves the drawer's active tool with `TOOL_BY_SLUG.get(route.query.tool)`. The resolved lazy component is cached in a `Map` so re-renders do not remount a running tool. A plain left click on a card calls `router.push({ path: '/', query: { tool } })`; modifier and middle clicks fall through to the card's `<a href>` so the standalone page opens in a new tab.
* **`StandaloneTool.vue`** resolves `TOOL_BY_SLUG.get(route.params.slug)`, wraps it in `defineAsyncComponent`, sets a per-tool localized title, description and canonical URL via `use-document-meta.js`, and redirects to `/` on an unknown slug. Note that the router itself has only the one dynamic route — the registry does the resolving, not a generated route table.
* **`Nav.vue`** lists the same tools in the navigation, applying the same `requiresOriginalSite` filter.

{% hint style="info" %}
`requiresOriginalSite: true` hides a tool on self-hosted instances, because it needs the private IPCheck.ing API and a signed-in user. The flag is evaluated against `store.configs.originalSite`, which the backend derives from the request's referer — see [Features Tied to IPCheck.ing](/developer/configuration/features-tied-to-ipcheck-ing.md).
{% endhint %}

Other registries live next to it in `data/`: `sections.js` (the homepage section IDs that drive the nav, scroll tracking and loading state), `ip-databases.js` (the geolocation sources users can switch between), `achievements.js` and `achievement-rules.js`, `changelog.json`, and `default-preferences.js`.

## Pinia store

`frontend/store.js` defines one store, `main`. It holds cross-component state rather than per-component detail:

* **Session / auth** — `user`, `isSignedIn`, `isFireBaseSet`, plus the sign-in, sign-out and auth-listener actions.
* **Backend feature flags** — `configs`, filled fire-and-forget by `fetchConfigs()` from `/api/configs`. Components read it reactively, so the first render never waits on that round trip. On arrival the flags also derive each geolocation source's availability and, if the stored source preference is no longer configured, migrate it to the nearest available one.
* **User preferences** — `userPreferences`, loaded from and written back to `localStorage`, with migration from legacy keys.
* **Page state** — `mountingStatus` and `loadingStatus` (one flag per section from `data/sections.js`), `currentSection`, `isMobile`, `isDarkMode`, `openSheet`, the toast `alert` slot.
* **Collected IPs** — `allIPs`, an array of `{ ip, country, location, asn, org }` merged from several components via `updateAllIPs()`; later sources back-fill fields earlier ones left empty. The Globalping pickers and the IP-history recorder read it.
* **Geolocation sources** — `ipDBs` (from `data/ip-databases.js`) with the `activeSources` getter; `enabled` is config-derived only, never flipped by runtime failures.
* **Achievements** — `userAchievements` plus a single-slot update pipeline (`triggerUpdateAchievements` / `achievementToUpdate`) that `User.vue` watches and reports to the backend.

State objects that must not be shared between store instances are produced by factories (`createInitialIpDBs()`, `createMountingStatus()`, …) rather than module-level literals.

## The app-events bus

`frontend/utils/app-events.js` is \~30 lines: a `Map` of event name to a `Set` of handlers, `onAppEvent(event, handler)` returning an unsubscribe function, and `emitAppEvent(event, payload)`. Emitting is fire-and-forget and a throwing handler is caught, so one broken subscriber cannot break the emitter. It imports nothing from Vue, so utils and plain modules can use it too.

Components emit domain events **unconditionally** — "the speed test finished", "the whois lookup ran" — and stay ignorant of who listens. Two pipelines ride the bus, both initialized once in `App.vue`.

```mermaid
flowchart TD
    C["Components (SpeedTest, Whois, IpInfos, …)"]
    BUS["utils/app-events.js"]
    AE["use-achievement-engine.js"]
    RC["use-report-collector.js"]
    SE["sentry-init.js"]
    ST["Pinia store slot → User.vue → backend"]
    SN["Report snapshots → share dialog + /r/:id"]

    C -->|"emitAppEvent('speedtest:finished', {...})"| BUS
    BUS --> AE --> ST
    BUS --> RC --> SN
    BUS --> SE
```

### Achievements engine

`data/achievement-rules.js` maps events to achievement slugs. Each rule is `{ event, slug, when? }`, where `when` is a pure predicate over the payload:

```js
{ event: 'speedtest:finished', slug: 'RapidPace', when: (p) => p.downloadSpeed >= 500 },
```

`composables/use-achievement-engine.js` subscribes one listener per rule and owns every cross-cutting guard: it drops events when the visitor is not signed in, skips already-unlocked achievements, evaluates `when`, then queues the slug. The store holds one achievement at a time, so simultaneous unlocks (a single speed test can cross three thresholds) are dispatched 2 seconds apart, and each is re-checked at dispatch time in case it was unlocked while queued.

Adding an achievement therefore means: an entry in `data/achievements.js`, a rule in `data/achievement-rules.js`, and a new domain event only if no suitable one already exists. Components are never touched.

### Report collector

The shareable diagnostic report rides the same bus. Every "my network" test emits `<domain>:finished` with its full structured result. `composables/use-report-collector.js` runs each payload through its builder in `utils/report-builders.js`, keeps the latest snapshot per section (latest wins), and exposes them read-only to the share dialog and the `/r/:id` page. The section shapes are whitelisted by `common/report-schema.js`, the same module the backend validates uploads against.

{% hint style="warning" %}
Builders fail soft: an unknown value silently drops the field. If you change a test's result semantics, update its builder whitelist and the schema enum in the same change, or the field will quietly disappear from reports instead of erroring.
{% endhint %}

## Boot sequence

`frontend/main.js` keeps the critical path short. It creates the app, Pinia, i18n and the router, then gates the first render on only three things, run in parallel: the auth listener (**only** for a visitor whose `auth-hint` flag says they were signed in), `store.loadPreferences()`, and `loadActiveLocaleMessages()`. Everything else is fire-and-forget or deferred to after mount — `store.fetchConfigs()`, analytics, the flag-icon collection (hundreds of KB), and Sentry.

It also registers a `vite:preloadError` listener at module evaluation: after a deploy, a page loaded from the old build will fail to lazy-import a hashed chunk that no longer exists, so the app reloads once (with a per-tab timestamp latch that prevents a reload loop when the real cause is an offline network).

## On-demand locales

`frontend/locales/i18n.js` creates the i18n instance with **empty** messages and a loader map of dynamic imports (`en`, `zh`, `fr`, `ru`). Only one locale is ever active per page load — switching language persists the choice and reboots the app — so `loadActiveLocaleMessages()` loads the active locale plus English as fallback, and `main.js` awaits it before mounting. Bundling all four eagerly cost about 44 KB gzipped of dead weight.

The same principle applies to sub-packs: the security-checklist datasets under `locales/security-checklist/` are loaded by that tool on demand, not from the boot path.

After messages land, `updateMeta()` sets `document.documentElement.lang` (with `zh` declared as `zh-CN`), the page title, and the keywords / description meta tags. Per-page overrides come from `composables/use-document-meta.js`.

## Env-gated dynamic init

Two optional integrations are gated at build time so a self-hosted deployment without them ships **no** related code at all.

* **Sentry** — gated on `VITE_SENTRY_DSN_FRONTEND`. Without the DSN, `sentry-init.js` is never imported and the SDK is not in the bundle. With it, the chunk loads after mount — or immediately if a pre-init error fires. A small buffer catches uncaught errors and rejections before init and flushes them afterwards.
* **Firebase Auth** — gated on `VITE_FIREBASE_API_KEY` + `VITE_FIREBASE_AUTH_DOMAIN` + `VITE_FIREBASE_PROJECT_ID`. `firebase-init.js` loads `firebase/app` and `firebase/auth` on the first `loadFirebaseAuth()` call — a signed-in boot, a sign-in click, or the background probe — and memoizes the result. A visitor who never signs in never downloads the SDK.

<details>

<summary>How the auth hint picks the boot path</summary>

`utils/auth-hint.js` stores a flag describing whether the last session was signed in. On boot `main.js` reads it:

* `'1'` — load Firebase and wait for the auth listener before the first render, so the first authenticated request already carries the token.
* `'0'` — mount immediately; the SDK is never loaded until a sign-in click.
* `null` (first visit since the flag shipped, or cleared storage) — mount immediately, then probe auth in the background after 3 seconds so the next boot takes the exact path.

</details>

App code must never import `@sentry/vue` directly — a static import would drag the SDK back into the main bundle. Explicit signals go through the event bus instead; `sentry-init.js` subscribes to `ip-source:exhausted` (an IP card whose whole source chain failed) the same way the achievement engine subscribes to its own events. Configuration lives in [Error Monitoring](/developer/configuration/error-monitoring.md).

## Helper placement

| Needs                            | Goes in                                             |
| -------------------------------- | --------------------------------------------------- |
| Vue reactivity or lifecycle      | `composables/` as `useXxx`                          |
| Nothing Vue-specific             | `utils/` (never `use-` prefixed)                    |
| shadcn support (`cn()`)          | `lib/`                                              |
| Something the backend also needs | `common/`, re-exported through a bridge in `utils/` |

Conventions for writing these files are in [Coding Conventions](/developer/development/coding-conventions.md); testing scope is in [Testing](/developer/development/testing.md).


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://docs.ipcheck.ing/developer/architecture/frontend.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
