Skip to content

Lightweight Accounts P1: email capture flow for logged-out consumer actions - #5101

Open
aseckin wants to merge 18 commits into
mainfrom
lightweight-accounts-p1
Open

Lightweight Accounts P1: email capture flow for logged-out consumer actions#5101
aseckin wants to merge 18 commits into
mainfrom
lightweight-accounts-p1

Conversation

@aseckin

@aseckin aseckin commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Frontend half of Lightweight Accounts P1 (#5045), built against the merged backend (#5052, #5066, #5085, and gated-action support in social auth from 9e543e5).

What this does

When a logged-out visitor takes a consumer action (vote on a question, subscribe to updates, or make a forecast), they get a capture drawer instead of the signup modal: enter an email, receive a magic link, and clicking it verifies the address, signs them in, and applies the action they were trying to take.

Demo

lightweight-accs.mp4

The flow

  • Three gated actions: post vote, post subscribe, question-level forecast (the three types the backend supports). Comment votes, key factors, and group/conditional forecast makers keep the existing signin modal.
  • Subscribe opens on a checkbox-card step ("When it resolves" preselected, forecast changes / new discussion opt-in) before the email step. Vote and forecast go straight to email with the drafted action attached.
  • Untouched slider on the forecast maker opens a sign-in-only variant of the drawer with honest copy (nothing to save), and never clears a previously pending action.
  • Sent state recaps what the link will do; resend has a 30s cooldown and re-sends the stored action (backend clears pending actions on empty requests, so this matters).
  • Repeat actions while unverified show a prefilled state with latest-wins copy ("It saves this vote instead").
  • Confirm-email banner persists under the top chrome (localStorage, 24h TTL matching link TTL) and reopens the drawer in recap state.
  • Dead links land on a recovery form that requests a fresh link, carrying the stored action.
  • Google path: the drawer offers "Sign in with Google"; the gated action is stashed in sessionStorage across the OAuth redirect and attached to the code exchange (applied best-effort server-side, 15-minute staleness guard).
  • Mobile: new BottomDrawer primitive on Base UI (animated enter/exit, swipe-down dismiss, max height capped below the navbar, standard title+close header, one active drawer at a time). Desktop uses the existing centered modal. The consumer question page also gains a mobile "Notify me when this resolves" CTA, and Predict now opens the maker for visitors in a bottom drawer instead of the fullscreen overlay.
  • Analytics: emailCaptureShown / emailSubmitted / emailSubmitFailed / captureAbandoned / subscribeOptions* / confirmBanner* / notifyCta* events with trigger + surface, never the raw email.

Mobile share drawer (reusable pattern)

The Share button on question pages now opens a bottom sheet on mobile (desktop keeps the dropdown): a 2x2 grid of Copy Link / X / Facebook / Embed tiles with pressed-state feedback and toasts, the drawer staying open across actions (Embed hands off to the embed modal). This establishes the house pattern for converting desktop interactions into mobile drawers: BottomDrawer with the title header + DrawerActionButton tiles, gated at the call site with useBreakpoint("sm")share_post_drawer.tsx is the reference example for future drawers.

Subscribe-capture A/B experiment

The subscribe flow ships behind a PostHog experiment testing whether the options step earns richer subscriptions or is just friction:

  • control: CTA copy "Notify me of updates", checkbox-card options step as described above.
  • test: CTA copy "Notify me when this resolves", straight to the email input, subscription is resolution-only.
  • Unenrolled / signed-in / flag missing / PostHog down: current status-quo flow (resolve copy + options step). Fail-open everywhere.

Enrollment reuses the anonymous-experiment infrastructure from #5082: middleware evaluates the flag server-side for eligible anonymous document requests, pins the assignment in a 26-week first-party cookie (plus a same-request header so the first pageview renders correctly), and shares the distinct_id with the autotranslation experiment so one visitor never has two identities. The variant is resolved server-side only on the question route (already dynamic) so static pages stay static.

PostHog setup

  1. Create an experiment with feature flag key subscribe_capture_experiment, variants control and test, 50/50 split. No targeting conditions needed (the middleware already restricts enrollment to anonymous non-bot visitors).
  2. Exposure ($feature_flag_called) registers only when an experiment surface is actually shown (CTA impression or subscribe drawer open), not on page load, so the exposed population is people who could be affected.
  3. Primary metric: emailSubmitted filtered to trigger = post_subscribe, relative to exposure.
  4. Secondary: subscription richness. subscribeOptionsContinued fires only in control and carries the selected types array; test is constant at one type. Also watch captureAbandoned (with step) for where each arm loses people.
  5. All capture events additionally carry a captureVariant property (control / test / none), and bootstrapped flags stamp $feature/subscribe_capture_experiment on every event.
  6. Guardrails worth watching after a few weeks: resend rate, and notification unsubscribe rate for accounts created by each arm.

Local/staging testing without the flag: pin an arm by setting the cookie metaculus_subscribe_capture_ab to x%3Acontrol or x%3Atest in devtools.

Also in this PR

  • CSRF token generation falls back to crypto.getRandomValues because crypto.randomUUID does not exist in insecure contexts and crashed OAuth URL construction (reachable from the signin modal on main too) when the site is served over plain http, e.g. device testing against the dev server.
  • Same family: useCopyUrl falls back to document.execCommand("copy") where navigator.clipboard is unavailable.
  • BinaryCPBar size lg now draws the SVG at real dimensions instead of a CSS transform, so its layout box matches the visual and the scale-compensation hacks at both call sites are gone.
  • Mobile spacing polish on consumer question pages (action row, prediction block, drawer paddings).

Verification

  • E2E against the live backend: subscribe with partial chip selection (DB confirmed only the selected subscription types are created), vote and forecast application on link click, latest-wins overwrite, auto-generated usernames, banner lifecycle, resend cooldown, recovery form, cross-action repeat copy.
  • Verified on real WebKit with real touch via the iOS Simulator (LAN IP origin), plus desktop Chromium light/dark.
  • Both experiment arms verified end to end by pinning the assignment cookie (control: new copy + options step; test: straight to email, resolution-only payload); unenrolled state verified as untouched status quo.
  • bun run lint (0 errors) and bun run build pass. (Note: the ~37 "Dynamic server usage" logs during build pre-exist on main; verified against clean HEAD, tracked separately.)

Summary by CodeRabbit

  • New Features
    • Added email capture for signing in, voting, subscribing to questions, and saving forecasts.
    • Added passwordless email links, recovery and resend options, alternate sign-in methods, and confirmation reminders.
    • Added question notification CTAs with localized messaging.
    • Added mobile bottom drawers for sharing and forecasting actions.
  • Improvements
    • Expanded localization across six languages.
    • Improved mobile layouts, forecast gauges, sharing, email validation, and copied-link feedback.
    • Added clearer confirmation messages after gated actions are completed.

Atakan Seckin and others added 5 commits July 30, 2026 12:58
…equest action

Client-side foundation for the lightweight accounts flow: the gated action
type union (post_vote / post_subscribe / forecast), a localStorage-backed
pending record store shared across trees via useSyncExternalStore (plus the
sessionStorage stash for OAuth carry-through), the wire mapping shared by the
email and social paths, and the requestEmailLink API client + server action
(Turnstile headers, always-204 anti-enumeration contract).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
BottomDrawer wraps Base UI's Drawer (animated enter/exit even when mounted
on demand, swipe-down dismiss, max height capped below the navbar). The
capture drawer runs the options/input/sent state machine: subscribe opens on
checkbox cards with only 'When it resolves' preselected, vote and forecast go
straight to email; includes prefilled repeat state, resend cooldown, Google
button with action stash, Turnstile, and per-trigger copy in en.json.
Registered as the emailCapture modal type. Non-English locales pending
translations:generate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Post votes, question subscriptions, and question-level forecasts now open
the capture drawer with the drafted action attached instead of the signup
modal. Forecast makers expose buildForecastPayload so the untouched-slider
case falls back to a sign-in-only drawer without clearing pending actions.
The consumer Predict button opens the maker for visitors (mobile: bottom
drawer instead of the fullscreen overlay; one active drawer at a time), and
the new NotifyMeCta gives mobile a subscribe entry point. Group/conditional
makers and comment/key-factor gates keep the existing signin modal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Device-local banner under the top chrome while a capture record is pending;
tapping it reopens the drawer in recap state with the resend cooldown. The
magic-link failure page gains an inline form that requests a fresh link,
re-sending the stored gated action when one exists.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The drawer's Google path stashes the pending action in sessionStorage before
the redirect; the callback attaches it to the code exchange (backend applies
it best-effort per 9e543e5) with a 15-minute staleness guard, and clears the
capture record on success.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The PR adds anonymous email capture for gated actions, email-link recovery, subscribe-capture experimentation, responsive mobile drawers, notification CTAs, sharing feedback, gauge layout updates, and translations in six locales.

Changes

Email capture and anonymous actions

Layer / File(s) Summary
Contracts and experiment enrollment
front_end/src/types/gated_actions.ts, front_end/src/utils/gated_actions.ts, front_end/src/services/subscribe_capture_*.ts, front_end/src/proxy.ts, front_end/src/contexts/experiments_context.tsx, front_end/src/services/api/auth/auth.server.ts
Adds gated-action contracts, backend mapping, email-link API support, subscribe-capture enrollment, experiment assignment handling, and CSRF token fallback support.
Pending state and email capture
front_end/src/components/email_capture/*, front_end/src/components/ui/drawer.tsx, front_end/src/app/(main)/auth/email/components/email_link_verify.tsx
Adds the email-capture drawer, local and session pending stores, resend handling, email-link recovery, Turnstile support, and responsive drawer rendering.
Gated-action integrations
front_end/src/components/forecast_maker/*, front_end/src/components/global_modals.tsx, front_end/src/components/post_card/basic_post_card/post_voter.tsx, front_end/src/contexts/*, front_end/src/app/(main)/accounts/social/[provider]/*, front_end/src/app/(main)/components/email_confirm_banner.tsx
Passes forecast, vote, subscription, and OAuth actions through email capture and restores pending actions after authentication.
Question subscription experiment
front_end/src/app/(main)/questions/[id]/[[...slug]]/page.tsx, front_end/src/app/(main)/questions/[id]/components/question_page_shell/*
Provides the subscribe-capture variant to question pages and adds notification CTA states and analytics.
Responsive question interactions
front_end/src/components/post_actions/share_post_drawer.tsx, front_end/src/components/ui/drawer_action_button.tsx, front_end/src/app/(main)/questions/[id]/components/question_view/*, front_end/src/hooks/share.ts, front_end/src/components/consumer_post_card/binary_cp_bar.tsx
Adds mobile sharing and forecast drawers, clipboard fallback behavior, gauge sizing changes, and question layout adjustments.
Localization coverage
front_end/messages/{en,cs,es,pt,zh,zh-TW}.json
Adds translations for email capture, authentication, notification CTAs, sharing feedback, and confirmation flows.

Estimated code review effort: 5 (Critical) | ~90 minutes

Possibly related PRs

Suggested reviewers: hlbmtc, elisescu, ncarazon

Poem

A rabbit hops through links so bright,
Captures email by moonlit light.
Drawers slide and forecasts flow,
Votes and shares now neatly go.
OAuth lands with banners clear,
Six locales make meaning near.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the primary change: adding an email-capture flow for logged-out consumer actions.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch lightweight-accounts-p1

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

crypto.randomUUID only exists in secure contexts, so building an OAuth URL
crashed the app when the site is accessed over plain http (e.g. LAN device
testing against the dev server). getRandomValues has no such restriction.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

🚀 Preview Environment

Your preview environment is ready!

Resource Details
🌐 Preview URL https://metaculus-pr-5101-lightweight-accounts-p1-preview.mtcl.cc
📦 Docker Image ghcr.io/metaculus/metaculus:lightweight-accounts-p1-3f244df
🗄️ PostgreSQL NeonDB branch preview/pr-5101-lightweight-accounts-p1
Redis Fly Redis mtc-redis-pr-5101-lightweight-accounts-p1

Details

  • Commit: 6e8f046fb75f3b03e1d804e6c955a3a74e449293
  • Branch: lightweight-accounts-p1
  • Fly App: metaculus-pr-5101-lightweight-accounts-p1

ℹ️ Preview Environment Info

Isolation:

  • PostgreSQL and Redis are fully isolated from production
  • Each PR gets its own database branch and Redis instance
  • Changes pushed to this PR will trigger a new deployment

Limitations:

  • Background workers and cron jobs are not deployed in preview environments
  • If you need to test background jobs, use Heroku staging environments

Cleanup:

  • This preview will be automatically destroyed when the PR is closed

Atakan Seckin and others added 3 commits August 5, 2026 09:10
Mobile capture drawer gets a unified header row: back button or wrapping
title inline with the close button at consistent padding (desktop modal
unchanged). BinaryCPBar's lg size now draws the SVG at real dimensions
instead of a CSS transform, so the layout box matches the visual and the
scale-compensation hacks at both call sites are gone. Tighter mobile
spacing on consumer question pages (action row, prediction block, notify
CTA, drawer paddings) and a consolidated drawer handle gap.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Share button opens a bottom sheet on mobile (desktop keeps the
dropdown): a 2x2 grid of Copy Link / X / Facebook / Embed tiles with
pressed-state feedback, toasts on action, and the drawer staying open
(Embed hands off to the embed modal). BottomDrawer gains a standard
title-plus-close header used by the predict drawer too, and the new
DrawerActionButton tile is the building block for future mobile drawers,
with share_post_drawer as the reference example. useCopyUrl falls back to
execCommand where navigator.clipboard is unavailable (insecure contexts).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two coherent bundles behind the subscribe_capture_experiment flag: control
shows the options step with 'Notify me of updates' copy, test goes straight
to the email input under 'Notify me when this resolves' and subscribes to
resolution only. Enrollment reuses the anonymous-experiment rails from
the autotranslation experiment (middleware evaluation, first-party cookie,
same-request header, shared distinct_id) with the variant resolved
server-side on the already-dynamic question route so static pages stay
static; the root-level drawer falls back to a synchronous cookie read.
Exposure registers on surface show, not page load, and capture events
carry a captureVariant property. Unenrolled, signed-in, and flag-off all
serve the status quo.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds es/cs/pt/zh/zh-TW translations for the 62 new keys (email capture
drawer, confirm banner, dead-link recovery, notify CTA, share drawer,
experiment copy). Placeholders and rich-text tags preserved; the
compositional subscribe phrases translated to read grammatically inside
the 'get updates when {a}' sentences.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@aseckin
aseckin marked this pull request as ready for review August 5, 2026 08:53
Atakan Seckin and others added 2 commits August 7, 2026 06:48
…magic-link sign-in

The post-sign-in toast now names what the link actually did ("you're now
following this question") and stays until dismissed with its own X, so the
only confirmation the deferred action was applied cannot scroll past unread.
The applied action travels in the redirect URL because the confirm-email
banner clears the device-local record the instant a user appears, before the
destination page mounts; a link opened on another device falls back to the
generic wording. Google sign-in now raises the same toast instead of
completing silently.

Submitting an email is optimistic: the sent state and reminder banner appear
immediately and only a failure pulls the user back, with the address intact
and an error toast. emailSubmitted still fires on confirmed success only, so
the experiment's primary metric cannot count failures.

Turnstile switches to interaction-only, so the widget is invisible unless
Cloudflare demands a challenge while staying mounted to re-issue the
single-use token for resend, which also removes the drawer's overflow. Plus:
terms line on the email step, centered secondary links, no-scrollbar on the
drawer scroller.

Facebook gives up its slot in both auth modals to a magic-link button, the
first route back in for accounts created by email link, which have no
password; it opens the same capture sheet with sign-in copy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
# Conflicts:
#	front_end/messages/cs.json
#	front_end/messages/es.json
#	front_end/messages/pt.json
#	front_end/messages/zh-TW.json
#	front_end/messages/zh.json
… wrapping

Logging out is a client-side navigation, so the root layout and its modal
state survive it: an open tutorial stayed on screen for a signed-out visitor
on the storefront. GlobalModals now refuses to render it without a user, and
AuthProvider syncs the server-provided user during render instead of in an
effect, since child effects run before parent ones and a page mounting right
after logout would otherwise read the signed-out user as still signed in.

Arriving by magic link also marks onboarding complete, so a visitor who came
to follow a question is not met with a forecaster tutorial. The update is
awaited and revalidating: fired-and-forgotten, staleTimes.dynamic serves the
destination page a cached payload carrying the old flag and the tutorial
opens anyway.

Also: Enter submits the capture and recovery email fields, which sit outside
a form and did nothing before; and the terms line wraps with text-balance.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@front_end/src/components/global_modals.tsx`:
- Around line 140-144: Update the authentication-change handling in global
modals so signing out clears any active onboarding modal from currentModal
state. Preserve the existing rendering guard in the OnboardingModal block, and
ensure the cleanup occurs when user becomes null rather than only hiding the
modal.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 4c356657-f672-420e-a699-950ce2422ef8

📥 Commits

Reviewing files that changed from the base of the PR and between 9ddc267 and f061666.

📒 Files selected for processing (5)
  • front_end/src/app/(main)/auth/email/components/email_link_verify.tsx
  • front_end/src/components/email_capture/email_capture_drawer.tsx
  • front_end/src/components/email_link_event_toast.tsx
  • front_end/src/components/global_modals.tsx
  • front_end/src/contexts/auth_context.tsx
🚧 Files skipped from review as they are similar to previous changes (2)
  • front_end/src/components/email_capture/email_capture_drawer.tsx
  • front_end/src/components/email_link_event_toast.tsx

Comment thread front_end/src/components/global_modals.tsx
The tutorial fires for any signed-in user whose onboarding flag is unset, and
only the magic-link path cleared it: arriving through Google still produced a
brand-new un-onboarded account, so the tutorial ambushed the user on the next
page that mounted OnboardingCheck. The social callback now marks onboarding
complete the same way, awaited and revalidating so the destination page is not
server-rendered from a cached payload carrying the old flag.

Telling a lightweight sign-in from an ordinary one needs a signal, and the
sessionStorage stash was nearly it — except it was only written when a gated
action existed, so Google from the sign-in drawer stashed nothing and would
have kept the tutorial. The drawer now stashes unconditionally, with a null
action when there is none, and the stash's presence is the discriminator.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Refusing to render the tutorial without a user leaves the request itself
sitting in modal state, and SimplifiedSignupModal signs a visitor in with a
bare setUser while owning its own open state: a stale tutorial would surface
the moment a user reappeared, with nothing having asked for it. Clear it when
the user goes away. The render guard stays, since the effect runs after paint
and would otherwise let a frame of tutorial through.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@aseckin

aseckin commented Aug 10, 2026

Copy link
Copy Markdown
Contributor Author

A few notes:

  • Let's hide cloudflare in confirmation modal
    image

  • Let’s use an optimistic update when the user submits their email, and show an error toast if the request fails. That should make the interaction feel almost instant and much smoother

  • Should we center "use a different email"?
    image

  • Should we add a disclaimer as we do in signup modal? From the other hand, our signup email already contain this

    By registering, you acknowledge and agree to Metaculus's ...

    image
  • I see a scrollbar in chrome responsive mode for iphone 14 pro max. Also, should we center cloudflare badge like this? It supports extra widget configurations we can use -- https://developers.cloudflare.com/turnstile/get-started/client-side-rendering/widget-configurations/
    image

all should be addressed, ty for feedback

@aseckin
aseckin requested review from hlbmtc and ncarazon August 10, 2026 08:45
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.

3 participants