From 1eb189d930bc149ecb7052003f2339bf5d63f873 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Sat, 21 Feb 2026 11:17:27 +0000 Subject: [PATCH 01/17] Update the schema to include `onboardingData` on the User and Project --- apps/webapp/app/models/project.server.ts | 9 ++++++--- apps/webapp/app/models/user.server.ts | 4 +++- .../migration.sql | 5 +++++ internal-packages/database/prisma/schema.prisma | 2 ++ 4 files changed, 16 insertions(+), 4 deletions(-) create mode 100644 internal-packages/database/prisma/migrations/20260220120000_add_onboarding_data_to_user_and_project/migration.sql diff --git a/apps/webapp/app/models/project.server.ts b/apps/webapp/app/models/project.server.ts index 2b25ad77410..e22da8d0dcd 100644 --- a/apps/webapp/app/models/project.server.ts +++ b/apps/webapp/app/models/project.server.ts @@ -1,8 +1,8 @@ import { nanoid, customAlphabet } from "nanoid"; import slug from "slug"; import { $replica, prisma } from "~/db.server"; -import type { Project } from "@trigger.dev/database"; -import { Organization, createEnvironment } from "./organization.server"; +import type { Prisma, Project } from "@trigger.dev/database"; +import { type Organization, createEnvironment } from "./organization.server"; import { env } from "~/env.server"; import { projectCreated } from "~/services/platform.v3.server"; export type { Project } from "@trigger.dev/database"; @@ -14,6 +14,7 @@ type Options = { name: string; userId: string; version: "v2" | "v3"; + onboardingData?: Prisma.InputJsonValue; }; export class ExceededProjectLimitError extends Error { @@ -24,7 +25,7 @@ export class ExceededProjectLimitError extends Error { } export async function createProject( - { organizationSlug, name, userId, version }: Options, + { organizationSlug, name, userId, version, onboardingData }: Options, attemptCount = 0 ): Promise { //check the user has permissions to do this @@ -84,6 +85,7 @@ export async function createProject( name, userId, version, + onboardingData, }, attemptCount + 1 ); @@ -100,6 +102,7 @@ export async function createProject( }, externalRef: `proj_${externalRefGenerator()}`, version: version === "v3" ? "V3" : "V2", + onboardingData: onboardingData ?? undefined, }, include: { organization: { diff --git a/apps/webapp/app/models/user.server.ts b/apps/webapp/app/models/user.server.ts index 3c5fbe16883..68550f6e98c 100644 --- a/apps/webapp/app/models/user.server.ts +++ b/apps/webapp/app/models/user.server.ts @@ -332,13 +332,15 @@ export function updateUser({ email, marketingEmails, referralSource, + onboardingData, }: Pick & { marketingEmails?: boolean; referralSource?: string; + onboardingData?: Prisma.InputJsonValue; }) { return prisma.user.update({ where: { id }, - data: { name, email, marketingEmails, referralSource, confirmedBasicDetails: true }, + data: { name, email, marketingEmails, referralSource, onboardingData, confirmedBasicDetails: true }, }); } diff --git a/internal-packages/database/prisma/migrations/20260220120000_add_onboarding_data_to_user_and_project/migration.sql b/internal-packages/database/prisma/migrations/20260220120000_add_onboarding_data_to_user_and_project/migration.sql new file mode 100644 index 00000000000..97861c2223d --- /dev/null +++ b/internal-packages/database/prisma/migrations/20260220120000_add_onboarding_data_to_user_and_project/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "public"."User" ADD COLUMN "onboardingData" JSONB; + +-- AlterTable +ALTER TABLE "public"."Project" ADD COLUMN "onboardingData" JSONB; diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma index e28b951f05d..c7e6616d278 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -43,6 +43,7 @@ model User { confirmedBasicDetails Boolean @default(false) referralSource String? + onboardingData Json? orgMemberships OrgMember[] sentInvites OrgMemberInvite[] @@ -411,6 +412,7 @@ model Project { customerQueries CustomerQuery[] buildSettings Json? + onboardingData Json? taskScheduleInstances TaskScheduleInstance[] metricsDashboards MetricsDashboard[] } From 38a9ee38ad553c82366bc57da8198948cad937b2 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Sat, 21 Feb 2026 11:17:58 +0000 Subject: [PATCH 02/17] Update the User create modal with new questions --- .../app/routes/confirm-basic-details.tsx | 189 +++++++++++++++--- 1 file changed, 159 insertions(+), 30 deletions(-) diff --git a/apps/webapp/app/routes/confirm-basic-details.tsx b/apps/webapp/app/routes/confirm-basic-details.tsx index 0596ee8b52a..971545b4367 100644 --- a/apps/webapp/app/routes/confirm-basic-details.tsx +++ b/apps/webapp/app/routes/confirm-basic-details.tsx @@ -1,11 +1,12 @@ import { conform, useForm } from "@conform-to/react"; import { parse } from "@conform-to/zod"; -import { ArrowRightIcon, EnvelopeIcon, HeartIcon, UserIcon } from "@heroicons/react/20/solid"; +import { ArrowRightIcon, EnvelopeIcon, UserIcon } from "@heroicons/react/20/solid"; import { HandRaisedIcon } from "@heroicons/react/24/solid"; -import { ActionFunction, json } from "@remix-run/node"; +import { RadioGroup } from "@radix-ui/react-radio-group"; +import { json, type ActionFunction } from "@remix-run/node"; import { Form, useActionData } from "@remix-run/react"; import { motion } from "framer-motion"; -import { forwardRef, useState } from "react"; +import { forwardRef, useEffect, useState } from "react"; import { z } from "zod"; import { AppContainer, MainCenteredContainer } from "~/components/layout/AppLayout"; import { BackgroundWrapper } from "~/components/BackgroundWrapper"; @@ -18,6 +19,8 @@ import { Hint } from "~/components/primitives/Hint"; import { Input } from "~/components/primitives/Input"; import { InputGroup } from "~/components/primitives/InputGroup"; import { Label } from "~/components/primitives/Label"; +import { RadioGroupItem } from "~/components/primitives/RadioButton"; +import { Select, SelectItem } from "~/components/primitives/Select"; import { prisma } from "~/db.server"; import { useFeatures } from "~/hooks/useFeatures"; import { useUser } from "~/hooks/useUser"; @@ -27,6 +30,40 @@ import { requireUserId } from "~/services/session.server"; import { rootPath } from "~/utils/pathBuilder"; import { getVercelInstallParams } from "~/v3/vercel"; +const referralSourceOptions = [ + "Search engine", + "YouTube", + "Twitter/X", + "LinkedIn", + "Word of mouth", + "AI assistant/LLM", + "Blog/article", + "Event", + "Other", +] as const; + +const roleOptions = [ + "Founder", + "Staff/principal engineer", + "Senior software engineer", + "Software engineer", + "AI/ML engineer", + "Engineering manager", + "Product engineer", + "Non technical builder using AI tools", + "Student/learner", + "Other", +] as const; + +function shuffleArray(arr: T[]): T[] { + const shuffled = [...arr]; + for (let i = shuffled.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; + } + return shuffled; +} + function createSchema( constraints: { isEmailUnique?: (email: string) => Promise; @@ -40,13 +77,11 @@ function createSchema( .email() .superRefine((email, ctx) => { if (constraints.isEmailUnique === undefined) { - //client-side validation skips this ctx.addIssue({ code: z.ZodIssueCode.custom, message: conform.VALIDATION_UNDEFINED, }); } else { - // Tell zod this is an async validation by returning the promise return constraints.isEmailUnique(email).then((isUnique) => { if (isUnique) { return; @@ -61,6 +96,9 @@ function createSchema( }), confirmEmail: z.string(), referralSource: z.string().optional(), + referralSourceOther: z.string().optional(), + role: z.string().optional(), + roleOther: z.string().optional(), }) .refine((value) => value.email === value.confirmEmail, { message: "Emails must match", @@ -99,19 +137,39 @@ export const action: ActionFunction = async ({ request }) => { } try { - const updatedUser = await updateUser({ + const onboardingData: Record = {}; + + if (submission.value.referralSource) { + onboardingData.referralSource = submission.value.referralSource; + if (submission.value.referralSource === "Other" && submission.value.referralSourceOther) { + onboardingData.referralSourceOther = submission.value.referralSourceOther; + } + } + + if (submission.value.role) { + onboardingData.role = submission.value.role; + if (submission.value.role === "Other" && submission.value.roleOther) { + onboardingData.roleOther = submission.value.roleOther; + } + } + + const referralSourceForLegacy = + submission.value.referralSource === "Other" && submission.value.referralSourceOther + ? `Other: ${submission.value.referralSourceOther}` + : submission.value.referralSource; + + await updateUser({ id: userId, name: submission.value.name, email: submission.value.email, - referralSource: submission.value.referralSource, + referralSource: referralSourceForLegacy, + onboardingData, }); - // Preserve Vercel integration params if present const vercelParams = getVercelInstallParams(request); let redirectUrl = rootPath(); if (vercelParams) { - // Redirect to orgs/new with params preserved const params = new URLSearchParams({ code: vercelParams.code, configurationId: vercelParams.configurationId, @@ -143,10 +201,24 @@ export default function Page() { const lastSubmission = useActionData(); const [enteredEmail, setEnteredEmail] = useState(user.email ?? ""); const { isManagedCloud } = useFeatures(); + const [selectedReferralSource, setSelectedReferralSource] = useState(); + const [selectedRole, setSelectedRole] = useState(""); - const [form, { name, email, confirmEmail, referralSource }] = useForm({ + const [shuffledReferralSources, setShuffledReferralSources] = useState([ + ...referralSourceOptions, + ]); + const [shuffledRoles, setShuffledRoles] = useState([...roleOptions]); + + useEffect(() => { + const nonOtherReferral = referralSourceOptions.filter((r) => r !== "Other"); + setShuffledReferralSources([...shuffleArray(nonOtherReferral), "Other"]); + + const nonOtherRoles = roleOptions.filter((r) => r !== "Other"); + setShuffledRoles([...shuffleArray(nonOtherRoles), "Other"]); + }, []); + + const [form, { name, email, confirmEmail }] = useForm({ id: "confirm-basic-details", - // TODO: type this lastSubmission: lastSubmission as any, onValidate({ formData }) { return parse(formData, { schema: createSchema() }); @@ -159,7 +231,7 @@ export default function Page() { return ( - +
- + - Your team will see this name and we'll use it to contact you. {name.error} - + - {!shouldShowConfirm && ( - Confirm this is the email you'd like for your Trigger.dev account. - )} {email.error} @@ -225,9 +297,6 @@ export default function Page() { icon={EnvelopeIcon} spellCheck={false} /> - - Check this is the email you'd like associated with your Trigger.dev account. - {confirmEmail.error} ) : ( @@ -235,16 +304,76 @@ export default function Page() { )} + {isManagedCloud && ( - - - - + <> +
+ + + + + {shuffledReferralSources.map((option) => ( + + ))} + + {selectedReferralSource === "Other" && ( +
+ +
+ )} +
+ + + + + + value={selectedRole} + setValue={setSelectedRole} + placeholder="Select an option" + variant="secondary/small" + dropdownIcon + items={shuffledRoles} + className="h-8 rounded border-charcoal-800 bg-charcoal-750 px-3 text-sm hover:border-charcoal-600 hover:bg-charcoal-650" + > + {(items) => + items.map((item) => ( + + {item} + + )) + } + + {selectedRole === "Other" && ( +
+ +
+ )} +
+ )} Date: Sat, 21 Feb 2026 11:19:33 +0000 Subject: [PATCH 03/17] Update the Org creation route with new questions --- .../webapp/app/routes/_app.orgs.new/route.tsx | 104 +++++++----------- 1 file changed, 40 insertions(+), 64 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.new/route.tsx b/apps/webapp/app/routes/_app.orgs.new/route.tsx index 0a5c7fdd6ae..46165ca656f 100644 --- a/apps/webapp/app/routes/_app.orgs.new/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.new/route.tsx @@ -2,8 +2,7 @@ import { conform, useForm } from "@conform-to/react"; import { parse } from "@conform-to/zod"; import { BuildingOffice2Icon } from "@heroicons/react/20/solid"; import { RadioGroup } from "@radix-ui/react-radio-group"; -import type { ActionFunction, LoaderFunctionArgs } from "@remix-run/node"; -import { json, redirect } from "@remix-run/node"; +import { json, redirect, type ActionFunction, type LoaderFunctionArgs } from "@remix-run/node"; import { Form, useActionData, useNavigation } from "@remix-run/react"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; @@ -19,18 +18,15 @@ import { Input } from "~/components/primitives/Input"; import { InputGroup } from "~/components/primitives/InputGroup"; import { Label } from "~/components/primitives/Label"; import { RadioGroupItem } from "~/components/primitives/RadioButton"; -import { TextArea } from "~/components/primitives/TextArea"; import { useFeatures } from "~/hooks/useFeatures"; import { createOrganization } from "~/models/organization.server"; import { NewOrganizationPresenter } from "~/presenters/NewOrganizationPresenter.server"; import { requireUser, requireUserId } from "~/services/session.server"; -import { sendNewOrgMessage } from "~/services/slack.server"; import { organizationPath, rootPath } from "~/utils/pathBuilder"; const schema = z.object({ orgName: z.string().min(3).max(50), companySize: z.string().optional(), - whyUseUs: z.string().optional(), }); export const loader = async ({ request }: LoaderFunctionArgs) => { @@ -53,23 +49,14 @@ export const action: ActionFunction = async ({ request }) => { } try { + const companySize = submission.value.companySize ?? null; + const organization = await createOrganization({ title: submission.value.orgName, userId: user.id, - companySize: submission.value.companySize ?? null, + companySize, }); - const whyUseUs = formData.get("whyUseUs"); - - if (whyUseUs) { - await sendNewOrgMessage({ - orgName: submission.value.orgName, - whyUseUs: whyUseUs.toString(), - userEmail: user.email, - }); - } - - // Preserve Vercel integration params if present const url = new URL(request.url); const code = url.searchParams.get("code"); const configurationId = url.searchParams.get("configurationId"); @@ -77,7 +64,6 @@ export const action: ActionFunction = async ({ request }) => { const next = url.searchParams.get("next"); if (code && configurationId && integration === "vercel") { - // Redirect to projects/new with params preserved const params = new URLSearchParams({ code, configurationId, @@ -104,7 +90,6 @@ export default function NewOrganizationPage() { const [form, { orgName }] = useForm({ id: "create-organization", - // TODO: type this lastSubmission: lastSubmission as any, onValidate({ formData }) { return parse(formData, { schema }); @@ -137,51 +122,42 @@ export default function NewOrganizationPage() { {orgName.error} {isManagedCloud && ( - <> - - - - - - - - - - - -