08 — Advertiser Dashboard
08 — Advertiser Dashboard
Section titled “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)
1. Purpose & scope
Section titled “1. Purpose & scope”In scope
Section titled “In scope”- The
nowyourlink.com/appsurface — Astro app routes under/app/*served byapps/webwith@astrojs/cloudflareadapter (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.jsSDK + ingest atapi.nowyourlink.com/conv+ storage in D1conversion_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.
Out of scope
Section titled “Out of scope”- Admin console (→ 10).
- Public site / archive / OG (→ 07).
- Payment capture, invoice generation, credit notes (→ 05).
- Engagement features (push, like) (→ 09).
2. Dashboard routes (/app/*)
Section titled “2. Dashboard routes (/app/*)”All routes require a valid magic-link session cookie (02). Unauthenticated requests redirect to /login.
| Route | Purpose |
|---|---|
/app | Landing tile grid: next live ad-day countdown, active bid count, unpaid invoices badge, ROAS sparkline (90d). Server-rendered, no islands. |
/app/calendar | 30-day rolling auction calendar (see §3). Server skeleton + interactive island. |
/app/bids | List of every bid the user has placed (active + historical); links to /app/auctions/:date. Filter: status (active / won / lost / cancelled). |
/app/library | Thin proxy view into the bidder’s creative library (03); upload, status badges (pending, approved, rejected), per-creative bid count. |
/app/auctions/:date | Per-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/invoices | List 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/settings | Company 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).
3. 30-day calendar UI
Section titled “3. 30-day calendar UI”- Server-rendered Astro skeleton at
/app/calendar— a 5×6 grid of day cells (today + 29 future days), each cell carries data-attributesauction-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(hydratedclient:load), subscribes to the per-user WS channel atws.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.outbidfor 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_changedpush). - 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.
4. During-ad live metrics
Section titled “4. During-ad live metrics”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.comwhile the slot was served). - Unique visitors (CF Web Analytics anonymous hash).
- Clicks (count of
/go/:auction_idredirect 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 1Hzmetrics.tickenvelope{ 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.
5. Post-campaign report
Section titled “5. Post-campaign report”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
clickstable → click count + timeline (1-min buckets). - D1
conversion_eventstable → 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:
- JSON blob stored in D1
campaign_reports(auction_id PRIMARY KEY, user_id, json, generated_at). - 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. - Dashboard render at
/app/auctions/:dateshows 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 underreports/${auction_id}/${user_id}.pdfwith 90-day lifecycle.
Metrics formulas:
- CPV =
winning_bid_amount_cents / 100 / unique_visitors. - CPC =
winning_bid_amount_cents / 100 / clicks. - ROAS — see §7.
6. Conversion pixel
Section titled “6. Conversion pixel”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.
6.1 Snippet served
Section titled “6.1 Snippet served”<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=1withCache-Control: public, max-age=31536000, immutable. Versioned via query param; bumpingv=2invalidates with new snippet. data-auctionis auto-detected from the script tag; required.data-site-keyis the bidder’s public site key from/app/settings(paired server-side withCONVERSION_PIXEL_SECRETfor HMAC).
6.2 Public SDK signature (exact)
Section titled “6.2 Public SDK signature (exact)”nyl('event', { type: 'page_view' | 'form_submit' | 'purchase', value?: number, currency?: 'EUR' });- Single function
nyl(verb, payload). Onlyverb='event'at MVP. valueis required whentype === 'purchase', optional otherwise.currencydefaults 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.
6.3 Privacy stance
Section titled “6.3 Privacy stance”- 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 apage_viewonly (no purchase/value), per DSGVO defensive default. - No
localStorageorIndexedDB; SDK is stateless.
6.4 Ingest endpoint
Section titled “6.4 Ingest endpoint”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/jsonX-NYL-Sig: ${hmac_sha256(body, site_key_secret)}— site_key_secret derived fromHMAC(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.
6.5 Storage
Section titled “6.5 Storage”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.
7. ROAS calculation
Section titled “7. ROAS calculation”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/:dateis a D1 aggregate refreshed every 60s during the ad-day, served via the same edge-cache path as other metrics. - “Historical ROAS” on
/applanding isavg(ROAS)over the user’s last 30 won auctions where pixel was installed (rows exist inconversion_events). - If
sum(...) = 0, display ”—” and the tooltip “Tracking active, no purchases yet” (E1 in §11). - If no
conversion_eventsrows exist for the auction, display “ROAS unavailable — install pixel” with a/app/settingslink.
8. Invoice tab
Section titled “8. Invoice tab”/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):
| Field | Source |
|---|---|
| Auction date | saga_runs.auction_id |
| Invoice number | invoices.number (Stripe) |
| Amount | invoices.amount_paid_cents |
| Status | invoices.status |
| Hosted PDF URL | invoices.hosted_invoice_url (Stripe) — opens in iframe-safe new tab |
| Download PDF | signed 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.
9. Auth
Section titled “9. Auth”- Magic-link session cookie from 02, stored in KV with 30-day TTL.
- Middleware at
apps/web/src/middleware.tschecks every/app/*route, redirects to/loginif 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.
10. Performance
Section titled “10. Performance”- D1 reads behind aggregate endpoints (
/dashboard/calendar,/v1/metrics/live,/v1/roas/...) cached at the edge withCache-Control: private, max-age=60and acache-keyof(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
/convper master §10.2; 100% on saga events remains in 05.
11. Edge cases
Section titled “11. Edge cases”| # | Case | Handling |
|---|---|---|
| E1 | Pixel installed but no purchase events | Display ”—” for ROAS; tooltip “Tracking active, no purchases yet”; page_view count still shown. |
| E2 | CORS preflight for /conv | OPTIONS returns full ACAO headers (§6.4); cached at edge 1h. |
| E3 | Click recorded before ad activated | apps/api/go/:auction_id checks header.status — if not LIVE, log defensively to clicks_pre_activation table, do not increment user-facing CTR. |
| E4 | Bidder removes pixel mid-ad-day | Still 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”. |
| E5 | Pixel HMAC mismatch | Reject with 401 + error envelope; never store the row; Sentry breadcrumb (no PII). |
| E6 | Pixel replay attack (same ts) | ts_sig verified against server-side HMAC of ts; out-of-window (> 5min skew) rejected. |
| E7 | Invoice list during saga running | Display row with status processing, no download link. |
| E8 | WS disconnect during live metrics | UI flips to REST polling at 5s; reconnect attempt with exponential backoff (06). |
| E9 | User opens /app/auctions/:date for a day they didn’t win | Page shows only their own bid history + final winning amount (public); no live-metrics panel, no report. |
| E10 | Post-campaign report email bounces | Cron retries 3x; on persistent fail, surface a banner on /app “Report email could not be delivered”. Dashboard render is always available regardless. |
| E11 | DNT=1 visitor fires purchase event | SDK silently downgrades to page_view; no value recorded; ROAS unaffected for that visitor. |
| E12 | Two simultaneous calendar islands (two tabs) | Both subscribe to same WS channel; 06 handles fan-out. No client-side dedupe needed. |
12. Acceptance criteria
Section titled “12. Acceptance criteria”- All 7
/app/*routes load with valid magic-link session; unauthenticated/app/*redirects to/login. - Calendar renders server-side with 30 cells in < 200ms p95; WS island hydrates and patches a
top3_position_changedevent within 1s. - During-ad live metrics update at 1Hz via WS; REST fallback kicks in within 5s of WS disconnect.
- Conversion pixel at
cdn.nowyourlink.com/pixel.js?v=1returns200,Cache-Control: immutable, < 4KB minified+gzipped. nyl('event', { type, value?, currency? })matches the exact signature;data-auctionis auto-detected./convaccepts HMAC-signed payload, anonymizes IP to/24, stores PII-free row inconversion_events.- ROAS displayed as
sum(purchase event_value) / winning_bid_amount, formattedNx, refreshed every 60s on ad-day. - Post-campaign email arrives within 60min of T+24h; CSV and PDF exports downloadable from dashboard.
- Invoice list reads from
/v1/invoices; both hosted URL and R2 signed URL work; never built client-side. - CF Access blocks
/admin/*but is absent from/app/*(verified via Terraform diff). - Privacy: zero third-party cookies set by any
/app/*response or by the pixel SDK; CF Web Analytics cookieless on every page. - Edge cache hit ratio on aggregate endpoints ≥ 80% in a synthetic 60s burst test.
13. Agent-team hints
Section titled “13. Agent-team hints”- Package layout:
apps/webhosts bothnowyourlink.comandnowyourlink.com/app(one Worker, host-binding split — seeresearch/astro-cf-monorepo-patterns.md§4). Routes undersrc/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 tocdn.nowyourlink.com/pixel.jsvia a smallwranglerstatic asset deploy. Keep the SDK dependency-free — no bundler runtime, no fetch polyfills. Use plainfetch+crypto.subtlefor 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);thenapps/api/src/app.tsdoesapp.route('/', convRoute). D1 prepared statement reused per isolate. - Live metrics: subscribe to 06’s
MetricsHubDOchannel; do not invent a separate WS DO. - Invoice list endpoint owned by
apps/apibut readssaga_runs(05’s mirror) — never touch Stripe directly fromapps/web. - PDF export uses
@resvg/resvg-wasm(already chosen in 07 for OG); reuse the renderer module. - Sentry:
@sentry/cloudflareonapps/webandapps/api; tagroute=/app/...andauction_idwhere applicable. - No CF Access wrapping on
app.host — Terraform must explicitly leave this host out of the Access application bound toadmin.. - Tests: integration-test the pixel happy path with a fake
crypto.subtleHMAC; snapshot-test the campaign report HTML; e2e the calendar island with Playwright againstunstable_dev.
14. Open questions
Section titled “14. Open questions”- Geo heatmap rendering: Mapbox static (paid) vs. inline SVG world map. Defer to
09-engagement-featuresif shared.