Skip to main content

Documentation Index

Fetch the complete documentation index at: https://developers.beta.dealroom.co/llms.txt

Use this file to discover all available pages before exploring further.

A portfolio monitoring dashboard for a VC fund. Paste a prompt into Claude, provide your Dealroom Application API key (client_id only — no secret) and your fund ID, and get a fully branded dashboard — themed from your fund’s logo.
Portfolio Dashboard Preview

How It Works

  1. Create a Browser app API key in your Dealroom dashboard.
  2. You paste the prompt into Claude with your Client ID and Investor ID.
  3. Claude generates a pure SPA — users log in with their Dealroom account, then the browser calls the API directly. No backend, no serverless proxy.
  4. Claude reads your logo, extracts brand colors, and generates a personalized dashboard.
Browser app keys are read-only by design. Tokens live in the browser — write operations are never allowed with this key type. For server-side writes, use a Programmatic key behind a proxy instead.

Create a Browser app API key

Before starting, create the key your SPA will use.
1

Open Settings > API

Go to Settings > API in your Dealroom dashboard.
2

Select "Browser app"

On the key type selector, pick Browser app. A read-only banner confirms the constraint.
3

Name your key

Use something descriptive, e.g. Portfolio Dashboard — Production.
4

Add allowed origins

List every domain your app runs on. The API blocks requests from any other origin.
http://localhost:5173
https://portfolio-staging.yourfund.com
https://portfolio.yourfund.com
Include http://localhost:5173 (or whatever port your Vite dev server uses) so you can develop locally.
5

Confirm callback URLs

Callbacks auto-fill as <origin>/callback when you type an origin — the form handles this for you. Override only if your app uses a different redirect path.
http://localhost:5173/callback
https://portfolio-staging.yourfund.com/callback
https://portfolio.yourfund.com/callback
6

Create and copy the Client ID

Click Create key. The dashboard shows the client_id — copy it. There is no client_secret for browser app keys (that’s the point: nothing sensitive ships in your bundle).
Need a new environment later? Open the key, edit it, and add the new origin + callback URL. No need to create another key per deploy target.

The Prompt

# Build a Dealroom Portfolio Dashboard (Browser SPA)

Create a **VC portfolio monitoring dashboard** using React + Vite + TypeScript that connects
directly to the Dealroom API from the browser via Auth0 Universal Login. The app is a pure
SPA — no backend, no serverless proxy. Deployable as static assets to Vercel, Netlify,
Cloudflare Pages, or S3/CloudFront.

## Tech Stack (pinned versions)

| Library              | Version | Purpose                                    |
| -------------------- | ------- | ------------------------------------------ |
| React                | 19.1    | UI framework                               |
| TypeScript           | 5.8     | Type safety                                |
| @auth0/auth0-spa-js  | 2       | OAuth2 PKCE for browser login              |
| Tailwind CSS         | 4.0     | Utility-first styling (PostCSS)            |
| shadcn/ui            | latest  | Radix UI-based component library           |
| Framer Motion        | 12      | Animations and transitions                 |
| TanStack Query       | v5      | Server state management                    |
| D3                   | 7       | Charts and data visualization              |
| MapLibre GL          | 5       | Interactive world map                      |
| Vite                 | 6       | Build tooling                              |

Use these exact major versions. Initialize with:

  npm create vite@latest portfolio-dashboard -- --template react-ts

Then install:

  npm install react@^19.1 react-dom@^19.1 @auth0/auth0-spa-js@^2 @tanstack/react-query@^5 \
    d3@^7 @types/d3@^7 maplibre-gl@^5 framer-motion@^12 tailwindcss@^4 @tailwindcss/postcss \
    clsx tailwind-merge
  npx shadcn@latest init


## API Version Pinning

CRITICAL: Pin the API version on every request via the API-Version header — otherwise
responses follow the latest, which may change without notice.

Ask the user which version to pin (format: YYYY-MM-DD). If they don't specify, default
to a recent stable version.

Create a constants file:

  // src/lib/constants.ts
  export const DEALROOM_API_VERSION = "<YYYY-MM-DD provided by user>";
  export const API_VERSION_HEADER = "API-Version";


## Step 0: Discover the API via OpenAPI (DO THIS FIRST)

NEVER hardcode endpoint paths from memory. The Dealroom API publishes a complete
OpenAPI 3.1 specification — fetch it and treat it as the single source of truth for
every path, parameter, filter, and response schema you use below.

  curl -s https://api-next.beta.dealroom.co/openapi -o openapi.json

### Example — find the "load a single investor" endpoint

Search the spec for investor-related paths:

  jq '.paths | to_entries[] | select(.key | test("/api/investors")) | {path: .key, methods: (.value | keys)}' openapi.json

You'll see something like:

  { "path": "/api/investors",      "methods": ["get"] }   // list investors
  { "path": "/api/investors/{id}", "methods": ["get"] }   // get one investor

Inspect the full schema (parameters, response, filters) for the one you want:

  jq '.paths["/api/investors/{id}"]' openapi.json

Then construct the full request URL by prepending the API base:

  GET https://api-next.beta.dealroom.co/api/investors/{INVESTOR_ID}

Apply this exact pattern for EVERY resource you use (investors, transactions,
entities, aggregations, taxonomies). When in doubt about a path, filter name, or
parameter, open openapi.json and confirm — do not guess.


## Step 1: Discover Your Client

Before writing any runtime code, fetch real data about the investor so you can personalize
the dashboard (logo, name, tagline, portfolio stats, location). Do this discovery once
via curl — the runtime SPA will re-fetch the same data via the user's token.

### 1a. Ask the user for their inputs

Before writing any code, collect these from the user — do not proceed until you have all
four. These come from the "Create a Browser app API key" flow in the Dealroom dashboard
(Settings > API). Browser app keys have NO client_secret; do not ask for one.

- CLIENT_ID — the Dealroom Browser app API key's client ID (public value, safe to ship)
- INVESTOR_URL — the Dealroom investor profile URL for their fund
  (e.g. `https://app-next.beta.dealroom.co/investors/33572`).
  DO NOT ask the user for a numeric ID. After they paste the URL, extract the trailing
  numeric segment yourself with e.g.:

    INVESTOR_ID=$(echo "$INVESTOR_URL" | sed -E 's|.*/investors/([0-9]+).*|\1|')

  and use INVESTOR_ID internally for all subsequent API calls.
- DEPLOY_ORIGIN — the URL where the app will run (e.g. `http://localhost:5173` for local,
  `https://portfolio.myfund.com` for production). Must already be registered in the key's
  Allowed origins.
- CALLBACK_URL — typically `<DEPLOY_ORIGIN>/callback`, must be registered in the key's
  Allowed callback URLs.

Auth0 tenant values are fixed for the platform — use them as given:
- AUTH0_DOMAIN = `accounts.beta.dealroom.co`
- API_AUDIENCE = `https://api-next.beta.dealroom.co`

If the user has not created a Browser app key yet, stop and walk them through it: have
them open Dealroom Settings > API, select "Browser app" key type, name it, add their
app's origins (e.g. http://localhost:5173 for local dev), and copy the client_id.

### 1b. Get a one-time token for discovery

For THIS curl-based discovery step only, ask the user to grab a short-lived bearer token
from their browser (open the Dealroom dashboard, copy the `Authorization` header from any
API request in DevTools' Network tab). Use it to fetch the investor profile below. This
is a one-off — the generated SPA handles its own auth via Universal Login at runtime.

### 1c. Fetch the Investor Profile

Use the path you discovered in Step 0 (`/api/investors/{id}`):

  curl -s "https://api-next.beta.dealroom.co/api/investors/{INVESTOR_ID}" \
    -H "Authorization: Bearer {USER_TOKEN}" \
    -H "API-Version: <version from changelog>" \
    -H "X-Client-Id: {CLIENT_ID}" | jq .data

This returns the investor's real data including:
- name, image (logo URL), tagline
- hq_country, hq_city
- investments: { total_count, total_invested, preferred_round }
- portfolio: { companies, total_rounds }
- tags (e.g. "Venture Capital")

### 1d. Fetch the Logo and Read Its Colors

Download the logo from the `image` URL and analyze its dominant colors:

  curl -s -o logo.png "{IMAGE_URL_FROM_RESPONSE}"

Read the downloaded logo image. Note the 2-3 dominant colors you see.

### 1e. Use This Context

Now you know the investor's:
- Name and tagline → use in the Header component, page title, and meta tags
- Logo URL → hardcode as the default in the Header
- Dominant colors → set as the initial CSS custom property values in your theme
  so the dashboard looks branded even before the runtime color extraction runs
- Portfolio stats (total invested, deal count, companies, preferred round) →
  use to pick appropriate number formatting and chart ranges
- Location (hq_country, hq_city) → use for the map's initial viewport center
- Tags → use to inform the dashboard subtitle or category label

This is the most important step. The dashboard should feel like it was hand-built
for this specific investor, not a generic template with a logo swapped in.


## Step 2: Auth0 SPA Setup

The SPA authenticates users via Auth0 Authorization Code + PKCE. No secrets in the
bundle; users sign in with their Dealroom account; the SPA gets a short-lived access
token scoped to read permissions (application keys are read-only by design).

  // src/lib/auth.ts
  import { createAuth0Client, type Auth0Client } from "@auth0/auth0-spa-js";

  let client: Auth0Client | null = null;

  export async function getAuth0(): Promise<Auth0Client> {
    if (client) return client;
    client = await createAuth0Client({
      domain: import.meta.env.VITE_AUTH0_DOMAIN,
      clientId: import.meta.env.VITE_CLIENT_ID,
      authorizationParams: {
        audience: import.meta.env.VITE_API_AUDIENCE,
        redirect_uri: window.location.origin + "/callback",
      },
      cacheLocation: "localstorage",
      useRefreshTokens: true,
    });
    return client;
  }

  export async function login() {
    const auth0 = await getAuth0();
    await auth0.loginWithRedirect();
  }

  export async function handleCallback() {
    const auth0 = await getAuth0();
    await auth0.handleRedirectCallback();
    window.history.replaceState({}, document.title, "/");
  }

  export async function getToken(): Promise<string> {
    const auth0 = await getAuth0();
    return auth0.getTokenSilently();
  }

  export async function isAuthenticated(): Promise<boolean> {
    const auth0 = await getAuth0();
    return auth0.isAuthenticated();
  }

Environment variables (committed to `.env.example`, set per environment in the host):
- VITE_AUTH0_DOMAIN — e.g. `accounts.beta.dealroom.co`
- VITE_CLIENT_ID — the Application API key's client_id (public; safe to ship)
- VITE_API_AUDIENCE — `https://api-next.beta.dealroom.co`
- VITE_INVESTOR_ID — the fund's Dealroom entity ID
- VITE_API_BASE — `https://api-next.beta.dealroom.co`

### Auth gate in main.tsx

  // src/main.tsx
  import { createRoot } from "react-dom/client";
  import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
  import { App } from "./App";
  import { handleCallback, isAuthenticated, login } from "./lib/auth";
  import "./app.css";

  const qc = new QueryClient();

  async function bootstrap() {
    if (window.location.pathname === "/callback") {
      await handleCallback();
    }
    if (!(await isAuthenticated())) {
      await login();
      return;
    }
    createRoot(document.getElementById("root")!).render(
      <QueryClientProvider client={qc}><App /></QueryClientProvider>
    );
  }

  bootstrap();

Users hitting the app are redirected to Auth0 Universal Login, sign in with their
Dealroom account, and land back on the dashboard with a valid access token.


## Step 3: Investor Branding (Runtime)

At runtime, the app fetches the investor profile directly from the Dealroom API and
extracts branding dynamically.

  GET https://api-next.beta.dealroom.co/<investor path from openapi.json>

Use the `image` URL for runtime color extraction:
1. Display the logo in the header
2. Extract 2-3 dominant colors (canvas-based: load image, draw, sample pixels, cluster)
3. Override shadcn/ui CSS custom properties:
    - --primary — most prominent non-white/non-black color (HSL format)
    - --secondary — second most prominent color
    - --accent — lighter tint of primary
    - --chart-1 through --chart-5 — palette for D3 charts
    - Keep shadcn defaults for --background, --foreground, --muted, --border
4. The initial theme (from Step 1 colors) is already close — runtime extraction
    is a progressive enhancement


## Step 4: Dashboard Data (TanStack Query)

All data fetching uses TanStack Query v5. Wrap the app in QueryClientProvider in
main.tsx (already done above), then use useQuery hooks in each component.

The SPA calls the Dealroom API **directly** from the browser. No proxy. Tokens come
from `getToken()` (auth0-spa-js, auto-refreshed via refresh tokens).

Before writing any hook below, look up the endpoint and its filters in openapi.json
(see Step 0). The examples show the STRUCTURE to build — verify every path, filter,
metric, and group_by key against the spec before using them:

  jq '.paths | keys' openapi.json                               # all endpoints
  jq '.paths["/api/transactions"].get.parameters' openapi.json  # params for one endpoint
  jq '.paths["/api/aggregate/funding-rounds"]' openapi.json     # aggregate spec

### API Client

  // src/lib/api.ts
  import { DEALROOM_API_VERSION, API_VERSION_HEADER } from "./constants";
  import { getToken } from "./auth";

  const API_BASE = import.meta.env.VITE_API_BASE;
  const CLIENT_ID = import.meta.env.VITE_CLIENT_ID;

  export async function apiFetch<T>(path: string, params?: Record<string, string>): Promise<T> {
    const url = new URL(path, API_BASE);
    if (params) Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, v));
    const token = await getToken();
    const res = await fetch(url, {
      headers: {
        Authorization: `Bearer ${token}`,
        [API_VERSION_HEADER]: DEALROOM_API_VERSION,
        "X-Client-Id": CLIENT_ID,
      },
    });
    if (!res.ok) throw new Error(`API error: ${res.status}`);
    return res.json();
  }

### Query Hooks

  // src/hooks/useInvestor.ts
  import { useQuery } from "@tanstack/react-query";
  import { apiFetch } from "../lib/api";

  export function useInvestor(investorId: string) {
    return useQuery({
      queryKey: ["investor", investorId],
      queryFn: () => apiFetch(`/api/investors/${investorId}`),
      staleTime: 5 * 60 * 1000,
    });
  }

  // src/hooks/useDashboard.ts — one useQuery per chart, all fire in parallel
  import { useQuery } from "@tanstack/react-query";
  import { apiFetch } from "../lib/api";

  const INV_FILTER = (id: string) => `investor_id[eq]:${id}`;

  export function useTransactions(investorId: string) {
    return useQuery({
      queryKey: ["transactions", investorId],
      queryFn: () => apiFetch("/api/transactions", {
        filter: INV_FILTER(investorId), sort: "-year", limit: "100",
      }),
      staleTime: 2 * 60 * 1000,
    });
  }

  export function useFundingByYear(investorId: string) {
    return useQuery({
      queryKey: ["funding-by-year", investorId],
      queryFn: () => apiFetch("/api/aggregate/funding-rounds", {
        metric: "sum:amount_usd", group_by: "year",
        filter: INV_FILTER(investorId), sort: "-year", limit: "20",
      }),
      staleTime: 5 * 60 * 1000,
    });
  }

  export function useRoundTypes(investorId: string) {
    return useQuery({
      queryKey: ["round-types", investorId],
      queryFn: () => apiFetch("/api/aggregate/funding-rounds", {
        metric: "count", group_by: "round_type",
        filter: INV_FILTER(investorId), sort: "-count", limit: "10",
      }),
      staleTime: 5 * 60 * 1000,
    });
  }

  export function useGeography(investorId: string) {
    return useQuery({
      queryKey: ["geography", investorId],
      queryFn: () => apiFetch("/api/aggregate/funding-rounds", {
        metric: "sum:amount_usd", group_by: "hq_country",
        filter: INV_FILTER(investorId), sort: "-sum", limit: "50",
      }),
      staleTime: 5 * 60 * 1000,
    });
  }

  export function useSectors(investorId: string) {
    return useQuery({
      queryKey: ["sectors", investorId],
      queryFn: () => apiFetch("/api/aggregate/funding-rounds", {
        metric: "count", group_by: "sector",
        filter: INV_FILTER(investorId), sort: "-count", limit: "10",
      }),
      staleTime: 5 * 60 * 1000,
    });
  }

Each component calls its own useQuery hook. Because they all render in the same tree
on mount, TanStack Query fires them in parallel automatically. Each chart independently
shows its loading/error/success state.

### API Response Shapes

Transactions (GET /transactions?filter=investor_id[eq]:{ID}&sort=-year&limit=100):
  {
    "data": [{
      "id": 456, "amount": "5000000.00", "year": 2024, "month": 3,
      "round_type": "SEED",
      "company": { "id": 789, "name": "Acme Corp", "image": "https://...", "hq_country": "Netherlands" },
      "investors": [{ "id": 123, "name": "Y Combinator", "is_lead": true }]
    }],
    "page": { "limit": 100, "offset": 0, "total": 4200 }
  }

Funding by year (GET /aggregate/funding-rounds?metric=sum:amount_usd&group_by=year&...):
  { "data": [{ "year": 2024, "sum": 1250000000 }, { "year": 2023, "sum": 980000000 }] }

Round types (GET /aggregate/funding-rounds?metric=count&group_by=round_type&...):
  { "data": [{ "round_type": "SEED", "count": 2100 }, { "round_type": "SERIES_A", "count": 890 }] }

Geography (GET /aggregate/funding-rounds?metric=sum:amount_usd&group_by=hq_country&...):
  { "data": [{ "hq_country": "United States", "sum": 6200000000 }, { "hq_country": "India", "sum": 450000000 }] }

Sectors (GET /aggregate/funding-rounds?metric=count&group_by=sector&...):
  { "data": [{ "sector": "Enterprise Software", "count": 680 }, { "sector": "Fintech", "count": 420 }] }


## Step 5: Project Structure

  portfolio-dashboard/
  ├── src/
  │   ├── main.tsx                  # Entry point: auth gate → QueryClientProvider → App
  │   ├── App.tsx                   # Root layout, data orchestration
  │   ├── lib/
  │   │   ├── auth.ts               # @auth0/auth0-spa-js wrapper
  │   │   ├── constants.ts          # DEALROOM_API_VERSION, API_VERSION_HEADER
  │   │   ├── api.ts                # apiFetch wrapper with auth + version header
  │   │   ├── colors.ts             # Logo color extraction + theme generation
  │   │   └── format.ts             # Number formatting ($1.2B, 4,200, etc.)
  │   ├── hooks/
  │   │   ├── useInvestor.ts        # useQuery: investor profile + branding
  │   │   └── useDashboard.ts       # useQuery per chart (parallel on mount)
  │   ├── components/
  │   │   ├── ui/                   # shadcn/ui components (Button, Card, Table, etc.)
  │   │   ├── Header.tsx            # Logo, name, tagline, logout — Framer Motion entrance
  │   │   ├── KpiCards.tsx          # shadcn Cards with animated counters
  │   │   ├── PortfolioTable.tsx    # shadcn Table, sortable columns
  │   │   ├── FundingByYear.tsx     # D3 bar chart (SVG)
  │   │   ├── RoundTypeChart.tsx    # D3 donut chart (SVG)
  │   │   ├── GeographyMap.tsx      # MapLibre GL choropleth
  │   │   ├── SectorTreemap.tsx     # D3 treemap (SVG)
  │   │   └── ThemeProvider.tsx     # Applies extracted colors to shadcn CSS vars
  │   ├── app.css                   # Tailwind v4 directives + shadcn theme vars
  │   └── components.json           # shadcn/ui config
  ├── postcss.config.js
  ├── .env.example                  # VITE_AUTH0_DOMAIN, VITE_CLIENT_ID, VITE_API_AUDIENCE, etc.
  ├── vite.config.ts
  ├── tsconfig.json
  └── package.json

No `api/`, `netlify/functions/`, or `functions/` directories — this is a pure SPA.


## Step 6: Implementation Requirements

### Color Extraction
- Canvas-based: load logo, draw to canvas, sample pixels
- Ignore near-white (#f0f0f0+) and near-black (#101010-)
- K-means or frequency clustering for 2-3 dominant colors
- Map to shadcn CSS custom properties (HSL format)
- Apply via ThemeProvider on <html> element

### D3 Charts
- Bar chart (FundingByYear): d3.scaleBand + d3.scaleLinear, SVG rects, Framer Motion animate, hover tooltip
- Donut chart (RoundTypeChart): d3.pie() + d3.arc(), center label with total, hover highlights segment
- Treemap (SectorTreemap): d3.treemap() + d3.hierarchy(), labeled rectangles, brand palette
- All responsive via ResizeObserver
- Color scale: d3.scaleOrdinal() with 5 extracted chart colors

### MapLibre GL World Map
- Free vector tiles (OpenFreeMap or MapTiler free tier)
- Choropleth: join aggregate data by country name to GeoJSON
- d3.scaleSequential mapped from brand primary
- Hover popup: country name + total invested
- Fit bounds to countries with data

### Data Attribution
Every chart and the map MUST include a small "Powered by Dealroom" attribution in the
bottom-right corner. Use the white Dealroom logo on a semi-transparent dark pill:

  Logo URL: https://dealroom.co/images/DR_logo_white@8x.png

Implementation:
- Create a reusable DealroomAttribution component
- Render as a small pill (e.g. 24px height) with the logo + "Dealroom" text
- Position: absolute bottom-right of each chart/map container
- Style: semi-transparent dark background (rgba(0,0,0,0.6)), rounded, white text
- Link the pill to https://dealroom.co

### Number Formatting
- Currency: $1.2B, $450M, $5.0M (compact, 1 decimal)
- Counts: 4,200 (comma-separated)
- Percentages: 42.5% (1 decimal)

### Portfolio Table
- shadcn Table component, sortable columns
- Columns: Company (logo + name), Country, Round Type, Amount, Year
- Link company names to https://app.dealroom.co/companies/{slug}

### KPI Cards
- shadcn Card + Framer Motion animated number count-up
- Total Invested, Total Deals, Portfolio Companies, Preferred Round

### Layout & States
- Tailwind v4 for layout, Framer Motion staggered entrance animations
- Loading: TanStack Query isPending → shadcn Skeleton
- Error: TanStack Query isError → shadcn Alert with refetch() retry
- 401 from API → call `login()` again (token refresh failed)
- Responsive: 2-col desktop, 1-col tablet, stacked mobile

### Header Logout
- Include a "Sign out" action in the header menu
- Calls `auth0.logout({ logoutParams: { returnTo: window.location.origin } })`


## Step 7: Configuration

  // src/lib/constants.ts
  export const DEALROOM_API_VERSION = "<version from changelog>";
  export const API_VERSION_HEADER = "API-Version";

  // .env.example
  VITE_AUTH0_DOMAIN=accounts.beta.dealroom.co
  VITE_CLIENT_ID=<your application key client_id>
  VITE_API_AUDIENCE=https://api-next.beta.dealroom.co
  VITE_API_BASE=https://api-next.beta.dealroom.co
  VITE_INVESTOR_ID=<your Dealroom investor entity id>


## Step 8: Deploy

Static asset deploys — no functions needed:

Vercel:     vercel deploy
Netlify:    netlify deploy --prod --dir=dist
Cloudflare: wrangler pages deploy dist

Set env vars (VITE_*) in each platform's dashboard. Make sure the deploy URL is
registered in the Application API key's `allowed_origins` and `allowed_callback_urls`
(both production and any preview domains).

Extend It

  • Sparklines per portfolio company (funding history over time)
  • Date range filter across all charts
  • PDF export with pdf-lib + html-to-image
  • Fund comparison — side-by-side view of two investors
  • Geo drill-down — click a country to filter table and charts