x
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { useCartStore } from '../store/cartStore'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ThaiAddressInput } from '../components/ui/ThaiAddressInput'
|
||||
|
||||
export default function CheckoutPage() {
|
||||
const { t, i18n } = useTranslation()
|
||||
@@ -20,7 +21,7 @@ export default function CheckoutPage() {
|
||||
const [loadingSettings, setLoadingSettings] = useState(true)
|
||||
|
||||
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 [isFreeShipping, setIsFreeShipping] = useState<boolean>(false)
|
||||
const [couponLoading, setCouponLoading] = useState(false)
|
||||
@@ -39,6 +40,12 @@ export default function CheckoutPage() {
|
||||
address: ''
|
||||
})
|
||||
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
|
||||
useEffect(() => {
|
||||
@@ -255,12 +262,20 @@ export default function CheckoutPage() {
|
||||
setLoading(true)
|
||||
|
||||
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 = {
|
||||
customerId: customer ? customer.id : null,
|
||||
customerName: formData.name,
|
||||
customerEmail: formData.email,
|
||||
customerPhone: formData.phone,
|
||||
shippingAddress: formData.address,
|
||||
shippingAddress: finalAddress,
|
||||
totalAmount: total,
|
||||
shippingFee: effectiveShippingFee,
|
||||
subtotal,
|
||||
@@ -365,42 +380,52 @@ export default function CheckoutPage() {
|
||||
placeholder="jane@example.com"
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-2 space-y-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..."
|
||||
/>
|
||||
<div className="md:col-span-2">
|
||||
{savedAddresses.length > 0 && (
|
||||
<div className="mt-2 space-y-2">
|
||||
<p className="text-[10px] uppercase font-bold tracking-widest text-spa-stone/70">{t('checkout.selectProfileAddress')}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{savedAddresses.map(addr => {
|
||||
const formatted = `${addr.addressLine} ต.${addr.subdistrict} อ.${addr.district} จ.${addr.province} ${addr.zipcode}`
|
||||
return (
|
||||
<button
|
||||
key={addr.id}
|
||||
type="button"
|
||||
onClick={() => setFormData({
|
||||
name: addr.recipientName,
|
||||
phone: addr.phone,
|
||||
email: formData.email,
|
||||
address: formatted
|
||||
})}
|
||||
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"
|
||||
>
|
||||
{addr.recipientName} ({addr.province}) {addr.isDefault && '⭐️'}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
<div className="space-y-3 mt-4 mb-4">
|
||||
<label className="text-xs uppercase tracking-widest text-spa-stone/80 font-bold">เลือกที่อยู่จัดส่ง</label>
|
||||
<div className="grid gap-3">
|
||||
{savedAddresses.map(addr => (
|
||||
<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'}`}>
|
||||
<input type="radio" name="addressSelect" checked={selectedAddressId === addr.id} onChange={() => setSelectedAddressId(addr.id)} className="mt-1 text-spa-forest focus:ring-spa-forest" />
|
||||
<div className="space-y-1">
|
||||
<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>
|
||||
<p className="text-xs text-spa-stone">{addr.phone}</p>
|
||||
<p className="text-xs text-spa-stone">{addr.addressLine} ต.{addr.subdistrict} อ.{addr.district} จ.{addr.province} {addr.zipcode}</p>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
<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" />
|
||||
<span className="text-sm font-bold text-pop-black">+ เพิ่มที่อยู่ใหม่</span>
|
||||
</label>
|
||||
</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>
|
||||
@@ -412,7 +437,7 @@ export default function CheckoutPage() {
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{loading && <Loader2 className="w-5 h-5 animate-spin" />}
|
||||
<span>{t('checkout.confirmOrder')}</span>
|
||||
<span>Proceed to Payment</span>
|
||||
</div>
|
||||
<span className="text-sm border-l border-white/20 pl-4">{formatPrice(total)}</span>
|
||||
</button>
|
||||
@@ -581,7 +606,7 @@ export default function CheckoutPage() {
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{loading && <Loader2 className="w-5 h-5 animate-spin" />}
|
||||
<span>{t('checkout.confirmOrder')}</span>
|
||||
<span>Proceed to Payment</span>
|
||||
</div>
|
||||
<span className="text-sm border-l border-white/20 pl-4">{formatPrice(total)}</span>
|
||||
</button>
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
Mail,
|
||||
Award
|
||||
} from 'lucide-react'
|
||||
import { ThaiAddressInput } from '../components/ui/ThaiAddressInput'
|
||||
|
||||
export default function ProfilePage() {
|
||||
const { token, customer, setAuth } = useAuthStore()
|
||||
@@ -580,56 +581,18 @@ export default function ProfilePage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<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={subdistrict}
|
||||
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>
|
||||
|
||||
<ThaiAddressInput
|
||||
value={{ subdistrict, district, province, zipcode }}
|
||||
onChange={(val) => {
|
||||
setSubdistrict(val.subdistrict);
|
||||
setDistrict(val.district);
|
||||
setProvince(val.province);
|
||||
setZipcode(val.zipcode);
|
||||
}}
|
||||
/>
|
||||
<div className="flex items-center gap-2.5 py-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
<input
|
||||
type="checkbox"
|
||||
id="isDefault"
|
||||
checked={isDefault}
|
||||
onChange={e => setIsDefault(e.target.checked)}
|
||||
|
||||
Reference in new issue
Block a user