diff --git a/apps/webapp/app/components/onboarding/TechnologyPicker.tsx b/apps/webapp/app/components/onboarding/TechnologyPicker.tsx new file mode 100644 index 00000000000..d9d45a9e534 --- /dev/null +++ b/apps/webapp/app/components/onboarding/TechnologyPicker.tsx @@ -0,0 +1,324 @@ +import * as Ariakit from "@ariakit/react"; +import { + XMarkIcon, + PlusIcon, + CubeIcon, + MagnifyingGlassIcon, + ChevronDownIcon, +} from "@heroicons/react/20/solid"; +import { useCallback, useMemo, useRef, useState } from "react"; +import { CheckboxIndicator } from "~/components/primitives/CheckboxIndicator"; +import { cn } from "~/utils/cn"; +import { matchSorter } from "match-sorter"; +import { ShortcutKey } from "~/components/primitives/ShortcutKey"; + +const pillColors = [ + "bg-green-800/40 border-green-600/50", + "bg-teal-800/40 border-teal-600/50", + "bg-blue-800/40 border-blue-600/50", + "bg-indigo-800/40 border-indigo-600/50", + "bg-violet-800/40 border-violet-600/50", + "bg-purple-800/40 border-purple-600/50", + "bg-fuchsia-800/40 border-fuchsia-600/50", + "bg-pink-800/40 border-pink-600/50", + "bg-rose-800/40 border-rose-600/50", + "bg-orange-800/40 border-orange-600/50", + "bg-amber-800/40 border-amber-600/50", + "bg-yellow-800/40 border-yellow-600/50", + "bg-lime-800/40 border-lime-600/50", + "bg-emerald-800/40 border-emerald-600/50", + "bg-cyan-800/40 border-cyan-600/50", + "bg-sky-800/40 border-sky-600/50", +]; + +function getPillColor(value: string): string { + let hash = 0; + for (let i = 0; i < value.length; i++) { + hash = (hash << 5) - hash + value.charCodeAt(i); + hash |= 0; + } + return pillColors[Math.abs(hash) % pillColors.length]; +} + +export const TECHNOLOGY_OPTIONS = [ + "Angular", + "Anthropic", + "Astro", + "AWS", + "Azure", + "BullMQ", + "Bun", + "Celery", + "Clerk", + "Cloudflare", + "Cohere", + "Convex", + "Deno", + "Docker", + "Drizzle", + "DynamoDB", + "Elevenlabs", + "Express", + "Fastify", + "Firebase", + "Fly.io", + "GCP", + "GraphQL", + "Hono", + "Hugging Face", + "Inngest", + "Kafka", + "Kubernetes", + "Laravel", + "LangChain", + "Mistral", + "MongoDB", + "MySQL", + "Neon", + "Nest.js", + "Next.js", + "Node.js", + "Nuxt", + "OpenAI", + "PlanetScale", + "PostgreSQL", + "Prisma", + "RabbitMQ", + "Railway", + "React", + "Redis", + "Remix", + "Render", + "Replicate", + "Resend", + "SQLite", + "Stripe", + "Supabase", + "SvelteKit", + "Temporal", + "tRPC", + "Turso", + "Upstash", + "Vercel", + "Vue", +] as const; + +type TechnologyPickerProps = { + value: string[]; + onChange: (value: string[]) => void; + customValues: string[]; + onCustomValuesChange: (values: string[]) => void; +}; + +export function TechnologyPicker({ + value, + onChange, + customValues, + onCustomValuesChange, +}: TechnologyPickerProps) { + const [open, setOpen] = useState(false); + const [searchValue, setSearchValue] = useState(""); + const [otherInputValue, setOtherInputValue] = useState(""); + const [showOtherInput, setShowOtherInput] = useState(false); + const otherInputRef = useRef(null); + + const allSelected = useMemo(() => [...value, ...customValues], [value, customValues]); + + const filteredOptions = useMemo(() => { + if (!searchValue) return TECHNOLOGY_OPTIONS; + return matchSorter([...TECHNOLOGY_OPTIONS], searchValue); + }, [searchValue]); + + const toggleOption = useCallback( + (option: string) => { + if (value.includes(option)) { + onChange(value.filter((v) => v !== option)); + } else { + onChange([...value, option]); + } + }, + [value, onChange] + ); + + const removeItem = useCallback( + (item: string) => { + if (value.includes(item)) { + onChange(value.filter((v) => v !== item)); + } else { + onCustomValuesChange(customValues.filter((v) => v !== item)); + } + }, + [value, onChange, customValues, onCustomValuesChange] + ); + + const addCustomValue = useCallback(() => { + const trimmed = otherInputValue.trim(); + if (trimmed && !customValues.includes(trimmed) && !value.includes(trimmed)) { + onCustomValuesChange([...customValues, trimmed]); + setOtherInputValue(""); + } + }, [otherInputValue, customValues, onCustomValuesChange, value]); + + const handleOtherKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + e.stopPropagation(); + addCustomValue(); + } + }, + [addCustomValue] + ); + + return ( +
+ {allSelected.length > 0 && ( +
+ {allSelected.map((item) => ( + + {item} + + + ))} +
+ )} + + { + setSearchValue(val); + }} + > + { + if (Array.isArray(v)) { + onChange(v); + } + }} + virtualFocus + > + +
+ + Select your technologies… +
+ +
+ + +
+ + +
+ + + {filteredOptions.map((option) => ( + { + e.preventDefault(); + toggleOption(option); + }} + > +
+ + {option} +
+
+ ))} + + {filteredOptions.length === 0 && !searchValue && ( +
No options
+ )} + + {filteredOptions.length === 0 && searchValue && ( +
+ No matches for “{searchValue}” +
+ )} +
+ +
+ {showOtherInput ? ( +
+ setOtherInputValue(e.target.value)} + onKeyDown={handleOtherKeyDown} + placeholder="Type and press Enter to add" + className="pl-0.5 flex-1 border-none bg-transparent text-2sm text-text-bright shadow-none outline-none ring-0 placeholder:text-text-dimmed focus:border-none focus:outline-none focus:ring-0" + autoFocus + /> + 0 ? "opacity-100" : "opacity-0" + )} + /> + +
+ ) : ( + + )} +
+
+
+
+
+ ); +} diff --git a/apps/webapp/app/components/primitives/CheckboxIndicator.tsx b/apps/webapp/app/components/primitives/CheckboxIndicator.tsx new file mode 100644 index 00000000000..0fe0f83b9aa --- /dev/null +++ b/apps/webapp/app/components/primitives/CheckboxIndicator.tsx @@ -0,0 +1,24 @@ +import { cn } from "~/utils/cn"; + +export function CheckboxIndicator({ checked }: { checked: boolean }) { + return ( +
+ {checked && ( + + + + )} +
+ ); +} diff --git a/apps/webapp/app/components/primitives/Select.tsx b/apps/webapp/app/components/primitives/Select.tsx index 82f750c42ed..d3e4c866891 100644 --- a/apps/webapp/app/components/primitives/Select.tsx +++ b/apps/webapp/app/components/primitives/Select.tsx @@ -338,9 +338,9 @@ export function SelectTrigger({ /> } > -
- {icon &&
{icon}
} -
{content}
+
+ {icon &&
{icon}
} +
{content}
{dropdownIcon === true ? ( , + checkPosition = "right", shortcut, ...props }: SelectItemProps) { const combobox = Ariakit.useComboboxContext(); const render = combobox ? : undefined; const ref = React.useRef(null); + const select = Ariakit.useSelectContext(); + const selectValue = select?.useState("value"); + + const isChecked = React.useMemo(() => { + if (!props.value || selectValue == null) return false; + if (Array.isArray(selectValue)) return selectValue.includes(props.value); + return selectValue === props.value; + }, [selectValue, props.value]); useShortcutKeys({ shortcut: shortcut, @@ -484,10 +496,16 @@ export function SelectItem({ )} ref={ref} > -
+
+ {checkPosition === "left" && } {icon}
{props.children || props.value}
- {checkIcon} + {checkPosition === "right" && checkIcon} {shortcut && ( { //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, }, 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/apps/webapp/app/routes/_app.orgs.$organizationSlug_.projects.new/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug_.projects.new/route.tsx index d02f869c703..3a7029f8937 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug_.projects.new/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug_.projects.new/route.tsx @@ -1,15 +1,17 @@ import { conform, useForm } from "@conform-to/react"; import { parse } from "@conform-to/zod"; -import { FolderIcon } from "@heroicons/react/20/solid"; -import type { ActionFunction, LoaderFunctionArgs } from "@remix-run/node"; -import { json } from "@remix-run/node"; +import { CommandLineIcon, FolderIcon } from "@heroicons/react/20/solid"; +import { json, type ActionFunction, type LoaderFunctionArgs } from "@remix-run/node"; import { Form, useActionData, useNavigation } from "@remix-run/react"; +import type { Prisma } from "@trigger.dev/database"; +import React, { useEffect, useState } from "react"; import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson"; import invariant from "tiny-invariant"; import { z } from "zod"; import { BackgroundWrapper } from "~/components/BackgroundWrapper"; import { Feedback } from "~/components/Feedback"; import { AppContainer, MainCenteredContainer } from "~/components/layout/AppLayout"; +import { TechnologyPicker } from "~/components/onboarding/TechnologyPicker"; import { Button, LinkButton } from "~/components/primitives/Buttons"; import { Callout } from "~/components/primitives/Callout"; import { Fieldset } from "~/components/primitives/Fieldset"; @@ -19,6 +21,7 @@ import { FormTitle } from "~/components/primitives/FormTitle"; import { Input } from "~/components/primitives/Input"; import { InputGroup } from "~/components/primitives/InputGroup"; import { Label } from "~/components/primitives/Label"; +import { Select, SelectItem } from "~/components/primitives/Select"; import { ButtonSpinner } from "~/components/primitives/Spinner"; import { prisma } from "~/db.server"; import { featuresForRequest } from "~/features.server"; @@ -34,6 +37,76 @@ import { } from "~/utils/pathBuilder"; import { generateVercelOAuthState } from "~/v3/vercel/vercelOAuthState.server"; +const WORKING_ON_OTHER = "Other/not sure yet"; + +const workingOnOptions = [ + "AI agent", + "Media processing pipeline", + "Media generation with AI", + "Event-driven workflow", + "Realtime streaming", + "Internal tool or background job", + WORKING_ON_OTHER, +] as const; + +const goalOptions = [ + "Ship a production workflow", + "Prototype or explore", + "Migrate an existing system", + "Learn how Trigger works", + "Evaluate against alternatives", +] 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 MultiSelectField({ + value, + setValue, + items, + icon, +}: { + value: string[]; + setValue: (value: string[]) => void; + items: string[]; + icon: React.ReactNode; +}) { + return ( + + value={value} + setValue={setValue} + placeholder="Select options" + variant="secondary/small" + dropdownIcon + icon={icon} + items={items} + className="h-8 min-w-0 border-0 bg-charcoal-750 pl-2 text-sm text-text-dimmed ring-charcoal-600 transition hover:bg-charcoal-650 hover:text-text-dimmed hover:ring-1" + text={(v) => + v.length === 0 ? undefined : ( + + {v.slice(0, 2).join(", ")} + {v.length > 2 && +{v.length - 2} more} + + ) + } + > + {(items) => + items.map((item) => ( + + {item} + + )) + } + + ); +} + export async function loader({ params, request }: LoaderFunctionArgs) { const userId = await requireUserId(request); const { organizationSlug } = OrganizationParamsSchema.parse(params); @@ -62,14 +135,12 @@ export async function loader({ params, request }: LoaderFunctionArgs) { throw new Response(null, { status: 404, statusText: "Organization not found" }); } - //if you don't have v3 access, you must select a plan const { isManagedCloud } = featuresForRequest(request); if (isManagedCloud && !organization.v3Enabled) { return redirect(selectPlanPath({ slug: organizationSlug })); } const url = new URL(request.url); - const message = url.searchParams.get("message"); return typedjson({ @@ -90,6 +161,11 @@ export async function loader({ params, request }: LoaderFunctionArgs) { const schema = z.object({ projectName: z.string().min(3, "Project name must have at least 3 characters").max(50), projectVersion: z.enum(["v2", "v3"]), + workingOn: z.string().optional(), + workingOnOther: z.string().optional(), + technologies: z.string().optional(), + technologiesOther: z.string().optional(), + goals: z.string().optional(), }); export const action: ActionFunction = async ({ request, params }) => { @@ -104,21 +180,50 @@ export const action: ActionFunction = async ({ request, params }) => { return json(submission); } - // Check for Vercel integration params in URL const url = new URL(request.url); const code = url.searchParams.get("code"); const configurationId = url.searchParams.get("configurationId"); const next = url.searchParams.get("next"); + const stringArraySchema = z.array(z.string()); + + function safeParseStringArray(value: string | undefined): string[] | undefined { + if (!value) return undefined; + try { + const result = stringArraySchema.safeParse(JSON.parse(value)); + return result.success && result.data.length > 0 ? result.data : undefined; + } catch { + return undefined; + } + } + + const onboardingData: Record = {}; + + const workingOn = safeParseStringArray(submission.value.workingOn); + if (workingOn) onboardingData.workingOn = workingOn; + + if (submission.value.workingOnOther) { + onboardingData.workingOnOther = submission.value.workingOnOther; + } + + const technologies = safeParseStringArray(submission.value.technologies); + if (technologies) onboardingData.technologies = technologies; + + const technologiesOther = safeParseStringArray(submission.value.technologiesOther); + if (technologiesOther) onboardingData.technologiesOther = technologiesOther; + + const goals = safeParseStringArray(submission.value.goals); + if (goals) onboardingData.goals = goals; + try { const project = await createProject({ organizationSlug: organizationSlug, name: submission.value.projectName, userId, version: submission.value.projectVersion, + onboardingData: Object.keys(onboardingData).length > 0 ? onboardingData : undefined, }); - // If this is a Vercel integration flow, generate state and redirect to connect if (code && configurationId) { const environment = await prisma.runtimeEnvironment.findFirst({ where: { @@ -195,7 +300,6 @@ export default function Page() { const [form, { projectName, projectVersion }] = useForm({ id: "create-project", - // TODO: type this lastSubmission: lastSubmission as any, onValidate({ formData }) { return parse(formData, { schema }); @@ -205,10 +309,25 @@ export default function Page() { const navigation = useNavigation(); const isLoading = navigation.state === "submitting" || navigation.state === "loading"; + const [selectedWorkingOn, setSelectedWorkingOn] = useState([]); + const [workingOnOther, setWorkingOnOther] = useState(""); + const [selectedTechnologies, setSelectedTechnologies] = useState([]); + const [customTechnologies, setCustomTechnologies] = useState([]); + const [selectedGoals, setSelectedGoals] = useState([]); + + const [shuffledWorkingOn, setShuffledWorkingOn] = useState([...workingOnOptions]); + + useEffect(() => { + const nonOther = workingOnOptions.filter((o) => o !== WORKING_ON_OTHER); + setShuffledWorkingOn([...shuffleArray(nonOther), WORKING_ON_OTHER]); + }, []); + + const showWorkingOnOther = selectedWorkingOn.includes(WORKING_ON_OTHER); + return ( - +
} @@ -223,7 +342,9 @@ export default function Page() { )}
- + )} + +
+ + + + } + /> + {showWorkingOnOther && ( + <> + + setWorkingOnOther(e.target.value)} + placeholder="Tell us what you're working on" + spellCheck={false} + containerClassName="h-8" + /> + + )} + + + + + + + + + + + + + } + /> + + { @@ -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 && ( - <> - - - - - - - - - - - -