From fd085ec193a30d3826e57343032c09767d21474e Mon Sep 17 00:00:00 2001 From: Tiago Yamamoto Date: Sun, 22 Feb 2026 12:39:46 -0600 Subject: [PATCH] feat(jobs/new): location autocomplete using /api/v1/locations/search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace free-text location input with debounced autocomplete - On country select, fetch its numeric ID from /api/v1/locations/countries and use it to scope the search results to that country - Typing ≥2 chars in location field triggers GET /api/v1/locations/search with 350ms debounce; results show city (blue) and state (green) badges - On result selection, stores cityId + regionId and sets the display label to "Name, Region" format; IDs are included in the job creation payload - Spinner shown while searching; dropdown closes on outside click / select - CEP search button preserved alongside the autocomplete Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/app/jobs/new/page.tsx | 135 +++++++++++++++++++++++++++-- 1 file changed, 129 insertions(+), 6 deletions(-) diff --git a/frontend/src/app/jobs/new/page.tsx b/frontend/src/app/jobs/new/page.tsx index f80d83c..13a8f3e 100644 --- a/frontend/src/app/jobs/new/page.tsx +++ b/frontend/src/app/jobs/new/page.tsx @@ -1,7 +1,7 @@ "use client"; import Image from "next/image"; -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { Search } from "lucide-react"; import { toast } from "sonner"; @@ -22,6 +22,21 @@ type SalaryMode = "range" | "fixed"; type ApplicationChannel = "email" | "url" | "phone"; +type LocationResult = { + id: number; + name: string; + type: "city" | "state"; + country_id: number; + state_id?: number; + region_name?: string; +}; + +type ApiCountry = { + id: number; + name: string; + iso2: string; +}; + const cleanDigits = (value: string) => value.replace(/\D/g, ""); const isValidCNPJ = (value: string) => { @@ -626,11 +641,70 @@ export default function PostJobPage() { const [paymentMethod, setPaymentMethod] = useState(""); const [descriptionLanguageTouched, setDescriptionLanguageTouched] = useState(false); + // Location autocomplete + const [apiCountries, setApiCountries] = useState([]); + const [locationIds, setLocationIds] = useState<{ cityId: number | null; regionId: number | null }>({ cityId: null, regionId: null }); + const [locationResults, setLocationResults] = useState([]); + const [locationSearching, setLocationSearching] = useState(false); + const [showLocationDropdown, setShowLocationDropdown] = useState(false); + const locationRef = useRef(null); + useEffect(() => { if (descriptionLanguageTouched) return; setJob((prev) => ({ ...prev, descriptionLanguage: locale })); }, [descriptionLanguageTouched, locale]); + // Fetch countries from API for location search (numeric IDs needed) + useEffect(() => { + const apiBase = process.env.NEXT_PUBLIC_API_URL || ""; + fetch(`${apiBase}/api/v1/locations/countries`) + .then((r) => r.json()) + .then((data: ApiCountry[]) => setApiCountries(data)) + .catch(() => {}); + }, []); + + // Derive numeric country ID from selected ISO2 code + const selectedCountryId = useMemo( + () => apiCountries.find((c) => c.iso2 === job.country)?.id ?? null, + [apiCountries, job.country] + ); + + // Debounced location search + const searchLocation = useCallback((query: string, countryId: number) => { + const apiBase = process.env.NEXT_PUBLIC_API_URL || ""; + setLocationSearching(true); + fetch(`${apiBase}/api/v1/locations/search?q=${encodeURIComponent(query)}&country_id=${countryId}`) + .then((r) => r.json()) + .then((data) => { + setLocationResults(Array.isArray(data) ? data : []); + setShowLocationDropdown(true); + }) + .catch(() => setLocationResults([])) + .finally(() => setLocationSearching(false)); + }, []); + + useEffect(() => { + const query = job.location.trim(); + if (query.length < 2 || !selectedCountryId) { + setLocationResults([]); + setShowLocationDropdown(false); + return; + } + const timer = setTimeout(() => searchLocation(query, selectedCountryId), 350); + return () => clearTimeout(timer); + }, [job.location, selectedCountryId, searchLocation]); + + // Close dropdown on outside click + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (locationRef.current && !locationRef.current.contains(e.target as Node)) { + setShowLocationDropdown(false); + } + }; + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, []); + const currentPrice = useMemo(() => { const country = billing.billingCountry || job.country; return pricingByCountry[country] || { amount: "Consulte comercial", duration: "30 dias" }; @@ -763,6 +837,8 @@ export default function PostJobPage() { title: job.title, description: job.description, location: `${job.location}, ${job.country}`, + ...(locationIds.cityId && { cityId: locationIds.cityId }), + ...(locationIds.regionId && { regionId: locationIds.regionId }), salaryMin: salaryMin || null, salaryMax: salaryMax || null, salaryType: job.salaryPeriod, @@ -878,11 +954,58 @@ export default function PostJobPage() {
- setJob({ ...job, location: e.target.value })} - /> +
+ { + setJob({ ...job, location: e.target.value }); + setLocationIds({ cityId: null, regionId: null }); + }} + onFocus={() => { + if (locationResults.length > 0) setShowLocationDropdown(true); + }} + /> + {locationSearching && ( +
+
+
+ )} + {showLocationDropdown && locationResults.length > 0 && ( +
+ {locationResults.map((result) => ( + + ))} +
+ )} +

{c.labels.locationHint}