diff --git a/apps/webapp/app/components/onboarding/TechnologyPicker.tsx b/apps/webapp/app/components/onboarding/TechnologyPicker.tsx new file mode 100644 index 00000000000..3d822e4b692 --- /dev/null +++ b/apps/webapp/app/components/onboarding/TechnologyPicker.tsx @@ -0,0 +1,375 @@ +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 = [ + "Airflow", + "Angular", + "Anthropic", + "Astro", + "Auth0", + "AWS", + "AWS SQS", + "Azure", + "BigQuery", + "BullMQ", + "Bun", + "Cassandra", + "Celery", + "ClickHouse", + "Clerk", + "Cloudflare", + "CockroachDB", + "Cohere", + "Convex", + "Databricks", + "Datadog", + "DeepSeek", + "Deno", + "DigitalOcean", + "Django", + "Docker", + "Drizzle", + "DynamoDB", + "Elasticsearch", + "Electron", + "Elevenlabs", + "Expo", + "Express", + "FastAPI", + "Fastify", + "Firebase", + "Flask", + "Fly.io", + "Gatsby", + "GCP", + "Go", + "Google Cloud Tasks", + "Google Gemini", + "GraphQL", + "Groq", + "Heroku", + "Hono", + "htmx", + "Hugging Face", + "Inngest", + "Kafka", + "Kubernetes", + "LangChain", + "Laravel", + "LlamaIndex", + "MariaDB", + "Midjourney", + "Mistral", + "MongoDB", + "Mongoose", + "MySQL", + "Neo4j", + "Neon", + "Nest.js", + "Netlify", + "Next.js", + "Node.js", + "Nuxt", + "Ollama", + "OpenAI", + "Perplexity", + "PHP", + "Pinecone", + "PlanetScale", + "Python", + "PostHog", + "PostgreSQL", + "Prisma", + "Pulumi", + "RabbitMQ", + "Railway", + "React", + "React Native", + "Redis", + "Redshift", + "Remix", + "Render", + "Replicate", + "Resend", + "Ruby on Rails", + "Rust", + "SendGrid", + "Sentry", + "Sidekiq", + "Snowflake", + "Solid.js", + "Spring Boot", + "SQLite", + "Stability AI", + "Stripe", + "Supabase", + "Svelte", + "SvelteKit", + "Tailwind CSS", + "Temporal", + "Terraform", + "Together AI", + "tRPC", + "Turso", + "Twilio", + "TypeORM", + "Upstash", + "Vercel", + "Vercel AI SDK", + "Vite", + "Vue", + "Weaviate", +] 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 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-2 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 && ( -
+
{variant === "success" ? ( - + ) : ( - + )}
{title && {title}} - + {message}
- - {/* 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 +585,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) => (