feat: add location selector and contract types

Backend:
- Created LocationHandler, LocationService, LocationRepository
- Added endpoints: GET /api/v1/locations/countries, states, cities, search
- Added migration 029_expand_employment_types.sql with new contract types (permanent, training, temporary, voluntary)
- Fixed .gitignore to allow internal/api folder

Frontend:
- Created LocationPicker component with country dropdown and city/state autocomplete search
- Integrated LocationPicker into PostJobPage
- Updated contract type options in job form (Permanent, Contract, Training, Temporary, Voluntary)
- Added locationsApi with search functionality to api.ts
This commit is contained in:
Tiago Yamamoto 2025-12-26 15:18:16 -03:00
parent 818edf2575
commit fb79e987bb
10 changed files with 724 additions and 28 deletions

2
backend/.gitignore vendored
View file

@ -21,4 +21,4 @@ go.work
.env
# Compiled binary
api
/api

View file

@ -0,0 +1,113 @@
package handlers
import (
"encoding/json"
"net/http"
"strconv"
"github.com/rede5/gohorsejobs/backend/internal/services"
)
type LocationHandlers struct {
service *services.LocationService
}
func NewLocationHandlers(service *services.LocationService) *LocationHandlers {
return &LocationHandlers{service: service}
}
// ListCountries returns all countries
func (h *LocationHandlers) ListCountries(w http.ResponseWriter, r *http.Request) {
countries, err := h.service.ListCountries(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(countries)
}
// ListStatesByCountry returns states for a given country ID
// Expects path param {id}
func (h *LocationHandlers) ListStatesByCountry(w http.ResponseWriter, r *http.Request) {
idStr := r.PathValue("id")
if idStr == "" {
http.Error(w, "Country ID is required", http.StatusBadRequest)
return
}
countryID, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
http.Error(w, "Invalid Country ID", http.StatusBadRequest)
return
}
states, err := h.service.ListStates(r.Context(), countryID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(states)
}
// ListCitiesByState returns cities for a given state ID
// Expects path param {id}
func (h *LocationHandlers) ListCitiesByState(w http.ResponseWriter, r *http.Request) {
idStr := r.PathValue("id")
if idStr == "" {
http.Error(w, "State ID is required", http.StatusBadRequest)
return
}
stateID, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
http.Error(w, "Invalid State ID", http.StatusBadRequest)
return
}
cities, err := h.service.ListCities(r.Context(), stateID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(cities)
}
// SearchLocations searches for cities or states in a country
// Query params: q (query), country_id (required)
func (h *LocationHandlers) SearchLocations(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query().Get("q")
countryIDStr := r.URL.Query().Get("country_id")
if q == "" || len(q) < 2 {
// Return empty list if query too short to avoid massive scan (though DB handles it, better UX)
w.Header().Set("Content-Type", "application/json")
w.Write([]byte("[]"))
return
}
if countryIDStr == "" {
http.Error(w, "country_id is required", http.StatusBadRequest)
return
}
countryID, err := strconv.ParseInt(countryIDStr, 10, 64)
if err != nil {
http.Error(w, "Invalid country_id", http.StatusBadRequest)
return
}
results, err := h.service.Search(r.Context(), q, countryID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(results)
}

View file

@ -0,0 +1,45 @@
package entity
import "time"
type Country struct {
ID int64 `json:"id" db:"id"`
Name string `json:"name" db:"name"`
ISO2 string `json:"iso2" db:"iso2"`
ISO3 string `json:"iso3" db:"iso3"`
PhoneCode string `json:"phone_code" db:"phonecode"`
Currency string `json:"currency" db:"currency"`
Emoji string `json:"emoji" db:"emoji"`
EmojiU string `json:"emojiU" db:"emoji_u"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
type State struct {
ID int64 `json:"id" db:"id"`
Name string `json:"name" db:"name"`
CountryID int64 `json:"country_id" db:"country_id"`
CountryCode string `json:"country_code" db:"country_code"`
ISO2 string `json:"iso2" db:"iso2"`
Type string `json:"type" db:"type"` // State, Province, etc.
Latitude float64 `json:"latitude" db:"latitude"`
Longitude float64 `json:"longitude" db:"longitude"`
}
type City struct {
ID int64 `json:"id" db:"id"`
Name string `json:"name" db:"name"`
StateID int64 `json:"state_id" db:"state_id"`
CountryID int64 `json:"country_id" db:"country_id"`
Latitude float64 `json:"latitude" db:"latitude"`
Longitude float64 `json:"longitude" db:"longitude"`
}
type LocationSearchResult struct {
ID int64 `json:"id"`
Name string `json:"name"`
Type string `json:"type"` // "state" or "city"
CountryID int64 `json:"country_id"`
StateID *int64 `json:"state_id,omitempty"`
RegionName string `json:"region_name,omitempty"` // e.g. State name if city
}

View file

@ -0,0 +1,144 @@
package postgres
import (
"context"
"database/sql"
"github.com/rede5/gohorsejobs/backend/internal/core/domain/entity"
)
type LocationRepository struct {
db *sql.DB
}
func NewLocationRepository(db *sql.DB) *LocationRepository {
return &LocationRepository{db: db}
}
func (r *LocationRepository) ListCountries(ctx context.Context) ([]*entity.Country, error) {
query := `SELECT id, name, iso2, iso3, phonecode, currency, emoji, emoji_u, created_at, updated_at FROM countries ORDER BY name ASC`
rows, err := r.db.QueryContext(ctx, query)
if err != nil {
return nil, err
}
defer rows.Close()
var countries []*entity.Country
for rows.Next() {
c := &entity.Country{}
// Scan columns. Be careful with NULL handling if fields are nullable, but schema says NOT NULL mostly.
// Check schema again. phonecode, etc can be null.
// For simplicity, we scan into sql.NullString or just string (and risk error if NULL).
// Migration 021 says:
// name VARCHAR(100) NOT NULL
// iso2 CHAR(2)
// phonecode VARCHAR(255)
// ...
// So nullable fields need handling.
var iso2, iso3, phonecode, currency, emoji, emojiU sql.NullString
if err := rows.Scan(&c.ID, &c.Name, &iso2, &iso3, &phonecode, &currency, &emoji, &emojiU, &c.CreatedAt, &c.UpdatedAt); err != nil {
return nil, err
}
c.ISO2 = iso2.String
c.ISO3 = iso3.String
c.PhoneCode = phonecode.String
c.Currency = currency.String
c.Emoji = emoji.String
c.EmojiU = emojiU.String
countries = append(countries, c)
}
return countries, nil
}
func (r *LocationRepository) ListStates(ctx context.Context, countryID int64) ([]*entity.State, error) {
query := `SELECT id, name, country_id, country_code, iso2, type, latitude, longitude FROM states WHERE country_id = $1 ORDER BY name ASC`
rows, err := r.db.QueryContext(ctx, query, countryID)
if err != nil {
return nil, err
}
defer rows.Close()
var states []*entity.State
for rows.Next() {
s := &entity.State{}
var iso2, typeStr sql.NullString
var lat, lng sql.NullFloat64
if err := rows.Scan(&s.ID, &s.Name, &s.CountryID, &s.CountryCode, &iso2, &typeStr, &lat, &lng); err != nil {
return nil, err
}
s.ISO2 = iso2.String
s.Type = typeStr.String
s.Latitude = lat.Float64
s.Longitude = lng.Float64
states = append(states, s)
}
return states, nil
}
func (r *LocationRepository) ListCities(ctx context.Context, stateID int64) ([]*entity.City, error) {
query := `SELECT id, name, state_id, country_id, latitude, longitude FROM cities WHERE state_id = $1 ORDER BY name ASC`
rows, err := r.db.QueryContext(ctx, query, stateID)
if err != nil {
return nil, err
}
defer rows.Close()
var cities []*entity.City
for rows.Next() {
c := &entity.City{}
// schema: latitude NOT NULL, longitude NOT NULL.
if err := rows.Scan(&c.ID, &c.Name, &c.StateID, &c.CountryID, &c.Latitude, &c.Longitude); err != nil {
return nil, err
}
cities = append(cities, c)
}
return cities, nil
}
func (r *LocationRepository) Search(ctx context.Context, query string, countryID int64) ([]*entity.LocationSearchResult, error) {
// Search Cities and States
// Simple LIKE query
q := "%" + query + "%"
sqlQuery := `
SELECT id, name, 'state' as type, country_id, NULL as state_id, '' as region_name
FROM states
WHERE country_id = $1 AND name ILIKE $2
UNION ALL
SELECT c.id, c.name, 'city' as type, c.country_id, c.state_id, s.name as region_name
FROM cities c
JOIN states s ON c.state_id = s.id
WHERE c.country_id = $1 AND c.name ILIKE $2
LIMIT 20
`
rows, err := r.db.QueryContext(ctx, sqlQuery, countryID, q)
if err != nil {
return nil, err
}
defer rows.Close()
var results []*entity.LocationSearchResult
for rows.Next() {
res := &entity.LocationSearchResult{}
var stateID sql.NullInt64
var regionName sql.NullString
if err := rows.Scan(&res.ID, &res.Name, &res.Type, &res.CountryID, &stateID, &regionName); err != nil {
return nil, err
}
if stateID.Valid {
id := stateID.Int64
res.StateID = &id
}
res.RegionName = regionName.String
results = append(results, res)
}
return results, nil
}

View file

@ -35,8 +35,10 @@ func NewRouter() http.Handler {
// --- CORE ARCHITECTURE INITIALIZATION ---
// Infrastructure
// Infrastructure
userRepo := postgres.NewUserRepository(database.DB)
companyRepo := postgres.NewCompanyRepository(database.DB)
locationRepo := postgres.NewLocationRepository(database.DB)
// Utils Services (Moved up for dependency injection)
credentialsService := services.NewCredentialsService(database.DB)
@ -45,6 +47,7 @@ func NewRouter() http.Handler {
fcmService := services.NewFCMService(credentialsService)
cloudflareService := services.NewCloudflareService(credentialsService)
emailService := services.NewEmailService(database.DB, credentialsService)
locationService := services.NewLocationService(locationRepo)
adminService := services.NewAdminService(database.DB)
@ -94,6 +97,7 @@ func NewRouter() http.Handler {
settingsHandler := apiHandlers.NewSettingsHandler(settingsService)
storageHandler := apiHandlers.NewStorageHandler(storageService)
adminHandlers := apiHandlers.NewAdminHandlers(adminService, auditService, jobService, cloudflareService)
locationHandlers := apiHandlers.NewLocationHandlers(locationService)
// Initialize Legacy Handlers
jobHandler := handlers.NewJobHandler(jobService)
@ -210,6 +214,12 @@ func NewRouter() http.Handler {
// Get Company by ID (Public)
mux.HandleFunc("GET /api/v1/companies/{id}", coreHandlers.GetCompanyByID)
// Location Routes (Public)
mux.HandleFunc("GET /api/v1/locations/countries", locationHandlers.ListCountries)
mux.HandleFunc("GET /api/v1/locations/countries/{id}/states", locationHandlers.ListStatesByCountry)
mux.HandleFunc("GET /api/v1/locations/states/{id}/cities", locationHandlers.ListCitiesByState)
mux.HandleFunc("GET /api/v1/locations/search", locationHandlers.SearchLocations)
// Notifications Route
mux.Handle("GET /api/v1/notifications", authMiddleware.HeaderAuthGuard(http.HandlerFunc(coreHandlers.ListNotifications)))
mux.Handle("POST /api/v1/tokens", authMiddleware.HeaderAuthGuard(http.HandlerFunc(coreHandlers.SaveFCMToken)))

View file

@ -0,0 +1,32 @@
package services
import (
"context"
"github.com/rede5/gohorsejobs/backend/internal/core/domain/entity"
"github.com/rede5/gohorsejobs/backend/internal/infrastructure/persistence/postgres"
)
type LocationService struct {
repo *postgres.LocationRepository
}
func NewLocationService(repo *postgres.LocationRepository) *LocationService {
return &LocationService{repo: repo}
}
func (s *LocationService) ListCountries(ctx context.Context) ([]*entity.Country, error) {
return s.repo.ListCountries(ctx)
}
func (s *LocationService) ListStates(ctx context.Context, countryID int64) ([]*entity.State, error) {
return s.repo.ListStates(ctx, countryID)
}
func (s *LocationService) ListCities(ctx context.Context, stateID int64) ([]*entity.City, error) {
return s.repo.ListCities(ctx, stateID)
}
func (s *LocationService) Search(ctx context.Context, query string, countryID int64) ([]*entity.LocationSearchResult, error) {
return s.repo.Search(ctx, query, countryID)
}

View file

@ -0,0 +1,19 @@
-- Migration: Update employment_type constraint with additional values
-- Description: Add permanent, training, temporary, voluntary to employment_type
-- Drop existing constraint and recreate with expanded values
ALTER TABLE jobs DROP CONSTRAINT IF EXISTS jobs_employment_type_check;
ALTER TABLE jobs ADD CONSTRAINT jobs_employment_type_check
CHECK (employment_type IN (
'full-time',
'part-time',
'dispatch',
'contract',
'permanent',
'training',
'temporary',
'voluntary'
));
COMMENT ON COLUMN jobs.employment_type IS 'Employment type: full-time, part-time, dispatch, contract, permanent, training, temporary, voluntary';

View file

@ -10,7 +10,32 @@ import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Building2, Briefcase, User, Mail, Lock, Phone, MapPin } from "lucide-react";
import {
Building2, Briefcase, Mail, Lock, Phone, MapPin,
Eye, EyeOff, Globe
} from "lucide-react";
import { LocationPicker } from "@/components/location-picker";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
// Common Country Codes
const COUNTRY_CODES = [
{ code: "+55", country: "Brasil (BR)" },
{ code: "+1", country: "Estados Unidos (US)" },
{ code: "+351", country: "Portugal (PT)" },
{ code: "+44", country: "Reino Unido (UK)" },
{ code: "+33", country: "França (FR)" },
{ code: "+49", country: "Alemanha (DE)" },
{ code: "+34", country: "Espanha (ES)" },
{ code: "+39", country: "Itália (IT)" },
{ code: "+81", country: "Japão (JP)" },
{ code: "+86", country: "China (CN)" },
{ code: "+91", country: "Índia (IN)" },
{ code: "+52", country: "México (MX)" },
{ code: "+54", country: "Argentina (AR)" },
{ code: "+57", country: "Colômbia (CO)" },
{ code: "+56", country: "Chile (CL)" },
{ code: "+51", country: "Peru (PE)" },
].sort((a, b) => a.country.localeCompare(b.country));
export default function PostJobPage() {
const router = useRouter();
@ -22,9 +47,14 @@ export default function PostJobPage() {
name: "",
email: "",
password: "",
confirmPassword: "",
ddi: "+55",
phone: "",
});
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
// Job data
const [job, setJob] = useState({
title: "",
@ -32,16 +62,35 @@ export default function PostJobPage() {
location: "",
salaryMin: "",
salaryMax: "",
employmentType: "full-time",
employmentType: "",
workMode: "remote",
});
const formatPhoneForDisplay = (value: string) => {
// Simple formatting to just allow numbers and basic separators if needed
// For now, just pass through but maybe restrict chars?
return value.replace(/[^\d\s-]/g, "");
};
const handleSubmit = async () => {
if (!company.name || !company.email || !company.password) {
toast.error("Preencha os dados da empresa");
toast.error("Preencha os dados obrigatórios da empresa");
setStep(1);
return;
}
if (company.password !== company.confirmPassword) {
toast.error("As senhas não coincidem");
setStep(1);
return;
}
if (company.password.length < 8) {
toast.error("A senha deve ter pelo menos 8 caracteres");
setStep(1);
return;
}
if (!job.title || !job.description) {
toast.error("Preencha os dados da vaga");
setStep(2);
@ -52,6 +101,10 @@ export default function PostJobPage() {
try {
const apiBase = process.env.NEXT_PUBLIC_API_URL || "";
// Format phone: DDI + Phone (digits only)
const cleanPhone = company.phone.replace(/\D/g, "");
const finalPhone = cleanPhone ? `${company.ddi}${cleanPhone}` : "";
// 1. Register Company (creates user + company)
const registerRes = await fetch(`${apiBase}/api/v1/auth/register/company`, {
method: "POST",
@ -60,7 +113,7 @@ export default function PostJobPage() {
companyName: company.name,
email: company.email,
password: company.password,
phone: company.phone,
phone: finalPhone,
}),
});
@ -181,31 +234,89 @@ export default function PostJobPage() {
/>
</div>
</div>
{/* Password Field */}
<div>
<Label>Senha *</Label>
<div className="relative">
<Lock className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input
type="password"
type={showPassword ? "text" : "password"}
value={company.password}
onChange={(e) => setCompany({ ...company, password: e.target.value })}
placeholder="••••••••"
className="pl-10"
className="pl-10 pr-10" // Extra padding for eye icon
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-3 text-muted-foreground hover:text-foreground focus:outline-none"
>
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
</div>
{/* Confirm Password Field */}
<div>
<Label>Confirmar Senha *</Label>
<div className="relative">
<Lock className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input
type={showConfirmPassword ? "text" : "password"}
value={company.confirmPassword}
onChange={(e) => setCompany({ ...company, confirmPassword: e.target.value })}
placeholder="••••••••"
className="pl-10 pr-10"
/>
<button
type="button"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
className="absolute right-3 top-3 text-muted-foreground hover:text-foreground focus:outline-none"
>
{showConfirmPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
</div>
{/* Phone Field with DDI */}
<div>
<Label>Telefone</Label>
<div className="relative">
<Phone className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input
value={company.phone}
onChange={(e) => setCompany({ ...company, phone: e.target.value })}
placeholder="(11) 99999-9999"
className="pl-10"
/>
<div className="flex gap-2">
<div className="w-[140px]">
<Select value={company.ddi} onValueChange={(val) => setCompany({ ...company, ddi: val })}>
<SelectTrigger className="pl-9 relative">
<Globe className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<SelectValue placeholder="DDI" />
</SelectTrigger>
<SelectContent>
{COUNTRY_CODES.map((item) => (
<SelectItem key={item.country} value={item.code}>
<span className="flex items-center justify-between w-full min-w-[80px]">
<span>{item.code}</span>
<span className="text-muted-foreground ml-2 text-xs truncate max-w-[100px]">{item.country}</span>
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="relative flex-1">
<Phone className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input
type="tel"
value={formatPhoneForDisplay(company.phone)}
onChange={(e) => setCompany({ ...company, phone: e.target.value })}
placeholder="11 99999-9999"
className="pl-10"
/>
</div>
</div>
<p className="text-xs text-muted-foreground mt-1 ml-1">
Selecione o código do país e digite o número com DDD.
</p>
</div>
<Button onClick={() => setStep(2)} className="w-full">
Próximo: Dados da Vaga
</Button>
@ -237,14 +348,11 @@ export default function PostJobPage() {
/>
</div>
<div>
<Label>Localização</Label>
<div className="relative">
<MapPin className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input
<div>
{/* Includes Label and Layout internally. */}
<LocationPicker
value={job.location}
onChange={(e) => setJob({ ...job, location: e.target.value })}
placeholder="São Paulo, SP (ou Remoto)"
className="pl-10"
onChange={(val) => setJob({ ...job, location: val })}
/>
</div>
</div>
@ -274,12 +382,14 @@ export default function PostJobPage() {
<select
value={job.employmentType}
onChange={(e) => setJob({ ...job, employmentType: e.target.value })}
className="w-full px-3 py-2 border rounded-lg"
className="w-full px-3 py-2 border rounded-lg bg-background"
>
<option value="full-time">CLT</option>
<option value="part-time">Meio Período</option>
<option value="contract">PJ</option>
<option value="internship">Estágio</option>
<option value="">Qualquer</option>
<option value="permanent">Permanente</option>
<option value="contract">Contrato (PJ)</option>
<option value="training">Estágio/Trainee</option>
<option value="temporary">Temporário</option>
<option value="voluntary">Voluntário</option>
</select>
</div>
<div>
@ -315,7 +425,7 @@ export default function PostJobPage() {
</h3>
<p><strong>Nome:</strong> {company.name}</p>
<p><strong>Email:</strong> {company.email}</p>
{company.phone && <p><strong>Telefone:</strong> {company.phone}</p>}
{company.phone && <p><strong>Telefone:</strong> {company.ddi} {company.phone}</p>}
</div>
<div className="bg-muted/50 rounded-lg p-4">
<h3 className="font-semibold mb-2 flex items-center gap-2">

View file

@ -0,0 +1,173 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { locationsApi, Country, LocationSearchResult } from "@/lib/api";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { MapPin, Loader2, Globe } from "lucide-react";
interface LocationPickerProps {
value: string; // The final string "City, State, Country"
onChange: (value: string) => void;
}
export function LocationPicker({ value, onChange }: LocationPickerProps) {
const [countries, setCountries] = useState<Country[]>([]);
const [selectedCountry, setSelectedCountry] = useState<string>(""); // ID as string
// Search
const [query, setQuery] = useState("");
const [results, setResults] = useState<LocationSearchResult[]>([]);
const [loading, setLoading] = useState(false);
const [showResults, setShowResults] = useState(false);
const wrapperRef = useRef<HTMLDivElement>(null);
// Initial Load
useEffect(() => {
locationsApi.listCountries().then(res => {
setCountries(res);
// Default to Brazil if available (User is Brazilian based on context)
const br = res.find(c => c.iso2 === "BR");
if (br) setSelectedCountry(br.id.toString());
}).catch(console.error);
}, []);
// Helper to get country name
const getCountryName = (id: string) => countries.find(c => c.id.toString() === id)?.name || "";
// Debounce Search
useEffect(() => {
if (!selectedCountry || query.length < 2) {
setResults([]);
return;
}
const timeout = setTimeout(() => {
setLoading(true);
locationsApi.search(query, selectedCountry)
.then(setResults)
.catch(err => {
console.error("Search failed", err);
setResults([]);
})
.finally(() => setLoading(false));
}, 500);
return () => clearTimeout(timeout);
}, [query, selectedCountry]);
// Click Outside to close
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (wrapperRef.current && !wrapperRef.current.contains(event.target as Node)) {
setShowResults(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
const handleSelectResult = (item: LocationSearchResult) => {
const countryName = getCountryName(selectedCountry);
let finalString = "";
if (item.type === 'city') {
// City, State, Country
finalString = `${item.name}, ${item.region_name}, ${countryName}`;
} else {
// State, Country
finalString = `${item.name}, ${countryName}`;
}
onChange(finalString);
setQuery(item.name); // Updates visible input to just name or full string?
// User image shows "City or state" as input.
// Usually we show the full formatted string or just the name.
// Let's show full string to be clear.
setQuery(finalString);
setShowResults(false);
};
return (
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{/* Country Select (Right in design but logical Left in flow, design shows separate) */}
{/* User image: Left: City/State (Input with target icon), Right: Country (Dropdown) */}
{/* City/State Search (2/3 width) */}
<div className="md:col-span-2 relative" ref={wrapperRef}>
<Label className="mb-1.5 block">Localização da Vaga</Label>
<div className="relative">
<MapPin className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input
value={query}
onChange={(e) => {
setQuery(e.target.value);
setShowResults(true);
// If user types manually conform to standard?
// We can allow free text, but search helps.
onChange(e.target.value);
}}
onFocus={() => setShowResults(true)}
placeholder={selectedCountry ? "Busque cidade ou estado..." : "Selecione o país primeiro"}
className="pl-10"
disabled={!selectedCountry}
/>
{loading && (
<div className="absolute right-3 top-3">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
)}
</div>
{/* Results Dropdown */}
{showResults && results.length > 0 && (
<div className="absolute z-10 w-full mt-1 bg-background border rounded-md shadow-lg max-h-60 overflow-auto">
{results.map((item) => (
<button
key={`${item.type}-${item.id}`}
onClick={() => handleSelectResult(item)}
className="w-full text-left px-4 py-2 hover:bg-muted text-sm flex flex-col"
>
<span className="font-medium">{item.name}</span>
<span className="text-xs text-muted-foreground">
{item.type === 'city' ? `${item.region_name}, Cidade` : `Estado/Região`}
</span>
</button>
))}
</div>
)}
</div>
{/* Country Select (1/3 width) */}
<div>
<Label className="mb-1.5 block">País</Label>
<Select
value={selectedCountry}
onValueChange={(val) => {
setSelectedCountry(val);
setQuery(""); // Clear location on country change
onChange("");
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Selecione" />
</SelectTrigger>
<SelectContent>
{countries.map((c) => (
<SelectItem key={c.id} value={c.id.toString()}>
<span className="flex items-center gap-2">
<span>{c.emoji}</span>
<span>{c.name}</span>
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
);
}

View file

@ -748,3 +748,53 @@ export const emailSettingsApi = {
body: JSON.stringify(data),
}),
};
// --- Location API ---
export interface Country {
id: number;
name: string;
iso2: string;
iso3: string;
phone_code: string;
currency: string;
emoji: string;
emojiU: string;
}
export interface State {
id: number;
name: string;
country_id: number;
country_code: string;
iso2: string;
type: string;
latitude: number;
longitude: number;
}
export interface City {
id: number;
name: string;
state_id: number;
country_id: number;
latitude: number;
longitude: number;
}
export interface LocationSearchResult {
id: number;
name: string;
type: 'state' | 'city';
country_id: number;
state_id?: number;
region_name?: string;
}
export const locationsApi = {
listCountries: () => apiRequest<Country[]>("/api/v1/locations/countries"),
listStates: (countryId: number | string) => apiRequest<State[]>(`/api/v1/locations/countries/${countryId}/states`),
listCities: (stateId: number | string) => apiRequest<City[]>(`/api/v1/locations/states/${stateId}/cities`),
search: (query: string, countryId: number | string) =>
apiRequest<LocationSearchResult[]>(`/api/v1/locations/search?q=${encodeURIComponent(query)}&country_id=${countryId}`),
};