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

File diff suppressed because it is too large. Load diff
@@ -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>
);
}
+61 -36
View File
@@ -7,6 +7,7 @@ import { useCartStore } from '../store/cartStore'
import { useAuthStore } from '../store/authStore' import { useAuthStore } from '../store/authStore'
import { Loader2 } from 'lucide-react' import { Loader2 } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { ThaiAddressInput } from '../components/ui/ThaiAddressInput'
export default function CheckoutPage() { export default function CheckoutPage() {
const { t, i18n } = useTranslation() const { t, i18n } = useTranslation()
@@ -20,7 +21,7 @@ export default function CheckoutPage() {
const [loadingSettings, setLoadingSettings] = useState(true) const [loadingSettings, setLoadingSettings] = useState(true)
const [couponCode, setCouponCode] = useState('') const [couponCode, setCouponCode] = useState('')
const [appliedCoupon, setAppliedCoupon] = useState<string | null>(null) const [appliedCoupon, setAppliedCoupon] = useState<number | 'new' | null>(null)
const [discountAmount, setDiscountAmount] = useState<number>(0) const [discountAmount, setDiscountAmount] = useState<number>(0)
const [isFreeShipping, setIsFreeShipping] = useState<boolean>(false) const [isFreeShipping, setIsFreeShipping] = useState<boolean>(false)
const [couponLoading, setCouponLoading] = useState(false) const [couponLoading, setCouponLoading] = useState(false)
@@ -39,6 +40,12 @@ export default function CheckoutPage() {
address: '' address: ''
}) })
const [savedAddresses, setSavedAddresses] = useState<CustomerAddress[]>([]) const [savedAddresses, setSavedAddresses] = useState<CustomerAddress[]>([])
const [selectedAddressId, setSelectedAddressId] = useState<number | 'new' | null>(null)
const [addressLine, setAddressLine] = useState('')
const [subdistrict, setSubdistrict] = useState('')
const [district, setDistrict] = useState('')
const [province, setProvince] = useState('')
const [zipcode, setZipcode] = useState('')
// Prefill form with LINE customer data and last used address // Prefill form with LINE customer data and last used address
useEffect(() => { useEffect(() => {
@@ -255,12 +262,20 @@ export default function CheckoutPage() {
setLoading(true) setLoading(true)
try { try {
let finalAddress = formData.address;
if (selectedAddressId && selectedAddressId !== 'new') {
const addr = savedAddresses.find(a => a.id === selectedAddressId);
if (addr) finalAddress = `${addr.addressLine} ต.${addr.subdistrict} อ.${addr.district} จ.${addr.province} ${addr.zipcode}`;
} else {
finalAddress = `${addressLine} ต.${subdistrict} อ.${district} จ.${province} ${zipcode}`;
}
const orderData = { const orderData = {
customerId: customer ? customer.id : null, customerId: customer ? customer.id : null,
customerName: formData.name, customerName: formData.name,
customerEmail: formData.email, customerEmail: formData.email,
customerPhone: formData.phone, customerPhone: formData.phone,
shippingAddress: formData.address, shippingAddress: finalAddress,
totalAmount: total, totalAmount: total,
shippingFee: effectiveShippingFee, shippingFee: effectiveShippingFee,
subtotal, subtotal,
@@ -365,42 +380,52 @@ export default function CheckoutPage() {
placeholder="jane@example.com" placeholder="jane@example.com"
/> />
</div> </div>
<div className="md:col-span-2 space-y-2"> <div className="md:col-span-2">
<label htmlFor="checkout-address" className="text-xs uppercase tracking-widest text-spa-stone/80 font-bold">{t('checkout.shippingAddress')}</label>
<textarea
id="checkout-address"
required
rows={3}
value={formData.address}
onChange={e => setFormData({ ...formData, address: e.target.value })}
className="w-full bg-spa-light border border-spa-stone/20 rounded-lg p-3 text-pop-black focus:border-spa-forest focus:ring-1 focus:ring-spa-forest outline-none transition-all resize-none placeholder:text-spa-stone/50"
placeholder="123 Serenity Street..."
/>
{savedAddresses.length > 0 && ( {savedAddresses.length > 0 && (
<div className="mt-2 space-y-2"> <div className="space-y-3 mt-4 mb-4">
<p className="text-[10px] uppercase font-bold tracking-widest text-spa-stone/70">{t('checkout.selectProfileAddress')}</p> <label className="text-xs uppercase tracking-widest text-spa-stone/80 font-bold">เลือกที่อยู่จัดส่ง</label>
<div className="flex flex-wrap gap-2"> <div className="grid gap-3">
{savedAddresses.map(addr => { {savedAddresses.map(addr => (
const formatted = `${addr.addressLine} ต.${addr.subdistrict} อ.${addr.district} จ.${addr.province} ${addr.zipcode}` <label key={addr.id} className={`flex items-start gap-3 p-4 rounded-xl border cursor-pointer transition-all ${selectedAddressId === addr.id ? 'border-spa-forest bg-spa-forest/5 shadow-sm' : 'border-spa-stone/20 hover:border-spa-stone/40'}`}>
return ( <input type="radio" name="addressSelect" checked={selectedAddressId === addr.id} onChange={() => setSelectedAddressId(addr.id)} className="mt-1 text-spa-forest focus:ring-spa-forest" />
<button <div className="space-y-1">
key={addr.id} <p className="text-sm font-bold text-pop-black">{addr.recipientName} {addr.isDefault && <span className="ml-2 text-[10px] bg-spa-forest/10 text-spa-forest font-bold px-2 py-0.5 rounded-full">เริ่มต้น</span>}</p>
type="button" <p className="text-xs text-spa-stone">{addr.phone}</p>
onClick={() => setFormData({ <p className="text-xs text-spa-stone">{addr.addressLine} ต.{addr.subdistrict} อ.{addr.district} จ.{addr.province} {addr.zipcode}</p>
name: addr.recipientName, </div>
phone: addr.phone, </label>
email: formData.email, ))}
address: formatted <label className={`flex items-center gap-3 p-4 rounded-xl border cursor-pointer transition-all ${selectedAddressId === 'new' ? 'border-spa-forest bg-spa-forest/5 shadow-sm' : 'border-spa-stone/20 hover:border-spa-stone/40'}`}>
})} <input type="radio" name="addressSelect" checked={selectedAddressId === 'new'} onChange={() => setSelectedAddressId('new')} className="text-spa-forest focus:ring-spa-forest" />
className="px-3 py-1.5 bg-spa-sage/10 hover:bg-spa-sage/20 border border-spa-stone/15 text-[10px] font-bold rounded-lg text-spa-forest transition-all text-left max-w-full truncate" <span className="text-sm font-bold text-pop-black">+ เพิ่มที่อยู่ใหม่</span>
> </label>
{addr.recipientName} ({addr.province}) {addr.isDefault && '⭐️'}
</button>
)
})}
</div> </div>
</div> </div>
)} )}
{(savedAddresses.length === 0 || selectedAddressId === 'new') && (
<div className="space-y-4 p-4 bg-spa-stone/5 border border-spa-stone/10 rounded-xl">
<div className="space-y-2">
<label className="text-[10px] uppercase font-bold tracking-widest text-spa-stone/80">ที่อยู่ (บ้านเลขที่, ถนน, ซอย)</label>
<input
type="text"
required
value={addressLine}
onChange={e => setAddressLine(e.target.value)}
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"
/>
</div>
<ThaiAddressInput
value={{ subdistrict, district, province, zipcode }}
onChange={(val) => {
setSubdistrict(val.subdistrict);
setDistrict(val.district);
setProvince(val.province);
setZipcode(val.zipcode);
}}
/>
</div>
)}
</div> </div>
</div> </div>
</div> </div>
@@ -412,7 +437,7 @@ export default function CheckoutPage() {
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{loading && <Loader2 className="w-5 h-5 animate-spin" />} {loading && <Loader2 className="w-5 h-5 animate-spin" />}
<span>{t('checkout.confirmOrder')}</span> <span>Proceed to Payment</span>
</div> </div>
<span className="text-sm border-l border-white/20 pl-4">{formatPrice(total)}</span> <span className="text-sm border-l border-white/20 pl-4">{formatPrice(total)}</span>
</button> </button>
@@ -581,7 +606,7 @@ export default function CheckoutPage() {
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{loading && <Loader2 className="w-5 h-5 animate-spin" />} {loading && <Loader2 className="w-5 h-5 animate-spin" />}
<span>{t('checkout.confirmOrder')}</span> <span>Proceed to Payment</span>
</div> </div>
<span className="text-sm border-l border-white/20 pl-4">{formatPrice(total)}</span> <span className="text-sm border-l border-white/20 pl-4">{formatPrice(total)}</span>
</button> </button>
+12 -49
View File
@@ -20,6 +20,7 @@ import {
Mail, Mail,
Award Award
} from 'lucide-react' } from 'lucide-react'
import { ThaiAddressInput } from '../components/ui/ThaiAddressInput'
export default function ProfilePage() { export default function ProfilePage() {
const { token, customer, setAuth } = useAuthStore() const { token, customer, setAuth } = useAuthStore()
@@ -580,56 +581,18 @@ export default function ProfilePage() {
/> />
</div> </div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4"> <ThaiAddressInput
<div className="space-y-1.5"> value={{ subdistrict, district, province, zipcode }}
<label className="text-[10px] uppercase font-bold tracking-widest text-spa-stone/80">แขวง / ตำบล</label> onChange={(val) => {
<input setSubdistrict(val.subdistrict);
type="text" setDistrict(val.district);
required setProvince(val.province);
value={subdistrict} setZipcode(val.zipcode);
onChange={e => setSubdistrict(e.target.value)} }}
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" />
/>
</div>
<div className="space-y-1.5">
<label className="text-[10px] uppercase font-bold tracking-widest text-spa-stone/80">เขต / อำเภอ</label>
<input
type="text"
required
value={district}
onChange={e => setDistrict(e.target.value)}
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"
/>
</div>
<div className="space-y-1.5">
<label className="text-[10px] uppercase font-bold tracking-widest text-spa-stone/80">จังหวัด</label>
<input
type="text"
required
value={province}
onChange={e => setProvince(e.target.value)}
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"
/>
</div>
<div className="space-y-1.5">
<label className="text-[10px] uppercase font-bold tracking-widest text-spa-stone/80">รหัสไปรษณีย์</label>
<input
type="text"
required
maxLength={5}
value={zipcode}
onChange={e => setZipcode(e.target.value.replace(/\D/g, ''))}
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"
/>
</div>
</div>
<div className="flex items-center gap-2.5 py-1"> <div className="flex items-center gap-2.5 py-1">
<input <input
type="checkbox" type="checkbox"
id="isDefault" id="isDefault"
checked={isDefault} checked={isDefault}
onChange={e => setIsDefault(e.target.checked)} onChange={e => setIsDefault(e.target.checked)}