From 56b0040f7a02ddf37515c4f49fb57b139c0a6263 Mon Sep 17 00:00:00 2001 From: Dwindi Ramadhana Date: Thu, 8 Jan 2026 15:13:59 +0700 Subject: [PATCH] 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) --- customer-spa/src/pages/Checkout/index.tsx | 149 +++++++++++++++++++--- includes/Api/CheckoutController.php | 125 ++++++++++++++++++ 2 files changed, 257 insertions(+), 17 deletions(-) diff --git a/customer-spa/src/pages/Checkout/index.tsx b/customer-spa/src/pages/Checkout/index.tsx index 0f5e103..2e94eae 100644 --- a/customer-spa/src/pages/Checkout/index.tsx +++ b/customer-spa/src/pages/Checkout/index.tsx @@ -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([]); const [customFieldData, setCustomFieldData] = useState>({}); + // Dynamic shipping rates + interface ShippingRate { + id: string; + label: string; + cost: number; + method_id: string; + instance_id: number; + } + const [shippingRates, setShippingRates] = useState([]); + const [selectedShippingRate, setSelectedShippingRate] = useState(''); + 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() {

Shipping Method

-
diff --git a/includes/Api/CheckoutController.php b/includes/Api/CheckoutController.php index dfa71ea..0822920 100644 --- a/includes/Api/CheckoutController.php +++ b/includes/Api/CheckoutController.php @@ -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, ]; } + + /** + * 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(), + ]; + } } \ No newline at end of file