Improve onboarding questions and sign-up routing (#225)
This commit is contained in:
parent
506dde84d1
commit
8b74c7da61
16
drizzle/0018_minor_mad_thinker.sql
Normal file
16
drizzle/0018_minor_mad_thinker.sql
Normal 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`);
|
||||||
2451
drizzle/meta/0018_snapshot.json
Normal file
2451
drizzle/meta/0018_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@ -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}
|
||||||
>
|
>
|
||||||
|
|||||||
360
src/client/features/onboarding/PostSignupOnboarding.tsx
Normal file
360
src/client/features/onboarding/PostSignupOnboarding.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
139
src/client/features/onboarding/onboardingModel.ts
Normal file
139
src/client/features/onboarding/onboardingModel.ts
Normal 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 = [
|
||||||
|
"1–3",
|
||||||
|
"4–10",
|
||||||
|
"11–25",
|
||||||
|
"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,
|
||||||
|
};
|
||||||
|
}
|
||||||
38
src/client/features/onboarding/useOnboardingRedirect.ts
Normal file
38
src/client/features/onboarding/useOnboardingRedirect.ts
Normal 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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
@ -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
2
src/env.d.ts
vendored
@ -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;
|
||||||
|
|||||||
@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
202
src/routes/_authenticated.onboarding.tsx
Normal file
202
src/routes/_authenticated.onboarding.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isVerified) {
|
||||||
captureClientEvent("auth:verification_success", {
|
captureClientEvent("auth:verification_success", {
|
||||||
redirect_to: redirectTo,
|
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) {
|
||||||
|
|||||||
@ -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" })
|
||||||
|
|||||||
106
src/serverFunctions/onboarding.ts
Normal file
106
src/serverFunctions/onboarding.ts
Normal 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 };
|
||||||
|
});
|
||||||
1
src/types/vite-env.d.ts
vendored
1
src/types/vite-env.d.ts
vendored
@ -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...
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user