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ố và 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
districtCodephụ thuộcprovinceCode: 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 provinceCode và districtCode 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/districtCodetrong 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.