Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 65 additions & 34 deletions apps/web/src/components/EntryShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1115,10 +1115,14 @@ function OnboardingView({
area = 'runtime';
stepIndex = '1';
stepName = 'connect';
} else {
} else if (step === 1) {
area = 'about_you';
stepIndex = '2';
stepName = 'about_you';
} else {
area = 'newsletter';
stepIndex = '3';
stepName = 'newsletter';
}
trackPageView(analytics.track, {
page_name: 'onboarding',
Expand All @@ -1138,6 +1142,10 @@ function OnboardingView({
// PR #2453 follow-up).
const onboardingStartedAtRef = useRef<number>(Date.now());
const lifecycleReportedRef = useRef(false);
// Guards `about_you_submit` to exactly one emit per onboarding session,
// independent of how many times the user crosses the About-you step via
// the clickable stepper or Back/Continue.
const aboutYouReportedRef = useRef(false);
function currentRuntimeType(): TrackingOnboardingRuntimeType {
if (runtime === 'amr') return 'amr_cloud';
if (runtime === 'local') return 'local_cli';
Expand All @@ -1150,7 +1158,8 @@ function OnboardingView({
stepName: TrackingOnboardingStepName;
} {
if (stepIdx === 0) return { area: 'runtime', stepIndex: '1', stepName: 'connect' };
return { area: 'about_you', stepIndex: '2', stepName: 'about_you' };
if (stepIdx === 1) return { area: 'about_you', stepIndex: '2', stepName: 'about_you' };
return { area: 'newsletter', stepIndex: '3', stepName: 'newsletter' };
}
function emitOnboardingClick(
element: TrackingOnboardingClickElement,
Expand Down Expand Up @@ -1244,6 +1253,7 @@ function OnboardingView({
const steps = [
t('settings.onboardingStepConnect'),
t('settings.onboardingStepProfile'),
t('settings.onboardingStepNewsletter'),
Comment thread
lefarcen marked this conversation as resolved.
];
const isLastStep = step === steps.length - 1;

Expand Down Expand Up @@ -1411,16 +1421,17 @@ function OnboardingView({
return;
}
if (isLastStep) {
// Emit the About-you survey snapshot FIRST, before the
// continue/complete pair. This is the bombproof carrier for the
// user's role / org size / use case / discovery source picks:
// per-dropdown clicks are racy on a fast Finish-setup (the user
// can pick all four dropdowns and click Finish inside one ~3s
// window, and PostHog's posthog-js client may not flush the
// individual rows before the route change unmounts the analytics
// provider). The snapshot click + the survey fields on
// Emit the About-you survey snapshot on the completion path, before
// the continue/complete pair. Reading `profileRef` captures the
// user's final role / org size / use case / discovery source picks
// even on a fast Finish. Gating it here — rather than when the user
// leaves the About-you step — keeps it exactly-once no matter how the
// final step was reached: primary CTA, Back-then-Continue, or a
// forward jump via the clickable stepper. `emitAboutYouSubmit` is
// additionally idempotent per session (see its `aboutYouReportedRef`
// guard). The snapshot click + the survey fields on
// `onboarding_complete_result` give the funnel two independent
// paths for the same data.
// carriers for the same data.
emitAboutYouSubmit();
const newsletterEmail = profileRef.current.email;
const shouldSubmitNewsletter =
Expand Down Expand Up @@ -1546,17 +1557,34 @@ function OnboardingView({
// the latest state. `'unknown'` covers an untouched field on the
// About-you step (the spec keeps the wire type open-string so a new
// role / use-case option doesn't force a contract bump).
//
// This now fires from the completion path (the final Newsletter step),
// so it stamps the About-you step coordinates explicitly instead of
// reading the live `step` via `emitOnboardingClick`: the event describes
// the About-you submission, not whatever step the user finished on. The
// `aboutYouReportedRef` guard keeps it exactly-once per session.
function emitAboutYouSubmit(): void {
if (aboutYouReportedRef.current) return;
const onboardingSessionId = onboardingSessionIdRef.current;
if (!onboardingSessionId) return;
aboutYouReportedRef.current = true;
const snapshot = profileRef.current;
emitOnboardingClick('about_you_submit', 'continue', {
trackOnboardingClick(analytics.track, {
page_name: 'onboarding',
area: 'about_you',
element: 'about_you_submit',
action: 'continue',
step_index: '2',
step_name: 'about_you',
onboarding_session_id: onboardingSessionId,
role: snapshot.role || 'unknown',
organization_size: snapshot.orgSize || 'unknown',
use_cases: snapshot.useCase.length > 0 ? snapshot.useCase : ['unknown'],
discovery_source: snapshot.source || 'unknown',
});
}

// Optional newsletter signup captured on the About-you step. The last-step
// Optional newsletter signup captured on the Newsletter step. The last-step
// button shows loading while this settles; failures are swallowed so
// onboarding completion never depends on the marketing site. A blank or
// malformed email is simply skipped. Only a boolean opt-in is tracked — the
Expand Down Expand Up @@ -1989,28 +2017,31 @@ function OnboardingView({
}}
/>
</div>
<div className="onboarding-view__newsletter-inline">
<OnboardingPanelHeader
title={t('settings.onboardingNewsletterTitle')}
body={t('settings.onboardingNewsletterBody')}
</div>
) : null}

{step === 2 ? (
<div className="onboarding-view__panel onboarding-view__panel--newsletter">
<OnboardingPanelHeader
title={t('settings.onboardingNewsletterTitle')}
body={t('settings.onboardingNewsletterBody')}
/>
<label className="onboarding-view__email-field">
<span className="onboarding-view__email-label">
{t('newsletter.label')}
</span>
<input
className="onboarding-view__email-input"
type="email"
autoComplete="email"
inputMode="email"
placeholder={t('newsletter.placeholder')}
value={profile.email}
onChange={(event) =>
setProfile((current) => ({ ...current, email: event.target.value }))
}
/>
<label className="onboarding-view__email-field">
<span className="onboarding-view__email-label">
{t('newsletter.label')}
</span>
<input
className="onboarding-view__email-input"
type="email"
autoComplete="email"
inputMode="email"
placeholder={t('newsletter.placeholder')}
value={profile.email}
onChange={(event) =>
setProfile((current) => ({ ...current, email: event.target.value }))
}
/>
</label>
</div>
</label>
</div>
) : null}

Expand Down
10 changes: 1 addition & 9 deletions apps/web/src/styles/home/entry-layout.css
Original file line number Diff line number Diff line change
Expand Up @@ -1696,7 +1696,7 @@

.onboarding-view__steps {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
width: min(860px, 100%);
margin: 0;
Expand Down Expand Up @@ -2874,14 +2874,6 @@
gap: 16px;
}

.onboarding-view__newsletter-inline {
display: grid;
gap: 14px;
margin-top: 8px;
padding-top: 18px;
border-top: 1px solid color-mix(in srgb, var(--border) 72%, transparent);
}

.onboarding-view__email-field {
display: grid;
gap: 8px;
Expand Down
97 changes: 94 additions & 3 deletions apps/web/tests/components/EntryShell.onboarding.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -620,6 +620,10 @@ describe('EntryShell onboarding Open Design AMR runtime', () => {
chooseDropdownOption('Organization size', /Growth company/i);
chooseDropdownOption('Use case', /Product design/i);
chooseDropdownOption('Where did you hear about us?', /Search/i);
fireEvent.click(screen.getByRole('button', { name: /^Continue$/i }));
await waitFor(() => {
expect(screen.getByRole('heading', { name: 'Stay in the loop' })).toBeTruthy();
});
await waitFor(() => {
expect(document.querySelector('.onboarding-view__email-input')).toBeTruthy();
});
Expand All @@ -642,11 +646,17 @@ describe('EntryShell onboarding Open Design AMR runtime', () => {
step_index: '2',
step_name: 'about_you',
}),
expect.objectContaining({
page_name: 'onboarding',
area: 'newsletter',
step_index: '3',
step_name: 'newsletter',
}),
]),
);

// The About-you survey snapshot fires from the final step and carries
// the user's role/org/use-case/source picks.
// The About-you survey snapshot fires when the user continues past
// the About-you step and carries the role/org/use-case/source picks.
expect(findTrackedEvent('ui_click', (payload) => payload.element === 'about_you_submit')).toMatchObject({
page_name: 'onboarding',
area: 'about_you',
Expand Down Expand Up @@ -693,11 +703,15 @@ describe('EntryShell onboarding Open Design AMR runtime', () => {
globalThis.fetch = fetchMock as typeof fetch;
renderOnboarding();

// Connect -> About you
// Connect -> About you -> Newsletter
fireEvent.click(await screen.findByRole('button', { name: /^Continue$/i }));
await waitFor(() => {
expect(screen.getByRole('heading', { name: 'About you' })).toBeTruthy();
});
fireEvent.click(screen.getByRole('button', { name: /^Continue$/i }));
await waitFor(() => {
expect(screen.getByRole('heading', { name: 'Stay in the loop' })).toBeTruthy();
});
await waitFor(() => {
expect(document.querySelector('.onboarding-view__email-input')).toBeTruthy();
});
Expand Down Expand Up @@ -746,6 +760,7 @@ describe('EntryShell onboarding Open Design AMR runtime', () => {
await waitFor(() => {
expect(screen.getByRole('heading', { name: 'About you' })).toBeTruthy();
});
fireEvent.click(screen.getByRole('button', { name: /^Continue$/i }));
await waitFor(() => {
expect(document.querySelector('.onboarding-view__email-input')).toBeTruthy();
});
Expand All @@ -754,6 +769,81 @@ describe('EntryShell onboarding Open Design AMR runtime', () => {
expect(fetchMock.mock.calls.some(([url]) => String(url).endsWith('/subscribe'))).toBe(false);
});

it('reports about_you_submit exactly once when jumping to the newsletter step via the stepper', async () => {
globalThis.fetch = vi.fn(async () =>
jsonResponse({
loggedIn: true,
profile: 'prod',
configPath: '/x',
user: { id: 'u', email: 'user@example.com' },
}),
) as typeof fetch;
renderOnboarding();

fireEvent.click(await screen.findByRole('button', { name: /^Continue$/i }));
await waitFor(() => {
expect(screen.getByRole('heading', { name: 'About you' })).toBeTruthy();
});
chooseDropdownOption('Your role', 'Engineer');

// Jump straight to the newsletter step via the clickable stepper,
// bypassing the primary Continue CTA. The survey snapshot must still
// fire exactly once — on the final Finish — not zero times.
fireEvent.click(screen.getByRole('button', { name: /Stay updated/i }));
await waitFor(() => {
expect(screen.getByRole('heading', { name: 'Stay in the loop' })).toBeTruthy();
});
fireEvent.click(screen.getByRole('button', { name: /Finish setup/i }));

const aboutYouSubmits = trackedEvents('ui_click')
.map(([, payload]) => payload as Record<string, unknown>)
.filter((payload) => payload.element === 'about_you_submit');
expect(aboutYouSubmits).toHaveLength(1);
expect(aboutYouSubmits[0]).toMatchObject({ role: 'engineer' });
});

it('reports about_you_submit exactly once across a Back-then-Continue detour', async () => {
globalThis.fetch = vi.fn(async () =>
jsonResponse({
loggedIn: true,
profile: 'prod',
configPath: '/x',
user: { id: 'u', email: 'user@example.com' },
}),
) as typeof fetch;
renderOnboarding();

fireEvent.click(await screen.findByRole('button', { name: /^Continue$/i }));
await waitFor(() => {
expect(screen.getByRole('heading', { name: 'About you' })).toBeTruthy();
});
chooseDropdownOption('Your role', 'Engineer');

// About you -> Newsletter
fireEvent.click(screen.getByRole('button', { name: /^Continue$/i }));
await waitFor(() => {
expect(screen.getByRole('heading', { name: 'Stay in the loop' })).toBeTruthy();
});
// Back -> About you
fireEvent.click(screen.getByRole('button', { name: /^Back$/i }));
await waitFor(() => {
expect(screen.getByRole('heading', { name: 'About you' })).toBeTruthy();
});
// Continue -> Newsletter again, then finish.
fireEvent.click(screen.getByRole('button', { name: /^Continue$/i }));
await waitFor(() => {
expect(screen.getByRole('heading', { name: 'Stay in the loop' })).toBeTruthy();
});
fireEvent.click(screen.getByRole('button', { name: /Finish setup/i }));

// The detour crosses the About-you step twice, but the snapshot must
// not double-fire.
const aboutYouSubmits = trackedEvents('ui_click')
.map(([, payload]) => payload as Record<string, unknown>)
.filter((payload) => payload.element === 'about_you_submit');
expect(aboutYouSubmits).toHaveLength(1);
});

it('persists the BYOK config before finishing onboarding', async () => {
globalThis.fetch = vi.fn(async (input, init) => {
const url = String(input);
Expand Down Expand Up @@ -801,6 +891,7 @@ describe('EntryShell onboarding Open Design AMR runtime', () => {
await waitFor(() => {
expect(screen.getByRole('heading', { name: 'About you' })).toBeTruthy();
});
fireEvent.click(screen.getByRole('button', { name: /^Continue$/i }));
await waitFor(() => {
expect(document.querySelector('.onboarding-view__email-input')).toBeTruthy();
});
Expand Down
13 changes: 13 additions & 0 deletions e2e/ui/amr-onboarding.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ test('[P0] @critical onboarding lets AMR Cloud sign in and complete setup after
await expect
.poll(() => page.evaluate(() => window.__amrOnboardingStatusCalls ?? 0))
.toBeGreaterThanOrEqual(2);
// Login success lands on the About-you step; advance past it to the
// newsletter step, which is now the final step that hosts Finish setup.
await expect(page.getByRole('button', { name: /^Continue$/i })).toBeVisible({ timeout: 10_000 });
await page.getByRole('button', { name: /^Continue$/i }).click();
await expect(page.getByRole('button', { name: /Finish setup/i })).toBeVisible({ timeout: 10_000 });
await expectOnboardingFinished(page);
await pollStoredConfig(page).toMatchObject({
Expand Down Expand Up @@ -115,6 +119,10 @@ test('[P0] onboarding recovers from a transient AMR status failure and still con

await page.getByRole('button', { name: /sign in to continue/i }).click();

// Recovery lands on About you; step through to the newsletter step where
// Finish setup now lives.
await expect(page.getByRole('button', { name: /^Continue$/i })).toBeVisible({ timeout: 12_000 });
await page.getByRole('button', { name: /^Continue$/i }).click();
await expect(page.getByRole('button', { name: /Finish setup/i })).toBeVisible({ timeout: 12_000 });
});

Expand Down Expand Up @@ -265,6 +273,9 @@ test('[P0] onboarding about-you step accepts profile selections and completes se
await expect(expectOnboardingTrigger(page, 'Use case')).toContainText('Prototype / app UI');
await expect(expectOnboardingTrigger(page, 'Where did you hear about us?')).toContainText('Search');

// About you is no longer the final step; advance to the newsletter step
// before finishing.
await page.getByRole('button', { name: /^Continue$/i }).click();
await page.getByRole('button', { name: /Finish setup/i }).click();

await expectOnboardingFinished(page);
Expand Down Expand Up @@ -322,6 +333,8 @@ test('[P0] @critical onboarding BYOK path can fetch models, test the provider, a

await page.getByRole('button', { name: /^Continue$/i }).click();
await expect(page.getByText(/Optional details for better defaults/i)).toBeVisible();
// Advance from About you to the newsletter step, then finish.
await page.getByRole('button', { name: /^Continue$/i }).click();
await page.getByRole('button', { name: /Finish setup/i }).click();

await expectOnboardingFinished(page);
Expand Down
6 changes: 3 additions & 3 deletions packages/contracts/src/analytics/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -436,9 +436,9 @@ export type TrackingChatPanelPageViewSource =
// --- Onboarding page_view (welcome flow) ---
//
// CSV row "Onboarding / page_view". Fires once per step exposure inside the
// welcome flow. The current first-run flow is Connect → About you; the
// design-system and generation literals remain in the contract for historical
// rows and a future reintroduction. Each step's `step_index` / `step_name`
// welcome flow. The current first-run flow is Connect → About you
// Newsletter; the design-system and generation literals remain in the
// contract for historical rows and a future reintroduction. Each step's `step_index` / `step_name`
// must match the enum pairs below. `onboarding_session_id` is generated once
// per session so dashboards can stitch the funnel.
export type TrackingOnboardingArea =
Expand Down
Loading