From fb79e987bb10b76d1b023b612ac0de1aa2e17a73 Mon Sep 17 00:00:00 2001 From: Tiago Yamamoto Date: Fri, 26 Dec 2025 15:18:16 -0300 Subject: [PATCH] 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 --- backend/.gitignore | 2 +- .../api/handlers/location_handlers.go | 113 ++++++++++++ .../internal/core/domain/entity/location.go | 45 +++++ .../postgres/location_repository.go | 144 +++++++++++++++ backend/internal/router/router.go | 10 + backend/internal/services/location_service.go | 32 ++++ .../029_expand_employment_types.sql | 19 ++ frontend/src/app/post-job/page.tsx | 164 ++++++++++++++--- frontend/src/components/location-picker.tsx | 173 ++++++++++++++++++ frontend/src/lib/api.ts | 50 +++++ 10 files changed, 724 insertions(+), 28 deletions(-) create mode 100644 backend/internal/api/handlers/location_handlers.go create mode 100644 backend/internal/core/domain/entity/location.go create mode 100644 backend/internal/infrastructure/persistence/postgres/location_repository.go create mode 100644 backend/internal/services/location_service.go create mode 100644 backend/migrations/029_expand_employment_types.sql create mode 100644 frontend/src/components/location-picker.tsx diff --git a/backend/.gitignore b/backend/.gitignore index ecf545a..cbb042e 100755 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -21,4 +21,4 @@ go.work .env # Compiled binary -api +/api diff --git a/backend/internal/api/handlers/location_handlers.go b/backend/internal/api/handlers/location_handlers.go new file mode 100644 index 0000000..7c836fd --- /dev/null +++ b/backend/internal/api/handlers/location_handlers.go @@ -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) +} diff --git a/backend/internal/core/domain/entity/location.go b/backend/internal/core/domain/entity/location.go new file mode 100644 index 0000000..7870834 --- /dev/null +++ b/backend/internal/core/domain/entity/location.go @@ -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 +} diff --git a/backend/internal/infrastructure/persistence/postgres/location_repository.go b/backend/internal/infrastructure/persistence/postgres/location_repository.go new file mode 100644 index 0000000..ff9a0ce --- /dev/null +++ b/backend/internal/infrastructure/persistence/postgres/location_repository.go @@ -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, ¤cy, &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, ®ionName); 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 +} diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 05044bc..35e9bf3 100755 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -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))) diff --git a/backend/internal/services/location_service.go b/backend/internal/services/location_service.go new file mode 100644 index 0000000..7bc5721 --- /dev/null +++ b/backend/internal/services/location_service.go @@ -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) +} diff --git a/backend/migrations/029_expand_employment_types.sql b/backend/migrations/029_expand_employment_types.sql new file mode 100644 index 0000000..3a96597 --- /dev/null +++ b/backend/migrations/029_expand_employment_types.sql @@ -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'; diff --git a/frontend/src/app/post-job/page.tsx b/frontend/src/app/post-job/page.tsx index 8a68dce..9ac624e 100644 --- a/frontend/src/app/post-job/page.tsx +++ b/frontend/src/app/post-job/page.tsx @@ -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() { /> + + {/* Password Field */}
setCompany({ ...company, password: e.target.value })} placeholder="••••••••" - className="pl-10" + className="pl-10 pr-10" // Extra padding for eye icon /> +
+ + {/* Confirm Password Field */} +
+ +
+ + setCompany({ ...company, confirmPassword: e.target.value })} + placeholder="••••••••" + className="pl-10 pr-10" + /> + +
+
+ + {/* Phone Field with DDI */}
-
- - setCompany({ ...company, phone: e.target.value })} - placeholder="(11) 99999-9999" - className="pl-10" - /> +
+
+ +
+
+ + setCompany({ ...company, phone: e.target.value })} + placeholder="11 99999-9999" + className="pl-10" + /> +
+

+ Selecione o código do país e digite o número com DDD. +

+ @@ -237,14 +348,11 @@ export default function PostJobPage() { />
- -
- - + {/* Includes Label and Layout internally. */} + setJob({ ...job, location: e.target.value })} - placeholder="São Paulo, SP (ou Remoto)" - className="pl-10" + onChange={(val) => setJob({ ...job, location: val })} />
@@ -274,12 +382,14 @@ export default function PostJobPage() {
@@ -315,7 +425,7 @@ export default function PostJobPage() {

Nome: {company.name}

Email: {company.email}

- {company.phone &&

Telefone: {company.phone}

} + {company.phone &&

Telefone: {company.ddi} {company.phone}

}

diff --git a/frontend/src/components/location-picker.tsx b/frontend/src/components/location-picker.tsx new file mode 100644 index 0000000..8d945cc --- /dev/null +++ b/frontend/src/components/location-picker.tsx @@ -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([]); + const [selectedCountry, setSelectedCountry] = useState(""); // ID as string + + // Search + const [query, setQuery] = useState(""); + const [results, setResults] = useState([]); + const [loading, setLoading] = useState(false); + const [showResults, setShowResults] = useState(false); + + const wrapperRef = useRef(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 ( +
+
+ {/* 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) */} +
+ +
+ + { + 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 && ( +
+ +
+ )} +
+ + {/* Results Dropdown */} + {showResults && results.length > 0 && ( +
+ {results.map((item) => ( + + ))} +
+ )} +
+ + {/* Country Select (1/3 width) */} +
+ + +
+
+
+ ); +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 9b4bc50..6ce9321 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -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("/api/v1/locations/countries"), + listStates: (countryId: number | string) => apiRequest(`/api/v1/locations/countries/${countryId}/states`), + listCities: (stateId: number | string) => apiRequest(`/api/v1/locations/states/${stateId}/cities`), + search: (query: string, countryId: number | string) => + apiRequest(`/api/v1/locations/search?q=${encodeURIComponent(query)}&country_id=${countryId}`), +}; +