A Next.js 15 reimagining of super.com/earn/activity.
The live page tells you something is happening; this demo tells you what state every
reward is in, when it was last checked, and what to do if it's stuck — using a closed
four-status model (Tracking → In Progress → Rewarded → Needs Attention) on top of the
same deals.getActivityOfferPlacements tRPC contract Super serves in production.
This demo is deliberately built to match Super's real production stack — not just the UI, but the API contract, naming conventions, and architecture. Here's what's the same:
| What | Super | This demo |
|---|---|---|
| UI library | Material UI (MUI) v6 + Emotion | MUI v6 + Emotion — same component primitives, same sx prop styling |
| Font | Poppins (observed in HAR) | Poppins via next/font/google — same weights, same scale |
| API style | tRPC — observed at /snapcommerce/api/trpc/{router}.{procedure} |
tRPC v11, same path prefix, same snapcommerce namespace |
| Wire format | snake_case field names — user_offer_activity, last_completed_at, icon_url, etc. |
Identical — Zod schemas and fixtures use the exact HAR field names |
| Response envelope | { result: { data: { data: <payload> } } } doubly-nested superjson shape |
Identical — { data: ... } envelope preserved throughout |
| Procedure naming | Verb-prefixed: getActivityOfferPlacements, updateActivityOfferPlacement |
Same convention for every procedure |
| Status model | RETRY, IN_PROGRESS observed; larger enum in production |
deriveActivityStatus() maps the same values → four user-facing statuses; unknown enum values pass through |
| Analytics | Amplitude | Console-logged stub with Amplitude-shaped event names and properties |
| Auth stack | Custom JWT; Ada chatbot | JWT payload stub in the Ada dialog; chat UI matched to observed layout |
| Runtime | Node.js on AWS | Node.js via Vercel Fluid Compute — same runtime, different host |
The main deviations are the framework (Next.js App Router vs. Super's webpack React SPA — the trade-off is collapsed frontend/backend/deploy at the cost of SSR semantics) and the host (Vercel vs. AWS — functionally identical for this surface). Everything user-observable — component library, font, API contract, field names — is matched field-for-field to what HAR captures show.
The app is deployed at superalexyates.com. You don't need to run anything locally to explore it — just open the link below and every v2 feature is reachable from it:
That combination loads one card per status and unlocks the admin tools for testing SSE, partner webhooks, and the Investigation Agent. See Admin flow below.
Want to run it locally instead? Follow the Quickstart below.
pnpm install
pnpm devThen open http://localhost:3000/earn/activity?scenario=mixed&admin=1.
Requirements: Node 20+, pnpm 10+ (via corepack enable).
Optional: set ANTHROPIC_API_KEY in a .env.local file to drive the Investigation Agent
against live Claude. Without it, the agent falls back to a deterministic recorded
response — useful offline and for CI.
The page reads ?scenario= and filters fixtures server-side in src/server/context.ts.
No UI toggle — safe to leave wired in production.
| URL | What it shows |
|---|---|
/earn/activity?scenario=mixed |
Hero demo — one card per status, in production order |
/earn/activity?scenario=tracking |
Fresh pending — "Verifying…" copy, no CTA |
/earn/activity?scenario=in-progress |
Multi-event offer with milestone progress + earnings line |
/earn/activity?scenario=needs-attention |
RETRY + stale pending offers; best URL for the Investigation Agent |
/earn/activity?scenario=rewarded |
Completed bucket — gold pill, muted surface |
Append &admin=1 to any of them to unlock the admin tools.
Add &admin=1 to any scenario URL. The flag is read client-side by useAdminMode
and is gated against production by default. The canonical entry point:
This URL is the fastest way to exercise every v2 pillar in under five minutes.
- Top of page, next to the title, look for the Live pill (
ConnectionPill.tsx).- Green = SSE open. Amber = reconnecting. Gray = offline.
- Open DevTools → Network → filter
EventStream. You'll see one open connection to/api/events/activitywithContent-Type: text/event-stream. - The very first frame is a
snapshotevent withlatest_seq. Every subsequent frame is anactivityevent carrying oneActivityEvent.
Click the floating Partner Simulator button (bottom-right — only rendered when admin=1).
Component: PartnerSimulator.tsx.
- Pick an offer (e.g. Uber Eats, which is Needs attention in the mixed scenario).
- Pick an event —
attribution_verified,milestone_completed,attribution_failed, orreward_paid. - Click Fire webhook.
What happens under the hood:
- The simulator HMAC-signs the body with the demo secret and POSTs to
/api/webhooks/partner. - The route verifies the signature via
verifySignature, de-dupes on(partner_slug, idempotency_key)usingidempotency.ts, appends anActivityEventto the in-memory log, and broadcasts it over SSE. - The card morphs in place: status pill animates, the card slides between sections
via
AnimatePresenceover 240ms. - The connection pill pulses once on the incoming frame.
- The simulator keeps a history of the last 10 fires (ok / duplicate / error).
Fire the same webhook twice — the second returns duplicate=true (200), not 202. Idempotency works.
- Open a second tab to the same URL. Both tabs show Live.
- Open DevTools → Network in the second tab. You will not see an open EventStream.
- Fire a webhook from the simulator in tab one. Both tabs update simultaneously.
Why: useActivitySubscription runs a
leader election over localStorage (leaderElection.ts,
3-second TTL) and a BroadcastChannel for fan-out. Only the leader holds the SSE; followers
receive events over the channel. Close the leader tab and a follower auto-promotes.
- With the Live pill green, stop the dev server (
Ctrl+C). - Wait 3–5s — pill goes amber.
- Restart (
pnpm dev). The client reconnects, sendsLast-Event-ID: <last seq>as a header, and the server replays events fromgetEventsSince(seq)inapp/api/events/activity/route.ts. - The pill goes green. No events missed.
This exact flow is a Playwright regression in playwright/my-activity-v2.spec.ts.
Switch to ?scenario=needs-attention&admin=1
for stuck offers guaranteed to trigger a recommendation.
- On the Hulu+ Live TV card, click the small
Why is this stuck?link. - The right-side
InvestigationDraweropens and streams from/api/events/investigation/[offer_id]. - Watch the tool-call tiles animate: each
tool_userenders with a spinner, then flips to a check mark whentool_resultarrives.queryOfferEvents— the offer's audit logqueryPartnerStatus— partner's attribution state (seeded to returnios_attribution_lag_24h)queryUserOfferHistory— optional, only when signal is thin
- The answer streams in — 2-3 short sentences, per the system prompt.
- The footer shows a Recommended next step button. Click Re-check attribution.
- Drawer closes → webhook fires → card moves to the Continue earning section.
The round trip (recommendation → user click → real webhook → event in log → UI patch) is fully wired and is the most compelling beat in the demo.
Model: claude-haiku-4-5 with ephemeral prompt caching on the system prompt + tools
(investigate.ts:182). Streaming via client.messages.stream.
No extended thinking (Haiku 4.5 doesn't support it; answers stay tight regardless).
Demo mode fallback: If ANTHROPIC_API_KEY is unset, anthropic.ts
returns null and runDemoInvestigation takes over —
same shape (two tool calls + streamed answer), pre-canned iOS-lag answer, 12ms-per-char
character streaming for feel. The drawer shows a Demo mode subtitle.
Caching: Completed investigations append an investigation_completed event. Reopening
the drawer checks findCachedInvestigation at the current seq
and hydrates instantly — no re-stream unless the event log has moved forward.
Every mutation you just performed is in the log. Either:
- Hit
/api/trpc/deals.getActivityEvents?input=%7B%22since_seq%22%3A0%7Din a new tab, or - (If wired) open the LiveOpsConsole via
Shift+Ctrl+L.
The UI is a pure projection of this log — projectOfferFromEvents
reduces events into an OfferSnapshot. Restart the server and the log resets; that's the
"three swaps to production" line in the demo script (swap #1 = move the log to Postgres).
| Pillar | Primary files | What to look for |
|---|---|---|
| Event-sourced ledger | log.ts, projection.ts, types.ts |
Seven event kinds — offer_created, offer_updated, offer_deleted, activity_status_changed, partner_webhook_received, event_milestone_completed, investigation_completed. Every mutation appends; the read path is a deterministic projection. |
| SSE broadcast + multi-tab sync | route.ts, useActivitySubscription.ts, leaderElection.ts, applyEvent.ts |
One EventSource per browser, leader-elected via localStorage + BroadcastChannel, auto-reconnect with Last-Event-ID replay, 30s heartbeat, exponential backoff [1, 2, 5, 10, 10]s. |
| Partner webhook simulator | route.ts, hmac.ts, idempotency.ts, translate.ts, PartnerSimulator.tsx |
HMAC-SHA256 signed, idempotent on (partner_slug, idempotency_key), returns 202 on new / 200 on duplicate. Admin panel gated behind ?admin=1. |
| Investigation Agent | investigate.ts, tools.ts, system-prompt.ts, cache.ts, InvestigationDrawer.tsx |
claude-haiku-4-5 streaming with tool use (3 tools), prompt caching, and a demo-mode fallback. Results cached against the event log seq so re-opening is instant. |
- Four user-facing statuses — derived in
deriveActivityStatus.tsfrombucket + user_offer_activity.status + created_at. Unknown enum values pass through, never throw. - Four card variants —
Tracking,InProgress,Rewarded,NeedsAttention— dispatched byOfferActivityCard.tsx. - Card morph animation —
AnimatePresence+motion.divfrom themotionpackage. Whenstatuschanges, old card fades out -6px, new card fades in +6px over 240ms (motion/constants.ts). - Status palette — all colors live in
src/theme/muiTheme.tsundertheme.palette.activity.{tracking,in_progress,rewarded,needs_attention}. Zero hex strings outside the theme.
- Glass-morphism card surface — frosted gradients in
GlassCard.tsx, backed bytheme.palette.glass.*. - Animated earnings count-up —
AnimatedNumber.tsxin the page header. - Progress affordances —
ProgressRing(SVG circle for % complete) andMilestoneProgress(checklist for multi-event offers). RelativeTimestamp(RelativeTimestamp.tsx) usingIntl.RelativeTimeFormat— "5 min ago", auto-updating.- Manual refresh — header button with 10s client-side throttle (
useRefreshActivity.ts) and inline "Checking for updates…" state. - Ada support stub —
AdaSupportMock.tsxopens a labeled Dialog with the would-be JWT payload, clearly marked(demo stub). - Coming-soon modal —
ComingSoonModal.tsxfor CTAs that would route to a real partner page. - Needs-attention banner —
NeedsAttentionStrip.tsxat the top when any offers are stuck. - Conflict toast —
ConflictToast.tsxfor concurrent-mutation scenarios. - Analytics stub — console-logged, Amplitude-shaped events in
src/lib/analytics.ts. 14 events covering views, clicks, webhook fires, and investigation actions. - Web Vitals telemetry —
src/lib/telemetry/(LCP, CLS, INP, TTFB, FCP).
All procedures live in src/server/routers/deals.ts under
deals.*. Paths are served at /api/trpc/deals.{procedure}.
| Procedure | Kind | Purpose |
|---|---|---|
getActivityOfferPlacements |
query | Projected offers by bucket (pending / in_progress / completed), filtered by ?scenario=. Matches the HAR shape field-for-field (snake_case, data.data envelope). |
getActivityEvents |
query | Paginated audit log — returns events where seq > since_seq plus latest_seq. |
createActivityOfferPlacement |
mutation | Seed a new offer; appends offer_created. |
updateActivityOfferPlacement |
mutation | Patch an offer; appends offer_updated. |
deleteActivityOfferPlacement |
mutation | Soft-delete; appends offer_deleted. |
getInvestigation |
query | Cache check — returns a cached investigation for the current seq, or null. |
simulatePartnerWebhook |
mutation | Fire a fake partner webhook — same code path as the real route, used by tests and the admin panel. |
Conventions (locked in CLAUDE.md): verb-prefixed procedure names,
snake_case on the wire, TRPCError with proper codes (NOT_FOUND, BAD_REQUEST,
UNAUTHORIZED), { data: ... } response envelope.
app/
├── earn/activity/page.tsx # Server Component, SSR-prefetch + HydrationBoundary
├── layout.tsx # Theme, Poppins font, tRPC + emotion providers
└── api/
├── trpc/[trpc]/route.ts # tRPC fetch adapter
├── events/
│ ├── activity/route.ts # SSE: snapshot frame, Last-Event-ID replay, 30s heartbeat
│ └── investigation/[offer_id]/route.ts # SSE: streams Investigation Agent events
└── webhooks/
└── partner/route.ts # HMAC-verified, idempotent webhook receiver
src/
├── server/
│ ├── trpc.ts # initTRPC + base procedure
│ ├── context.ts # reads ?scenario=, exports SCENARIOS union
│ ├── routers/
│ │ ├── _app.ts # appRouter = { deals: dealsRouter }
│ │ └── deals.ts # Every tRPC procedure + tests alongside
│ ├── events/
│ │ ├── log.ts # appendEvent, getEventsSince, onAppend listener registry
│ │ ├── projection.ts # events → OfferSnapshot
│ │ ├── broadcaster.ts # SSE fan-out from log listeners
│ │ ├── buildOffer.ts # snapshot → wire envelope
│ │ └── types.ts # ActivityEvent union, OfferSnapshot, Bucket, ScenarioKey
│ ├── ai/
│ │ ├── anthropic.ts # client singleton, isDemoMode()
│ │ ├── investigate.ts # streamInvestigation async generator (live + demo)
│ │ ├── tools.ts # 3 tool definitions + runTool(name, input)
│ │ ├── system-prompt.ts # INVESTIGATION_MODEL = 'claude-haiku-4-5'
│ │ └── cache.ts # findCachedInvestigation(offer_id, seq)
│ ├── webhooks/
│ │ ├── hmac.ts # signBody, verifySignature (constant-time)
│ │ ├── idempotency.ts # composite key store
│ │ ├── translate.ts # partner event → ActivityEvent kind
│ │ └── process.ts # end-to-end webhook → event pipeline
│ └── fixtures/
│ └── scenarios.json # One fixture, 4 keys (tracking, in_progress, rewarded, needs_attention)
├── features/
│ └── earn/activity/
│ ├── components/ # Cards, drawer, hero, pills, tiles, progress, modals
│ ├── hooks/ # useActivityOfferPlacements, useInvestigation, useRefreshActivity
│ ├── admin/ # PartnerSimulator, useAdminMode, signPartnerWebhook
│ └── lib/ # deriveActivityStatus, deriveActivityTotals, formatRelative, types
├── lib/
│ ├── analytics.ts # console.log stub, Amplitude-shaped events
│ ├── motion/constants.ts # CARD_MORPH_MS = 240
│ ├── realtime/
│ │ ├── useActivitySubscription.ts # Main SSE hook (leader-elected)
│ │ ├── leaderElection.ts # localStorage lease with 3s TTL
│ │ ├── applyEvent.ts # ActivityEvent → React Query cache patch
│ │ ├── useApplyServerEvents.ts # Apply a batch from a webhook response
│ │ ├── useEventPolling.ts # Polling fallback (reserved)
│ │ ├── optimistic.ts # Optimistic store (reserved for v3)
│ │ └── useOptimisticStore.ts
│ ├── telemetry/ # Web Vitals reporting
│ ├── trpc.ts # Client setup
│ └── trpcProvider.tsx # Provider; forwards ?scenario= through links
└── theme/
└── muiTheme.ts # palette.activity.*, palette.glass.*, Poppins typography
playwright/
├── my-activity.spec.ts # v1 smoke: page load, card states, filtering
├── my-activity-v2.spec.ts # SSE connect, webhook fire, multi-tab sync, Last-Event-ID resume
└── my-activity-v2-optimistic.spec.ts # Reserved
.github/workflows/ci.yml # typecheck → test → build → playwright (ANTHROPIC_API_KEY forced empty)
| Variable | Required | Purpose |
|---|---|---|
ANTHROPIC_API_KEY |
No | Enables live Investigation Agent. If unset or empty, the agent falls back to demo mode. CI forces this to '' for deterministic runs. |
PARTNER_HMAC_SECRET |
No | HMAC secret for webhook verification. Defaults to demo-partner-secret in hmac.ts. The admin simulator signs with the same default, so webhooks work out of the box. |
pnpm dev # localhost:3000
pnpm typecheck # tsc --noEmit
pnpm test # vitest run
pnpm test:watch # vitest in watch mode
pnpm e2e # playwright test
pnpm build # next build
pnpm start # next start (requires prior build)
pnpm lint # next lint.github/workflows/ci.yml runs typecheck → test → build → Playwright
on every push and pull_request. ANTHROPIC_API_KEY is forced empty so the Investigation
Agent runs in demo mode on CI (deterministic, fast). Live Anthropic calls are exercised
via mocked unit tests in src/server/ai/investigate.test.ts.
Traces upload on failure.
- Server:
deals.test.ts,log.test.ts,projection.test.ts,broadcaster.test.ts,hmac.test.ts,idempotency.test.ts,process.test.ts,translate.test.ts,investigate.test.ts,tools.test.ts. - Realtime:
leaderElection.test.ts,applyEvent.test.ts,optimistic.test.ts,useEventPolling.test.tsx. - Components: every card variant, drawer, pill, tile, and modal has a co-located
.test.tsxundersrc/features/earn/activity/components/. - Admin:
PartnerSimulator.test.tsx,signPartnerWebhook.test.ts,useAdminMode.test.tsx. - Playwright: SSE resume with
Last-Event-IDis exercised end-to-end on every CI run.
| Layer | Choice | Mirrors Super? |
|---|---|---|
| Framework | Next.js 15 App Router + TypeScript (strict) | Demo deviation (Super uses webpack SPA) |
| API | tRPC v11 + @tanstack/react-query v5 |
Yes |
| UI | MUI v6 + Emotion, Poppins via next/font/google |
Yes |
| Motion | motion v12 |
— |
| AI | @anthropic-ai/sdk + SSE |
— |
| Validation | Zod | Likely |
| Tests | Vitest + Testing Library, Playwright | — |
| Package manager | pnpm 10 | — |
| Deploy | Vercel (Fluid Compute defaults) | Demo deviation (Super on AWS) |
Full rationale and locks in CLAUDE.md §3 and docs/super-architecture.md.
Bugbot is also wired into CI and automatically reviews PRs for regressions and bugs.
Stock Next.js 15 project — vercel --prod ships it as-is.
pnpm dlx vercel login
pnpm dlx vercel link
pnpm dlx vercel --prodSet ANTHROPIC_API_KEY in project env for live agent. Smoke-test by opening each scenario
URL (?scenario=mixed&admin=1, ?scenario=needs-attention&admin=1) on the production
hostname in an incognito window — SSE, webhooks, and the agent should all work.
docs/demo-script.md — seven-minute walkthrough you can run cold
against the preview URL. Six acts, each ~60–180s:
- Baseline (
?scenario=mixed) — four sections, Live pill, Refresh - Partner push (
?scenario=mixed&admin=1) — fireattribution_verified, watch card morph - Multi-tab sync — same URL in two tabs, verify BroadcastChannel fan-out
- Investigation Agent (
?scenario=needs-attention&admin=1) — streams, tools, recommendation - Under the hood — DevTools EventStream,
getActivityEventsaudit log - What would change in production — three swaps (event log, secrets, real tools)
If you only have 90 seconds: run Act 4 alone. It carries the demo.
- docs/demo-script.md — 7-minute v2 walkthrough
- docs/investigations/my-activity-v2-live-platform-phases.md — v2 execution plan: event log, SSE, Investigation Agent, CI
- docs/investigations/my-activity-page-progress-improvement-plan.md — original problem statement, HAR evidence, four-status model, card anatomy
- docs/investigations/my-activity-implementation-phases.md — v1 12-hour execution plan, eight slots, off-ramps, additive scope
- docs/super-architecture.md — reverse-engineered stack map of the real Super.com surface
- CLAUDE.md — agent guardrails for working in this repo