Skip to content

feat(dashboard): zero-friction onboarding page#3

Merged
aschkanAH merged 4 commits into
feat-onboardingfrom
ai-zero-friction-onboarding-e6a8
Apr 28, 2026
Merged

feat(dashboard): zero-friction onboarding page#3
aschkanAH merged 4 commits into
feat-onboardingfrom
ai-zero-friction-onboarding-e6a8

Conversation

@aschkanAH
Copy link
Copy Markdown
Owner

@aschkanAH aschkanAH commented Apr 28, 2026

Summary

Adds a /onboarding route shown immediately after native registration. The page renders inside the existing authenticated app shell (sidebar retained) but uses a slim header that only carries a "Skip to Dashboard" affordance.

Headline: "From zero to inference in seconds."
Subheading: "Your workspace is fully provisioned. Run a sample payload right from your browser to see Doubleword in action."

Routing & layout

  • New lazy-loaded route /onboarding, gated by ProtectedRoute so unauthenticated users get bounced to login.
  • The route deliberately does not wrap with <AppLayout> because the spec asks for a header that only contains "Skip to Dashboard". Instead the component composes SidebarProvider + AppSidebar + SidebarInset directly so the standard sidebar is retained but the header is bespoke.
  • RegisterForm post-signup landing logic is layered:
    1. Explicit ?redirect= (e.g. org invite acceptance) wins.
    2. Server-driven onboarding_redirect_url (when the user cache entry populated by AuthProvider.checkAuthStatus carries one) — AuthProvider initiates a hard window.location.href redirect for that case, so we deliberately do not call navigate() in the form handler to avoid racing the pending hard nav.
    3. Default to /onboarding for the zero-friction flow.
  • "Skip to Dashboard" and the post-success redirect both navigate to /models via react-router rather than app.doubleword.ai/models so this works on self-hosted/non-prod origins.

Interactive sections

  1. Live API key — On mount, calls the existing useCreateApiKey hook to mint a real realtime key against currentUser.id. Failures are surfaced inline (with a hint to use the API Keys page) and don't block the rest of the flow. Copy uses the shared copyToClipboard util and flips to a checkmark for 2s.
  2. Workload runner — Three coupled toggles:
    • Async (default) vs Batch — swaps the cost copy ($1.87 / 25%, $1.25 / 50%) and the rendered payload (single JSON vs three-row JSONL).
    • Browser vs Terminal — Browser mode shows the JSON/JSONL payload + a "Run Now" button. Terminal mode shows Python/cURL snippets with the freshly-minted key pre-injected, plus a "Listening for your request…" indicator that flips to success when clicked.
    • Python vs cURL — only relevant in Terminal mode.
    • "Run Now" cycles through idle → running → success on a 2.5s timer (per spec). Behind the scenes it fires a real upload + useCreateBatch so a job actually appears in the dashboard the user is about to land on. The timer-based UX path is independent of the network call so the redirect is predictable even when the upload is slow.
    • Either success state schedules a 2s redirect to /models.
    • Payloads are built as JS objects and serialized via JSON.stringify; model alias and API key are escaped via JSON.stringify inner-string form before being interpolated into Python and cURL snippets, so aliases containing quotes / backslashes / control characters do not produce malformed code.
  3. Team invite — Configured per-environment via VITE_INVITE_WEBHOOK_URL (typically the Growth team's Zapier Catch Hook). The form is hidden entirely when the env var is unset, so no environment silently drops invite submissions. POST is fire-and-forget under mode: 'no-cors' because the Zapier host doesn't return CORS headers; body is sent as application/x-www-form-urlencoded via URLSearchParams rather than JSON, because no-cors silently downgrades non-safelisted content-types and Zapier won't auto-parse text/plain into structured fields.

Background "Hello World" batch & toast

On mount, an info toast ("Sample Batch Started…") is fired with a 6s duration via the existing sonner toaster. In parallel, a one-row JSONL is uploaded and a batch is created via the same hooks the dashboard uses elsewhere, so the user actually has data to look at after they land on /models. Both the toast and the background job are gated by an idempotency ref so React StrictMode double-invokes don't double-fire.

The shared Toaster was also fixed in this PR: the existing wiring forwarded the bare HSL-component shadcn tokens (var(--popover) resolves to 0 0% 100%) into sonner's --normal-bg, producing invalid CSS and falling through to sonner's translucent default. This made every toast in the app illegible when overlaying content. Tokens are now wrapped in hsl(...) so the resolved background is a real opaque color.

Configuration

Env var Purpose Default
VITE_INVITE_WEBHOOK_URL URL of the Zapier (or other) Catch Hook used by the onboarding "invite a teammate" form. unset → invite section hidden

Design system compliance

  • Reuses Button, SidebarProvider, SidebarInset, SidebarTrigger, and the AppSidebar directly.
  • Uses doubleword-* color tokens (doubleword-primary, doubleword-red-50/-300/-light/-dark, doubleword-text-*, doubleword-border*, doubleword-neutral-*) for all branded surfaces. The Async card and primary CTA pick up the doubleword red; Batch keeps the existing blue accent for differentiation.
  • Sonner toasts are reused as the "Sample Batch Started" notification rather than a bespoke toast component.
  • No new fonts introduced; inherits the global Space Grotesk / sans stack.

QA

  • pnpm run lint clean.
  • pnpm exec tsc -b tsconfig.app.json clean.
  • pnpm run build succeeds.
  • pnpm test -- --run: 498 / 498 passing.

Notes / risks

  • The Zapier webhook call is fire-and-forget (opaque response under no-cors); we can't detect transport failures from the browser.
  • The "Run Now" UX intentionally simulates a 2.5s success regardless of the underlying batch outcome. If the real upload fails we log a warning but don't surface the error in the UI — the user is about to be redirected anyway, and they'll see the actual job state on /models.
  • Background API key creation runs on every visit to /onboarding. Combined with the "won't be shown again" copy, that's intentional for the first-visit flow but means revisiting the page for any reason will mint a new throwaway key. If we want to avoid that, follow-up work should gate this behind the same onboarding_completed_${userId} localStorage flag the auth context already uses.
  • The sonner background fix is global. Toasts elsewhere in the app will gain an opaque card-style background instead of their previous translucent fallback. Any component that relied on toasts being see-through will need to be revisited, but I didn't find any in tree.
Open in Web Open in Cursor 

Adds /onboarding route shown immediately after native registration. The page
renders inside the standard authenticated app shell (sidebar retained) but
uses a slim header that only carries a 'Skip to Dashboard' affordance.

Three interactive steps:

1. Live API key — calls the existing useCreateApiKey hook on mount so the
   user sees a real key (not a placeholder) and can copy it.
2. Workload runner — async vs batch toggle, browser vs CLI execution
   toggle, Python vs cURL language toggle. Browser mode pre-renders the
   payload and a Run Now button that uploads + creates a real batch via
   useCreateBatch / useUploadFileWithProgress while still cycling through
   idle → running → success on a 2.5s timer for predictable UX. CLI mode
   shows a 'Listening for request…' indicator that flips to success on
   click. Both flows redirect to /models 2s after success.
3. Team invite — POSTs the supplied email to the configured Zapier hook
   (no-cors, fire-and-forget) and surfaces a sent confirmation.

Also fires a background 'Hello World' batch + toast on mount so users see
data on the dashboard when they land.

The post-signup default redirect from RegisterForm is now /onboarding;
explicit ?redirect= and the server-driven onboarding_redirect_url still
take priority.

Co-authored-by: aschkanAH <aschkanAH@users.noreply.github.com>
@aschkanAH aschkanAH marked this pull request as ready for review April 28, 2026 03:23
mode: 'no-cors' silently downgrades Content-Type: application/json to
text/plain because JSON isn't CORS-safelisted. Zapier Catch Hooks only
auto-parse JSON when the request actually carries an application/json
content-type, so the previous code was firing the webhook successfully
but with all fields empty.

Switching the body to URLSearchParams produces an
application/x-www-form-urlencoded body (which IS CORS-safelisted) and
which Zapier does parse into structured fields.

Co-authored-by: aschkanAH <aschkanAH@users.noreply.github.com>
Comment thread dashboard/src/components/features/onboarding/Onboarding/Onboarding.tsx Outdated
Replaces the hardcoded Zapier hook with VITE_INVITE_WEBHOOK_URL. The
invite section is hidden entirely when the variable is unset so we don't
collect emails with nowhere to send them; the submit handler also has
a defensive guard for stale builds.

Co-authored-by: aschkanAH <aschkanAH@users.noreply.github.com>
Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

There are 3 total unresolved issues (including 1 from previous review).

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit f8ee3a7. Configure here.

Comment thread dashboard/src/components/auth/RegisterForm.tsx Outdated
…gate race

Three fixes from review:

1. buildAsyncPayload, buildJsonlPayload, and the snippet generators were
   interpolating the model alias into JSON / Python / cURL string
   literals via raw template literals. Catalog aliases come from user-
   controlled DB metadata and may legitimately contain quotes,
   backslashes, or control characters, which would yield invalid JSON.
   handleRunNow's JSON.parse(buildAsyncPayload(...)) would throw on such
   aliases, yet the simulated 2.5s timer would still flip the UI to
   'success' and redirect — falsely telling the user a workload was
   queued.

   Payloads are now built as objects and serialized via JSON.stringify
   (JSONL/JSON cases), and code-snippet interpolations go through
   escapeForLiteral which uses JSON.stringify's inner string form to
   correctly escape both Python and shell-string literal special chars.

2. The CLI listener block exposed '(Click to simulate success)' to
   desktop users. 'Simulate' is internal language that breaks the
   illusion of the onboarding flow. Replaced with '(Click to continue)'
   and updated the aria-label to match.

3. RegisterForm called navigate('/onboarding') unconditionally after
   register(). When the server returns onboarding_redirect_url,
   AuthProvider.checkAuthStatus has already initiated a hard navigation
   via window.location.href — the new client-side push raced that and
   could briefly mount the wrong route before the hard nav resolved.
   We now read the just-populated current-user cache entry and skip
   navigate() when an onboarding_redirect_url is present, deferring
   cleanly to the server-driven redirect (matching the pre-onboarding
   behaviour for that path).

Co-authored-by: aschkanAH <aschkanAH@users.noreply.github.com>
@aschkanAH aschkanAH merged commit 618e399 into feat-onboarding Apr 28, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants