feat(checkout): searchable address fields and Rajaongkir integration
Admin SPA: - Changed billing/shipping state from Select to SearchableSelect Customer SPA: - Added cmdk package for command palette - Created popover, command, and searchable-select UI components - Added searchable country and state fields to checkout - Fetches countries/states from /countries API - Auto-clears state when country changes Backend: - Added generic woonoow/shipping/before_calculate hook - Removed hardcoded Rajaongkir session handling Documentation: - Updated RAJAONGKIR_INTEGRATION.md with: - Complete searchable destination selector plugin code - JavaScript implementation - React component version - REST API endpoint for destination search
This commit is contained in:
@@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { useCartStore } from '@/lib/cart/store';
|
||||
import { useCheckoutSettings } from '@/hooks/useAppearanceSettings';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { SearchableSelect } from '@/components/ui/searchable-select';
|
||||
import Container from '@/components/Layout/Container';
|
||||
import SEOHead from '@/components/SEOHead';
|
||||
import { formatPrice } from '@/lib/currency';
|
||||
@@ -97,6 +98,62 @@ export default function Checkout() {
|
||||
const [showBillingForm, setShowBillingForm] = useState(true);
|
||||
const [showShippingForm, setShowShippingForm] = useState(true);
|
||||
|
||||
// Countries and states data
|
||||
const [countries, setCountries] = useState<{ code: string; name: string }[]>([]);
|
||||
const [states, setStates] = useState<Record<string, Record<string, string>>>({});
|
||||
const [defaultCountry, setDefaultCountry] = useState('');
|
||||
|
||||
// Load countries and states
|
||||
useEffect(() => {
|
||||
const loadCountries = async () => {
|
||||
try {
|
||||
const data = await api.get<{
|
||||
countries: { code: string; name: string }[];
|
||||
states: Record<string, Record<string, string>>;
|
||||
default_country: string;
|
||||
}>('/countries');
|
||||
setCountries(data.countries || []);
|
||||
setStates(data.states || {});
|
||||
setDefaultCountry(data.default_country || '');
|
||||
|
||||
// Set default country if not already set
|
||||
if (!billingData.country && data.default_country) {
|
||||
setBillingData(prev => ({ ...prev, country: data.default_country }));
|
||||
}
|
||||
if (!shippingData.country && data.default_country) {
|
||||
setShippingData(prev => ({ ...prev, country: data.default_country }));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load countries:', error);
|
||||
}
|
||||
};
|
||||
loadCountries();
|
||||
}, []);
|
||||
|
||||
// Country/state options for SearchableSelect
|
||||
const countryOptions = countries.map(c => ({ value: c.code, label: `${c.name} (${c.code})` }));
|
||||
const billingStateOptions = Object.entries(states[billingData.country] || {}).map(([code, name]) => ({ value: code, label: name }));
|
||||
const shippingStateOptions = Object.entries(states[shippingData.country] || {}).map(([code, name]) => ({ value: code, label: name }));
|
||||
|
||||
// Clear state when country changes
|
||||
useEffect(() => {
|
||||
if (billingData.country && billingData.state) {
|
||||
const countryStates = states[billingData.country] || {};
|
||||
if (!countryStates[billingData.state]) {
|
||||
setBillingData(prev => ({ ...prev, state: '' }));
|
||||
}
|
||||
}
|
||||
}, [billingData.country, states]);
|
||||
|
||||
useEffect(() => {
|
||||
if (shippingData.country && shippingData.state) {
|
||||
const countryStates = states[shippingData.country] || {};
|
||||
if (!countryStates[shippingData.state]) {
|
||||
setShippingData(prev => ({ ...prev, state: '' }));
|
||||
}
|
||||
}
|
||||
}, [shippingData.country, states]);
|
||||
|
||||
// Load saved addresses
|
||||
useEffect(() => {
|
||||
const loadAddresses = async () => {
|
||||
@@ -491,14 +548,24 @@ export default function Checkout() {
|
||||
className="w-full border rounded-lg px-4 py-2"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">Country *</label>
|
||||
<SearchableSelect
|
||||
options={countryOptions}
|
||||
value={billingData.country}
|
||||
onChange={(v) => setBillingData({ ...billingData, country: v })}
|
||||
placeholder="Select country"
|
||||
disabled={countries.length <= 1}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">State / Province *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
<SearchableSelect
|
||||
options={billingStateOptions}
|
||||
value={billingData.state}
|
||||
onChange={(e) => setBillingData({ ...billingData, state: e.target.value })}
|
||||
className="w-full border rounded-lg px-4 py-2"
|
||||
onChange={(v) => setBillingData({ ...billingData, state: v })}
|
||||
placeholder={billingStateOptions.length ? "Select state" : "N/A"}
|
||||
disabled={!billingStateOptions.length}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -511,16 +578,6 @@ export default function Checkout() {
|
||||
className="w-full border rounded-lg px-4 py-2"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">Country *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={billingData.country}
|
||||
onChange={(e) => setBillingData({ ...billingData, country: e.target.value })}
|
||||
className="w-full border rounded-lg px-4 py-2"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -652,14 +709,24 @@ export default function Checkout() {
|
||||
className="w-full border rounded-lg px-4 py-2"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">Country *</label>
|
||||
<SearchableSelect
|
||||
options={countryOptions}
|
||||
value={shippingData.country}
|
||||
onChange={(v) => setShippingData({ ...shippingData, country: v })}
|
||||
placeholder="Select country"
|
||||
disabled={countries.length <= 1}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">State / Province *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
<SearchableSelect
|
||||
options={shippingStateOptions}
|
||||
value={shippingData.state}
|
||||
onChange={(e) => setShippingData({ ...shippingData, state: e.target.value })}
|
||||
className="w-full border rounded-lg px-4 py-2"
|
||||
onChange={(v) => setShippingData({ ...shippingData, state: v })}
|
||||
placeholder={shippingStateOptions.length ? "Select state" : "N/A"}
|
||||
disabled={!shippingStateOptions.length}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -672,16 +739,6 @@ export default function Checkout() {
|
||||
className="w-full border rounded-lg px-4 py-2"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">Country *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={shippingData.country}
|
||||
onChange={(e) => setShippingData({ ...shippingData, country: e.target.value })}
|
||||
className="w-full border rounded-lg px-4 py-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user