From b56597deec29a5d324942dab2cc53c3e7d65dc0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Uruchurtu?= Date: Thu, 9 Jul 2026 17:55:10 -0600 Subject: [PATCH 1/3] feat(web): replace blocking customer survey --- apps/web/src/app/(app)/layout.tsx | 2 + .../web/src/app/account-verification/page.tsx | 3 +- .../app/customer-source-survey/page.test.ts | 51 ++++ .../src/app/customer-source-survey/page.tsx | 16 +- apps/web/src/app/get-started/page.tsx | 3 +- .../src/app/users/after-sign-in/route.test.ts | 4 - .../web/src/app/users/after-sign-in/route.tsx | 3 - .../components/CustomerSourcePrompt.test.ts | 23 ++ .../src/components/CustomerSourcePrompt.tsx | 125 ++++++++++ .../src/components/CustomerSourceSurvey.tsx | 72 ------ apps/web/src/lib/survey-redirect.ts | 16 -- apps/web/src/routers/user-router.test.ts | 225 ++---------------- .../account-verification-redirect.test.ts | 63 ++--- apps/web/src/tests/survey-redirect.test.ts | 47 ---- .../tests/e2e/app-shell-accessibility.spec.ts | 1 - apps/web/tests/e2e/auth.setup.ts | 19 +- .../tests/e2e/customer-source-survey.spec.ts | 203 ---------------- .../e2e/get-started-dashboard-link.spec.ts | 19 +- apps/web/tests/e2e/onboarding-wizard.spec.ts | 13 - apps/web/tests/setup-smoke/profile.spec.ts | 1 - dev/seed/AGENTS.md | 3 +- dev/seed/app/create-user.ts | 9 +- dev/seed/app/org-dashboard.ts | 1 - dev/seed/coding-plans/demo-data.ts | 1 - dev/seed/cost-insights/spend-evidence.ts | 2 - 25 files changed, 261 insertions(+), 664 deletions(-) create mode 100644 apps/web/src/app/customer-source-survey/page.test.ts create mode 100644 apps/web/src/components/CustomerSourcePrompt.test.ts create mode 100644 apps/web/src/components/CustomerSourcePrompt.tsx delete mode 100644 apps/web/src/components/CustomerSourceSurvey.tsx delete mode 100644 apps/web/src/lib/survey-redirect.ts delete mode 100644 apps/web/src/tests/survey-redirect.test.ts delete mode 100644 apps/web/tests/e2e/customer-source-survey.spec.ts diff --git a/apps/web/src/app/(app)/layout.tsx b/apps/web/src/app/(app)/layout.tsx index eac1d1ad50..418c5d9457 100644 --- a/apps/web/src/app/(app)/layout.tsx +++ b/apps/web/src/app/(app)/layout.tsx @@ -9,6 +9,7 @@ import { AdminOmnibox } from '@/components/admin-omnibox'; import { AppShellSkipLink } from '@/components/AppShellSkipLink'; import { PrefetchedOrganizations } from './components/PrefetchedOrganizations'; import { PlatformPresenceMount } from './components/PlatformPresenceMount'; +import { CustomerSourcePrompt } from '@/components/CustomerSourcePrompt'; export default function AppLayout({ children }: { children: React.ReactNode }) { return ( @@ -31,6 +32,7 @@ export default function AppLayout({ children }: { children: React.ReactNode }) { + diff --git a/apps/web/src/app/account-verification/page.tsx b/apps/web/src/app/account-verification/page.tsx index f681883e30..0c35672994 100644 --- a/apps/web/src/app/account-verification/page.tsx +++ b/apps/web/src/app/account-verification/page.tsx @@ -8,7 +8,6 @@ import { getUserFromAuthOrRedirect } from '@/lib/user/server'; import { getStytchStatus, handleSignupPromotion, type SignupSource } from '@/lib/stytch'; import { PageContainer } from '@/components/layouts/PageContainer'; import { isValidCallbackPath } from '@/lib/getSignInCallbackUrl'; -import { maybeInterceptWithSurvey } from '@/lib/survey-redirect'; import { isOpenclawAdvisorCallback } from '@/lib/signup-source'; import { isCreditCampaignCallback, lookupCampaignBySlug } from '@/lib/credit-campaigns'; @@ -76,7 +75,7 @@ export default async function AccountVerificationPage({ searchParams }: AppPageP if (stytchStatus !== null) { const hasUsableCallback = isValidCallback && !stripCreditCampaignCallback; const finalDestination = hasUsableCallback ? callbackStr : '/get-started'; - redirect(maybeInterceptWithSurvey(user, finalDestination)); + redirect(finalDestination); } return ( diff --git a/apps/web/src/app/customer-source-survey/page.test.ts b/apps/web/src/app/customer-source-survey/page.test.ts new file mode 100644 index 0000000000..784198ba5d --- /dev/null +++ b/apps/web/src/app/customer-source-survey/page.test.ts @@ -0,0 +1,51 @@ +const mockRedirect = jest.fn(() => { + throw new Error('NEXT_REDIRECT'); +}); + +jest.mock('next/navigation', () => ({ + redirect: (...args: [string]) => mockRedirect(...args), +})); + +const mockGetUserFromAuthOrRedirect = jest.fn, [string]>(); +jest.mock('@/lib/user/server', () => ({ + getUserFromAuthOrRedirect: (...args: [string]) => mockGetUserFromAuthOrRedirect(...args), +})); + +import CustomerSourceSurveyPage from './page'; + +async function renderPage(searchParams: Record = {}) { + try { + await CustomerSourceSurveyPage({ + searchParams: Promise.resolve(searchParams), + params: Promise.resolve(undefined), + }); + } catch (error) { + if (!(error instanceof Error) || error.message !== 'NEXT_REDIRECT') throw error; + } +} + +describe('GET /customer-source-survey', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockGetUserFromAuthOrRedirect.mockResolvedValue(); + }); + + it('redirects authenticated users to the dashboard resolver', async () => { + await renderPage(); + + expect(mockGetUserFromAuthOrRedirect).toHaveBeenCalledWith('/users/sign_in'); + expect(mockRedirect).toHaveBeenCalledWith('/get-started'); + }); + + it('preserves a valid in-app callbackPath', async () => { + await renderPage({ callbackPath: '/profile' }); + + expect(mockRedirect).toHaveBeenCalledWith('/profile'); + }); + + it('rejects an external callbackPath', async () => { + await renderPage({ callbackPath: 'https://example.com' }); + + expect(mockRedirect).toHaveBeenCalledWith('/get-started'); + }); +}); diff --git a/apps/web/src/app/customer-source-survey/page.tsx b/apps/web/src/app/customer-source-survey/page.tsx index 020280eab0..1223f94b05 100644 --- a/apps/web/src/app/customer-source-survey/page.tsx +++ b/apps/web/src/app/customer-source-survey/page.tsx @@ -1,28 +1,16 @@ import { redirect } from 'next/navigation'; import { getUserFromAuthOrRedirect } from '@/lib/user/server'; -import { KiloCardLayout } from '@/components/KiloCardLayout'; import { isValidCallbackPath } from '@/lib/getSignInCallbackUrl'; -import { CustomerSourceSurvey } from '@/components/CustomerSourceSurvey'; export default async function CustomerSourceSurveyPage({ searchParams }: AppPageProps) { - const user = await getUserFromAuthOrRedirect('/users/sign_in'); + await getUserFromAuthOrRedirect('/users/sign_in'); const params = await searchParams; - // Determine where to go after survey const callbackParam = params.callbackPath; const redirectPath = callbackParam && typeof callbackParam === 'string' && isValidCallbackPath(callbackParam) ? callbackParam : '/get-started'; - // If already answered, skip past - if (user.customer_source !== null) { - redirect(redirectPath); - } - - return ( - - - - ); + redirect(redirectPath); } diff --git a/apps/web/src/app/get-started/page.tsx b/apps/web/src/app/get-started/page.tsx index df06d3c209..6236bc3318 100644 --- a/apps/web/src/app/get-started/page.tsx +++ b/apps/web/src/app/get-started/page.tsx @@ -1,5 +1,4 @@ import { buildLandingRedirectUrl } from '@/lib/landing-redirect'; -import { maybeInterceptWithSurvey } from '@/lib/survey-redirect'; import { getProfileRedirectPath, getUserFromAuth } from '@/lib/user/server'; import { redirect } from 'next/navigation'; @@ -18,5 +17,5 @@ export default async function GetStartedPage({ searchParams }: AppPageProps) { redirect('/account-verification'); } - redirect(maybeInterceptWithSurvey(user, await getProfileRedirectPath(user))); + redirect(await getProfileRedirectPath(user)); } diff --git a/apps/web/src/app/users/after-sign-in/route.test.ts b/apps/web/src/app/users/after-sign-in/route.test.ts index e4ade47914..c879eec9cf 100644 --- a/apps/web/src/app/users/after-sign-in/route.test.ts +++ b/apps/web/src/app/users/after-sign-in/route.test.ts @@ -31,10 +31,6 @@ jest.mock('@/lib/impact/debug', () => ({ jest.mock('@/lib/posthog', () => jest.fn(() => ({ capture: jest.fn() }))); -jest.mock('@/lib/survey-redirect', () => ({ - maybeInterceptWithSurvey: jest.fn((_, responsePath: string) => responsePath), -})); - jest.mock('@/lib/credit-campaigns', () => ({ isCreditCampaignCallback: jest.fn(() => null), lookupCampaignBySlug: jest.fn(), diff --git a/apps/web/src/app/users/after-sign-in/route.tsx b/apps/web/src/app/users/after-sign-in/route.tsx index d1f6202916..6d7c083ce1 100644 --- a/apps/web/src/app/users/after-sign-in/route.tsx +++ b/apps/web/src/app/users/after-sign-in/route.tsx @@ -1,6 +1,5 @@ import { getProfileRedirectPath, getUserFromAuth } from '@/lib/user/server'; import { isValidCallbackPath } from '@/lib/getSignInCallbackUrl'; -import { maybeInterceptWithSurvey } from '@/lib/survey-redirect'; import PostHogClient from '@/lib/posthog'; import { getAffiliateAttribution } from '@/lib/affiliate-attribution'; import { recordAffiliateAttributionAndQueueParentEvent } from '@/lib/impact/affiliate-events'; @@ -152,8 +151,6 @@ export async function GET(request: NextRequest) { } else { responsePath = '/account-verification'; } - } else { - responsePath = maybeInterceptWithSurvey(user, responsePath); } } diff --git a/apps/web/src/components/CustomerSourcePrompt.test.ts b/apps/web/src/components/CustomerSourcePrompt.test.ts new file mode 100644 index 0000000000..f1766297ac --- /dev/null +++ b/apps/web/src/components/CustomerSourcePrompt.test.ts @@ -0,0 +1,23 @@ +import { shouldShowCustomerSourcePrompt } from './CustomerSourcePrompt'; + +describe('shouldShowCustomerSourcePrompt', () => { + it('shows for a user without a saved source on app pages', () => { + expect(shouldShowCustomerSourcePrompt(null, '/profile')).toBe(true); + }); + + it('does not show after a user answers or dismisses the prompt', () => { + expect(shouldShowCustomerSourcePrompt('GitHub', '/profile')).toBe(false); + expect(shouldShowCustomerSourcePrompt('', '/profile')).toBe(false); + expect(shouldShowCustomerSourcePrompt(undefined, '/profile')).toBe(false); + }); + + it('does not show during product setup flows', () => { + expect(shouldShowCustomerSourcePrompt(null, '/gastown/onboarding')).toBe(false); + expect( + shouldShowCustomerSourcePrompt( + null, + '/organizations/2dce8b38-32dc-4b71-b0ec-0e3d646cbdc4/welcome' + ) + ).toBe(false); + }); +}); diff --git a/apps/web/src/components/CustomerSourcePrompt.tsx b/apps/web/src/components/CustomerSourcePrompt.tsx new file mode 100644 index 0000000000..e9c5cf4a0a --- /dev/null +++ b/apps/web/src/components/CustomerSourcePrompt.tsx @@ -0,0 +1,125 @@ +'use client'; + +import { useState } from 'react'; +import { usePathname } from 'next/navigation'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { LoaderCircle } from 'lucide-react'; +import { useUser } from '@/hooks/useUser'; +import { useTRPC } from '@/lib/trpc/utils'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; + +const ORGANIZATION_WELCOME_PATH = /^\/organizations\/[0-9a-f-]{36}\/welcome$/; + +export function shouldShowCustomerSourcePrompt( + customerSource: string | null | undefined, + pathname: string +): boolean { + return ( + customerSource === null && + pathname !== '/gastown/onboarding' && + !ORGANIZATION_WELCOME_PATH.test(pathname) + ); +} + +export function CustomerSourcePrompt() { + const pathname = usePathname(); + const { data: user } = useUser(); + const trpc = useTRPC(); + const queryClient = useQueryClient(); + const [source, setSource] = useState(''); + const [dismissed, setDismissed] = useState(false); + + function completePrompt() { + setDismissed(true); + void queryClient.invalidateQueries({ queryKey: ['user'] }); + } + + const submitSource = useMutation( + trpc.user.submitCustomerSource.mutationOptions({ + onSuccess: completePrompt, + }) + ); + const dismissPrompt = useMutation( + trpc.user.skipCustomerSource.mutationOptions({ + onSuccess: completePrompt, + }) + ); + + const isPending = submitSource.isPending || dismissPrompt.isPending; + const error = submitSource.error ?? dismissPrompt.error; + + if (dismissed || !shouldShowCustomerSourcePrompt(user?.customer_source, pathname)) { + return null; + } + + function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + const trimmedSource = source.trim(); + if (trimmedSource) submitSource.mutate({ source: trimmedSource }); + } + + return ( + + ); +} diff --git a/apps/web/src/components/CustomerSourceSurvey.tsx b/apps/web/src/components/CustomerSourceSurvey.tsx deleted file mode 100644 index ba3c49ecb4..0000000000 --- a/apps/web/src/components/CustomerSourceSurvey.tsx +++ /dev/null @@ -1,72 +0,0 @@ -'use client'; - -import { useState } from 'react'; -import { useRouter } from 'next/navigation'; -import { useMutation } from '@tanstack/react-query'; -import { useTRPC } from '@/lib/trpc/utils'; -import { Button } from '@/components/ui/button'; -import { Textarea } from '@/components/ui/textarea'; - -type CustomerSourceSurveyProps = { - redirectPath: string; -}; - -export function CustomerSourceSurvey({ redirectPath }: CustomerSourceSurveyProps) { - const [source, setSource] = useState(''); - const [skipped, setSkipped] = useState(false); - const router = useRouter(); - const trpc = useTRPC(); - - const { mutate: submitSource, isPending } = useMutation( - trpc.user.submitCustomerSource.mutationOptions({ - onSuccess: () => { - router.push(redirectPath); - }, - }) - ); - - const { mutate: skipSource } = useMutation( - trpc.user.skipCustomerSource.mutationOptions({ - onSuccess: () => { - router.push(redirectPath); - }, - onError: () => { - setSkipped(false); - }, - }) - ); - - function handleSkip() { - setSkipped(true); - skipSource(); - } - - return ( -
-