Improve onboarding questions and sign-up routing (#225)

This commit is contained in:
Ben Senescu 2026-05-26 23:05:20 -04:00 committed by GitHub
parent 506dde84d1
commit 8b74c7da61
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 3412 additions and 16 deletions

View File

@ -0,0 +1,16 @@
CREATE TABLE `user_onboarding_answers` (
`user_id` text PRIMARY KEY NOT NULL,
`organization_id` text NOT NULL,
`interested_features` text DEFAULT '[]' NOT NULL,
`work_for` text,
`client_website_count` text,
`found_via` text,
`mcp_setup_intent` text,
`completed_at` text,
`created_at` text DEFAULT (current_timestamp) NOT NULL,
`updated_at` text DEFAULT (current_timestamp) NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`organization_id`) REFERENCES `organization`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `user_onboarding_answers_organization_idx` ON `user_onboarding_answers` (`organization_id`);

File diff suppressed because it is too large Load Diff

View File

@ -127,6 +127,13 @@
"when": 1779331224828, "when": 1779331224828,
"tag": "0017_omniscient_dagger", "tag": "0017_omniscient_dagger",
"breakpoints": true "breakpoints": true
},
{
"idx": 18,
"version": "6",
"when": 1779849000462,
"tag": "0018_minor_mad_thinker",
"breakpoints": true
} }
] ]
} }

View File

@ -55,7 +55,7 @@ export function AuthMethodChooser({
<div className="space-y-3"> <div className="space-y-3">
<button <button
type="button" type="button"
className="btn w-full bg-white text-neutral border border-base-content/15 hover:bg-base-100 hover:border-base-content/25 disabled:bg-base-300" className="btn w-full border border-black/10 bg-white text-neutral-900 hover:border-black/20 hover:bg-neutral-50 disabled:bg-white disabled:text-neutral-500 disabled:opacity-70"
onClick={onContinueWithGoogle} onClick={onContinueWithGoogle}
disabled={disabled || isBusy} disabled={disabled || isBusy}
> >

View File

@ -0,0 +1,360 @@
import { ArrowLeft, ArrowRight, Check } from "lucide-react";
import type { ReactNode } from "react";
import { Fragment } from "react";
import {
CLIENT_WEBSITE_COUNT_OPTIONS,
CLIENT_WORK_FOR,
INTEREST_OPTIONS,
type OnboardingAnswers,
SOURCE_OPTIONS,
WORK_FOR_OPTIONS,
} from "@/client/features/onboarding/onboardingModel";
type PostSignupOnboardingProps = {
firstName: string;
title?: string;
helperText?: string;
step: number;
answers: OnboardingAnswers;
onAnswersChange: (answers: OnboardingAnswers) => void;
onNext: () => void;
onBack: () => void;
onSkip: () => void;
onFinish: (mcpSetupIntent: "yes" | "no") => void;
isSaving: boolean;
accountMenu: ReactNode;
};
export function PostSignupOnboarding({
firstName,
title,
helperText,
step,
answers,
onAnswersChange,
onNext,
onBack,
onSkip,
onFinish,
isSaving,
accountMenu,
}: PostSignupOnboardingProps) {
const canContinue =
step === 0
? answers.selectedInterests.length > 0
: step === 1
? Boolean(answers.workFor)
: step === 2
? Boolean(answers.source)
: true;
const updateAnswers = (patch: Partial<OnboardingAnswers>) =>
onAnswersChange({ ...answers, ...patch });
return (
<div className="w-full max-w-md space-y-6">
{accountMenu}
<div className="text-center space-y-3">
<img
src="/transparent-logo.png"
alt="OpenSEO"
className="mx-auto size-10 rounded-lg"
/>
<p className="text-xs font-medium uppercase tracking-wide text-base-content/50">
Step {step + 1} of 4
</p>
<h1 className="text-xl font-semibold">
{title ??
(firstName
? `Welcome to OpenSEO, ${firstName}!`
: "Welcome to OpenSEO!")}
</h1>
<p className="text-sm text-base-content/60">
{helperText ?? "A few quick answers to set things up."}
</p>
</div>
<div className="rounded-lg border border-base-300 bg-base-100 p-5 shadow-sm">
{step === 0 ? (
<OnboardingChoiceGroup
title="What tasks matter to you most?"
description="Pick up to 3."
maxSelections={3}
options={[...INTEREST_OPTIONS]}
selectedValues={answers.selectedInterests}
onToggle={(value) => {
updateAnswers({
selectedInterests: answers.selectedInterests.includes(value)
? answers.selectedInterests.filter((item) => item !== value)
: [...answers.selectedInterests, value],
});
}}
otherValue={answers.interestOther}
onOtherChange={(interestOther) => updateAnswers({ interestOther })}
multiple
/>
) : step === 1 ? (
<OnboardingChoiceGroup
title="Who are you doing SEO for?"
options={[...WORK_FOR_OPTIONS]}
selectedValues={answers.workFor ? [answers.workFor] : []}
onToggle={(workFor) => updateAnswers({ workFor })}
otherValue={answers.workForOther}
onOtherChange={(workForOther) => updateAnswers({ workForOther })}
followUp={{
showForValue: CLIENT_WORK_FOR,
label: "About how many client sites do you work on?",
options: [...CLIENT_WEBSITE_COUNT_OPTIONS],
value: answers.clientWebsiteCount,
onChange: (clientWebsiteCount) =>
updateAnswers({ clientWebsiteCount }),
}}
/>
) : step === 2 ? (
<OnboardingChoiceGroup
title="How did you find OpenSEO?"
options={[...SOURCE_OPTIONS]}
selectedValues={answers.source ? [answers.source] : []}
onToggle={(source) => updateAnswers({ source })}
otherValue={answers.sourceOther}
onOtherChange={(sourceOther) => updateAnswers({ sourceOther })}
/>
) : (
<McpRecommendation
isSaving={isSaving}
onBack={onBack}
onSetup={() => onFinish("yes")}
onSkip={() => onFinish("no")}
/>
)}
{step < 3 ? (
<div className="mt-5 flex items-center justify-between gap-3">
<button
type="button"
className="btn btn-ghost"
disabled={step === 0 || isSaving}
onClick={onBack}
>
Back
</button>
<div className="flex items-center gap-2">
<button
type="button"
className="btn btn-ghost btn-sm text-base-content/55"
disabled={isSaving}
onClick={onSkip}
>
Skip
</button>
<button
type="button"
className="btn btn-soft"
disabled={!canContinue || isSaving}
onClick={onNext}
>
Continue
<ArrowRight className="size-4" />
</button>
</div>
</div>
) : null}
</div>
</div>
);
}
function McpRecommendation({
isSaving,
onBack,
onSetup,
onSkip,
}: {
isSaving: boolean;
onBack: () => void;
onSetup: () => void;
onSkip: () => void;
}) {
const capabilities = [
"Keyword research",
"Competitor research",
"Link prospecting",
];
return (
<div className="flex flex-col">
<button
type="button"
className="btn btn-ghost btn-sm -ml-2 mb-2 self-start gap-1.5 text-base-content/60"
disabled={isSaving}
onClick={onBack}
>
<ArrowLeft className="size-4" />
Back
</button>
<h2 className="text-lg font-semibold">Set up OpenSEO MCP?</h2>
<p className="mt-1.5 text-sm leading-relaxed text-base-content/70">
The most powerful way to use OpenSEO use AI to supercharge your SEO
skills.
</p>
<ul className="mt-4 w-full space-y-2">
{capabilities.map((capability) => (
<li key={capability} className="flex items-center gap-2.5 text-sm">
<span className="flex size-5 shrink-0 items-center justify-center rounded-full bg-base-200 text-base-content">
<Check className="size-3" />
</span>
<span className="text-base-content/80">{capability}</span>
</li>
))}
</ul>
<button
type="button"
className="btn btn-neutral mt-5 w-full"
disabled={isSaving}
onClick={onSetup}
>
Yes, set up MCP
<ArrowRight className="size-4" />
</button>
<button
type="button"
className="btn btn-ghost btn-sm mt-2 w-full text-base-content/60"
disabled={isSaving}
onClick={onSkip}
>
Not now
</button>
</div>
);
}
function OnboardingChoiceGroup({
title,
description,
reason,
options,
selectedValues,
onToggle,
otherValue,
onOtherChange,
multiple = false,
maxSelections,
followUp,
}: {
title: string;
description?: string;
reason?: string;
options: string[];
selectedValues: string[];
onToggle: (value: string) => void;
otherValue: string;
onOtherChange: (value: string) => void;
multiple?: boolean;
maxSelections?: number;
followUp?: {
showForValue: string;
label: string;
options: string[];
value: string;
onChange: (value: string) => void;
};
}) {
const isOtherSelected = selectedValues.includes("Other");
const showFollowUp =
followUp !== undefined && selectedValues.includes(followUp.showForValue);
const atLimit =
maxSelections !== undefined && selectedValues.length >= maxSelections;
return (
<div className="space-y-4">
<div>
<h2 className="text-lg font-semibold">{title}</h2>
{description ? (
<p className="mt-1 text-sm text-base-content/60">{description}</p>
) : null}
{reason ? (
<p className="mt-2 text-xs leading-relaxed text-base-content/55">
{reason}
</p>
) : null}
</div>
<div className="grid gap-2">
{options.map((option) => {
const selected = selectedValues.includes(option);
const disabled = atLimit && !selected;
const showFollowUpHere =
showFollowUp && followUp?.showForValue === option;
return (
<Fragment key={option}>
<button
type="button"
className={`flex min-h-11 items-center justify-between rounded-lg border px-3 py-2 text-left text-sm transition-colors ${
selected
? "border-base-content bg-base-200 text-base-content"
: disabled
? "border-base-300 text-base-content/35 cursor-not-allowed"
: "border-base-300 text-base-content/75 hover:border-base-content/40 hover:bg-base-200/60"
}`}
aria-pressed={selected}
disabled={disabled}
onClick={() => onToggle(option)}
>
<span>{option}</span>
{selected ? <Check className="size-4 shrink-0" /> : null}
</button>
{showFollowUpHere && followUp ? (
<div className="rounded-lg border border-base-300 bg-base-200/40 px-3 py-2.5">
<p className="text-sm text-base-content/70">
{followUp.label}
</p>
<div className="mt-2 flex flex-wrap gap-2">
{followUp.options.map((followUpOption) => {
const followUpSelected =
followUp.value === followUpOption;
return (
<button
key={followUpOption}
type="button"
className={`rounded-md border px-3 py-1.5 text-sm transition-colors ${
followUpSelected
? "border-base-content bg-base-200 text-base-content"
: "border-base-300 text-base-content/75 hover:border-base-content/40 hover:bg-base-200/60"
}`}
aria-pressed={followUpSelected}
onClick={() =>
followUp.onChange(
followUpSelected ? "" : followUpOption,
)
}
>
{followUpOption}
</button>
);
})}
</div>
</div>
) : null}
</Fragment>
);
})}
</div>
{isOtherSelected ? (
<input
type="text"
className="input input-bordered w-full"
placeholder={multiple ? "Tell us what else..." : "Tell us more..."}
value={otherValue}
onChange={(event) => onOtherChange(event.target.value)}
/>
) : null}
</div>
);
}

View File

@ -0,0 +1,139 @@
import { queryOptions } from "@tanstack/react-query";
import { getOnboardingAnswers } from "@/serverFunctions/onboarding";
export const ONBOARDING_LAST_STEP = 3;
export const INTEREST_OPTIONS = [
"AI workflows with Claude or Codex (MCP)",
"Keyword research",
"Competitor research",
"Backlink analysis",
"Site audits",
"Rank tracking",
"Other",
] as const;
export const WORK_FOR_OPTIONS = [
"My clients",
"My own startup or business",
"My employer's website",
"My own side project",
"I'm exploring before choosing a project",
"Other",
] as const;
export const CLIENT_WORK_FOR = "My clients";
export const CLIENT_WEBSITE_COUNT_OPTIONS = [
"13",
"410",
"1125",
"25+",
] as const;
export const SOURCE_OPTIONS = [
"Google",
"Reddit",
"X / Twitter",
"GitHub",
"ChatGPT",
"Claude",
"Friend or colleague",
"Other",
] as const;
/** In-progress form state. Step is tracked separately in the URL. */
export type OnboardingAnswers = {
selectedInterests: string[];
interestOther: string;
workFor: string;
workForOther: string;
clientWebsiteCount: string;
source: string;
sourceOther: string;
};
/** Answers as persisted in the DB (read back via getOnboardingAnswers). */
type SavedOnboardingAnswers = {
interestedFeatures: string[];
workFor: string | null;
clientWebsiteCount: string | null;
foundVia: string | null;
mcpSetupIntent: string | null;
};
export const onboardingAnswersQueryOptions = () =>
queryOptions({
queryKey: ["onboardingAnswers"],
queryFn: () => getOnboardingAnswers(),
});
// Saved answers normalize "Other" selections into free text, so restoring the
// UI means mapping any value that isn't a known option back onto "Other".
function restoreSingleChoice(
saved: string | null,
options: readonly string[],
): { value: string; other: string } {
if (!saved) return { value: "", other: "" };
if (options.includes(saved)) return { value: saved, other: "" };
return { value: "Other", other: saved };
}
export function restoreOnboardingAnswers(
saved: SavedOnboardingAnswers,
): OnboardingAnswers {
const known = saved.interestedFeatures.filter((value) =>
(INTEREST_OPTIONS as readonly string[]).includes(value),
);
const custom = saved.interestedFeatures.filter(
(value) => !(INTEREST_OPTIONS as readonly string[]).includes(value),
);
const work = restoreSingleChoice(saved.workFor, WORK_FOR_OPTIONS);
const found = restoreSingleChoice(saved.foundVia, SOURCE_OPTIONS);
return {
selectedInterests: custom.length > 0 ? [...known, "Other"] : known,
interestOther: custom[0] ?? "",
workFor: work.value,
workForOther: work.other,
clientWebsiteCount:
work.value === CLIENT_WORK_FOR ? (saved.clientWebsiteCount ?? "") : "",
source: found.value,
sourceOther: found.other,
};
}
/**
* Convert the in-progress form into the persisted payload. `step` decides which
* fields are mature enough to write so we don't clobber later answers on save.
*/
export function buildOnboardingPayload(
answers: OnboardingAnswers,
step: number,
extra: { mcpSetupIntent?: "yes" | "no"; completed?: boolean } = {},
) {
const interestedFeatures = answers.selectedInterests.map((value) =>
value === "Other" && answers.interestOther.trim()
? answers.interestOther.trim()
: value,
);
const workFor =
answers.workFor === "Other" && answers.workForOther.trim()
? answers.workForOther.trim()
: answers.workFor || undefined;
// Only persist a client-site estimate when "My clients" is selected; clear it
// otherwise so a stale value from an earlier pass doesn't linger.
const clientWebsiteCount =
answers.workFor === CLIENT_WORK_FOR ? answers.clientWebsiteCount : "";
const foundVia =
answers.source === "Other" && answers.sourceOther.trim()
? answers.sourceOther.trim()
: answers.source || undefined;
return {
...(step >= 0 ? { interestedFeatures } : {}),
...(step >= 1 ? { workFor, clientWebsiteCount } : {}),
...(step >= 2 ? { foundVia } : {}),
...extra,
};
}

View File

@ -0,0 +1,38 @@
import { useQuery } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import { useEffect } from "react";
import { onboardingAnswersQueryOptions } from "@/client/features/onboarding/onboardingModel";
import { useSession } from "@/lib/auth-client";
import { isHostedClientAuthMode } from "@/lib/auth-mode";
export function useOnboardingRedirect() {
const navigate = useNavigate();
const { data: session } = useSession();
const isHostedMode = isHostedClientAuthMode();
const onboardingQuery = useQuery({
...onboardingAnswersQueryOptions(),
enabled: isHostedMode && Boolean(session?.user?.id),
});
useEffect(() => {
if (
!isHostedMode ||
!session?.user?.id ||
onboardingQuery.isLoading ||
onboardingQuery.isError ||
onboardingQuery.data?.completedAt ||
window.location.pathname === "/onboarding"
) {
return;
}
void navigate({ to: "/onboarding", search: { step: 0 }, replace: true });
}, [
isHostedMode,
navigate,
onboardingQuery.data?.completedAt,
onboardingQuery.isError,
onboardingQuery.isLoading,
session?.user?.id,
]);
}

View File

@ -7,7 +7,7 @@ import {
index, index,
} from "drizzle-orm/sqlite-core"; } from "drizzle-orm/sqlite-core";
import { sql } from "drizzle-orm"; import { sql } from "drizzle-orm";
import { organization } from "./better-auth-schema"; import { organization, user } from "./better-auth-schema";
// This stores users for Cloudflare Access and local_noauth mode // This stores users for Cloudflare Access and local_noauth mode
// since they don't map to better-auth's user schema // since they don't map to better-auth's user schema
@ -19,6 +19,33 @@ export const delegatedUsers = sqliteTable("delegated_users", {
.default(sql`(current_timestamp)`), .default(sql`(current_timestamp)`),
}); });
export const userOnboardingAnswers = sqliteTable(
"user_onboarding_answers",
{
userId: text("user_id")
.primaryKey()
.references(() => user.id, { onDelete: "cascade" }),
organizationId: text("organization_id")
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
interestedFeatures: text("interested_features").notNull().default("[]"),
workFor: text("work_for"),
clientWebsiteCount: text("client_website_count"),
foundVia: text("found_via"),
mcpSetupIntent: text("mcp_setup_intent"),
completedAt: text("completed_at"),
createdAt: text("created_at")
.notNull()
.default(sql`(current_timestamp)`),
updatedAt: text("updated_at")
.notNull()
.default(sql`(current_timestamp)`),
},
(table) => [
index("user_onboarding_answers_organization_idx").on(table.organizationId),
],
);
// Projects for keyword research // Projects for keyword research
export const projects = sqliteTable( export const projects = sqliteTable(
"projects", "projects",

2
src/env.d.ts vendored
View File

@ -7,6 +7,7 @@ declare namespace Cloudflare {
OAUTH_KV: KVNamespace; OAUTH_KV: KVNamespace;
AUTH_MODE?: "cloudflare_access" | "local_noauth" | "hosted"; AUTH_MODE?: "cloudflare_access" | "local_noauth" | "hosted";
BYPASS_EMAIL_VERIFICATION?: string;
TEAM_DOMAIN?: string; TEAM_DOMAIN?: string;
POLICY_AUD?: string; POLICY_AUD?: string;
POSTHOG_PUBLIC_KEY?: string; POSTHOG_PUBLIC_KEY?: string;
@ -26,6 +27,7 @@ declare namespace Cloudflare {
interface ImportMetaEnv { interface ImportMetaEnv {
readonly AUTH_MODE?: "cloudflare_access" | "local_noauth" | "hosted"; readonly AUTH_MODE?: "cloudflare_access" | "local_noauth" | "hosted";
readonly BYPASS_EMAIL_VERIFICATION?: string;
readonly POSTHOG_PUBLIC_KEY?: string; readonly POSTHOG_PUBLIC_KEY?: string;
readonly POSTHOG_HOST?: string; readonly POSTHOG_HOST?: string;
readonly VITE_E2E_DOMAIN_FIXTURES?: string; readonly VITE_E2E_DOMAIN_FIXTURES?: string;

View File

@ -18,6 +18,7 @@ import { Route as ProjectRouteRouteImport } from './routes/_project/route'
import { Route as AppRouteRouteImport } from './routes/_app/route' import { Route as AppRouteRouteImport } from './routes/_app/route'
import { Route as AppIndexRouteImport } from './routes/_app/index' import { Route as AppIndexRouteImport } from './routes/_app/index'
import { Route as AuthenticatedSubscribeRouteImport } from './routes/_authenticated.subscribe' import { Route as AuthenticatedSubscribeRouteImport } from './routes/_authenticated.subscribe'
import { Route as AuthenticatedOnboardingRouteImport } from './routes/_authenticated.onboarding'
import { Route as AuthenticatedOauthConsentRouteImport } from './routes/_authenticated.oauth-consent' import { Route as AuthenticatedOauthConsentRouteImport } from './routes/_authenticated.oauth-consent'
import { Route as AuthSignUpRouteImport } from './routes/_auth.sign-up' import { Route as AuthSignUpRouteImport } from './routes/_auth.sign-up'
import { Route as AuthSignInRouteImport } from './routes/_auth.sign-in' import { Route as AuthSignInRouteImport } from './routes/_auth.sign-in'
@ -85,6 +86,11 @@ const AuthenticatedSubscribeRoute = AuthenticatedSubscribeRouteImport.update({
path: '/subscribe', path: '/subscribe',
getParentRoute: () => AuthenticatedRoute, getParentRoute: () => AuthenticatedRoute,
} as any) } as any)
const AuthenticatedOnboardingRoute = AuthenticatedOnboardingRouteImport.update({
id: '/onboarding',
path: '/onboarding',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthenticatedOauthConsentRoute = const AuthenticatedOauthConsentRoute =
AuthenticatedOauthConsentRouteImport.update({ AuthenticatedOauthConsentRouteImport.update({
id: '/oauth-consent', id: '/oauth-consent',
@ -235,6 +241,7 @@ export interface FileRoutesByFullPath {
'/sign-in': typeof AuthSignInRoute '/sign-in': typeof AuthSignInRoute
'/sign-up': typeof AuthSignUpRoute '/sign-up': typeof AuthSignUpRoute
'/oauth-consent': typeof AuthenticatedOauthConsentRoute '/oauth-consent': typeof AuthenticatedOauthConsentRoute
'/onboarding': typeof AuthenticatedOnboardingRoute
'/subscribe': typeof AuthenticatedSubscribeRoute '/subscribe': typeof AuthenticatedSubscribeRoute
'/p/$projectId': typeof ProjectPProjectIdRouteRouteWithChildren '/p/$projectId': typeof ProjectPProjectIdRouteRouteWithChildren
'/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute '/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute
@ -267,6 +274,7 @@ export interface FileRoutesByTo {
'/sign-in': typeof AuthSignInRoute '/sign-in': typeof AuthSignInRoute
'/sign-up': typeof AuthSignUpRoute '/sign-up': typeof AuthSignUpRoute
'/oauth-consent': typeof AuthenticatedOauthConsentRoute '/oauth-consent': typeof AuthenticatedOauthConsentRoute
'/onboarding': typeof AuthenticatedOnboardingRoute
'/subscribe': typeof AuthenticatedSubscribeRoute '/subscribe': typeof AuthenticatedSubscribeRoute
'/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute '/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute
'/api/auth/$': typeof ApiAuthSplatRoute '/api/auth/$': typeof ApiAuthSplatRoute
@ -300,6 +308,7 @@ export interface FileRoutesById {
'/_auth/sign-in': typeof AuthSignInRoute '/_auth/sign-in': typeof AuthSignInRoute
'/_auth/sign-up': typeof AuthSignUpRoute '/_auth/sign-up': typeof AuthSignUpRoute
'/_authenticated/oauth-consent': typeof AuthenticatedOauthConsentRoute '/_authenticated/oauth-consent': typeof AuthenticatedOauthConsentRoute
'/_authenticated/onboarding': typeof AuthenticatedOnboardingRoute
'/_authenticated/subscribe': typeof AuthenticatedSubscribeRoute '/_authenticated/subscribe': typeof AuthenticatedSubscribeRoute
'/_app/': typeof AppIndexRoute '/_app/': typeof AppIndexRoute
'/_project/p/$projectId': typeof ProjectPProjectIdRouteRouteWithChildren '/_project/p/$projectId': typeof ProjectPProjectIdRouteRouteWithChildren
@ -335,6 +344,7 @@ export interface FileRouteTypes {
| '/sign-in' | '/sign-in'
| '/sign-up' | '/sign-up'
| '/oauth-consent' | '/oauth-consent'
| '/onboarding'
| '/subscribe' | '/subscribe'
| '/p/$projectId' | '/p/$projectId'
| '/help/dataforseo-api-key' | '/help/dataforseo-api-key'
@ -367,6 +377,7 @@ export interface FileRouteTypes {
| '/sign-in' | '/sign-in'
| '/sign-up' | '/sign-up'
| '/oauth-consent' | '/oauth-consent'
| '/onboarding'
| '/subscribe' | '/subscribe'
| '/help/dataforseo-api-key' | '/help/dataforseo-api-key'
| '/api/auth/$' | '/api/auth/$'
@ -399,6 +410,7 @@ export interface FileRouteTypes {
| '/_auth/sign-in' | '/_auth/sign-in'
| '/_auth/sign-up' | '/_auth/sign-up'
| '/_authenticated/oauth-consent' | '/_authenticated/oauth-consent'
| '/_authenticated/onboarding'
| '/_authenticated/subscribe' | '/_authenticated/subscribe'
| '/_app/' | '/_app/'
| '/_project/p/$projectId' | '/_project/p/$projectId'
@ -498,6 +510,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthenticatedSubscribeRouteImport preLoaderRoute: typeof AuthenticatedSubscribeRouteImport
parentRoute: typeof AuthenticatedRoute parentRoute: typeof AuthenticatedRoute
} }
'/_authenticated/onboarding': {
id: '/_authenticated/onboarding'
path: '/onboarding'
fullPath: '/onboarding'
preLoaderRoute: typeof AuthenticatedOnboardingRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_authenticated/oauth-consent': { '/_authenticated/oauth-consent': {
id: '/_authenticated/oauth-consent' id: '/_authenticated/oauth-consent'
path: '/oauth-consent' path: '/oauth-consent'
@ -790,11 +809,13 @@ const AuthRouteWithChildren = AuthRoute._addFileChildren(AuthRouteChildren)
interface AuthenticatedRouteChildren { interface AuthenticatedRouteChildren {
AuthenticatedOauthConsentRoute: typeof AuthenticatedOauthConsentRoute AuthenticatedOauthConsentRoute: typeof AuthenticatedOauthConsentRoute
AuthenticatedOnboardingRoute: typeof AuthenticatedOnboardingRoute
AuthenticatedSubscribeRoute: typeof AuthenticatedSubscribeRoute AuthenticatedSubscribeRoute: typeof AuthenticatedSubscribeRoute
} }
const AuthenticatedRouteChildren: AuthenticatedRouteChildren = { const AuthenticatedRouteChildren: AuthenticatedRouteChildren = {
AuthenticatedOauthConsentRoute: AuthenticatedOauthConsentRoute, AuthenticatedOauthConsentRoute: AuthenticatedOauthConsentRoute,
AuthenticatedOnboardingRoute: AuthenticatedOnboardingRoute,
AuthenticatedSubscribeRoute: AuthenticatedSubscribeRoute, AuthenticatedSubscribeRoute: AuthenticatedSubscribeRoute,
} }

View File

@ -7,6 +7,7 @@ import {
getCurrentAuthRedirectFromHref, getCurrentAuthRedirectFromHref,
getSignInSearch, getSignInSearch,
} from "@/lib/auth-redirect"; } from "@/lib/auth-redirect";
import { useOnboardingRedirect } from "@/client/features/onboarding/useOnboardingRedirect";
export const Route = createFileRoute("/_app")({ export const Route = createFileRoute("/_app")({
component: AppRouteLayout, component: AppRouteLayout,
@ -16,6 +17,7 @@ function AppRouteLayout() {
const navigate = useNavigate(); const navigate = useNavigate();
const { data: session, isPending } = useSession(); const { data: session, isPending } = useSession();
const isHostedMode = isHostedClientAuthMode(); const isHostedMode = isHostedClientAuthMode();
useOnboardingRedirect();
useEffect(() => { useEffect(() => {
if (isPending || !isHostedMode || session?.user?.id) { if (isPending || !isHostedMode || session?.user?.id) {

View File

@ -48,9 +48,12 @@ function SignUpPage() {
const search = Route.useSearch(); const search = Route.useSearch();
const navigate = useNavigate(); const navigate = useNavigate();
const { redirectTo, isHostedMode } = useAuthPageState(search.redirect); const { redirectTo, isHostedMode } = useAuthPageState(search.redirect);
const postSignupRedirect = redirectTo === "/" ? "/onboarding" : redirectTo;
const [showEmailForm, setShowEmailForm] = useState(false); const [showEmailForm, setShowEmailForm] = useState(false);
const [isStartingGoogle, setIsStartingGoogle] = useState(false); const [isStartingGoogle, setIsStartingGoogle] = useState(false);
const [socialError, setSocialError] = useState<string | null>(null); const [socialError, setSocialError] = useState<string | null>(null);
const bypassEmailVerification =
import.meta.env.BYPASS_EMAIL_VERIFICATION === "true";
const form = useForm({ const form = useForm({
defaultValues: { defaultValues: {
@ -75,9 +78,15 @@ function SignUpPage() {
email, email,
password: value.password, password: value.password,
callbackURL: (() => { callbackURL: (() => {
if (bypassEmailVerification) {
return new URL(
postSignupRedirect,
window.location.origin,
).toString();
}
const url = new URL("/verify-email", window.location.origin); const url = new URL("/verify-email", window.location.origin);
if (redirectTo !== "/") { if (postSignupRedirect !== "/") {
url.searchParams.set("redirect", redirectTo); url.searchParams.set("redirect", postSignupRedirect);
} }
return url.toString(); return url.toString();
})(), })(),
@ -96,9 +105,13 @@ function SignUpPage() {
captureClientEvent("auth:sign_up_success", { captureClientEvent("auth:sign_up_success", {
redirect_to: redirectTo, redirect_to: redirectTo,
}); });
if (bypassEmailVerification) {
window.location.replace(postSignupRedirect);
return;
}
void navigate({ void navigate({
to: "/verify-email", to: "/verify-email",
search: { email, ...getSignInSearch(redirectTo) }, search: { email, ...getSignInSearch(postSignupRedirect) },
}); });
} catch { } catch {
formApi.setErrorMap({ formApi.setErrorMap({
@ -112,18 +125,17 @@ function SignUpPage() {
}); });
async function handleContinueWithGoogle() { async function handleContinueWithGoogle() {
const callbackURL = redirectTo;
setSocialError(null); setSocialError(null);
setIsStartingGoogle(true); setIsStartingGoogle(true);
try { try {
captureClientEvent("auth:sign_up_google_start", { captureClientEvent("auth:sign_up_google_start", {
redirect_to: callbackURL, redirect_to: redirectTo,
}); });
const result = await authClient.signIn.social({ const result = await authClient.signIn.social({
provider: "google", provider: "google",
callbackURL, callbackURL: redirectTo,
newUserCallbackURL: callbackURL, newUserCallbackURL: postSignupRedirect,
requestSignUp: true, requestSignUp: true,
}); });

View File

@ -0,0 +1,202 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router";
import { Settings, User } from "lucide-react";
import { useState } from "react";
import { ThemePreferenceMenuItems } from "@/client/components/ThemePreferenceMenuItems";
import { PostSignupOnboarding } from "@/client/features/onboarding/PostSignupOnboarding";
import {
buildOnboardingPayload,
ONBOARDING_LAST_STEP,
type OnboardingAnswers,
onboardingAnswersQueryOptions,
restoreOnboardingAnswers,
} from "@/client/features/onboarding/onboardingModel";
import { captureClientEvent } from "@/client/lib/posthog";
import { queryClient } from "@/client/tanstack-db";
import { signOutAndRedirect, useSession } from "@/lib/auth-client";
import { saveOnboardingAnswers } from "@/serverFunctions/onboarding";
const ONBOARDING_EXISTING_USER_CUTOFF = "2026-05-27T00:00:00.000Z";
const clampStep = (step: number) =>
Math.min(Math.max(0, Math.trunc(step)), ONBOARDING_LAST_STEP);
export const Route = createFileRoute("/_authenticated/onboarding")({
// Step lives in the URL so it survives refresh and works with back/forward.
validateSearch: (search: Record<string, unknown>): { step: number } => {
const raw = Number(search.step);
return { step: Number.isFinite(raw) ? clampStep(raw) : 0 };
},
// Send users who already finished onboarding home before rendering. Running
// this in beforeLoad (not a component effect) means it can't race with the
// navigation we trigger after the final step.
beforeLoad: async () => {
const data = await queryClient.ensureQueryData(
onboardingAnswersQueryOptions(),
);
if (data.completedAt) {
throw redirect({ to: "/", replace: true });
}
},
component: OnboardingPage,
});
function OnboardingPage() {
const { data: session } = useSession();
const onboardingQuery = useQuery(onboardingAnswersQueryOptions());
if (!onboardingQuery.data) {
return null;
}
const userCreatedAt = onboardingQuery.data.userCreatedAt
? Date.parse(onboardingQuery.data.userCreatedAt)
: Date.now();
const isExistingUser =
userCreatedAt < Date.parse(ONBOARDING_EXISTING_USER_CUTOFF);
const firstName = session?.user?.name?.split(" ")[0] || "";
return (
<OnboardingFlow
firstName={firstName}
isExistingUser={isExistingUser}
initialAnswers={restoreOnboardingAnswers(onboardingQuery.data.answers)}
email={session?.user?.email}
/>
);
}
function OnboardingFlow({
firstName,
isExistingUser,
initialAnswers,
email,
}: {
firstName: string;
isExistingUser: boolean;
initialAnswers: OnboardingAnswers;
email: string | undefined;
}) {
const navigate = useNavigate();
const { step } = Route.useSearch();
const [answers, setAnswers] = useState<OnboardingAnswers>(initialAnswers);
const saveMutation = useMutation({
mutationFn: (extra: {
mcpSetupIntent?: "yes" | "no";
completed?: boolean;
}) =>
saveOnboardingAnswers({
data: buildOnboardingPayload(answers, step, extra),
}),
onError: (error) => {
console.error("Failed to save onboarding answers", error);
},
});
const goToStep = (next: number) =>
void navigate({ to: "/onboarding", search: { step: clampStep(next) } });
const handleNext = () => {
if (step === 0) {
captureClientEvent("onboarding:interests_selected", {
interests: answers.selectedInterests,
interest_other: answers.interestOther.trim() || undefined,
});
}
saveMutation.mutate({});
goToStep(step + 1);
};
const handleSkip = () => {
saveMutation.mutate({});
captureClientEvent("onboarding:step_skipped", { step });
goToStep(step + 1);
};
const handleFinish = async (mcpSetupIntent: "yes" | "no") => {
try {
await saveMutation.mutateAsync({ mcpSetupIntent, completed: true });
// Refresh the shared cache so the destination's onboarding-redirect guard
// sees the completed state and doesn't bounce the user back here.
await queryClient.invalidateQueries({ queryKey: ["onboardingAnswers"] });
} catch {
// Already logged by the mutation's onError; still navigate the user on.
}
captureClientEvent("onboarding:completed", {
interests: answers.selectedInterests,
work_for: answers.workFor,
source: answers.source,
wants_mcp_setup: mcpSetupIntent === "yes",
});
if (mcpSetupIntent === "yes") {
void navigate({ to: "/ai", replace: true });
} else {
void navigate({ to: "/", replace: true });
}
};
return (
<PostSignupOnboarding
firstName={firstName}
title={isExistingUser ? "Tell us about your work" : undefined}
helperText={
isExistingUser
? "A little context helps us decide where to focus. You can also reach me anytime at ben@openseo.so."
: undefined
}
step={step}
answers={answers}
onAnswersChange={setAnswers}
onNext={handleNext}
onBack={() => goToStep(step - 1)}
onSkip={handleSkip}
onFinish={handleFinish}
isSaving={saveMutation.isPending}
accountMenu={<OnboardingAccountMenu email={email} />}
/>
);
}
function OnboardingAccountMenu({ email }: { email: string | undefined }) {
if (!email) return null;
const handleSignOut = () => signOutAndRedirect();
return (
<div className="fixed top-4 right-4">
<div className="dropdown dropdown-end">
<button
type="button"
tabIndex={0}
className="btn btn-ghost btn-circle"
aria-label="Open account menu"
>
<User className="h-5 w-5" />
</button>
<ul
tabIndex={0}
className="dropdown-content z-20 menu mt-3 min-w-56 rounded-box border border-base-300 bg-base-100 p-2 shadow-lg"
>
<li className="menu-title max-w-full">
<span className="truncate text-base-content" data-ph-mask>
{email}
</span>
</li>
<li>
<a href="/settings" className="flex items-center gap-2">
<Settings className="h-4 w-4" />
Settings
</a>
</li>
<ThemePreferenceMenuItems />
<li>
<button type="button" onClick={handleSignOut}>
Sign out
</button>
</li>
</ul>
</div>
</div>
);
}

View File

@ -1,5 +1,6 @@
import { Outlet, createFileRoute, redirect } from "@tanstack/react-router"; import { Outlet, createFileRoute, redirect } from "@tanstack/react-router";
import { FreePlanBanner } from "@/client/features/billing/FreePlanBanner"; import { FreePlanBanner } from "@/client/features/billing/FreePlanBanner";
import { useOnboardingRedirect } from "@/client/features/onboarding/useOnboardingRedirect";
import { getErrorCode } from "@/client/lib/error-messages"; import { getErrorCode } from "@/client/lib/error-messages";
import { AuthenticatedAppLayout } from "@/client/layout/AppShell"; import { AuthenticatedAppLayout } from "@/client/layout/AppShell";
import { isHostedClientAuthMode } from "@/lib/auth-mode"; import { isHostedClientAuthMode } from "@/lib/auth-mode";
@ -33,6 +34,7 @@ export const Route = createFileRoute("/_project/p/$projectId")({
function ProjectLayout() { function ProjectLayout() {
const { projectId } = Route.useParams(); const { projectId } = Route.useParams();
useOnboardingRedirect();
return ( return (
<AuthenticatedAppLayout <AuthenticatedAppLayout

View File

@ -102,6 +102,8 @@ function VerifyEmailPage() {
const redirectTo = normalizeAuthRedirect(search.redirect); const redirectTo = normalizeAuthRedirect(search.redirect);
const isHostedMode = isHostedClientAuthMode(); const isHostedMode = isHostedClientAuthMode();
const { data: session, isPending } = useSession(); const { data: session, isPending } = useSession();
const bypassEmailVerification =
import.meta.env.BYPASS_EMAIL_VERIFICATION === "true";
const errorMessage = getVerificationErrorMessage(search.error); const errorMessage = getVerificationErrorMessage(search.error);
const verificationIssueType = search.error const verificationIssueType = search.error
? verificationIssueSchema.parse(search.error) ? verificationIssueSchema.parse(search.error)
@ -120,13 +122,15 @@ function VerifyEmailPage() {
}); });
useEffect(() => { useEffect(() => {
if (!isVerified) { if (!isVerified && !bypassEmailVerification) {
return; return;
} }
captureClientEvent("auth:verification_success", { if (isVerified) {
redirect_to: redirectTo, captureClientEvent("auth:verification_success", {
}); redirect_to: redirectTo,
});
}
// Full page reload instead of client-side navigation: the auth→app // Full page reload instead of client-side navigation: the auth→app
// transition needs a clean server-side load so that all server function // transition needs a clean server-side load so that all server function
@ -134,7 +138,7 @@ function VerifyEmailPage() {
// hit the server before updated handlers are ready, causing // hit the server before updated handlers are ready, causing
// "action is not a function" errors). // "action is not a function" errors).
window.location.replace(redirectTo); window.location.replace(redirectTo);
}, [isVerified, redirectTo]); }, [bypassEmailVerification, isVerified, redirectTo]);
useEffect(() => { useEffect(() => {
if (!verificationIssueType) { if (!verificationIssueType) {

View File

@ -1,5 +1,5 @@
import { createServerFn } from "@tanstack/react-start";
import { env } from "cloudflare:workers"; import { env } from "cloudflare:workers";
import { createServerFn } from "@tanstack/react-start";
import { requireAuthenticatedContext } from "@/serverFunctions/middleware"; import { requireAuthenticatedContext } from "@/serverFunctions/middleware";
export const getSeoApiKeyStatus = createServerFn({ method: "GET" }) export const getSeoApiKeyStatus = createServerFn({ method: "GET" })

View File

@ -0,0 +1,106 @@
import { createServerFn } from "@tanstack/react-start";
import { eq } from "drizzle-orm";
import { z } from "zod";
import { user, userOnboardingAnswers } from "@/db/schema";
import { db } from "@/db";
import { requireAuthenticatedContext } from "@/serverFunctions/middleware";
const onboardingAnswersSchema = z.object({
interestedFeatures: z.array(z.string()).optional(),
workFor: z.string().optional(),
clientWebsiteCount: z.string().optional(),
foundVia: z.string().optional(),
mcpSetupIntent: z.enum(["yes", "no"]).optional(),
completed: z.boolean().optional(),
});
export const getOnboardingAnswers = createServerFn({ method: "GET" })
.middleware(requireAuthenticatedContext)
.handler(async ({ context }) => {
const answers = await db.query.userOnboardingAnswers.findFirst({
columns: {
completedAt: true,
interestedFeatures: true,
workFor: true,
clientWebsiteCount: true,
foundVia: true,
mcpSetupIntent: true,
},
where: eq(userOnboardingAnswers.userId, context.userId),
});
const hostedUser = await db.query.user.findFirst({
columns: {
createdAt: true,
},
where: eq(user.id, context.userId),
});
let interestedFeatures: string[] = [];
if (answers?.interestedFeatures) {
try {
const parsed: unknown = JSON.parse(answers.interestedFeatures);
if (Array.isArray(parsed)) {
interestedFeatures = parsed.filter(
(value): value is string => typeof value === "string",
);
}
} catch {
interestedFeatures = [];
}
}
return {
completedAt: answers?.completedAt ?? null,
userCreatedAt: hostedUser?.createdAt?.toISOString() ?? null,
answers: {
interestedFeatures,
workFor: answers?.workFor ?? null,
clientWebsiteCount: answers?.clientWebsiteCount ?? null,
foundVia: answers?.foundVia ?? null,
mcpSetupIntent: answers?.mcpSetupIntent ?? null,
},
};
});
export const saveOnboardingAnswers = createServerFn({ method: "POST" })
.middleware(requireAuthenticatedContext)
.inputValidator((data: unknown) => onboardingAnswersSchema.parse(data))
.handler(async ({ data, context }) => {
const now = new Date().toISOString();
const completedAt = data.completed ? now : undefined;
const set = {
...(data.interestedFeatures
? { interestedFeatures: JSON.stringify(data.interestedFeatures) }
: {}),
...(data.workFor !== undefined ? { workFor: data.workFor } : {}),
...(data.clientWebsiteCount !== undefined
? { clientWebsiteCount: data.clientWebsiteCount }
: {}),
...(data.foundVia !== undefined ? { foundVia: data.foundVia } : {}),
...(data.mcpSetupIntent !== undefined
? { mcpSetupIntent: data.mcpSetupIntent }
: {}),
...(completedAt !== undefined ? { completedAt } : {}),
updatedAt: now,
};
await db
.insert(userOnboardingAnswers)
.values({
userId: context.userId,
organizationId: context.organizationId,
interestedFeatures: JSON.stringify(data.interestedFeatures ?? []),
workFor: data.workFor,
clientWebsiteCount: data.clientWebsiteCount,
foundVia: data.foundVia,
mcpSetupIntent: data.mcpSetupIntent,
completedAt,
updatedAt: now,
})
.onConflictDoUpdate({
target: userOnboardingAnswers.userId,
set,
});
return { ok: true };
});

View File

@ -7,6 +7,7 @@ interface ViteTypeOptions {
interface ImportMetaEnv { interface ImportMetaEnv {
readonly VITE_SHOW_DEVTOOLS?: string; readonly VITE_SHOW_DEVTOOLS?: string;
readonly BYPASS_EMAIL_VERIFICATION?: string;
// more env variables... // more env variables...
} }

View File

@ -21,7 +21,13 @@ export default defineConfig(({ mode }) => {
const emitSourcemaps = env.POSTHOG_SOURCEMAPS === "true"; const emitSourcemaps = env.POSTHOG_SOURCEMAPS === "true";
return { return {
envPrefix: ["VITE_", "AUTH_MODE", "POSTHOG_PUBLIC_KEY", "POSTHOG_HOST"], envPrefix: [
"VITE_",
"AUTH_MODE",
"BYPASS_EMAIL_VERIFICATION",
"POSTHOG_PUBLIC_KEY",
"POSTHOG_HOST",
],
server: { server: {
allowedHosts, allowedHosts,
port, port,