← back to Open Seo
src/client/features/onboarding/onboardingModel.ts
148 lines
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 own startup or business",
"My clients",
"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 = [
"Product Hunt",
"Google",
"Reddit",
"X / Twitter",
"GitHub",
"AI (Claude, ChatGPT, etc)",
"Friend or colleague",
"Other",
] as const;
// Keep the mobile list short: these still count as known options, they just
// aren't shown on small screens.
export const SOURCE_OPTIONS_HIDDEN_ON_MOBILE = [
"Reddit",
"X / Twitter",
"AI (Claude, ChatGPT, etc)",
] 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: { 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,
};
}