---
name: himetrica-user-source-column
description: Add a "Source" column (first-touch referrer with favicon and UTM, e.g. google.com, twitter.com, Direct) to an admin panel's users/customers table using Himetrica, matched by the app's user id with email fallback. Use when the user wants to see where each user came from, acquisition channel, campaign, or referrer in their admin users list or customers table.
---

# Himetrica source column

Goal: the admin's users table gets a "Source" column showing where each user first came
from (referrer favicon + domain, campaign on hover). One Himetrica call per page of rows.

Requires `himetrica-install` (tracker + `identify` with `userId` = the app's user id, and
email). Users who signed up before the tracker was installed show "—".

## 0. Gather

- Env (server only): `HIMETRICA_SECRET_KEY` (`hm_sk_...`, Himetrica > project settings >
  API) and `HIMETRICA_PROJECT_ID` (`GET https://app.himetrica.com/api/v1/me` returns it).
- The users table component, how its rows are fetched (server or client), each row's user
  id and email, and the admin auth guard used by the panel's server routes.

## 1. Server client (create once, reuse if another himetrica-* skill already made it)

The secret key only ever runs on the server. No server in the app (pure SPA on
Supabase/Firebase)? Put this in a serverless/edge function and call that from the SPA.
Never expose it through `NEXT_PUBLIC_`/`VITE_`.

```ts
// lib/himetrica.server.ts  (server-only; in Next.js add: import "server-only";)
const BASE_URL = "https://app.himetrica.com/api/v1";
type Params = Record<string, string | number | boolean | undefined>;
const cache = new Map<string, { at: number; data: unknown }>();

export class HimetricaError extends Error {
  constructor(public status: number, public code: string, message: string) {
    super(message);
  }
}

export async function himetrica<T>(path: string, params: Params = {}, ttlSeconds = 60): Promise<T> {
  const key = process.env.HIMETRICA_SECRET_KEY;
  const projectId = process.env.HIMETRICA_PROJECT_ID;
  if (!key || !projectId) throw new HimetricaError(500, "NOT_CONFIGURED", "Missing HIMETRICA_SECRET_KEY or HIMETRICA_PROJECT_ID");

  const url = new URL(`${BASE_URL}/projects/${projectId}${path}`);
  for (const [k, v] of Object.entries(params)) if (v !== undefined) url.searchParams.set(k, String(v));

  const hit = cache.get(url.href);
  if (hit && Date.now() - hit.at < ttlSeconds * 1000) return hit.data as T;

  const res = await fetch(url, { headers: { "X-API-Key": key }, cache: "no-store" });
  const body = await res.json().catch(() => ({}));
  if (!res.ok) throw new HimetricaError(res.status, body.code ?? "HTTP_ERROR", body.error ?? res.statusText);

  if (cache.size > 500) cache.clear();
  cache.set(url.href, { at: Date.now(), data: body.data });
  return body.data as T;
}

export async function himetricaPost<T>(path: string, payload: unknown): Promise<T> {
  const key = process.env.HIMETRICA_SECRET_KEY;
  const projectId = process.env.HIMETRICA_PROJECT_ID;
  if (!key || !projectId) throw new HimetricaError(500, "NOT_CONFIGURED", "Missing HIMETRICA_SECRET_KEY or HIMETRICA_PROJECT_ID");

  const res = await fetch(`${BASE_URL}/projects/${projectId}${path}`, {
    method: "POST",
    headers: { "X-API-Key": key, "Content-Type": "application/json" },
    body: JSON.stringify(payload),
    cache: "no-store",
  });
  const body = await res.json().catch(() => ({}));
  if (!res.ok) throw new HimetricaError(res.status, body.code ?? "HTTP_ERROR", body.error ?? res.statusText);
  return body.data as T;
}
```

(Deno/Supabase Edge: `Deno.env.get(...)` instead of `process.env`.)

## 2. The endpoint

`POST /visitors/lookup` with `{ users: Array<{ userId?: string; email?: string }> }`,
1 to 100 items, in the same order as the table rows. Each item is matched by `userId`
first, then by email (case-insensitive). Results come back in input order:

```ts
type LookupResult = {
  input: { userId?: string; email?: string };
  match: "userId" | "email" | null;         // null = never seen by Himetrica
  visitor: null | {
    id: string; userId: string | null; email: string | null; name: string | null;
    country: string | null; firstSeenAt: string; lastSeenAt: string;
    sessionsCount: number;
    firstTouch: {
      referrerDomain: string | null;         // null = direct
      referrerFavicon: string | null;
      utmSource: string | null; utmMedium: string | null; utmCampaign: string | null;
      landingPage: string | null;
      firstSessionAt: string | null;
    };
    dashboardUrl: string;                    // the visitor's profile in Himetrica
  };
};
// response data: { results: LookupResult[] }
```

Himetrica caches each lookup for 5 minutes. The endpoint allows 20 calls per minute per
key (up to 2,000 users), so keep the 10 minute server cache below and never call it per row. Never call `/visitors/{ref}` per row
(that endpoint is limited to 10/min).

## 3. Server function

```ts
// lib/himetrica-sources.server.ts
import { himetricaPost } from "./himetrica.server";

export type UserSource = {
  source: string;               // domain or "Direct"
  favicon: string | null;
  campaign: string | null;      // "google / cpc / spring-sale"
  landingPage: string | null;
  himetricaUrl: string;
};

const TTL_MS = 10 * 60 * 1000;
const memo = new Map<string, { at: number; value: UserSource | null }>();
const keyOf = (u: { userId?: string; email?: string }) => `${u.userId ?? ""}|${(u.email ?? "").toLowerCase()}`;

export async function getSources(users: Array<{ userId?: string; email?: string }>): Promise<Array<UserSource | null>> {
  const now = Date.now();
  const missing = users.filter((u) => {
    const hit = memo.get(keyOf(u));
    return !hit || now - hit.at > TTL_MS;
  });

  for (let i = 0; i < missing.length; i += 100) {
    const batch = missing.slice(i, i + 100);
    const { results } = await himetricaPost<{ results: any[] }>("/visitors/lookup", { users: batch });
    results.forEach((r, j) => {
      const v = r.visitor;
      memo.set(keyOf(batch[j]), {
        at: now,
        value: v && {
          source: v.firstTouch.referrerDomain?.replace(/^www\./, "") ?? "Direct",
          favicon: v.firstTouch.referrerFavicon,
          campaign: [v.firstTouch.utmSource, v.firstTouch.utmMedium, v.firstTouch.utmCampaign].filter(Boolean).join(" / ") || null,
          landingPage: v.firstTouch.landingPage,
          himetricaUrl: v.dashboardUrl,
        },
      });
    });
  }
  if (memo.size > 5000) memo.clear();
  return users.map((u) => memo.get(keyOf(u))?.value ?? null);
}
```

Pass both `userId` and `email` for every row when the table has them.

## 4. Wire it into the table

- Server-rendered table: call `getSources(rows.map(r => ({ userId: r.id, email: r.email })))`
  in the loader that fetches the page of users and pass the result with the rows.
- Client-fetched table: add `POST /api/admin/himetrica/sources` `{ users }` (max 100,
  behind the admin guard) that returns `getSources(users)`; fetch it after the rows
  arrive, keyed on the page's ids.

The table must render immediately; the column fills in when the data arrives and falls
back to "—" on any error (403 `PLAN_REQUIRED` included). Never fail the table because of
Himetrica.

## 5. Cell

- Favicon (16px, rounded) + domain. Use `favicon`, else
  `https://www.google.com/s2/favicons?domain=<domain>&sz=32`. "Direct" gets a neutral dot.
- Tooltip: campaign (when present) and landing page.
- `null` → "—" in muted text, tooltip "Not seen by Himetrica yet".
- Row action or cell click: open the admin's user page (or `himetricaUrl` in a new tab
  if the panel has no user page).
- Match the table's existing cell styles; no new dependencies.

## Done when

- [ ] One `/visitors/lookup` call per page of rows; nothing calls `/visitors/{ref}` per row.
- [ ] Every row sends `userId` (the app's id) and `email` when available.
- [ ] Route (if any) behind the admin guard; secret key server-only.
- [ ] Table renders without waiting for Himetrica; errors degrade to "—".
