diff --git a/apps/webapp/app/components/onboarding/TechnologyPicker.tsx b/apps/webapp/app/components/onboarding/TechnologyPicker.tsx new file mode 100644 index 00000000000..6f8c4523687 --- /dev/null +++ b/apps/webapp/app/components/onboarding/TechnologyPicker.tsx @@ -0,0 +1,322 @@ +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="flex-1 border-none bg-transparent pl-0.5 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/Avatar.tsx b/apps/webapp/app/components/primitives/Avatar.tsx index 0cb74c2ba60..9bc95d55b16 100644 --- a/apps/webapp/app/components/primitives/Avatar.tsx +++ b/apps/webapp/app/components/primitives/Avatar.tsx @@ -1,8 +1,10 @@ import { + BoltIcon, BuildingOffice2Icon, CodeBracketSquareIcon, FaceSmileIcon, FireIcon, + GlobeAltIcon, RocketLaunchIcon, StarIcon, } from "@heroicons/react/20/solid"; @@ -25,7 +27,8 @@ export const AvatarData = z.discriminatedUnion("type", [ }), z.object({ type: z.literal(AvatarType.enum.image), - url: z.string().url(), + url: z.string(), + lastIconHex: z.string().optional(), }), ]); @@ -85,6 +88,7 @@ export const avatarIcons: Record + + + ); + } + return ( - - Organization avatar + + Organization avatar ); } 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 && ( (null); + const timeoutRef = useRef>(); + + const update = useCallback( + (value: string) => { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + timeoutRef.current = setTimeout(() => { + const domain = extractDomain(value); + if (domain && domain.includes(".")) { + setUrl(faviconUrl(domain, size)); + } else { + setUrl(null); + } + }, 400); + }, + [size] + ); + + useEffect(() => { + update(urlInput); + return () => { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + }; + }, [urlInput, update]); + + return url; +} diff --git a/apps/webapp/app/models/organization.server.ts b/apps/webapp/app/models/organization.server.ts index 66b1d5c5b20..14315dd337c 100644 --- a/apps/webapp/app/models/organization.server.ts +++ b/apps/webapp/app/models/organization.server.ts @@ -1,6 +1,7 @@ import type { Organization, OrgMember, + Prisma, Project, RuntimeEnvironment, User, @@ -22,8 +23,12 @@ export async function createOrganization( title, userId, companySize, + onboardingData, + avatar, }: Pick & { userId: User["id"]; + onboardingData?: Prisma.InputJsonValue; + avatar?: Prisma.InputJsonValue; }, attemptCount = 0 ): Promise { @@ -47,6 +52,8 @@ export async function createOrganization( title, userId, companySize, + onboardingData, + avatar, }, attemptCount + 1 ); @@ -59,6 +66,8 @@ export async function createOrganization( title, slug: uniqueOrgSlug, companySize, + onboardingData: onboardingData ?? undefined, + avatar: avatar ?? undefined, maximumConcurrencyLimit: env.DEFAULT_ORG_EXECUTION_CONCURRENCY_LIMIT, members: { create: { diff --git a/apps/webapp/app/models/project.server.ts b/apps/webapp/app/models/project.server.ts index 2b25ad77410..0dc634b5ab7 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, }, 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.settings._index/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings._index/route.tsx index e4c3967a36a..124a93ed757 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings._index/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings._index/route.tsx @@ -5,10 +5,12 @@ import { CheckIcon, ExclamationTriangleIcon, FolderIcon, + GlobeAltIcon, TrashIcon, } from "@heroicons/react/20/solid"; -import { Form, type MetaFunction, useActionData, useNavigation } from "@remix-run/react"; +import { Form, type MetaFunction, useActionData, useNavigation, useSubmit } from "@remix-run/react"; import { type ActionFunction, json, type LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { useEffect, useRef, useState } from "react"; import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; import { InlineCode } from "~/components/code/InlineCode"; @@ -26,35 +28,31 @@ import { parseAvatar, defaultAvatarHex, defaultAvatarColors, + type Avatar as AvatarT, } from "~/components/primitives/Avatar"; import { Button } from "~/components/primitives/Buttons"; import { Fieldset } from "~/components/primitives/Fieldset"; import { FormButtons } from "~/components/primitives/FormButtons"; import { FormError } from "~/components/primitives/FormError"; -import { Header2, Header3 } from "~/components/primitives/Headers"; +import { Header2 } from "~/components/primitives/Headers"; 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 { NavBar, PageTitle } from "~/components/primitives/PageHeader"; -import { - Popover, - PopoverContent, - PopoverCustomTrigger, - PopoverTrigger, -} from "~/components/primitives/Popover"; -import { Spinner, SpinnerWhite } from "~/components/primitives/Spinner"; +import { Popover, PopoverContent, PopoverTrigger } from "~/components/primitives/Popover"; +import { SpinnerWhite } from "~/components/primitives/Spinner"; import { prisma } from "~/db.server"; -import { useOrganization } from "~/hooks/useOrganizations"; +import { useFaviconUrl } from "~/hooks/useFaviconUrl"; import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server"; import { clearCurrentProject } from "~/services/dashboardPreferences.server"; import { DeleteOrganizationService } from "~/services/deleteOrganization.server"; import { logger } from "~/services/logger.server"; import { requireUser, requireUserId } from "~/services/session.server"; import { cn } from "~/utils/cn"; +import { extractDomain, faviconUrl as buildFaviconUrl } from "~/utils/favicon"; import { OrganizationParamsSchema, - organizationPath, organizationSettingsPath, rootPath, } from "~/utils/pathBuilder"; @@ -79,6 +77,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { slug: true, title: true, avatar: true, + onboardingData: true, }, }); @@ -86,8 +85,21 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { throw new Response("Not found", { status: 404 }); } + const onboardingData = toRecord(organization.onboardingData); + + const parsedAvatar = parseAvatar(organization.avatar, defaultAvatar); + const lastIconHex = + parsedAvatar.type === "image" && parsedAvatar.lastIconHex + ? parsedAvatar.lastIconHex + : defaultAvatarHex; + return typedjson({ - organization: { ...organization, avatar: parseAvatar(organization.avatar, defaultAvatar) }, + organization: { + ...organization, + avatar: parsedAvatar, + companyUrl: typeof onboardingData.companyUrl === "string" ? onboardingData.companyUrl : "", + lastIconHex, + }, }); }; @@ -102,6 +114,7 @@ export function createSchema( type: AvatarType, name: z.string().optional(), hex: z.string().optional(), + url: z.string().optional(), }), z.object({ action: z.literal("rename"), @@ -199,6 +212,43 @@ export const action: ActionFunction = async ({ request, params }) => { } } case "avatar": { + const orgWhere = { + slug: organizationSlug, + members: { some: { userId: user.id } }, + }; + + if (submission.value.type === "image") { + const url = submission.value.url ?? ""; + const domain = url ? extractDomain(url) : null; + + const existing = await prisma.organization.findFirst({ + where: orgWhere, + select: { avatar: true, onboardingData: true }, + }); + + const existingData = toRecord(existing?.onboardingData); + const existingAvatar = parseAvatar(existing?.avatar ?? null, defaultAvatar); + const lastIconHex = extractLastIconHex(existingAvatar); + + await prisma.organization.update({ + where: orgWhere, + data: { + avatar: { + type: "image", + url: domain ? buildFaviconUrl(domain) : "", + ...(lastIconHex ? { lastIconHex } : {}), + }, + onboardingData: { ...existingData, companyUrl: url }, + }, + }); + + return redirectWithSuccessMessage( + organizationSettingsPath({ slug: organizationSlug }), + request, + `Updated logo` + ); + } + const avatar = AvatarData.safeParse(submission.value); if (!avatar.success) { @@ -210,14 +260,7 @@ export const action: ActionFunction = async ({ request, params }) => { } await prisma.organization.update({ - where: { - slug: organizationSlug, - members: { - some: { - userId: user.id, - }, - }, - }, + where: orgWhere, data: { avatar: avatar.data, }, @@ -226,12 +269,13 @@ export const action: ActionFunction = async ({ request, params }) => { return redirectWithSuccessMessage( organizationSettingsPath({ slug: organizationSlug }), request, - `Updated icon` + `Updated logo` ); } } - } catch (error: any) { - return json({ errors: { body: error.message } }, { status: 400 }); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : "An unexpected error occurred"; + return json({ errors: { body: message } }, { status: 400 }); } }; @@ -374,89 +418,170 @@ export default function Page() { ); } -function LogoForm({ organization }: { organization: { avatar: Avatar; title: string } }) { +function LogoForm({ + organization, +}: { + organization: { avatar: AvatarT; title: string; companyUrl: string; lastIconHex: string }; +}) { const navigation = useNavigation(); - const isSubmitting = - navigation.state != "idle" && navigation.formData?.get("action") === "avatar"; - const avatar = navigation.formData ? avatarFromFormData(navigation.formData) ?? organization.avatar : organization.avatar; - const hex = "hex" in avatar ? avatar.hex : defaultAvatarHex; + const hex = + "hex" in avatar + ? avatar.hex + : avatar.type === "image" && avatar.lastIconHex + ? avatar.lastIconHex + : organization.lastIconHex; + const mode: "logo" | "icon" = avatar.type === "image" ? "logo" : "icon"; + + const [companyUrl, setCompanyUrl] = useState(organization.companyUrl); + const faviconPreview = useFaviconUrl(companyUrl); + const [faviconError, setFaviconError] = useState(false); + const logoFormRef = useRef(null); + const submit = useSubmit(); + const initializedRef = useRef(false); + const prevFaviconRef = useRef(faviconPreview); + + useEffect(() => { + if (!initializedRef.current) { + initializedRef.current = true; + prevFaviconRef.current = faviconPreview; + return; + } + if (faviconPreview === prevFaviconRef.current) return; + prevFaviconRef.current = faviconPreview; + if (mode === "logo" && logoFormRef.current) { + submit(logoFormRef.current); + } + }, [faviconPreview, mode, submit]); + + const showFavicon = faviconPreview && !faviconError; return (
- -
-
- -
- {/* Letters */} -
+ +
+ {/* Row 1: Logo from URL */} + - - - - - {/* Icons */} - {Object.entries(avatarIcons).map(([name]) => ( -
- - - - +
+ { + setCompanyUrl(e.target.value); + setFaviconError(false); + }} + onFocus={() => { + if (mode !== "logo" && logoFormRef.current) { + submit(logoFormRef.current); + } + }} + placeholder="Enter your company URL to generate a logo" + variant="medium" + containerClassName="flex-1" + /> +
+
+ + {/* Row 2: Icon picker */} +
+
+ + + +
- ))} - {/* Hex */} - +
+ {/* Letters */} +
+ + + + +
+ {/* Icons */} + {Object.entries(avatarIcons).map(([name]) => ( +
+ + + + + +
+ ))} + {/* Color picker */} + +
+
@@ -466,7 +591,7 @@ function LogoForm({ organization }: { organization: { avatar: Avatar; title: str function HexPopover({ avatar, hex }: { avatar: Avatar; hex: string }) { return ( - + -
+ - - {"name" in avatar && } + + {avatar.type === "icon" && } {defaultAvatarColors.map((color) => (