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:
@@ -58,9 +58,8 @@ export default function Checkout() {
|
|||||||
|
|
||||||
// Calculate totals
|
// Calculate totals
|
||||||
const subtotal = cart.items.reduce((sum, item) => sum + (item.price * item.quantity), 0);
|
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 tax = 0; // TODO: Calculate tax
|
||||||
const total = subtotal + shipping + tax;
|
// Note: shipping is calculated from dynamic shippingCost state (defined below)
|
||||||
|
|
||||||
// Form state
|
// Form state
|
||||||
const [billingData, setBillingData] = useState({
|
const [billingData, setBillingData] = useState({
|
||||||
@@ -159,6 +158,19 @@ export default function Checkout() {
|
|||||||
const [checkoutFields, setCheckoutFields] = useState<CheckoutField[]>([]);
|
const [checkoutFields, setCheckoutFields] = useState<CheckoutField[]>([]);
|
||||||
const [customFieldData, setCustomFieldData] = useState<Record<string, string>>({});
|
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
|
// Fetch checkout fields from API
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadCheckoutFields = async () => {
|
const loadCheckoutFields = async () => {
|
||||||
@@ -217,7 +229,88 @@ export default function Checkout() {
|
|||||||
return () => document.removeEventListener('woonoow:field_label', handleLabelEvent);
|
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(() => {
|
useEffect(() => {
|
||||||
const loadAddresses = async () => {
|
const loadAddresses = async () => {
|
||||||
if (!user?.isLoggedIn) {
|
if (!user?.isLoggedIn) {
|
||||||
@@ -420,6 +513,7 @@ export default function Checkout() {
|
|||||||
ship_to_different: false,
|
ship_to_different: false,
|
||||||
},
|
},
|
||||||
payment_method: paymentMethod,
|
payment_method: paymentMethod,
|
||||||
|
shipping_method: selectedShippingRate || undefined, // Selected shipping rate ID
|
||||||
customer_note: orderNotes,
|
customer_note: orderNotes,
|
||||||
// Include all custom field data for backend processing
|
// Include all custom field data for backend processing
|
||||||
custom_fields: customFieldData,
|
custom_fields: customFieldData,
|
||||||
@@ -949,20 +1043,41 @@ export default function Checkout() {
|
|||||||
<div className="mb-4 pb-4 border-b">
|
<div className="mb-4 pb-4 border-b">
|
||||||
<h3 className="font-medium mb-3">Shipping Method</h3>
|
<h3 className="font-medium mb-3">Shipping Method</h3>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="flex items-center justify-between p-3 border rounded-lg cursor-pointer hover:bg-gray-50">
|
{isLoadingRates ? (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 p-3 text-sm text-gray-500">
|
||||||
<input type="radio" name="shipping" value="free" defaultChecked className="w-4 h-4" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
<span className="text-sm">Free Shipping</span>
|
<span>Calculating shipping rates...</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm font-medium">Free</span>
|
) : shippingRates.length === 0 ? (
|
||||||
</label>
|
<div className="p-3 text-sm text-gray-500">
|
||||||
<label className="flex items-center justify-between p-3 border rounded-lg cursor-pointer hover:bg-gray-50">
|
{billingData.country
|
||||||
<div className="flex items-center gap-2">
|
? 'No shipping methods available for your location'
|
||||||
<input type="radio" name="shipping" value="express" className="w-4 h-4" />
|
: 'Enter your address to see shipping options'}
|
||||||
<span className="text-sm">Express Shipping</span>
|
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm font-medium">$15.00</span>
|
) : (
|
||||||
|
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>
|
</label>
|
||||||
|
))
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -983,7 +1098,7 @@ export default function Checkout() {
|
|||||||
{!isVirtualOnly && (
|
{!isVirtualOnly && (
|
||||||
<div className="flex justify-between text-sm">
|
<div className="flex justify-between text-sm">
|
||||||
<span>Shipping</span>
|
<span>Shipping</span>
|
||||||
<span>{shipping === 0 ? 'Free' : formatPrice(shipping)}</span>
|
<span>{shippingCost === 0 ? 'Free' : formatPrice(shippingCost)}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{tax > 0 && (
|
{tax > 0 && (
|
||||||
@@ -994,7 +1109,7 @@ export default function Checkout() {
|
|||||||
)}
|
)}
|
||||||
<div className="border-t pt-2 flex justify-between font-bold text-lg">
|
<div className="border-t pt-2 flex justify-between font-bold text-lg">
|
||||||
<span>Total</span>
|
<span>Total</span>
|
||||||
<span>{formatPrice(total - discountTotal)}</span>
|
<span>{formatPrice(subtotal + (isVirtualOnly ? 0 : shippingCost) + tax - discountTotal)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,12 @@ class CheckoutController {
|
|||||||
],
|
],
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
// Get available shipping rates for given address
|
||||||
|
register_rest_route($namespace, '/checkout/shipping-rates', [
|
||||||
|
'methods' => 'POST',
|
||||||
|
'callback' => [ new self(), 'get_shipping_rates' ],
|
||||||
|
'permission_callback' => [ \WooNooW\Api\Permissions::class, 'anon_or_wp_nonce' ],
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -773,4 +779,123 @@ class CheckoutController {
|
|||||||
'default_country' => $default_country,
|
'default_country' => $default_country,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get available shipping rates for given address
|
||||||
|
* POST /checkout/shipping-rates
|
||||||
|
* Body: { shipping: { country, state, city, postcode, destination_id? }, items: [...] }
|
||||||
|
*/
|
||||||
|
public function get_shipping_rates(WP_REST_Request $r): array {
|
||||||
|
$payload = $r->get_json_params();
|
||||||
|
$shipping = $payload['shipping'] ?? [];
|
||||||
|
$items = $payload['items'] ?? [];
|
||||||
|
|
||||||
|
$country = wc_clean($shipping['country'] ?? '');
|
||||||
|
$state = wc_clean($shipping['state'] ?? '');
|
||||||
|
$city = wc_clean($shipping['city'] ?? '');
|
||||||
|
$postcode = wc_clean($shipping['postcode'] ?? '');
|
||||||
|
|
||||||
|
if (empty($country)) {
|
||||||
|
return [
|
||||||
|
'ok' => true,
|
||||||
|
'rates' => [],
|
||||||
|
'message' => 'Country is required',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger hook for plugins to set session data (e.g., Rajaongkir destination_id)
|
||||||
|
do_action('woonoow/shipping/before_calculate', $shipping, $items);
|
||||||
|
|
||||||
|
// Set customer location for shipping calculation
|
||||||
|
if (WC()->customer) {
|
||||||
|
WC()->customer->set_shipping_country($country);
|
||||||
|
WC()->customer->set_shipping_state($state);
|
||||||
|
WC()->customer->set_shipping_city($city);
|
||||||
|
WC()->customer->set_shipping_postcode($postcode);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build package for shipping calculation
|
||||||
|
$contents = [];
|
||||||
|
$contents_cost = 0;
|
||||||
|
foreach ($items as $item) {
|
||||||
|
$product = wc_get_product($item['product_id'] ?? 0);
|
||||||
|
if (!$product) continue;
|
||||||
|
$qty = max(1, (int)($item['quantity'] ?? $item['qty'] ?? 1));
|
||||||
|
$price = (float) wc_get_price_to_display($product);
|
||||||
|
$contents[] = [
|
||||||
|
'data' => $product,
|
||||||
|
'quantity' => $qty,
|
||||||
|
'line_total' => $price * $qty,
|
||||||
|
];
|
||||||
|
$contents_cost += $price * $qty;
|
||||||
|
}
|
||||||
|
|
||||||
|
$package = [
|
||||||
|
'destination' => [
|
||||||
|
'country' => $country,
|
||||||
|
'state' => $state,
|
||||||
|
'city' => $city,
|
||||||
|
'postcode' => $postcode,
|
||||||
|
],
|
||||||
|
'contents' => $contents,
|
||||||
|
'contents_cost' => $contents_cost,
|
||||||
|
'applied_coupons' => [],
|
||||||
|
'user' => ['ID' => get_current_user_id()],
|
||||||
|
];
|
||||||
|
|
||||||
|
// Get matching shipping zone
|
||||||
|
$zone = WC_Shipping_Zones::get_zone_matching_package($package);
|
||||||
|
if (!$zone) {
|
||||||
|
return [
|
||||||
|
'ok' => true,
|
||||||
|
'rates' => [],
|
||||||
|
'message' => 'No shipping zone matches your location',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get enabled shipping methods from zone
|
||||||
|
$methods = $zone->get_shipping_methods(true);
|
||||||
|
$rates = [];
|
||||||
|
|
||||||
|
foreach ($methods as $method) {
|
||||||
|
// Check if method has rates (some methods like live rate need to calculate)
|
||||||
|
if (method_exists($method, 'get_rates_for_package')) {
|
||||||
|
$method_rates = $method->get_rates_for_package($package);
|
||||||
|
foreach ($method_rates as $rate) {
|
||||||
|
$rates[] = [
|
||||||
|
'id' => $rate->get_id(),
|
||||||
|
'label' => $rate->get_label(),
|
||||||
|
'cost' => (float) $rate->get_cost(),
|
||||||
|
'method_id' => $rate->get_method_id(),
|
||||||
|
'instance_id' => $rate->get_instance_id(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Fallback for simple methods
|
||||||
|
$method_id = $method->id . ':' . $method->get_instance_id();
|
||||||
|
$cost = 0;
|
||||||
|
|
||||||
|
// Try to get cost from method
|
||||||
|
if (isset($method->cost)) {
|
||||||
|
$cost = (float) $method->cost;
|
||||||
|
} elseif (method_exists($method, 'get_option')) {
|
||||||
|
$cost = (float) $method->get_option('cost', 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
$rates[] = [
|
||||||
|
'id' => $method_id,
|
||||||
|
'label' => $method->get_title(),
|
||||||
|
'cost' => $cost,
|
||||||
|
'method_id' => $method->id,
|
||||||
|
'instance_id' => $method->get_instance_id(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'ok' => true,
|
||||||
|
'rates' => $rates,
|
||||||
|
'zone_name' => $zone->get_zone_name(),
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user