Saltar a contenido

⚠ Vigencia por confirmar. Guía de implementación para el storefront (otro repo). Depende del endpoint GET /store/geozones, que no existe en las ramas publicadas (14749d6 al 2026-08-19).

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

  1. Verificar que el endpoint retorna datos correctamente:

    curl http://localhost:9000/store/geozones | jq
    

  2. Verificar que los selects cargan y filtran correctamente

  3. Verificar que al cambiar departamento se limpia la ciudad

  4. Verificar que al seleccionar ciudad se actualiza el formData

  5. Verificar que el checkout continúa correctamente con la nueva estructura