Skip to content

08 — Advertiser Dashboard

Status: Draft Date: 2026-05-23 Owner: frontend + backend Parent: 2026-05-23-nowyourlink-master-design.md (binding — D17 Astro, D20 subdomains, D22 pixel in MVP) Depends on: 02 identity-onboarding (magic-link sessions), 03 ad-moderation (creative library), 04 auction-engine (bid history, getRankedBids), 05 payment-saga (invoice list + hosted PDF URL), 06 real-time-hub (WS live signals) Research: research/astro-cf-monorepo-patterns.md (Astro + @astrojs/cloudflare, island hydration), research/cf-cost-projection.md (edge cache 60s TTL on aggregates)


  • The nowyourlink.com/app surface — Astro app routes under /app/* served by apps/web with @astrojs/cloudflare adapter (same Worker, different host binding per master §4.8).
  • Bidder dashboard: 30-day calendar, bid management UI, creative-library link, per-auction detail.
  • Live during-ad metrics for the day the bidder’s creative is the Spotlight.
  • Post-campaign report emitted T+24h after ad-day end (email + dashboard tile).
  • Conversion pixel: cdn.nowyourlink.com/pixel.js SDK + ingest at api.nowyourlink.com/conv + storage in D1 conversion_events.
  • ROAS calculation (live + historical).
  • CSV / PDF export for any single-campaign report.
  • Invoice list + download surface — list rows owned here, hosted PDF URL delegated to 05.
  • Admin console (→ 10).
  • Public site / archive / OG (→ 07).
  • Payment capture, invoice generation, credit notes (→ 05).
  • Engagement features (push, like) (→ 09).

All routes require a valid magic-link session cookie (02). Unauthenticated requests redirect to /login.

RoutePurpose
/appLanding tile grid: next live ad-day countdown, active bid count, unpaid invoices badge, ROAS sparkline (90d). Server-rendered, no islands.
/app/calendar30-day rolling auction calendar (see §3). Server skeleton + interactive island.
/app/bidsList of every bid the user has placed (active + historical); links to /app/auctions/:date. Filter: status (active / won / lost / cancelled).
/app/libraryThin proxy view into the bidder’s creative library (03); upload, status badges (pending, approved, rejected), per-creative bid count.
/app/auctions/:datePer-auction detail: your current bid + supersede chain, your rank, total active bidders (count only — never reveals others’ amounts), anti-snipe state, “raise bid” CTA. If the user’s bid is the Spotlight for that date, the page renders the live metrics panel (§4) once T ≥ ad-day start, and the post-campaign report (§5) once T ≥ T+24h.
/app/invoicesList of invoices via 05’s GET /v1/invoices?user_id=... (see §8). Status, amount, auction_id, “view PDF” (Stripe hosted URL) and “download PDF” (R2 URL).
/app/settingsCompany profile (read-only — name + VAT come from 02), notification prefs, conversion pixel snippet generator + site key, default UTM tags.

Total: 7 routes.

Layout: shared AppShell.astro with left rail (Calendar, Bids, Library, Invoices, Settings) + topbar (locale switch, logout).


  • Server-rendered Astro skeleton at /app/calendar — a 5×6 grid of day cells (today + 29 future days), each cell carries data-attributes auction-id, your-bid-status, bidder-count-bucket.
  • Initial render uses a D1-cached aggregate fetched from the apps/api Hono route GET /dashboard/calendar (ADR-0001 / D40; 60s edge cache key by user_id) — no per-request DO fan-out.
  • One interactive island, CalendarLive.tsx (hydrated client:load), subscribes to the per-user WS channel at ws.nowyourlink.com/u/${user_id} (06) and patches cells on incoming events:
    • top3_position_changed → flip the cell’s “Top 3” indicator (per master D28).
    • bid.outbid for one of the user’s bids → red border + toast.
    • auction.extended → countdown tag updates.
  • Signals visible per cell:
    • Bidder count (bucketed: 1-3 / 4-10 / 10+ — never exact count, prevents counter-bidding sniffing).
    • Your bid status: none / active / leading / top-3 / outbid / won / lost.
    • Top-3 indicator (from 06’s top3_position_changed push).
    • Anti-snipe enabled flag (read-only badge).
  • Clicking a cell navigates to /app/auctions/:date. The cell is a <a> — SSR-friendly fallback if JS is off.

When auction_id = today and winner_user_id = current_user, /app/auctions/:date renders a LiveMetricsPanel.tsx island.

Metrics shown, all updating live:

  • Page views (total impressions on nowyourlink.com while the slot was served).
  • Unique visitors (CF Web Analytics anonymous hash).
  • Clicks (count of /go/:auction_id redirect hits).
  • CTR = clicks / unique-visitors (rolling 5-min window + cumulative).

Transport:

  • Primary: WS subscribe to ws.nowyourlink.com/m/${auction_id} (06). 06 broadcasts a 1Hz metrics.tick envelope { pv, uv, clicks, ts } derived from D1 aggregates updated by the click recorder (apps/api/go/[auction_id]) and CF Web Analytics replication.
  • REST fallback: GET api.nowyourlink.com/v1/metrics/live?auction_id=... polled at 5s if WS disconnects. Same JSON shape. (Hono route on apps/api per ADR-0001 / D40.)
  • Backpressure: 06 batches at 1Hz per master D28 / research §10.2; client never sees > 1 update / s.

Privacy: no per-visitor data is exposed — only counts.


Trigger: scheduled Worker (apps/cron) running at T_ad-day-end + 24h (i.e. 48h after the slot first activated). Cron pulls every auction_id whose ad-day completed in the last hour.

Aggregation step runs over:

  • CF Web Analytics dataset (server-side fetch) → impressions, unique visitors, country, device, browser, top referrers.
  • D1 clicks table → click count + timeline (1-min buckets).
  • D1 conversion_events table → conversion count, value sum, ROAS (§7).
  • D1 engagement_events (owned by 09 but read here) → likes, share count. (Disqus comment count removed — Disqus dropped, spec 09 §4.)

Output:

  1. JSON blob stored in D1 campaign_reports(auction_id PRIMARY KEY, user_id, json, generated_at).
  2. Rendered HTML email via Resend (D14), subject Your Spotlight on ${auction_id} — Report. Body contains: total impressions, CTR, click timeline (inline SVG), geo heat snapshot (top-10 list, not a literal heatmap PNG to keep email lean), device split, browser split, top referrers, social engagement, CPV, CPC, ROAS (if pixel installed; otherwise “ROAS unavailable — install pixel”), link to dashboard for full report.
  3. Dashboard render at /app/auctions/:date shows the same data with a real Mapbox-static-image geo heatmap (CDN-cached) + interactive timeline.

CSV / PDF export:

  • GET /app/auctions/:date/export.csv — flat rows: timestamp, metric, value.
  • GET /app/auctions/:date/export.pdf — Worker-rendered via @resvg/resvg-wasm + a Pug template. Stored ephemerally in R2 under reports/${auction_id}/${user_id}.pdf with 90-day lifecycle.

Metrics formulas:

  • CPV = winning_bid_amount_cents / 100 / unique_visitors.
  • CPC = winning_bid_amount_cents / 100 / clicks.
  • ROAS — see §7.

Implementation amendment (2026-10-08): The repaired browser path serves the stateless v2 SDK at https://nowyourlink.com/pixel.js?v=2 (one-hour public cache). The Settings snippet queues calls before download and includes only the public site key. The SDK posts to /pixel/collect on that origin; the web Worker validates the live Spotlight’s target origin and owner, supplies its auction day and account ID, then signs and forwards through the existing API service binding to /conv. HMAC secrets remain server-side. Migration 0064 adds nullable account ownership; historical rows remain unassigned. Settings and get_account_settings expose only the account’s last observed receipt, with distinct unobserved/unavailable states. This supersedes the v1 CDN and fixed-day snippet below; it does not introduce an installation test or bidding requirement. Load only after required consent; both SDK and ingest preserve DNT/GPC purchase suppression. See the decision and local evidence.

<script async src="https://cdn.nowyourlink.com/pixel.js?v=1"
data-auction="2026-06-12"
data-site-key="pk_live_xxx"></script>
  • File served from cdn.nowyourlink.com/pixel.js?v=1 with Cache-Control: public, max-age=31536000, immutable. Versioned via query param; bumping v=2 invalidates with new snippet.
  • data-auction is auto-detected from the script tag; required.
  • data-site-key is the bidder’s public site key from /app/settings (paired server-side with CONVERSION_PIXEL_SECRET for HMAC).
nyl('event', { type: 'page_view' | 'form_submit' | 'purchase', value?: number, currency?: 'EUR' });
  • Single function nyl(verb, payload). Only verb='event' at MVP.
  • value is required when type === 'purchase', optional otherwise.
  • currency defaults to 'EUR' (master non-goal: multi-currency not at MVP).
  • The SDK auto-fires nyl('event', { type: 'page_view' }) on load (DOMContentLoaded).
  • A nyl.q = nyl.q || [] async queue pattern is supported for late-loading.
  • No third-party cookies. SDK never sets document.cookie. Visitor identity is a daily-rotating hash: sha256(client_ip || ua || daily_salt) computed server-side at /conv; the SDK never sends raw IP/UA hashes.
  • IP anonymization: server truncates client IP to /24 (IPv4) or /48 (IPv6) before hashing; raw IP never persisted.
  • Daily salt rotates at 00:00 UTC; visitor_hash is therefore not cross-day-linkable.
  • DoNotTrack header DNT: 1 → SDK records a page_view only (no purchase/value), per DSGVO defensive default.
  • No localStorage or IndexedDB; SDK is stateless.

POST https://api.nowyourlink.com/conv — implemented as a Hono POST /conv route on apps/api (ADR-0001 / D40) with hono/cors mounted ONLY on /conv (CORS middleware is NOT global) and an HMAC-verifier middleware chained before the handler.

Headers:

  • Content-Type: application/json
  • X-NYL-Sig: ${hmac_sha256(body, site_key_secret)} — site_key_secret derived from HMAC(CONVERSION_PIXEL_SECRET, site_key).
  • X-NYL-Site-Key: pk_live_xxx

Body (signed):

{
"auction_id": "2026-06-12",
"event_type": "page_view" | "form_submit" | "purchase",
"event_value": 49.90,
"currency": "EUR",
"visitor_hash": "<sha256-server-computed>",
"ts": "2026-06-12T14:23:11.412Z",
"ua_short": "Chrome/126 macOS",
"referrer": "https://google.com",
"country": "DE",
"ts_sig": "<hmac of ts to prevent replay>"
}

The SDK posts the subset the browser knows (auction_id, event_type, event_value?, currency?, ts, referrer). The ingest Worker enriches with visitor_hash, ua_short, country (CF geo header), and re-signs ts_sig against CONVERSION_PIXEL_SECRET.

CORS: OPTIONS /conv returns Access-Control-Allow-Origin: *, Access-Control-Allow-Methods: POST, Access-Control-Allow-Headers: Content-Type, X-NYL-Sig, X-NYL-Site-Key.

D1 conversion_events:

CREATE TABLE conversion_events (
id TEXT PRIMARY KEY, -- ulid
auction_id TEXT NOT NULL,
event_type TEXT NOT NULL,
event_value_cents INTEGER, -- nullable; only set for purchase
currency TEXT, -- 'EUR'
visitor_hash TEXT NOT NULL,
ua_short TEXT,
referrer TEXT,
country TEXT, -- ISO-3166-1 alpha-2
ts TEXT NOT NULL,
received_at TEXT NOT NULL
);
CREATE INDEX idx_conv_auction_ts ON conversion_events(auction_id, ts);
CREATE INDEX idx_conv_auction_type ON conversion_events(auction_id, event_type);

Raw request bodies are never persisted; we discard the original payload after the row is written. No PII column exists.


Formula (exact):

ROAS = sum(event_value_cents WHERE event_type = 'purchase' AND auction_id = X)
/ winning_bid_amount_cents (of auction X)
  • Dimensionless ratio. Displayed as 2.3x (one decimal).
  • “Live ROAS” on /app/auctions/:date is a D1 aggregate refreshed every 60s during the ad-day, served via the same edge-cache path as other metrics.
  • “Historical ROAS” on /app landing is avg(ROAS) over the user’s last 30 won auctions where pixel was installed (rows exist in conversion_events).
  • If sum(...) = 0, display ”—” and the tooltip “Tracking active, no purchases yet” (E1 in §11).
  • If no conversion_events rows exist for the auction, display “ROAS unavailable — install pixel” with a /app/settings link.

/app/invoices lists invoices via GET api.nowyourlink.com/v1/invoices?user_id=${session.user_id} — a Hono route on apps/api (ADR-0001 / D40) that reads the 05-owned saga_runs mirror + Stripe invoice_id + the R2-archived PDF URL.

Row schema (rendered):

FieldSource
Auction datesaga_runs.auction_id
Invoice numberinvoices.number (Stripe)
Amountinvoices.amount_paid_cents
Statusinvoices.status
Hosted PDF URLinvoices.hosted_invoice_url (Stripe) — opens in iframe-safe new tab
Download PDFsigned R2 URL (5-min expiry) to invoices/${year}/${auction_id}/${invoice_number}.pdf

The dashboard never builds PDF URLs itself — it asks 05’s API which returns either the Stripe-hosted URL or a freshly signed R2 URL.

Stripe hosted invoice URLs cannot be iframed (Stripe sets X-Frame-Options: DENY); UI uses target="_blank" rel="noopener". Marked “iframe-safe view” in the route table means we render Stripe links as new-tab launches, not literal iframes.


  • Magic-link session cookie from 02, stored in KV with 30-day TTL.
  • Middleware at apps/web/src/middleware.ts checks every /app/* route, redirects to /login if invalid.
  • CF Access is NOT used here — explicitly. CF Access (D15) only protects admin.nowyourlink.com (sub-spec 10). Advertisers use magic-link only.
  • Logout = KV delete + cookie clear.

  • D1 reads behind aggregate endpoints (/dashboard/calendar, /v1/metrics/live, /v1/roas/...) cached at the edge with Cache-Control: private, max-age=60 and a cache-key of (user_id, auction_id, route).
  • WS subscriptions for live counters (06); REST polling at 5s as fallback.
  • Calendar cells render server-side on first request, hydrate once.
  • Pixel ingest endpoint runs without DO — pure Worker → D1 prepared statement; p99 < 20ms.
  • Worker Logs sampled at 10% for /conv per master §10.2; 100% on saga events remains in 05.

#CaseHandling
E1Pixel installed but no purchase eventsDisplay ”—” for ROAS; tooltip “Tracking active, no purchases yet”; page_view count still shown.
E2CORS preflight for /convOPTIONS returns full ACAO headers (§6.4); cached at edge 1h.
E3Click recorded before ad activatedapps/api/go/:auction_id checks header.status — if not LIVE, log defensively to clicks_pre_activation table, do not increment user-facing CTR.
E4Bidder removes pixel mid-ad-dayStill bill (we do not refund pixel-driven metrics gaps); dashboard shows data through MAX(received_at) and an info banner “Last pixel event Xh ago”.
E5Pixel HMAC mismatchReject with 401 + error envelope; never store the row; Sentry breadcrumb (no PII).
E6Pixel replay attack (same ts)ts_sig verified against server-side HMAC of ts; out-of-window (> 5min skew) rejected.
E7Invoice list during saga runningDisplay row with status processing, no download link.
E8WS disconnect during live metricsUI flips to REST polling at 5s; reconnect attempt with exponential backoff (06).
E9User opens /app/auctions/:date for a day they didn’t winPage shows only their own bid history + final winning amount (public); no live-metrics panel, no report.
E10Post-campaign report email bouncesCron retries 3x; on persistent fail, surface a banner on /app “Report email could not be delivered”. Dashboard render is always available regardless.
E11DNT=1 visitor fires purchase eventSDK silently downgrades to page_view; no value recorded; ROAS unaffected for that visitor.
E12Two simultaneous calendar islands (two tabs)Both subscribe to same WS channel; 06 handles fan-out. No client-side dedupe needed.

  1. All 7 /app/* routes load with valid magic-link session; unauthenticated /app/* redirects to /login.
  2. Calendar renders server-side with 30 cells in < 200ms p95; WS island hydrates and patches a top3_position_changed event within 1s.
  3. During-ad live metrics update at 1Hz via WS; REST fallback kicks in within 5s of WS disconnect.
  4. Conversion pixel at cdn.nowyourlink.com/pixel.js?v=1 returns 200, Cache-Control: immutable, < 4KB minified+gzipped.
  5. nyl('event', { type, value?, currency? }) matches the exact signature; data-auction is auto-detected.
  6. /conv accepts HMAC-signed payload, anonymizes IP to /24, stores PII-free row in conversion_events.
  7. ROAS displayed as sum(purchase event_value) / winning_bid_amount, formatted Nx, refreshed every 60s on ad-day.
  8. Post-campaign email arrives within 60min of T+24h; CSV and PDF exports downloadable from dashboard.
  9. Invoice list reads from /v1/invoices; both hosted URL and R2 signed URL work; never built client-side.
  10. CF Access blocks /admin/* but is absent from /app/* (verified via Terraform diff).
  11. Privacy: zero third-party cookies set by any /app/* response or by the pixel SDK; CF Web Analytics cookieless on every page.
  12. Edge cache hit ratio on aggregate endpoints ≥ 80% in a synthetic 60s burst test.

  • Package layout: apps/web hosts both nowyourlink.com and nowyourlink.com/app (one Worker, host-binding split — see research/astro-cf-monorepo-patterns.md §4). Routes under src/pages/app/* use SSR (output: 'server'), the rest of the site uses static + selective islands.
  • Pixel SDK lives in packages/pixel/src/index.ts; built to cdn.nowyourlink.com/pixel.js via a small wrangler static asset deploy. Keep the SDK dependency-free — no bundler runtime, no fetch polyfills. Use plain fetch + crypto.subtle for any client-side HMAC if added later (not needed at MVP — HMAC is server-side).
  • Ingest Worker at apps/api/src/routes/conv.ts — Hono route module (ADR-0001 / D40): const convRoute = new Hono().post('/conv', cors(corsOpts), verifyHmac(), handler); then apps/api/src/app.ts does app.route('/', convRoute). D1 prepared statement reused per isolate.
  • Live metrics: subscribe to 06’s MetricsHubDO channel; do not invent a separate WS DO.
  • Invoice list endpoint owned by apps/api but reads saga_runs (05’s mirror) — never touch Stripe directly from apps/web.
  • PDF export uses @resvg/resvg-wasm (already chosen in 07 for OG); reuse the renderer module.
  • Sentry: @sentry/cloudflare on apps/web and apps/api; tag route=/app/... and auction_id where applicable.
  • No CF Access wrapping on app. host — Terraform must explicitly leave this host out of the Access application bound to admin..
  • Tests: integration-test the pixel happy path with a fake crypto.subtle HMAC; snapshot-test the campaign report HTML; e2e the calendar island with Playwright against unstable_dev.

  • Geo heatmap rendering: Mapbox static (paid) vs. inline SVG world map. Defer to 09-engagement-features if shared.