Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
2 changes: 2 additions & 0 deletions apps/web/src/app/(app)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 { CustomerSourceSurvey } from '@/components/CustomerSourceSurvey';
export default function AppLayout({ children }: { children: React.ReactNode }) {
return (
<RoleTestingProvider>
Expand All @@ -31,6 +32,7 @@ export default function AppLayout({ children }: { children: React.ReactNode }) {
</div>
</PrefetchedOrganizations>
</SidebarProvider>
<CustomerSourceSurvey />

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.

WARNING: Survey card is not excluded from onboarding/welcome flows

<CustomerSourceSurvey /> is mounted unconditionally in the (app) layout, so it renders on every route under this layout, including /gastown/onboarding (apps/web/src/app/(app)/gastown/onboarding) and the org welcome flow (apps/web/src/app/(app)/organizations/[id]/welcome). The PR description states a goal of "stop the survey from intercepting onboarding navigation," but there is no path-based exclusion here (unlike the shouldShowCustomerSourcePrompt gating that existed earlier in this branch's history and was dropped). The card is non-blocking for routing, but it will still visually overlay the onboarding wizard and org welcome screens.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

</EventServiceProvider>
</PageTitleProvider>
<AdminOmnibox />
Expand Down
16 changes: 2 additions & 14 deletions apps/web/src/app/customer-source-survey/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<KiloCardLayout title="Where did you hear about Kilo Code?" className="max-w-2xl">
<CustomerSourceSurvey redirectPath={redirectPath} />
</KiloCardLayout>
);
redirect(redirectPath);
}
98 changes: 49 additions & 49 deletions apps/web/src/components/CustomerSourceSurvey.tsx
Original file line number Diff line number Diff line change
@@ -1,72 +1,72 @@
'use client';

import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { useMutation } from '@tanstack/react-query';
import { useTRPC } from '@/lib/trpc/utils';
import { useUser } from '@/hooks/useUser';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';

type CustomerSourceSurveyProps = {
redirectPath: string;
};

export function CustomerSourceSurvey({ redirectPath }: CustomerSourceSurveyProps) {
export function CustomerSourceSurvey() {
const [source, setSource] = useState('');
const [skipped, setSkipped] = useState(false);
const router = useRouter();
const [dismissed, setDismissed] = useState(false);
const { data: user } = useUser();
const trpc = useTRPC();

const { mutate: submitSource, isPending } = useMutation(
const submitSource = useMutation(
trpc.user.submitCustomerSource.mutationOptions({
onSuccess: () => {
router.push(redirectPath);
},
onSuccess: () => setDismissed(true),
})
);

const { mutate: skipSource } = useMutation(
const skipSource = useMutation(
trpc.user.skipCustomerSource.mutationOptions({
onSuccess: () => {
router.push(redirectPath);
},
onError: () => {
setSkipped(false);
},
onSuccess: () => setDismissed(true),
})
);

function handleSkip() {
setSkipped(true);
skipSource();
if (dismissed || user?.customer_source !== null) {
return null;
}

return (
<div className="space-y-4 px-6 pb-6">
<Textarea
autoFocus
placeholder="Example: A YouTube video from Theo"
value={source}
onChange={e => setSource(e.target.value)}
rows={3}
maxLength={1000}
/>
<div className="flex items-center justify-between">
<button
type="button"
onClick={handleSkip}
disabled={skipped || isPending}
className="cursor-pointer text-muted-foreground text-sm hover:underline"
>
{skipped ? 'Skipping...' : 'Skip'}
</button>
<Button
onClick={() => submitSource({ source: source.trim() })}
disabled={isPending || skipped || source.trim().length === 0}
>
{isPending ? 'Submitting...' : 'Submit'}
</Button>
</div>
</div>
<aside className="fixed right-4 bottom-20 z-40 w-[calc(100vw-2rem)] max-w-sm">
<Card className="shadow-lg">
<CardHeader className="p-4 pb-3">
<CardTitle>Where did you hear about Kilo Code?</CardTitle>
<CardDescription>A short answer helps us understand what is working.</CardDescription>
</CardHeader>
<CardContent className="space-y-3 p-4 pt-0">
<div className="space-y-1.5">
<Label htmlFor="customer-source">Source</Label>
<Input
id="customer-source"
placeholder="GitHub, a teammate, YouTube..."
value={source}
onChange={event => setSource(event.target.value)}
maxLength={1000}
/>
</div>
<div className="flex items-center justify-between gap-2">
<Button
type="button"
variant="ghost"
onClick={() => skipSource.mutate()}
disabled={skipSource.isPending || submitSource.isPending}
>
Dismiss
</Button>
<Button
onClick={() => submitSource.mutate({ source: source.trim() })}
disabled={submitSource.isPending || skipSource.isPending || !source.trim()}
>
Save answer
</Button>
</div>
</CardContent>
</Card>
</aside>
);
}
10 changes: 3 additions & 7 deletions apps/web/src/lib/survey-redirect.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,12 @@
import type { User } from '@kilocode/db/schema';

/**
* If the user has not yet completed the customer-source survey,
* wraps `destinationPath` so the user lands on the survey first,
* then continues to `destinationPath` after completing it.
* Preserves the existing call sites while the customer-source question is
* presented non-blockingly in the authenticated app shell.
*/
export function maybeInterceptWithSurvey(
user: Pick<User, 'customer_source'>,
_user: Pick<User, 'customer_source'>,
destinationPath: string
): string {
if (user.customer_source === null && !destinationPath.startsWith('/customer-source-survey')) {
return `/customer-source-survey?callbackPath=${encodeURIComponent(destinationPath)}`;
}
return destinationPath;
}
29 changes: 9 additions & 20 deletions apps/web/src/tests/account-verification-redirect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
* redirect directly to the final destination (callbackPath or /get-started),
* skipping the survey entirely.
* - If stytchStatus !== null AND user.customer_source === null:
* redirect to /customer-source-survey with callbackPath forwarding.
* redirect directly to the final destination without blocking on the survey.
* - If stytchStatus === null: render the page (no redirect).
*/

Expand Down Expand Up @@ -163,43 +163,37 @@ describe('account-verification redirect logic', () => {
// Case: verified user who has NOT completed the survey
// ---------------------------------------------------------------
describe('when stytchStatus is non-null AND customer_source is null (survey not completed)', () => {
it('should redirect to /customer-source-survey with /get-started as default destination', async () => {
it('should redirect to /get-started as the default destination', async () => {
const user = makeUser({ customer_source: null });
mockGetUserFromAuthOrRedirect.mockResolvedValue(user);
mockGetStytchStatus.mockResolvedValue(true);

await renderPage();

expect(mockRedirect).toHaveBeenCalledTimes(1);
expect(mockRedirect).toHaveBeenCalledWith(
`/customer-source-survey?callbackPath=${encodeURIComponent('/get-started')}`
);
expect(mockRedirect).toHaveBeenCalledWith('/get-started');
});

it('should redirect to /customer-source-survey with callbackPath forwarded', async () => {
it('should redirect directly to callbackPath', async () => {
const user = makeUser({ customer_source: null });
mockGetUserFromAuthOrRedirect.mockResolvedValue(user);
mockGetStytchStatus.mockResolvedValue(true);

await renderPage({ callbackPath: '/get-started' });

expect(mockRedirect).toHaveBeenCalledTimes(1);
expect(mockRedirect).toHaveBeenCalledWith(
`/customer-source-survey?callbackPath=${encodeURIComponent('/get-started')}`
);
expect(mockRedirect).toHaveBeenCalledWith('/get-started');
});

it('should redirect to /customer-source-survey with an org callbackPath forwarded', async () => {
it('should redirect directly to an org callbackPath', async () => {
const user = makeUser({ customer_source: null });
mockGetUserFromAuthOrRedirect.mockResolvedValue(user);
mockGetStytchStatus.mockResolvedValue(true);

await renderPage({ callbackPath: '/organizations/some-org-id' });

expect(mockRedirect).toHaveBeenCalledTimes(1);
expect(mockRedirect).toHaveBeenCalledWith(
`/customer-source-survey?callbackPath=${encodeURIComponent('/organizations/some-org-id')}`
);
expect(mockRedirect).toHaveBeenCalledWith('/organizations/some-org-id');
});
});

Expand Down Expand Up @@ -277,9 +271,7 @@ describe('account-verification redirect logic', () => {
await renderPage();

expect(mockRedirect).toHaveBeenCalledTimes(1);
expect(mockRedirect).toHaveBeenCalledWith(
`/customer-source-survey?callbackPath=${encodeURIComponent('/get-started')}`
);
expect(mockRedirect).toHaveBeenCalledWith('/get-started');
});

it('should skip survey when customer_source is set even with stytchStatus=false', async () => {
Expand Down Expand Up @@ -556,10 +548,7 @@ describe('account-verification redirect logic', () => {
await renderPage({ callbackPath: 'https://evil.com/phish' });

expect(mockRedirect).toHaveBeenCalledTimes(1);
// Invalid callbackPath is dropped — redirect to survey with default destination
expect(mockRedirect).toHaveBeenCalledWith(
`/customer-source-survey?callbackPath=${encodeURIComponent('/get-started')}`
);
expect(mockRedirect).toHaveBeenCalledWith('/get-started');
});

it('should ignore invalid callbackPath for user with customer_source set', async () => {
Expand Down
47 changes: 0 additions & 47 deletions apps/web/src/tests/survey-redirect.test.ts

This file was deleted.

Loading