feat(checkout): implement dynamic shipping rate fetching

Backend:
- Added /checkout/shipping-rates REST endpoint
- Returns available shipping methods from matching zone
- Triggers woonoow/shipping/before_calculate hook for Rajaongkir

Frontend:
- Added ShippingRate interface and state
- Added fetchShippingRates with 500ms debounce
- Replaced hardcoded shipping options with dynamic rates
- Added loading and empty state handling
- Added shipping_method to order submission payload

This fixes:
- Rajaongkir rates not appearing (now fetched from API)
- Free shipping showing despite disabled (now from WC zones)
This commit is contained in:
Dwindi Ramadhana
2026-01-08 15:13:59 +07:00
parent 533cf5e7d2
commit 56b0040f7a
2 changed files with 257 additions and 17 deletions

View File

@@ -58,9 +58,8 @@ export default function Checkout() {
// Calculate totals
const subtotal = cart.items.reduce((sum, item) => sum + (item.price * item.quantity), 0);
const shipping = isVirtualOnly ? 0 : 0; // No shipping for virtual products
const tax = 0; // TODO: Calculate tax
const total = subtotal + shipping + tax;
// Note: shipping is calculated from dynamic shippingCost state (defined below)
// Form state
const [billingData, setBillingData] = useState({
@@ -159,6 +158,19 @@ export default function Checkout() {
const [checkoutFields, setCheckoutFields] = useState<CheckoutField[]>([]);
const [customFieldData, setCustomFieldData] = useState<Record<string, string>>({});
// Dynamic shipping rates
interface ShippingRate {
id: string;
label: string;
cost: number;
method_id: string;
instance_id: number;
}
const [shippingRates, setShippingRates] = useState<ShippingRate[]>([]);
const [selectedShippingRate, setSelectedShippingRate] = useState<string>('');
const [isLoadingRates, setIsLoadingRates] = useState(false);
const [shippingCost, setShippingCost] = useState(0);
// Fetch checkout fields from API
useEffect(() => {
const loadCheckoutFields = async () => {
@@ -217,7 +229,88 @@ export default function Checkout() {
return () => document.removeEventListener('woonoow:field_label', handleLabelEvent);
}, []);
// Load saved addresses
// Fetch shipping rates when address changes
const fetchShippingRates = async () => {
if (isVirtualOnly || !cart.items.length) return;
// Get address data (use shipping if different, otherwise billing)
const addressData = shipToDifferentAddress ? shippingData : billingData;
// Need at least country to calculate shipping
if (!addressData.country) {
setShippingRates([]);
return;
}
setIsLoadingRates(true);
try {
const items = cart.items.map(item => ({
product_id: item.product_id,
quantity: item.quantity,
}));
const destinationId = shipToDifferentAddress
? customFieldData['shipping_destination_id']
: customFieldData['billing_destination_id'];
const response = await api.post<{ ok: boolean; rates: ShippingRate[]; zone_name?: string }>('/checkout/shipping-rates', {
shipping: {
country: addressData.country,
state: addressData.state,
city: addressData.city,
postcode: addressData.postcode,
destination_id: destinationId || undefined,
},
items,
});
if (response.ok && response.rates) {
setShippingRates(response.rates);
// Auto-select first rate if none selected
if (response.rates.length > 0 && !selectedShippingRate) {
setSelectedShippingRate(response.rates[0].id);
setShippingCost(response.rates[0].cost);
}
}
} catch (error) {
console.error('Failed to fetch shipping rates:', error);
setShippingRates([]);
} finally {
setIsLoadingRates(false);
}
};
// Trigger shipping rate fetch when address or destination changes
useEffect(() => {
const addressData = shipToDifferentAddress ? shippingData : billingData;
const destinationId = shipToDifferentAddress
? customFieldData['shipping_destination_id']
: customFieldData['billing_destination_id'];
// Debounce the fetch
const timeoutId = setTimeout(() => {
if (addressData.country) {
fetchShippingRates();
}
}, 500);
return () => clearTimeout(timeoutId);
}, [
billingData.country, billingData.state, billingData.city, billingData.postcode,
shippingData.country, shippingData.state, shippingData.city, shippingData.postcode,
shipToDifferentAddress,
customFieldData['billing_destination_id'],
customFieldData['shipping_destination_id'],
cart.items.length,
]);
// Update shipping cost when rate selected
const handleShippingRateChange = (rateId: string) => {
setSelectedShippingRate(rateId);
const rate = shippingRates.find(r => r.id === rateId);
setShippingCost(rate?.cost || 0);
};
useEffect(() => {
const loadAddresses = async () => {
if (!user?.isLoggedIn) {
@@ -420,6 +513,7 @@ export default function Checkout() {
ship_to_different: false,
},
payment_method: paymentMethod,
shipping_method: selectedShippingRate || undefined, // Selected shipping rate ID
customer_note: orderNotes,
// Include all custom field data for backend processing
custom_fields: customFieldData,
@@ -949,20 +1043,41 @@ export default function Checkout() {
<div className="mb-4 pb-4 border-b">
<h3 className="font-medium mb-3">Shipping Method</h3>
<div className="space-y-2">
<label className="flex items-center justify-between p-3 border rounded-lg cursor-pointer hover:bg-gray-50">
<div className="flex items-center gap-2">
<input type="radio" name="shipping" value="free" defaultChecked className="w-4 h-4" />
<span className="text-sm">Free Shipping</span>
{isLoadingRates ? (
<div className="flex items-center gap-2 p-3 text-sm text-gray-500">
<Loader2 className="h-4 w-4 animate-spin" />
<span>Calculating shipping rates...</span>
</div>
<span className="text-sm font-medium">Free</span>
</label>
<label className="flex items-center justify-between p-3 border rounded-lg cursor-pointer hover:bg-gray-50">
<div className="flex items-center gap-2">
<input type="radio" name="shipping" value="express" className="w-4 h-4" />
<span className="text-sm">Express Shipping</span>
) : shippingRates.length === 0 ? (
<div className="p-3 text-sm text-gray-500">
{billingData.country
? 'No shipping methods available for your location'
: 'Enter your address to see shipping options'}
</div>
<span className="text-sm font-medium">$15.00</span>
</label>
) : (
shippingRates.map((rate) => (
<label
key={rate.id}
className={`flex items-center justify-between p-3 border rounded-lg cursor-pointer hover:bg-gray-50 ${selectedShippingRate === rate.id ? 'border-primary bg-primary/5' : ''
}`}
>
<div className="flex items-center gap-2">
<input
type="radio"
name="shipping_method"
value={rate.id}
checked={selectedShippingRate === rate.id}
onChange={() => handleShippingRateChange(rate.id)}
className="w-4 h-4"
/>
<span className="text-sm">{rate.label}</span>
</div>
<span className="text-sm font-medium">
{rate.cost === 0 ? 'Free' : formatPrice(rate.cost)}
</span>
</label>
))
)}
</div>
</div>
)}
@@ -983,7 +1098,7 @@ export default function Checkout() {
{!isVirtualOnly && (
<div className="flex justify-between text-sm">
<span>Shipping</span>
<span>{shipping === 0 ? 'Free' : formatPrice(shipping)}</span>
<span>{shippingCost === 0 ? 'Free' : formatPrice(shippingCost)}</span>
</div>
)}
{tax > 0 && (
@@ -994,7 +1109,7 @@ export default function Checkout() {
)}
<div className="border-t pt-2 flex justify-between font-bold text-lg">
<span>Total</span>
<span>{formatPrice(total - discountTotal)}</span>
<span>{formatPrice(subtotal + (isVirtualOnly ? 0 : shippingCost) + tax - discountTotal)}</span>
</div>
</div>