Implementado
La guía se ejecutó: existen src/lib/data/geozones.ts, src/types/geozones.ts y los
componentes city-select / department-select en el checkout (commit 682b5b1,
"selector de ciudades"). Se conserva como contrato del endpoint GET /store/geozones.
Implementación de Selección de Ciudades en Storefront¶
Resumen¶
Esta guía documenta los pasos para implementar la selección de ciudades y departamentos en el formulario de dirección de envío del checkout, utilizando los datos de GeoZones del backend.
Endpoint del Backend¶
GET /store/geozones¶
Retorna la lista de departamentos y ciudades disponibles.
Response:
{
"departments": [
{ "code": "PYASU", "name": "Asunción" },
{ "code": "PY11", "name": "Central" },
{ "code": "PY10", "name": "Alto Paraná" }
],
"cities": [
{
"name": "Asunción",
"province_code": "PYASU",
"has_shipping": true,
"providers": {
"mc_available": true,
"mc_hours": 24,
"clx_available": false,
"clx_hours": null,
"aex_available": false,
"aex_hours": null
}
},
{
"name": "San Lorenzo",
"province_code": "PY11",
"has_shipping": true,
"providers": { ... }
}
]
}
Pasos de Implementación¶
Paso 1: Crear tipos TypeScript¶
Archivo: src/types/geozones.ts
export interface Department {
code: string
name: string
}
export interface CityProviders {
mc_available: boolean
mc_hours: number | null
clx_available: boolean
clx_hours: number | null
aex_available: boolean
aex_hours: number | null
}
export interface City {
name: string
province_code: string
has_shipping: boolean
providers: CityProviders
}
export interface GeoZonesResponse {
departments: Department[]
cities: City[]
}
Paso 2: Crear función de data fetching¶
Archivo: src/lib/data/geozones.ts
import { sdk } from "@lib/config"
import { GeoZonesResponse } from "@/types/geozones"
export async function getGeoZones(): Promise<GeoZonesResponse> {
const response = await sdk.client.fetch<GeoZonesResponse>("/store/geozones", {
method: "GET",
cache: "force-cache",
next: {
tags: ["geozones"],
},
})
return response
}
Paso 3: Crear componente DepartmentSelect¶
Archivo: src/modules/checkout/components/department-select/index.tsx
import { forwardRef, useMemo } from "react"
import { Department } from "@/types/geozones"
import NativeSelect, {
NativeSelectProps,
} from "@modules/common/components/native-select"
interface DepartmentSelectProps extends Omit<NativeSelectProps, "children"> {
departments: Department[] | undefined
}
const DepartmentSelect = forwardRef<HTMLSelectElement, DepartmentSelectProps>(
({ departments, placeholder = "Departamento", ...props }, ref) => {
const options = useMemo(() => {
return departments?.map((dept) => ({
value: dept.code,
label: dept.name,
}))
}, [departments])
return (
<NativeSelect ref={ref} placeholder={placeholder} {...props}>
{options?.map(({ value, label }) => (
<option key={value} value={value}>
{label}
</option>
))}
</NativeSelect>
)
}
)
DepartmentSelect.displayName = "DepartmentSelect"
export default DepartmentSelect
Paso 4: Crear componente CitySelect¶
Archivo: src/modules/checkout/components/city-select/index.tsx
import { forwardRef, useMemo } from "react"
import { City } from "@/types/geozones"
import NativeSelect, {
NativeSelectProps,
} from "@modules/common/components/native-select"
interface CitySelectProps extends Omit<NativeSelectProps, "children"> {
cities: City[] | undefined
selectedDepartment: string
}
const CitySelect = forwardRef<HTMLSelectElement, CitySelectProps>(
({ cities, selectedDepartment, placeholder = "Ciudad", ...props }, ref) => {
// Filtrar ciudades por departamento seleccionado
const filteredCities = useMemo(() => {
if (!selectedDepartment || !cities) return []
return cities
.filter((city) => city.province_code === selectedDepartment)
.sort((a, b) => a.name.localeCompare(b.name))
}, [cities, selectedDepartment])
return (
<NativeSelect
ref={ref}
placeholder={placeholder}
disabled={!selectedDepartment}
{...props}
>
{filteredCities.map((city) => (
<option key={city.name} value={city.name}>
{city.name}
{!city.has_shipping && " (sin envío)"}
</option>
))}
</NativeSelect>
)
}
)
CitySelect.displayName = "CitySelect"
export default CitySelect
Paso 5: Modificar ShippingAddress¶
Archivo: src/modules/checkout/components/shipping-address/index.tsx
5.1 Importaciones¶
import { useEffect, useState, useMemo } from "react"
import DepartmentSelect from "@modules/checkout/components/department-select"
import CitySelect from "@modules/checkout/components/city-select"
import { getGeoZones } from "@lib/data/geozones"
import { GeoZonesResponse } from "@/types/geozones"
5.2 Estado para GeoZones¶
Agregar dentro del componente:
const [geoZones, setGeoZones] = useState<GeoZonesResponse | null>(null)
const [selectedDepartment, setSelectedDepartment] = useState<string>("")
// Cargar geozones al montar
useEffect(() => {
getGeoZones().then(setGeoZones).catch(console.error)
}, [])
// Sincronizar departamento cuando cambia la dirección guardada
useEffect(() => {
if (formData["shipping_address.province"]) {
// Buscar el código del departamento basado en el nombre o código guardado
const dept = geoZones?.departments.find(
(d) =>
d.code === formData["shipping_address.province"] ||
d.name === formData["shipping_address.province"]
)
if (dept) {
setSelectedDepartment(dept.code)
}
}
}, [formData["shipping_address.province"], geoZones])
5.3 Handlers¶
const handleDepartmentChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const deptCode = e.target.value
setSelectedDepartment(deptCode)
// IMPORTANTE: Guardar el CÓDIGO del departamento en province
// Medusa usa este campo para hacer match con GeoZone.province_code
handleInputChange({
target: { name: "shipping_address.province", value: deptCode },
} as any)
// Limpiar la ciudad al cambiar departamento
handleInputChange({
target: { name: "shipping_address.city", value: "" },
} as any)
}
const handleCityChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
handleInputChange({
target: { name: "shipping_address.city", value: e.target.value },
} as any)
}
5.4 Reemplazar campos en el JSX¶
Cambiar los inputs de texto por los selects:
{/* ANTES: Campo de texto para ciudad */}
{/* <Input label="Ciudad" name="shipping_address.city" ... /> */}
{/* DESPUÉS: Selects de departamento y ciudad */}
<div className="grid grid-cols-2 gap-4">
<DepartmentSelect
departments={geoZones?.departments}
value={selectedDepartment}
onChange={handleDepartmentChange}
required
data-testid="department-select"
/>
<CitySelect
cities={geoZones?.cities}
selectedDepartment={selectedDepartment}
value={formData["shipping_address.city"]}
onChange={handleCityChange}
required
data-testid="city-select"
/>
</div>
{/* Campo de Barrio (antes era province, ahora es independiente) */}
<Input
label="Barrio"
name="shipping_address.address_2"
autoComplete="address-line2"
value={formData["shipping_address.address_2"]}
onChange={handleInputChange}
data-testid="neighborhood-input"
/>
Paso 6: Ajustar mapeo de campos¶
IMPORTANTE: El campo province de Medusa es usado internamente para hacer match con province_code de las GeoZones al filtrar shipping options. Por esto, province DEBE contener el código del departamento, no el nombre ni el barrio.
| Campo Medusa | Uso anterior (incorrecto) | Uso nuevo (correcto) |
|---|---|---|
province |
"Barrio" (texto libre) | Código del departamento (ej: "PY11") |
city |
Texto libre | Nombre de ciudad del select |
address_2 |
No usado | Barrio (texto libre) |
¿Por qué es crítico?
Medusa filtra las shipping options usando este código:
filters: {
address: {
province_code: cart.shipping_address?.province, // <-- Debe coincidir con GeoZone.province_code
city: cart.shipping_address?.city,
// ...
},
}
Si province contiene "Villa Morra" (un barrio) en lugar de "PY11" (código de Central), Medusa no encontrará ninguna GeoZone que coincida y no retornará shipping options.
Estructura de Archivos Final¶
src/
├── types/
│ └── geozones.ts # Tipos TypeScript
├── lib/
│ └── data/
│ └── geozones.ts # Función de fetch
└── modules/
└── checkout/
└── components/
├── department-select/
│ └── index.tsx # Select de departamentos
├── city-select/
│ └── index.tsx # Select de ciudades
└── shipping-address/
└── index.tsx # Modificado
Consideraciones¶
Cache¶
El endpoint /store/geozones se puede cachear agresivamente ya que los departamentos y ciudades no cambian frecuentemente. Se usa cache: "force-cache" con tags para invalidación.
Indicador de disponibilidad¶
Cada ciudad incluye has_shipping: boolean que indica si hay al menos un proveedor de envío disponible. Se puede mostrar un indicador visual:
- Verde: ciudad con envío disponible
- Gris/Rojo: ciudad sin envío (el checkout mostrará error al calcular envío)
Compatibilidad con direcciones guardadas¶
Las direcciones guardadas previamente (con texto libre) seguirán funcionando. El sistema intenta hacer match del valor guardado con los departamentos/ciudades del catálogo.
Validación¶
La validación en el checkout sigue igual - si el usuario selecciona una ciudad sin envío disponible, el error aparecerá al intentar calcular opciones de envío.
Testing¶
-
Verificar que el endpoint retorna datos correctamente:
curl http://localhost:9000/store/geozones | jq -
Verificar que los selects cargan y filtran correctamente
-
Verificar que al cambiar departamento se limpia la ciudad
-
Verificar que al seleccionar ciudad se actualiza el formData
-
Verificar que el checkout continúa correctamente con la nueva estructura