diff --git a/frontend/src/app/dashboard/candidato/perfil/page.tsx b/frontend/src/app/dashboard/candidato/perfil/page.tsx index 4f47945..6a72bcf 100644 --- a/frontend/src/app/dashboard/candidato/perfil/page.tsx +++ b/frontend/src/app/dashboard/candidato/perfil/page.tsx @@ -30,6 +30,7 @@ import { Label } from "@/components/ui/label"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Badge } from "@/components/ui/badge"; import { Separator } from "@/components/ui/separator"; +import { LocationAutocomplete } from "@/components/location-autocomplete"; import { Navbar } from "@/components/navbar"; import { Footer } from "@/components/footer"; diff --git a/frontend/src/app/dashboard/companies/new/page.tsx b/frontend/src/app/dashboard/companies/new/page.tsx index 9a31886..f812052 100644 --- a/frontend/src/app/dashboard/companies/new/page.tsx +++ b/frontend/src/app/dashboard/companies/new/page.tsx @@ -12,6 +12,7 @@ import { toast } from "sonner" import { useTranslation } from "@/lib/i18n" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import Link from "next/link" +import { LocationAutocomplete } from "@/components/location-autocomplete" const formatCNPJ = (value: string) => { return value @@ -257,13 +258,12 @@ export default function NewCompanyPage() { /> -
+
- setFormData({ ...formData, address: e.target.value })} - placeholder="Endereço completo" + onSelect={(label) => setFormData({ ...formData, address: label })} + placeholder="Buscar cidade ou estado..." />
diff --git a/frontend/src/app/dashboard/jobs/new/page.tsx b/frontend/src/app/dashboard/jobs/new/page.tsx index 387c6d1..e4dad69 100644 --- a/frontend/src/app/dashboard/jobs/new/page.tsx +++ b/frontend/src/app/dashboard/jobs/new/page.tsx @@ -40,6 +40,8 @@ type ApiCountry = { const DESCRIPTION_MIN_LENGTH = 20 +import { LocationAutocomplete } from "@/components/location-autocomplete" + export default function DashboardNewJobPage() { const router = useRouter() const { t } = useTranslation() @@ -52,7 +54,7 @@ export default function DashboardNewJobPage() { title: "", description: "", location: "", - country: "", + country: "BR", employmentType: "", workMode: "", workingHours: "", @@ -71,12 +73,7 @@ export default function DashboardNewJobPage() { status: "draft", }) - 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(() => { adminCompaniesApi.list(undefined, 1, 100) @@ -85,53 +82,6 @@ export default function DashboardNewJobPage() { .finally(() => setLoadingCompanies(false)) }, [t]) - 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(() => { }) - }, []) - - const selectedCountryId = useMemo( - () => apiCountries.find((c) => c.iso2 === formData.country)?.id ?? null, - [apiCountries, formData.country] - ) - - 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 = formData.location.trim() - if (query.length < 2 || !selectedCountryId) { - setLocationResults([]) - setShowLocationDropdown(false) - return - } - const timer = setTimeout(() => searchLocation(query, selectedCountryId), 350) - return () => clearTimeout(timer) - }, [formData.location, selectedCountryId, searchLocation]) - - 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 set = (field: string, value: string | boolean) => setFormData((prev) => ({ ...prev, [field]: value })) @@ -335,61 +285,15 @@ export default function DashboardNewJobPage() {
-
- { - set("location", e.target.value) - setLocationIds({ cityId: null, regionId: null }) - }} - onFocus={() => { - if (locationResults.length > 0) setShowLocationDropdown(true) - }} - /> - {locationSearching && ( -
-
-
- )} - {showLocationDropdown && locationResults.length > 0 && ( -
- {locationResults.map((result) => ( - - ))} -
- )} -
+ { + set("location", label) + setLocationIds({ cityId, regionId }) + }} + placeholder={t("admin.jobs.create.placeholders.locationSearch")} + />
diff --git a/frontend/src/components/home-search.tsx b/frontend/src/components/home-search.tsx index 28c00e7..dccdddf 100644 --- a/frontend/src/components/home-search.tsx +++ b/frontend/src/components/home-search.tsx @@ -7,6 +7,7 @@ import { Input } from "@/components/ui/input" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { useRouter } from "next/navigation" import { useTranslation } from "@/lib/i18n" +import { LocationAutocomplete } from "@/components/location-autocomplete" export function HomeSearch() { const router = useRouter() @@ -84,14 +85,12 @@ export function HomeSearch() { {/* Location */}
-
- setLocation(e.target.value)} - /> -
+ setLocation(label)} + className="h-12 bg-gray-50 border-gray-200" + placeholder={t("home.search.location")} + />
{/* Salary */} diff --git a/frontend/src/components/location-autocomplete.tsx b/frontend/src/components/location-autocomplete.tsx new file mode 100644 index 0000000..8c456c2 --- /dev/null +++ b/frontend/src/components/location-autocomplete.tsx @@ -0,0 +1,171 @@ +"use client" + +import * as React from "react" +import { Check, ChevronsUpDown, Loader2, MapPin } from "lucide-react" +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover" +import { useTranslation } from "@/lib/i18n" + +export type LocationResult = { + id: number + name: string + type: "city" | "state" + country_id: number + state_id?: number + region_name?: string +} + +interface LocationAutocompleteProps { + value: string + onSelect: (location: string, cityId: number | null, stateId: number | null) => void + placeholder?: string + disabled?: boolean + className?: string + countryIso?: string +} + +export function LocationAutocomplete({ + value, + onSelect, + placeholder, + disabled = false, + className, + countryIso = "BR" +}: LocationAutocompleteProps) { + const { t } = useTranslation() + const [open, setOpen] = React.useState(false) + const [loading, setLoading] = React.useState(false) + const [results, setResults] = React.useState([]) + const [search, setSearch] = React.useState("") + + const fetchLocations = React.useCallback(async (query: string) => { + if (query.length < 2) return + + setLoading(true) + try { + const apiBase = process.env.NEXT_PUBLIC_API_URL || "" + const response = await fetch(`${apiBase}/api/v1/locations/search?q=${encodeURIComponent(query)}&country_iso=${countryIso}`) + const data = await response.json() + setResults(Array.isArray(data) ? data : []) + } catch (error) { + console.error("Error fetching locations:", error) + setResults([]) + } finally { + setLoading(false) + } + }, [countryIso]) + + React.useEffect(() => { + const timer = setTimeout(() => { + if (search) fetchLocations(search) + }, 300) + return () => clearTimeout(timer) + }, [search, fetchLocations]) + + return ( + + + + + + + + + {loading && ( +
+ +
+ )} + {!loading && results.length === 0 && search.length >= 2 && ( + {t("common.location.empty", { defaultValue: "Nenhuma localização encontrada." })} + )} + {!loading && search.length < 2 && ( +
+ Digite pelo menos 2 caracteres para buscar +
+ )} + + {results.map((result) => { + const label = result.region_name + ? `${result.name}, ${result.region_name}` + : result.name + + return ( + { + onSelect( + label, + result.type === "city" ? result.id : null, + result.type === "state" ? result.id : (result.state_id || null) + ) + setOpen(false) + }} + > + +
+ {result.name} + {result.region_name && ( + {result.region_name} + )} +
+ + {result.type} + +
+ ) + })} +
+
+
+
+
+ ) +} + +function Badge({ className, variant, ...props }: any) { + return ( +
+ ) +} diff --git a/frontend/src/lib/i18n/pt.json b/frontend/src/lib/i18n/pt.json new file mode 100644 index 0000000..39bd495 --- /dev/null +++ b/frontend/src/lib/i18n/pt.json @@ -0,0 +1,13 @@ +{ + "admin.notifications.push.success": "Notificação push enviada com sucesso", + "admin.notifications.push.error": "Falha ao enviar notificação push", + "admin.notifications.messaging.connected": "Conectado ao serviço de mensageria", + "admin.notifications.messaging.error": "Falha ao conectar ao serviço de mensageria", + "common": { + "location": { + "select": "Selecionar localização...", + "search": "Buscar cidade ou estado...", + "empty": "Nenhuma localização encontrada." + } + } +}