This commit is contained in:
Your Name committed 2026-06-15 04:18:55 -04:00
1 parent bce5c48b59
commit 2098548140
4 files changed
+67715 -85

No files matched your search

@@ -0,0 +1,158 @@
import { useState, useEffect, useRef } from 'react';
export interface AddressData {
subdistrict: string;
district: string;
province: string;
zipcode: string;
}
interface ThaiAddressInputProps {
value: AddressData;
onChange: (val: AddressData) => void;
}
interface ThaiAddressDB {
district: string;
amphoe: string;
province: string;
zipcode: number;
}
export function ThaiAddressInput({ value, onChange }: ThaiAddressInputProps) {
const [db, setDb] = useState<ThaiAddressDB[]>([]);
const [results, setResults] = useState<ThaiAddressDB[]>([]);
const [focusedField, setFocusedField] = useState<keyof AddressData | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
fetch('/data/thai_addresses.json')
.then(res => res.json())
.then(data => setDb(data))
.catch(err => console.error('Failed to load thai addresses:', err));
}, []);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setResults([]);
setFocusedField(null);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const handleSearch = (text: string, field: keyof AddressData) => {
onChange({ ...value, [field]: text });
if (text.length < 2) {
setResults([]);
return;
}
const searchStr = text.toLowerCase();
const filtered = db.filter(item =>
item.district.toLowerCase().includes(searchStr) ||
item.amphoe.toLowerCase().includes(searchStr) ||
item.province.toLowerCase().includes(searchStr) ||
item.zipcode.toString().includes(searchStr)
).slice(0, 20); // max 20 results
setResults(filtered);
};
const handleSelect = (item: ThaiAddressDB) => {
onChange({
...value,
subdistrict: item.district,
district: item.amphoe,
province: item.province,
zipcode: item.zipcode.toString(),
});
setResults([]);
setFocusedField(null);
};
const renderDropdown = (field: keyof AddressData) => {
if (focusedField !== field || results.length === 0) return null;
return (
<ul className="absolute z-50 w-full bg-white border border-spa-stone/20 rounded-lg mt-1 max-h-60 overflow-y-auto shadow-xl">
{results.map((item, idx) => (
<li
key={`${item.district}-${item.amphoe}-${item.province}-${item.zipcode}-${idx}`}
className="p-3 hover:bg-spa-sage/10 cursor-pointer text-xs text-pop-black border-b border-spa-stone/10 last:border-0"
onMouseDown={(e) => {
// use onMouseDown to prevent onBlur from firing before onClick
e.preventDefault();
handleSelect(item);
}}
>
ต.{item.district} อ.{item.amphoe} จ.{item.province} {item.zipcode}
</li>
))}
</ul>
);
};
return (
<div ref={containerRef} className="grid grid-cols-2 md:grid-cols-4 gap-4 w-full">
<div className="space-y-1.5 relative">
<label className="text-[10px] uppercase font-bold tracking-widest text-spa-stone/80">แขวง / ตำบล</label>
<input
type="text"
required
value={value.subdistrict}
onChange={e => handleSearch(e.target.value, 'subdistrict')}
onFocus={() => { setFocusedField('subdistrict'); handleSearch(value.subdistrict, 'subdistrict'); }}
onBlur={() => setTimeout(() => { if (focusedField === 'subdistrict') setFocusedField(null); }, 200)}
className="w-full bg-spa-light border border-spa-stone/20 rounded-lg p-2.5 text-xs text-pop-black focus:border-spa-forest outline-none transition-all"
/>
{renderDropdown('subdistrict')}
</div>
<div className="space-y-1.5 relative">
<label className="text-[10px] uppercase font-bold tracking-widest text-spa-stone/80">เขต / อำเภอ</label>
<input
type="text"
required
value={value.district}
onChange={e => handleSearch(e.target.value, 'district')}
onFocus={() => { setFocusedField('district'); handleSearch(value.district, 'district'); }}
onBlur={() => setTimeout(() => { if (focusedField === 'district') setFocusedField(null); }, 200)}
className="w-full bg-spa-light border border-spa-stone/20 rounded-lg p-2.5 text-xs text-pop-black focus:border-spa-forest outline-none transition-all"
/>
{renderDropdown('district')}
</div>
<div className="space-y-1.5 relative">
<label className="text-[10px] uppercase font-bold tracking-widest text-spa-stone/80">จังหวัด</label>
<input
type="text"
required
value={value.province}
onChange={e => handleSearch(e.target.value, 'province')}
onFocus={() => { setFocusedField('province'); handleSearch(value.province, 'province'); }}
onBlur={() => setTimeout(() => { if (focusedField === 'province') setFocusedField(null); }, 200)}
className="w-full bg-spa-light border border-spa-stone/20 rounded-lg p-2.5 text-xs text-pop-black focus:border-spa-forest outline-none transition-all"
/>
{renderDropdown('province')}
</div>
<div className="space-y-1.5 relative">
<label className="text-[10px] uppercase font-bold tracking-widest text-spa-stone/80">รหัสไปรษณีย์</label>
<input
type="text"
required
value={value.zipcode}
onChange={e => handleSearch(e.target.value, 'zipcode')}
onFocus={() => { setFocusedField('zipcode'); handleSearch(value.zipcode, 'zipcode'); }}
onBlur={() => setTimeout(() => { if (focusedField === 'zipcode') setFocusedField(null); }, 200)}
className="w-full bg-spa-light border border-spa-stone/20 rounded-lg p-2.5 text-xs text-pop-black focus:border-spa-forest outline-none transition-all"
/>
{renderDropdown('zipcode')}
</div>
</div>
);
}