Bỏ qua

FE Integration Guide: Province & District Filter

Effective: June 2026
Backend branch: feature/enrich-football-booking-with-users-name-and-field-name
Base URL (local): http://localhost:3000 (API Gateway)


Overview

Search trang tìm kiếm sân bóng đã hỗ trợ filter theo tỉnh/thành phốquận/huyện.
Dữ liệu hành chính dựa trên cấu trúc 2 cấp sau sáp nhập ngày 1/7/2025 (34 tỉnh/thành).

Tỉnh/Thành phố (34 đơn vị)
  └── Quận / Huyện / Thị xã (~500 đơn vị)
       └── Tên đường (free-text, do FE tự quản lý)

Endpoints mới

1. GET /locations/provinces

Lấy danh sách 34 tỉnh/thành phố. Public, không cần token.

GET http://localhost:3000/locations/provinces

Response:

[
  { "code": "01", "name": "Hà Nội", "nameEn": "Ha Noi", "type": "thanh_pho" },
  { "code": "02", "name": "Hải Phòng", "nameEn": "Hai Phong", "type": "thanh_pho" },
  { "code": "16", "name": "Đà Nẵng", "nameEn": "Da Nang", "type": "thanh_pho" },
  { "code": "22", "name": "Hồ Chí Minh", "nameEn": "Ho Chi Minh City", "type": "thanh_pho" },
  { "code": "29", "name": "Cần Thơ", "nameEn": "Can Tho", "type": "thanh_pho" }
]
Field Type Mô tả
code string Mã tỉnh 2 ký tự — dùng làm provinceCode khi search
name string Tên tiếng Việt
nameEn string Tên tiếng Anh
type string "tinh" hoặc "thanh_pho"

Cache: Response được cache 24h trên server. FE nên cache thêm ở client (localStorage/sessionStorage) để tránh call lặp.


2. GET /locations/provinces/:code/districts

Lấy danh sách quận/huyện thuộc tỉnh/thành. Public, không cần token.

GET http://localhost:3000/locations/provinces/22/districts

Response:

[
  { "code": "139", "name": "Quận 1", "nameEn": "District 1", "type": "quan" },
  { "code": "149", "name": "Quận Bình Thạnh", "nameEn": "Binh Thanh District", "type": "quan" },
  { "code": "155", "name": "Thành phố Thủ Đức", "nameEn": "Thu Duc City", "type": "thanh_pho" },
  { "code": "156", "name": "Huyện Bình Chánh", "nameEn": "Binh Chanh District", "type": "huyen" }
]
Field Type Mô tả
code string Mã quận 3 ký tự — dùng làm districtCode khi search
name string Tên tiếng Việt
nameEn string Tên tiếng Anh
type string "quan" / "huyen" / "thi_xa" / "thanh_pho"

Error 404 khi code không hợp lệ:

{ "statusCode": 404, "message": "Province with code \"99\" not found" }

3. GET /fields/search — thêm 2 param mới

Hai query params mới bổ sung vào endpoint search hiện có:

Param Type Mô tả
provinceCode string Mã tỉnh (2 ký tự, từ endpoint provinces)
districtCode string Mã quận (3 ký tự, từ endpoint districts)

Có thể kết hợp với tất cả các filter hiện có khác.

Ví dụ:

GET /fields/search?provinceCode=22&districtCode=155&fieldSize=FOOTBALL_7&limit=10
GET /fields/search?provinceCode=01&minRate=100000&maxRate=300000
GET /fields/search?provinceCode=22&sortBy=price&sortOrder=asc

Hướng dẫn tích hợp UI

Luồng đề xuất cho filter panel

1. App init → gọi GET /locations/provinces → render dropdown "Tỉnh/Thành phố"
2. User chọn tỉnh → gọi GET /locations/provinces/:code/districts → render dropdown "Quận/Huyện"
3. User chọn quận (optional) → add params vào search request
4. User submit search → gọi GET /fields/search?provinceCode=...&districtCode=...&...

Rules quan trọng

  • districtCode phụ thuộc provinceCode: Không cho phép chọn quận khi chưa chọn tỉnh.
  • Reset districtCode khi đổi tỉnh: Khi user thay đổi tỉnh, phải xóa districtCode đã chọn.
  • Cả hai đều optional: Có thể search không cần cả hai, chỉ cần tỉnh, hoặc cả tỉnh lẫn quận.
  • Không validate code phía FE: Backend tự handle nếu code không tồn tại (sẽ trả về 0 kết quả hoặc 404).

Code mẫu (TypeScript / React)

Custom hook: useLocations

// hooks/useLocations.ts
import { useEffect, useState } from 'react';

export interface Province {
  code: string;
  name: string;
  nameEn: string;
  type: 'tinh' | 'thanh_pho';
}

export interface District {
  code: string;
  name: string;
  nameEn: string;
  type: 'quan' | 'huyen' | 'thi_xa' | 'thanh_pho';
}

const PROVINCES_CACHE_KEY = 'vn_provinces_cache';

export function useProvinces() {
  const [provinces, setProvinces] = useState<Province[]>([]);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    const cached = sessionStorage.getItem(PROVINCES_CACHE_KEY);
    if (cached) {
      setProvinces(JSON.parse(cached));
      return;
    }

    setLoading(true);
    fetch('/api/locations/provinces')
      .then((r) => r.json())
      .then((data: Province[]) => {
        setProvinces(data);
        sessionStorage.setItem(PROVINCES_CACHE_KEY, JSON.stringify(data));
      })
      .finally(() => setLoading(false));
  }, []);

  return { provinces, loading };
}

export function useDistricts(provinceCode: string | null) {
  const [districts, setDistricts] = useState<District[]>([]);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    if (!provinceCode) {
      setDistricts([]);
      return;
    }

    const cacheKey = `vn_districts_${provinceCode}`;
    const cached = sessionStorage.getItem(cacheKey);
    if (cached) {
      setDistricts(JSON.parse(cached));
      return;
    }

    setLoading(true);
    fetch(`/api/locations/provinces/${provinceCode}/districts`)
      .then((r) => r.json())
      .then((data: District[]) => {
        setDistricts(data);
        sessionStorage.setItem(cacheKey, JSON.stringify(data));
      })
      .finally(() => setLoading(false));
  }, [provinceCode]);

  return { districts, loading };
}

Filter component mẫu

// components/LocationFilter.tsx
import { useState } from 'react';
import { useDistricts, useProvinces } from '@/hooks/useLocations';

interface LocationFilterProps {
  onProvinceChange: (code: string | null) => void;
  onDistrictChange: (code: string | null) => void;
}

export function LocationFilter({ onProvinceChange, onDistrictChange }: LocationFilterProps) {
  const { provinces, loading: loadingProvinces } = useProvinces();
  const [selectedProvince, setSelectedProvince] = useState<string | null>(null);
  const { districts, loading: loadingDistricts } = useDistricts(selectedProvince);

  function handleProvinceChange(code: string) {
    const value = code || null;
    setSelectedProvince(value);
    onProvinceChange(value);
    onDistrictChange(null); // reset district khi đổi tỉnh
  }

  return (
    <div className="flex gap-3">
      <select
        disabled={loadingProvinces}
        onChange={(e) => handleProvinceChange(e.target.value)}
        defaultValue=""
      >
        <option value="">-- Tất cả tỉnh/thành --</option>
        {provinces.map((p) => (
          <option key={p.code} value={p.code}>
            {p.name}
          </option>
        ))}
      </select>

      <select
        disabled={!selectedProvince || loadingDistricts}
        onChange={(e) => onDistrictChange(e.target.value || null)}
        value=""
      >
        <option value="">-- Tất cả quận/huyện --</option>
        {districts.map((d) => (
          <option key={d.code} value={d.code}>
            {d.name}
          </option>
        ))}
      </select>
    </div>
  );
}

Search request mẫu

// services/field.service.ts
export interface FieldSearchParams {
  provinceCode?: string;
  districtCode?: string;
  name?: string;
  fieldSize?: 'FOOTBALL_5' | 'FOOTBALL_7' | 'FOOTBALL_11';
  surfaceType?: 'GRASS' | 'ARTIFICIAL' | 'CONCRETE';
  minRate?: number;
  maxRate?: number;
  latitude?: number;
  longitude?: number;
  radius?: number;
  minRating?: number;
  amenities?: string[];
  sortBy?: 'distance' | 'price' | 'newest' | 'rating';
  sortOrder?: 'asc' | 'desc';
  page?: number;
  limit?: number;
}

export async function searchFields(params: FieldSearchParams) {
  const query = new URLSearchParams();

  Object.entries(params).forEach(([key, value]) => {
    if (value === undefined || value === null || value === '') return;
    if (Array.isArray(value)) {
      value.forEach((v) => query.append(key, v));
    } else {
      query.set(key, String(value));
    }
  });

  const res = await fetch(`/api/fields/search?${query.toString()}`);
  if (!res.ok) throw new Error('Search failed');
  return res.json();
}

// Ví dụ sử dụng:
// const results = await searchFields({
//   provinceCode: '22',
//   districtCode: '155',
//   fieldSize: 'FOOTBALL_7',
//   limit: 10,
// });

Tạo/Cập nhật sân với province/district

Khi FE cho phép field owner nhập địa chỉ, nên bổ sung 2 field provinceCodedistrictCode vào form tạo/sửa sân.

POST /fields — thêm 2 field optional:

{
  "name": "Sân bóng ABC",
  "address": "123 Nguyễn Trãi, Phường 3",
  "provinceCode": "22",
  "districtCode": "141",
  "latitude": 10.7626,
  "longitude": 106.6823,
  "hourlyRate": 200000,
  "capacity": 14,
  "fieldSize": "FOOTBALL_7",
  "surfaceType": "ARTIFICIAL"
}

PUT /fields/:id — tương tự, 2 field đều optional.


Lưu ý

  • Existing fields: Các sân đã tạo trước khi tính năng này live sẽ có provinceCode = null. Search theo province sẽ không tìm thấy chúng cho đến khi owner cập nhật.
  • Hiển thị tên tỉnh/quận: Backend chỉ trả provinceCode/districtCode trong field response, không kèm tên. FE cần join với danh sách đã fetch từ /locations/provinces để hiển thị tên.
  • Không có cấp phường/xã: Cấu trúc hành chính mới chỉ có 2 cấp. Phường/xã đã bị bãi bỏ từ 1/7/2025 — không có endpoint nào cho cấp này.