From bbacb403b3295bbd224a2a59ddd5664414ab698c Mon Sep 17 00:00:00 2001 From: Rashaad Lee Hue Date: Sat, 29 Aug 2026 22:46:16 -0400 Subject: [PATCH] feat: suggestions in role, company, and location fields Adds a Base UI Autocomplete wrapper and uses it in the application form. Role and Location combine the user's past entries with a starter list; Company suggests from history only. Free text is still allowed. Co-Authored-By: Claude Fable 5 --- .../application-record/page.tsx | 28 +++-- components/applications/application-form.tsx | 98 ++++++++++----- components/applications/suggest-input.tsx | 67 ++++++++++ .../applications/suggestions-provider.tsx | 24 ++++ components/ui/autocomplete.tsx | 119 ++++++++++++++++++ lib/suggestions.ts | 118 +++++++++++++++++ 6 files changed, 410 insertions(+), 44 deletions(-) create mode 100644 components/applications/suggest-input.tsx create mode 100644 components/applications/suggestions-provider.tsx create mode 100644 components/ui/autocomplete.tsx create mode 100644 lib/suggestions.ts diff --git a/app/(application-record)/application-record/page.tsx b/app/(application-record)/application-record/page.tsx index 7cfa20a..dbcd37f 100644 --- a/app/(application-record)/application-record/page.tsx +++ b/app/(application-record)/application-record/page.tsx @@ -3,9 +3,11 @@ import { redirect } from "next/navigation"; import { getUser } from "@/lib/session"; import { getUserApplications } from "@/db/queries"; import { computeStats } from "@/lib/applications"; +import { buildSuggestions } from "@/lib/suggestions"; import { StatsRow } from "@/components/dashboard/stats-row"; import { ApplicationsView } from "@/components/applications/applications-view"; import { NewApplicationButton } from "@/components/applications/application-dialog"; +import { SuggestionsProvider } from "@/components/applications/suggestions-provider"; import { Chatbot } from "@/components/ai-chatbot/chatbot"; export const metadata: Metadata = { @@ -21,19 +23,21 @@ export default async function ApplicationRecord() { const applications = await getUserApplications(user.id); return ( -
-
-
-

Applications

-

- Everything you have applied to, in one place. -

+ +
+
+
+

Applications

+

+ Everything you have applied to, in one place. +

+
+
- + + +
- - - -
+ ); } diff --git a/components/applications/application-form.tsx b/components/applications/application-form.tsx index 00abcca..db2a877 100644 --- a/components/applications/application-form.tsx +++ b/components/applications/application-form.tsx @@ -31,6 +31,8 @@ import { SelectValue, } from "@/components/ui/select"; import { DateField } from "./date-field"; +import { SuggestInput } from "./suggest-input"; +import { useSuggestions } from "./suggestions-provider"; import { statusClasses } from "./status-badge"; import { cn } from "@/lib/utils"; @@ -44,6 +46,7 @@ export function ApplicationForm({ onDone: () => void; }) { const [pending, startTransition] = useTransition(); + const suggestions = useSuggestions(); const form = useForm({ resolver: zodResolver(applicationSchema), defaultValues: toFormValues(application), @@ -72,41 +75,72 @@ export function ApplicationForm({ return (
- - Role - - {errors.role?.message} - + ( + + Role + + {fieldState.error?.message} + + )} + /> - - Company - - {errors.company_name?.message} - + ( + + Company + + {fieldState.error?.message} + + )} + />
- - Location - - {errors.location?.message} - + ( + + Location + + {fieldState.error?.message} + + )} + /> void; + onBlur?: () => void; + items: readonly string[]; + placeholder?: string; + invalid?: boolean; + ref?: Ref; +}) { + return ( + + + + + {(item: string) => ( + + {item} + + )} + + + + ); +} diff --git a/components/applications/suggestions-provider.tsx b/components/applications/suggestions-provider.tsx new file mode 100644 index 0000000..7f9dabf --- /dev/null +++ b/components/applications/suggestions-provider.tsx @@ -0,0 +1,24 @@ +"use client"; + +import { createContext, useContext } from "react"; +import { DEFAULT_SUGGESTIONS, type Suggestions } from "@/lib/suggestions"; + +const SuggestionsContext = createContext(DEFAULT_SUGGESTIONS); + +/** + * Makes the user's past entries available to the application form no matter + * where the dialog is opened from (page header, row actions, cards). + */ +export function SuggestionsProvider({ + suggestions, + children, +}: { + suggestions: Suggestions; + children: React.ReactNode; +}) { + return {children}; +} + +export function useSuggestions() { + return useContext(SuggestionsContext); +} diff --git a/components/ui/autocomplete.tsx b/components/ui/autocomplete.tsx new file mode 100644 index 0000000..111400b --- /dev/null +++ b/components/ui/autocomplete.tsx @@ -0,0 +1,119 @@ +"use client"; + +import * as React from "react"; +import { Autocomplete as AutocompletePrimitive } from "@base-ui/react/autocomplete"; + +import { cn } from "@/lib/utils"; + +const Autocomplete = AutocompletePrimitive.Root; + +function AutocompleteInput({ className, ...props }: AutocompletePrimitive.Input.Props) { + return ( + + ); +} + +function AutocompleteContent({ + className, + children, + side = "bottom", + sideOffset = 4, + align = "start", + alignOffset = 0, + ...props +}: AutocompletePrimitive.Popup.Props & + Pick) { + return ( + + + + {children} + + + + ); +} + +function AutocompleteList({ className, ...props }: AutocompletePrimitive.List.Props) { + return ( + + ); +} + +function AutocompleteItem({ className, ...props }: AutocompletePrimitive.Item.Props) { + return ( + + ); +} + +function AutocompleteEmpty({ className, ...props }: AutocompletePrimitive.Empty.Props) { + return ( + + ); +} + +function AutocompleteGroup({ className, ...props }: AutocompletePrimitive.Group.Props) { + return ( + + ); +} + +function AutocompleteGroupLabel({ className, ...props }: AutocompletePrimitive.GroupLabel.Props) { + return ( + + ); +} + +export { + Autocomplete, + AutocompleteContent, + AutocompleteEmpty, + AutocompleteGroup, + AutocompleteGroupLabel, + AutocompleteInput, + AutocompleteItem, + AutocompleteList, +}; diff --git a/lib/suggestions.ts b/lib/suggestions.ts new file mode 100644 index 0000000..add3860 --- /dev/null +++ b/lib/suggestions.ts @@ -0,0 +1,118 @@ +import type { Application } from "@/lib/applications"; + +/** Starter values shown before a user has any history of their own. */ +export const ROLE_SUGGESTIONS = [ + "Software Engineer", + "Senior Software Engineer", + "Staff Software Engineer", + "Frontend Engineer", + "Backend Engineer", + "Full Stack Engineer", + "Mobile Engineer", + "iOS Engineer", + "Android Engineer", + "DevOps Engineer", + "Site Reliability Engineer", + "Platform Engineer", + "Security Engineer", + "QA Engineer", + "Data Engineer", + "Data Scientist", + "Data Analyst", + "Machine Learning Engineer", + "AI Engineer", + "Engineering Manager", + "Product Manager", + "Project Manager", + "Product Designer", + "UX Designer", + "UI Designer", + "Technical Writer", + "Solutions Engineer", + "Sales Engineer", + "Customer Success Manager", + "Business Analyst", + "Software Engineering Intern", + "Junior Software Engineer", +] as const; + +export const LOCATION_SUGGESTIONS = [ + "Remote", + "Hybrid", + "Remote (US)", + "New York, NY", + "San Francisco, CA", + "San Jose, CA", + "Los Angeles, CA", + "San Diego, CA", + "Seattle, WA", + "Austin, TX", + "Dallas, TX", + "Houston, TX", + "Chicago, IL", + "Boston, MA", + "Denver, CO", + "Atlanta, GA", + "Miami, FL", + "Washington, DC", + "Philadelphia, PA", + "Phoenix, AZ", + "Portland, OR", + "Salt Lake City, UT", + "Raleigh, NC", + "Charlotte, NC", + "Minneapolis, MN", + "Detroit, MI", + "Toronto, ON", + "Vancouver, BC", + "London, UK", + "Berlin, Germany", + "Amsterdam, Netherlands", + "Dublin, Ireland", + "Bangalore, India", + "Singapore", + "Sydney, Australia", +] as const; + +export type Suggestions = { + roles: string[]; + companies: string[]; + locations: string[]; +}; + +export const DEFAULT_SUGGESTIONS: Suggestions = { + roles: [...ROLE_SUGGESTIONS], + companies: [], + locations: [...LOCATION_SUGGESTIONS], +}; + +/** + * Dedupes case-insensitively and keeps the first spelling seen, so values + * the user already typed win over the starter list. + */ +function unique(values: readonly string[]) { + const seen = new Set(); + const out: string[] = []; + for (const raw of values) { + const value = raw.trim(); + if (!value) continue; + const key = value.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + out.push(value); + } + return out; +} + +/** + * Builds the suggestion lists for the application form. `applications` is + * expected newest first, so the user's most recent entries sort to the top, + * followed by the starter values. + */ +export function buildSuggestions(applications: readonly Application[]): Suggestions { + return { + roles: unique([...applications.map((a) => a.role), ...ROLE_SUGGESTIONS]), + companies: unique(applications.map((a) => a.company_name)), + locations: unique([...applications.map((a) => a.location), ...LOCATION_SUGGESTIONS]), + }; +}