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,
|
||||
"tag": "0017_omniscient_dagger",
|
||||
"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">
|
||||
<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}
|
||||
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,
|
||||
} from "drizzle-orm/sqlite-core";
|
||||
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
|
||||
// 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)`),
|
||||
});
|
||||
|
||||
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
|
||||
export const projects = sqliteTable(
|
||||
"projects",
|
||||
|
||||
2
src/env.d.ts
vendored
2
src/env.d.ts
vendored
@ -7,6 +7,7 @@ declare namespace Cloudflare {
|
||||
OAUTH_KV: KVNamespace;
|
||||
|
||||
AUTH_MODE?: "cloudflare_access" | "local_noauth" | "hosted";
|
||||
BYPASS_EMAIL_VERIFICATION?: string;
|
||||
TEAM_DOMAIN?: string;
|
||||
POLICY_AUD?: string;
|
||||
POSTHOG_PUBLIC_KEY?: string;
|
||||
@ -26,6 +27,7 @@ declare namespace Cloudflare {
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly AUTH_MODE?: "cloudflare_access" | "local_noauth" | "hosted";
|
||||
readonly BYPASS_EMAIL_VERIFICATION?: string;
|
||||
readonly POSTHOG_PUBLIC_KEY?: string;
|
||||
readonly POSTHOG_HOST?: 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 AppIndexRouteImport } from './routes/_app/index'
|
||||
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 AuthSignUpRouteImport } from './routes/_auth.sign-up'
|
||||
import { Route as AuthSignInRouteImport } from './routes/_auth.sign-in'
|
||||
@ -85,6 +86,11 @@ const AuthenticatedSubscribeRoute = AuthenticatedSubscribeRouteImport.update({
|
||||
path: '/subscribe',
|
||||
getParentRoute: () => AuthenticatedRoute,
|
||||
} as any)
|
||||
const AuthenticatedOnboardingRoute = AuthenticatedOnboardingRouteImport.update({
|
||||
id: '/onboarding',
|
||||
path: '/onboarding',
|
||||
getParentRoute: () => AuthenticatedRoute,
|
||||
} as any)
|
||||
const AuthenticatedOauthConsentRoute =
|
||||
AuthenticatedOauthConsentRouteImport.update({
|
||||
id: '/oauth-consent',
|
||||
@ -235,6 +241,7 @@ export interface FileRoutesByFullPath {
|
||||
'/sign-in': typeof AuthSignInRoute
|
||||
'/sign-up': typeof AuthSignUpRoute
|
||||
'/oauth-consent': typeof AuthenticatedOauthConsentRoute
|
||||
'/onboarding': typeof AuthenticatedOnboardingRoute
|
||||
'/subscribe': typeof AuthenticatedSubscribeRoute
|
||||
'/p/$projectId': typeof ProjectPProjectIdRouteRouteWithChildren
|
||||
'/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute
|
||||
@ -267,6 +274,7 @@ export interface FileRoutesByTo {
|
||||
'/sign-in': typeof AuthSignInRoute
|
||||
'/sign-up': typeof AuthSignUpRoute
|
||||
'/oauth-consent': typeof AuthenticatedOauthConsentRoute
|
||||
'/onboarding': typeof AuthenticatedOnboardingRoute
|
||||
'/subscribe': typeof AuthenticatedSubscribeRoute
|
||||
'/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute
|
||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||
@ -300,6 +308,7 @@ export interface FileRoutesById {
|
||||
'/_auth/sign-in': typeof AuthSignInRoute
|
||||
'/_auth/sign-up': typeof AuthSignUpRoute
|
||||
'/_authenticated/oauth-consent': typeof AuthenticatedOauthConsentRoute
|
||||
'/_authenticated/onboarding': typeof AuthenticatedOnboardingRoute
|
||||
'/_authenticated/subscribe': typeof AuthenticatedSubscribeRoute
|
||||
'/_app/': typeof AppIndexRoute
|
||||
'/_project/p/$projectId': typeof ProjectPProjectIdRouteRouteWithChildren
|
||||
@ -335,6 +344,7 @@ export interface FileRouteTypes {
|
||||
| '/sign-in'
|
||||
| '/sign-up'
|
||||
| '/oauth-consent'
|
||||
| '/onboarding'
|
||||
| '/subscribe'
|
||||
| '/p/$projectId'
|
||||
| '/help/dataforseo-api-key'
|
||||
@ -367,6 +377,7 @@ export interface FileRouteTypes {
|
||||
| '/sign-in'
|
||||
| '/sign-up'
|
||||
| '/oauth-consent'
|
||||
| '/onboarding'
|
||||
| '/subscribe'
|
||||
| '/help/dataforseo-api-key'
|
||||
| '/api/auth/$'
|
||||
@ -399,6 +410,7 @@ export interface FileRouteTypes {
|
||||
| '/_auth/sign-in'
|
||||
| '/_auth/sign-up'
|
||||
| '/_authenticated/oauth-consent'
|
||||
| '/_authenticated/onboarding'
|
||||
| '/_authenticated/subscribe'
|
||||
| '/_app/'
|
||||
| '/_project/p/$projectId'
|
||||
@ -498,6 +510,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AuthenticatedSubscribeRouteImport
|
||||
parentRoute: typeof AuthenticatedRoute
|
||||
}
|
||||
'/_authenticated/onboarding': {
|
||||
id: '/_authenticated/onboarding'
|
||||
path: '/onboarding'
|
||||
fullPath: '/onboarding'
|
||||
preLoaderRoute: typeof AuthenticatedOnboardingRouteImport
|
||||
parentRoute: typeof AuthenticatedRoute
|
||||
}
|
||||
'/_authenticated/oauth-consent': {
|
||||
id: '/_authenticated/oauth-consent'
|
||||
path: '/oauth-consent'
|
||||
@ -790,11 +809,13 @@ const AuthRouteWithChildren = AuthRoute._addFileChildren(AuthRouteChildren)
|
||||
|
||||
interface AuthenticatedRouteChildren {
|
||||
AuthenticatedOauthConsentRoute: typeof AuthenticatedOauthConsentRoute
|
||||
AuthenticatedOnboardingRoute: typeof AuthenticatedOnboardingRoute
|
||||
AuthenticatedSubscribeRoute: typeof AuthenticatedSubscribeRoute
|
||||
}
|
||||
|
||||
const AuthenticatedRouteChildren: AuthenticatedRouteChildren = {
|
||||
AuthenticatedOauthConsentRoute: AuthenticatedOauthConsentRoute,
|
||||
AuthenticatedOnboardingRoute: AuthenticatedOnboardingRoute,
|
||||
AuthenticatedSubscribeRoute: AuthenticatedSubscribeRoute,
|
||||
}
|
||||
|
||||
|
||||
@ -7,6 +7,7 @@ import {
|
||||
getCurrentAuthRedirectFromHref,
|
||||
getSignInSearch,
|
||||
} from "@/lib/auth-redirect";
|
||||
import { useOnboardingRedirect } from "@/client/features/onboarding/useOnboardingRedirect";
|
||||
|
||||
export const Route = createFileRoute("/_app")({
|
||||
component: AppRouteLayout,
|
||||
@ -16,6 +17,7 @@ function AppRouteLayout() {
|
||||
const navigate = useNavigate();
|
||||
const { data: session, isPending } = useSession();
|
||||
const isHostedMode = isHostedClientAuthMode();
|
||||
useOnboardingRedirect();
|
||||
|
||||
useEffect(() => {
|
||||
if (isPending || !isHostedMode || session?.user?.id) {
|
||||
|
||||
@ -48,9 +48,12 @@ function SignUpPage() {
|
||||
const search = Route.useSearch();
|
||||
const navigate = useNavigate();
|
||||
const { redirectTo, isHostedMode } = useAuthPageState(search.redirect);
|
||||
const postSignupRedirect = redirectTo === "/" ? "/onboarding" : redirectTo;
|
||||
const [showEmailForm, setShowEmailForm] = useState(false);
|
||||
const [isStartingGoogle, setIsStartingGoogle] = useState(false);
|
||||
const [socialError, setSocialError] = useState<string | null>(null);
|
||||
const bypassEmailVerification =
|
||||
import.meta.env.BYPASS_EMAIL_VERIFICATION === "true";
|
||||
|
||||
const form = useForm({
|
||||
defaultValues: {
|
||||
@ -75,9 +78,15 @@ function SignUpPage() {
|
||||
email,
|
||||
password: value.password,
|
||||
callbackURL: (() => {
|
||||
if (bypassEmailVerification) {
|
||||
return new URL(
|
||||
postSignupRedirect,
|
||||
window.location.origin,
|
||||
).toString();
|
||||
}
|
||||
const url = new URL("/verify-email", window.location.origin);
|
||||
if (redirectTo !== "/") {
|
||||
url.searchParams.set("redirect", redirectTo);
|
||||
if (postSignupRedirect !== "/") {
|
||||
url.searchParams.set("redirect", postSignupRedirect);
|
||||
}
|
||||
return url.toString();
|
||||
})(),
|
||||
@ -96,9 +105,13 @@ function SignUpPage() {
|
||||
captureClientEvent("auth:sign_up_success", {
|
||||
redirect_to: redirectTo,
|
||||
});
|
||||
if (bypassEmailVerification) {
|
||||
window.location.replace(postSignupRedirect);
|
||||
return;
|
||||
}
|
||||
void navigate({
|
||||
to: "/verify-email",
|
||||
search: { email, ...getSignInSearch(redirectTo) },
|
||||
search: { email, ...getSignInSearch(postSignupRedirect) },
|
||||
});
|
||||
} catch {
|
||||
formApi.setErrorMap({
|
||||
@ -112,18 +125,17 @@ function SignUpPage() {
|
||||
});
|
||||
|
||||
async function handleContinueWithGoogle() {
|
||||
const callbackURL = redirectTo;
|
||||
setSocialError(null);
|
||||
setIsStartingGoogle(true);
|
||||
|
||||
try {
|
||||
captureClientEvent("auth:sign_up_google_start", {
|
||||
redirect_to: callbackURL,
|
||||
redirect_to: redirectTo,
|
||||
});
|
||||
const result = await authClient.signIn.social({
|
||||
provider: "google",
|
||||
callbackURL,
|
||||
newUserCallbackURL: callbackURL,
|
||||
callbackURL: redirectTo,
|
||||
newUserCallbackURL: postSignupRedirect,
|
||||
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 { FreePlanBanner } from "@/client/features/billing/FreePlanBanner";
|
||||
import { useOnboardingRedirect } from "@/client/features/onboarding/useOnboardingRedirect";
|
||||
import { getErrorCode } from "@/client/lib/error-messages";
|
||||
import { AuthenticatedAppLayout } from "@/client/layout/AppShell";
|
||||
import { isHostedClientAuthMode } from "@/lib/auth-mode";
|
||||
@ -33,6 +34,7 @@ export const Route = createFileRoute("/_project/p/$projectId")({
|
||||
|
||||
function ProjectLayout() {
|
||||
const { projectId } = Route.useParams();
|
||||
useOnboardingRedirect();
|
||||
|
||||
return (
|
||||
<AuthenticatedAppLayout
|
||||
|
||||
@ -102,6 +102,8 @@ function VerifyEmailPage() {
|
||||
const redirectTo = normalizeAuthRedirect(search.redirect);
|
||||
const isHostedMode = isHostedClientAuthMode();
|
||||
const { data: session, isPending } = useSession();
|
||||
const bypassEmailVerification =
|
||||
import.meta.env.BYPASS_EMAIL_VERIFICATION === "true";
|
||||
const errorMessage = getVerificationErrorMessage(search.error);
|
||||
const verificationIssueType = search.error
|
||||
? verificationIssueSchema.parse(search.error)
|
||||
@ -120,13 +122,15 @@ function VerifyEmailPage() {
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVerified) {
|
||||
if (!isVerified && !bypassEmailVerification) {
|
||||
return;
|
||||
}
|
||||
|
||||
captureClientEvent("auth:verification_success", {
|
||||
redirect_to: redirectTo,
|
||||
});
|
||||
if (isVerified) {
|
||||
captureClientEvent("auth:verification_success", {
|
||||
redirect_to: redirectTo,
|
||||
});
|
||||
}
|
||||
|
||||
// Full page reload instead of client-side navigation: the auth→app
|
||||
// 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
|
||||
// "action is not a function" errors).
|
||||
window.location.replace(redirectTo);
|
||||
}, [isVerified, redirectTo]);
|
||||
}, [bypassEmailVerification, isVerified, redirectTo]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!verificationIssueType) {
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { createServerFn } from "@tanstack/react-start";
|
||||
import { env } from "cloudflare:workers";
|
||||
import { createServerFn } from "@tanstack/react-start";
|
||||
import { requireAuthenticatedContext } from "@/serverFunctions/middleware";
|
||||
|
||||
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 {
|
||||
readonly VITE_SHOW_DEVTOOLS?: string;
|
||||
readonly BYPASS_EMAIL_VERIFICATION?: string;
|
||||
// more env variables...
|
||||
}
|
||||
|
||||
|
||||
@ -21,7 +21,13 @@ export default defineConfig(({ mode }) => {
|
||||
const emitSourcemaps = env.POSTHOG_SOURCEMAPS === "true";
|
||||
|
||||
return {
|
||||
envPrefix: ["VITE_", "AUTH_MODE", "POSTHOG_PUBLIC_KEY", "POSTHOG_HOST"],
|
||||
envPrefix: [
|
||||
"VITE_",
|
||||
"AUTH_MODE",
|
||||
"BYPASS_EMAIL_VERIFICATION",
|
||||
"POSTHOG_PUBLIC_KEY",
|
||||
"POSTHOG_HOST",
|
||||
],
|
||||
server: {
|
||||
allowedHosts,
|
||||
port,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user