| 1 |
<?php |
| 2 |
/** |
| 3 |
* Class Frontend/Container file. |
| 4 |
* |
| 5 |
* @package PostNLWooCommerce\Frontend |
| 6 |
*/ |
| 7 |
|
| 8 |
namespace PostNLWooCommerce\Frontend; |
| 9 |
|
| 10 |
use PostNLWooCommerce\Address_Utils; |
| 11 |
use PostNLWooCommerce\Shipping_Method\Settings; |
| 12 |
use PostNLWooCommerce\Rest_API\Service_Factory; |
| 13 |
use PostNLWooCommerce\Rest_API\Contracts\Pickup_Location_Service_Interface; |
| 14 |
use PostNLWooCommerce\Rest_API\Contracts\Timeframe_Service_Interface; |
| 15 |
use PostNLWooCommerce\Utils; |
| 16 |
use PostNLWooCommerce\Helper\Mapping; |
| 17 |
use PostNLWooCommerce\Frontend\Checkout_Fields; |
| 18 |
|
| 19 |
if ( ! defined( 'ABSPATH' ) ) { |
| 20 |
exit; |
| 21 |
} |
| 22 |
|
| 23 |
/** |
| 24 |
* Class Delivery_Day |
| 25 |
* |
| 26 |
* @package PostNLWooCommerce\Frontend |
| 27 |
*/ |
| 28 |
class Container { |
| 29 |
/** |
| 30 |
* Settings class instance. |
| 31 |
* |
| 32 |
* @var PostNLWooCommerce\Shipping_Method\Settings |
| 33 |
*/ |
| 34 |
protected $settings; |
| 35 |
|
| 36 |
/** |
| 37 |
* Lazy-initialised Service_Factory instance. |
| 38 |
* |
| 39 |
* @var Service_Factory|null |
| 40 |
*/ |
| 41 |
private $service_factory_instance = null; |
| 42 |
|
| 43 |
/** |
| 44 |
* Tab field name. |
| 45 |
* |
| 46 |
* @var tab_field |
| 47 |
*/ |
| 48 |
protected $tab_field = POSTNL_SETTINGS_ID . '_option'; |
| 49 |
|
| 50 |
/** |
| 51 |
* Init and hook in the integration. |
| 52 |
* |
| 53 |
* @param bool $register_hooks Whether to register the WordPress hooks. The |
| 54 |
* bootstrap instance (Main::get_frontend()) passes |
| 55 |
* true; transient instances created only to reuse |
| 56 |
* helper methods (e.g. the blocks AJAX handler) must |
| 57 |
* pass false, otherwise the global woocommerce_package_rates |
| 58 |
* filters get registered twice and inject_letterbox_rates_for_all_methods |
| 59 |
* runs twice in the same request, duplicating the 24h/48h rates. |
| 60 |
*/ |
| 61 |
public function __construct( bool $register_hooks = true ) { |
| 62 |
$this->settings = Settings::get_instance(); |
| 63 |
|
| 64 |
if ( $register_hooks ) { |
| 65 |
$this->init_hooks(); |
| 66 |
} |
| 67 |
} |
| 68 |
|
| 69 |
/** |
| 70 |
* Collection of hooks when initiation. |
| 71 |
*/ |
| 72 |
public function init_hooks() { |
| 73 |
add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_scripts_styles' ) ); |
| 74 |
|
| 75 |
add_action( 'woocommerce_review_order_after_shipping', array( $this, 'postnl_fields' ), 10 ); |
| 76 |
add_action( 'woocommerce_cart_calculate_fees', array( $this, 'add_cart_fees' ), 10, 1 ); |
| 77 |
|
| 78 |
add_filter( 'woocommerce_update_order_review_fragments', array( $this, 'fill_validated_address' ) ); |
| 79 |
add_filter( 'woocommerce_cart_shipping_method_full_label', array( $this, 'add_shipping_method_icon' ), 10, 2 ); |
| 80 |
|
| 81 |
if ( ! Utils::is_blocks_checkout() ) { |
| 82 |
add_filter( 'woocommerce_package_rates', array( $this, 'inject_postnl_base_fees' ), 20, 2 ); |
| 83 |
} |
| 84 |
add_filter( 'woocommerce_package_rates', array( $this, 'inject_letterbox_rates_for_all_methods' ), 15, 2 ); |
| 85 |
add_filter( 'woocommerce_cart_shipping_packages', array( $this, 'add_postnl_option_to_package' ) ); |
| 86 |
} |
| 87 |
|
| 88 |
/** |
| 89 |
* Return a lazy-initialised Service_Factory instance. |
| 90 |
* |
| 91 |
* @return Service_Factory |
| 92 |
*/ |
| 93 |
private function service_factory(): Service_Factory { |
| 94 |
if ( null === $this->service_factory_instance ) { |
| 95 |
$this->service_factory_instance = new Service_Factory( $this->settings ); |
| 96 |
} |
| 97 |
return $this->service_factory_instance; |
| 98 |
} |
| 99 |
|
| 100 |
/** |
| 101 |
* Enqueue scripts and style. |
| 102 |
*/ |
| 103 |
public function enqueue_scripts_styles() { |
| 104 |
if ( ! is_checkout() && ! is_cart() ) { |
| 105 |
return; |
| 106 |
} |
| 107 |
|
| 108 |
// Enqueue styles. |
| 109 |
wp_enqueue_style( |
| 110 |
'postnl-fe-checkout', |
| 111 |
POSTNL_WC_PLUGIN_DIR_URL . '/assets/css/fe-checkout.css', |
| 112 |
array( 'postnl-fill-in-button' ), |
| 113 |
POSTNL_WC_VERSION |
| 114 |
); |
| 115 |
|
| 116 |
// Only enqueue JS for classic checkout. |
| 117 |
if ( Utils::is_blocks_checkout() ) { |
| 118 |
return; |
| 119 |
} |
| 120 |
|
| 121 |
if ( is_cart() ) { |
| 122 |
return; |
| 123 |
} |
| 124 |
|
| 125 |
wp_enqueue_script( |
| 126 |
'postnl-fe-checkout', |
| 127 |
POSTNL_WC_PLUGIN_DIR_URL . '/assets/js/fe-checkout.js', |
| 128 |
array( 'jquery' ), |
| 129 |
POSTNL_WC_VERSION, |
| 130 |
true |
| 131 |
); |
| 132 |
|
| 133 |
$settings = Settings::get_instance(); |
| 134 |
|
| 135 |
wp_localize_script( |
| 136 |
'postnl-fe-checkout', |
| 137 |
'postnlParams', |
| 138 |
array( |
| 139 |
'i18n' => array( |
| 140 |
'deliveryDays' => esc_html__( 'Delivery Days', 'postnl-for-woocommerce' ), |
| 141 |
'pickup' => esc_html__( 'Pickup', 'postnl-for-woocommerce' ), |
| 142 |
), |
| 143 |
'delivery_day_fee_formatted' => Utils::get_formatted_fee_total_price( $settings->get_delivery_days_fee() ), |
| 144 |
'pickup_fee_formatted' => Utils::get_formatted_fee_total_price( $settings->get_pickup_delivery_fee() ), |
| 145 |
'currency' => array( |
| 146 |
'symbol' => html_entity_decode( get_woocommerce_currency_symbol(), ENT_QUOTES, 'UTF-8' ), |
| 147 |
'symbolPosition' => get_option( 'woocommerce_currency_pos', 'left' ), |
| 148 |
'decimalSeparator' => wc_get_price_decimal_separator(), |
| 149 |
'thousandSeparator' => wc_get_price_thousand_separator(), |
| 150 |
'precision' => wc_get_price_decimals(), |
| 151 |
), |
| 152 |
) |
| 153 |
); |
| 154 |
} |
| 155 |
|
| 156 |
/** |
| 157 |
* Get enabled tabs. |
| 158 |
* |
| 159 |
* @param array $response Response from PostNL Checkout Rest API. |
| 160 |
* |
| 161 |
* @return array |
| 162 |
*/ |
| 163 |
public function get_available_tabs( $response ) { |
| 164 |
return apply_filters( 'postnl_frontend_checkout_tab', array(), $response ); |
| 165 |
} |
| 166 |
|
| 167 |
/** |
| 168 |
* Get tab field value. |
| 169 |
* |
| 170 |
* @param array $post_data Array of global _POST data. |
| 171 |
* |
| 172 |
* @return String |
| 173 |
*/ |
| 174 |
public function get_tab_field_value( $post_data ) { |
| 175 |
return ( ! empty( $post_data[ $this->tab_field ] ) ) ? $post_data[ $this->tab_field ] : ''; |
| 176 |
} |
| 177 |
|
| 178 |
/** |
| 179 |
* Get checkout $_POST['post_data']. |
| 180 |
* |
| 181 |
* @return array |
| 182 |
*/ |
| 183 |
public function get_checkout_post_data() { |
| 184 |
if ( empty( $_REQUEST['post_data'] ) ) { |
| 185 |
return array(); |
| 186 |
} |
| 187 |
|
| 188 |
$post_data = array(); |
| 189 |
|
| 190 |
parse_str( sanitize_text_field( wp_unslash( urldecode( $_REQUEST['post_data'] ) ) ), $post_data ); |
| 191 |
|
| 192 |
return $post_data; |
| 193 |
} |
| 194 |
|
| 195 |
/** |
| 196 |
* Get data from PostNL Checkout Rest API. |
| 197 |
* |
| 198 |
* @param array $post_data Checkout post input. |
| 199 |
* |
| 200 |
* @return array. |
| 201 |
* @throws \Exception If the checkout data process has error. |
| 202 |
*/ |
| 203 |
public function get_checkout_data( $post_data ) { |
| 204 |
$response = self::aggregate_delivery_options( |
| 205 |
$this->service_factory()->timeframe_service(), |
| 206 |
$this->service_factory()->pickup_location_service(), |
| 207 |
$post_data, |
| 208 |
$this->settings->is_pickup_points_enabled() |
| 209 |
); |
| 210 |
$letterbox = Utils::is_cart_eligible_auto_letterbox( \WC()->cart ); |
| 211 |
|
| 212 |
return array( |
| 213 |
'response' => $response, |
| 214 |
'post_data' => $post_data, |
| 215 |
'letterbox' => $letterbox, |
| 216 |
); |
| 217 |
} |
| 218 |
|
| 219 |
/** |
| 220 |
* Compose the checkout response from the delivery-day and pickup-location services. |
| 221 |
* |
| 222 |
* V1 returned delivery days and pickup points in a single /shipment/v1/checkout |
| 223 |
* response, so the Legacy transport resolves both flows to one shared |
| 224 |
* Checkout_Service instance whose one response already carries DeliveryOptions |
| 225 |
* and PickupOptions. V4 splits them into two endpoints, so Service_Factory hands |
| 226 |
* back a distinct pickup service that must be queried separately and its result |
| 227 |
* merged back into the delivery-day response. |
| 228 |
* |
| 229 |
* The two transports are reconciled here, at the consumer, so the assembled |
| 230 |
* array is identical either way and Frontend\Delivery_Day / Frontend\Dropoff_Points |
| 231 |
* (and the blocks components reached via Extend_Block_Core) render the same |
| 232 |
* regardless of which transport answered. |
| 233 |
* |
| 234 |
* The V4 lookups are issued sequentially: the plugin and the SDK have no async |
| 235 |
* transport, so genuine parallel execution is deferred. Repeat pageloads for the |
| 236 |
* same address are served from the SDK response cache, so the second call is cheap |
| 237 |
* after the first. |
| 238 |
* |
| 239 |
* Error semantics match the legacy single call: either service throwing aborts the |
| 240 |
* whole lookup, so a failure never renders delivery days without pickup points or |
| 241 |
* vice versa. |
| 242 |
* |
| 243 |
* The pickup gate mirrors legacy as well. Rest_API\Base_Info sends the same |
| 244 |
* is_pickup_points_enabled() setting as pickup_points_enabled, and |
| 245 |
* Rest_API\Legacy\Checkout\Client only adds 'Pickup' to the request options when it |
| 246 |
* is set, so a merchant who disabled pickup points gets a response with no |
| 247 |
* PickupOptions key on either transport and the pickup tab stays hidden. Delivery |
| 248 |
* days deliberately have no matching gate: Base_Info hardcodes delivery_days_enabled |
| 249 |
* to true. |
| 250 |
* |
| 251 |
* @param Timeframe_Service_Interface $timeframe_service Delivery-day service. |
| 252 |
* @param Pickup_Location_Service_Interface $pickup_service Pickup-location service. |
| 253 |
* @param array $post_data Checkout post input. |
| 254 |
* @param bool $pickup_enabled Whether the merchant enabled pickup points. |
| 255 |
* |
| 256 |
* @return array Combined response carrying both DeliveryOptions and PickupOptions. |
| 257 |
* @throws \Exception If either underlying service request fails. |
| 258 |
*/ |
| 259 |
protected static function aggregate_delivery_options( Timeframe_Service_Interface $timeframe_service, Pickup_Location_Service_Interface $pickup_service, $post_data, bool $pickup_enabled ) { |
| 260 |
$response = $timeframe_service->get_delivery_options( $post_data ); |
| 261 |
|
| 262 |
// Legacy shares one instance across both flows; its single response already |
| 263 |
// holds both halves, so a second call would only repeat the same request. |
| 264 |
// A disabled setting skips the lookup outright, leaving PickupOptions absent. |
| 265 |
if ( $pickup_service === $timeframe_service || ! $pickup_enabled ) { |
| 266 |
return $response; |
| 267 |
} |
| 268 |
|
| 269 |
$pickup = $pickup_service->get_pickup_locations( $post_data ); |
| 270 |
$response['PickupOptions'] = isset( $pickup['PickupOptions'] ) ? $pickup['PickupOptions'] : array(); |
| 271 |
|
| 272 |
return $response; |
| 273 |
} |
| 274 |
|
| 275 |
/** |
| 276 |
* Get default value for NL -> NL if nothing is picked. |
| 277 |
* |
| 278 |
* @param Array $response Response from checkout API. |
| 279 |
* @param Array $post_data Submitted post input. |
| 280 |
* |
| 281 |
* @return Array. |
| 282 |
*/ |
| 283 |
public function get_default_value( $response, $post_data ) { |
| 284 |
$default_val = array( |
| 285 |
'val' => '', |
| 286 |
'day' => '', |
| 287 |
'date' => '', |
| 288 |
'from' => '', |
| 289 |
'to' => '', |
| 290 |
'type' => '', |
| 291 |
'price' => '', |
| 292 |
); |
| 293 |
|
| 294 |
if ( empty( $response['DeliveryOptions'] ) ) { |
| 295 |
return $default_val; |
| 296 |
} |
| 297 |
|
| 298 |
$non_standard_fees = Base::non_standard_fees_data(); |
| 299 |
|
| 300 |
foreach ( $response['DeliveryOptions'] as $delivery_option ) { |
| 301 |
if ( empty( $delivery_option['DeliveryDate'] ) || empty( $delivery_option['Timeframe'] ) ) { |
| 302 |
continue; |
| 303 |
} |
| 304 |
|
| 305 |
$options = array_map( |
| 306 |
function ( $timeframe ) use ( $non_standard_fees ) { |
| 307 |
$type = array_shift( $timeframe['Options'] ); |
| 308 |
$price = isset( $non_standard_fees[ $type ] ) ? $non_standard_fees[ $type ]['fee_price'] : 0; |
| 309 |
|
| 310 |
return array( |
| 311 |
'from' => Utils::get_hour_min( $timeframe['From'] ), |
| 312 |
'to' => Utils::get_hour_min( $timeframe['To'] ), |
| 313 |
'type' => $type, |
| 314 |
'price' => $price, |
| 315 |
); |
| 316 |
}, |
| 317 |
$delivery_option['Timeframe'] |
| 318 |
); |
| 319 |
|
| 320 |
$options = array_filter( |
| 321 |
$options, |
| 322 |
function ( $option ) use ( $non_standard_fees ) { |
| 323 |
return ! isset( $non_standard_fees[ $option['type'] ] ); |
| 324 |
} |
| 325 |
); |
| 326 |
|
| 327 |
if ( empty( $options ) ) { |
| 328 |
continue; |
| 329 |
} |
| 330 |
|
| 331 |
$timestamp = strtotime( $delivery_option['DeliveryDate'] ); |
| 332 |
$default_val['day'] = gmdate( 'l', $timestamp ); |
| 333 |
$default_val['date'] = gmdate( 'Y-m-d', $timestamp ); |
| 334 |
$default_val['from'] = $options[0]['from']; |
| 335 |
$default_val['to'] = $options[0]['to']; |
| 336 |
$default_val['type'] = $options[0]['type']; |
| 337 |
$default_val['price'] = $options[0]['price']; |
| 338 |
$default_val['val'] = sanitize_title( $default_val['date'] . '_' . $default_val['from'] . '-' . $default_val['to'] . '_' . $default_val['price'] ); |
| 339 |
|
| 340 |
return $default_val; |
| 341 |
} |
| 342 |
|
| 343 |
return $default_val; |
| 344 |
} |
| 345 |
|
| 346 |
/** |
| 347 |
* Get the carrier base cost by reading the currently-selected PostNL rate |
| 348 |
* and subtracting the PostNL tab fee that was injected into it. |
| 349 |
* |
| 350 |
* @param array $post_data Checkout post data (used to determine active tab). |
| 351 |
* |
| 352 |
* @return float Carrier base cost (≥ 0). |
| 353 |
*/ |
| 354 |
private function get_carrier_base_cost( array $post_data ): float { |
| 355 |
$option = $post_data['postnl_option'] ?? ''; |
| 356 |
|
| 357 |
$tab_fee = 0.0; |
| 358 |
if ( 'delivery_day' === $option ) { |
| 359 |
$tab_fee = (float) $this->settings->get_delivery_days_fee(); |
| 360 |
} elseif ( 'dropoff_points' === $option ) { |
| 361 |
$tab_fee = (float) $this->settings->get_pickup_delivery_fee(); |
| 362 |
} |
| 363 |
|
| 364 |
$chosen = WC()->session ? WC()->session->get( 'chosen_shipping_methods', array() ) : array(); |
| 365 |
$packages = WC()->shipping()->get_packages(); |
| 366 |
$supported = $this->settings->get_supported_shipping_methods(); |
| 367 |
|
| 368 |
foreach ( $packages as $i => $package ) { |
| 369 |
$method_key = $chosen[ $i ] ?? ''; |
| 370 |
|
| 371 |
if ( ! isset( $package['rates'][ $method_key ] ) ) { |
| 372 |
continue; |
| 373 |
} |
| 374 |
|
| 375 |
$rate = $package['rates'][ $method_key ]; |
| 376 |
|
| 377 |
if ( in_array( $rate->get_method_id(), $supported, true ) ) { |
| 378 |
// The morning/evening extra fee is folded into the rate; subtract it per-package. |
| 379 |
$extra_fee = 0.0; |
| 380 |
if ( 'delivery_day' === $option ) { |
| 381 |
$extra_fee = (float) ( $package['destination']['postnl_delivery_day_price'] ?? WC()->session->get( 'postnl_delivery_day_price', 0 ) ); |
| 382 |
} |
| 383 |
return max( 0.0, (float) $rate->cost - $tab_fee - $extra_fee ); |
| 384 |
} |
| 385 |
} |
| 386 |
|
| 387 |
return 0.0; |
| 388 |
} |
| 389 |
|
| 390 |
/** |
| 391 |
* Check whether a supported PostNL shipping method is the chosen method. |
| 392 |
* |
| 393 |
* Used to detect PostNL threshold-based free shipping: if a PostNL method is |
| 394 |
* selected but its carrier base cost is 0, the minimum_for_free_shipping |
| 395 |
* threshold has been reached and all PostNL fees should be suppressed. |
| 396 |
* |
| 397 |
* @return bool |
| 398 |
*/ |
| 399 |
private function is_postnl_method_chosen(): bool { |
| 400 |
$chosen = WC()->session ? WC()->session->get( 'chosen_shipping_methods', array() ) : array(); |
| 401 |
$packages = WC()->shipping()->get_packages(); |
| 402 |
$supported = $this->settings->get_supported_shipping_methods(); |
| 403 |
|
| 404 |
foreach ( $packages as $i => $package ) { |
| 405 |
$method_key = $chosen[ $i ] ?? ''; |
| 406 |
if ( isset( $package['rates'][ $method_key ] ) && in_array( $package['rates'][ $method_key ]->get_method_id(), $supported, true ) ) { |
| 407 |
return true; |
| 408 |
} |
| 409 |
} |
| 410 |
|
| 411 |
return false; |
| 412 |
} |
| 413 |
|
| 414 |
/** |
| 415 |
* Add delivery day & Pickup points fields. |
| 416 |
* |
| 417 |
* @param array $post_data Checkout post input. |
| 418 |
* |
| 419 |
* @return void. |
| 420 |
* @throws \Exception. |
| 421 |
*/ |
| 422 |
public function display_fields( $post_data ) { |
| 423 |
|
| 424 |
$checkout_data = $this->get_checkout_data( $post_data ); |
| 425 |
|
| 426 |
if ( empty( $checkout_data['response'] ) ) { |
| 427 |
return; |
| 428 |
} |
| 429 |
|
| 430 |
$is_free_shipping = Utils::is_free_shipping_applied(); |
| 431 |
$delivery_day_fee = (float) $this->settings->get_delivery_days_fee(); |
| 432 |
$pickup_fee = (float) $this->settings->get_pickup_delivery_fee(); |
| 433 |
$carrier_base_cost = $is_free_shipping ? 0.0 : $this->get_carrier_base_cost( $checkout_data['post_data'] ); |
| 434 |
|
| 435 |
// PostNL threshold-based free shipping: a PostNL method is selected but its |
| 436 |
// rate was registered with cost = 0 (minimum_for_free_shipping reached). |
| 437 |
if ( ! $is_free_shipping && 0.0 === $carrier_base_cost && $this->is_postnl_method_chosen() ) { |
| 438 |
$is_free_shipping = true; |
| 439 |
} |
| 440 |
|
| 441 |
$tabs = $this->get_available_tabs( $checkout_data['response'] ); |
| 442 |
$default_tab = $this->settings->get_default_checkout_tab(); |
| 443 |
|
| 444 |
$tab_ids = array_column( $tabs, 'id' ); |
| 445 |
if ( ! empty( $tabs ) && ! in_array( $default_tab, $tab_ids, true ) ) { |
| 446 |
$default_tab = $tabs[0]['id']; |
| 447 |
} |
| 448 |
|
| 449 |
// TODO: generalise to mirror the JS path in client/checkout/postnl-container/block.js |
| 450 |
// (findIndex + splice/unshift). Today this only fires for 'dropoff_points' |
| 451 |
// because there are exactly two tabs and 'delivery_day' is naturally first; |
| 452 |
// adding a third tab without revisiting this branch will silently break |
| 453 |
// reorder in the shortcode checkout while the blocks checkout keeps working. |
| 454 |
if ( 'dropoff_points' === $default_tab && count( $tabs ) > 1 ) { |
| 455 |
$preferred = array_filter( $tabs, static fn( $t ) => $t['id'] === $default_tab ); |
| 456 |
$rest = array_filter( $tabs, static fn( $t ) => $t['id'] !== $default_tab ); |
| 457 |
$tabs = array_merge( array_values( $preferred ), array_values( $rest ) ); |
| 458 |
} |
| 459 |
|
| 460 |
$template_args = array( |
| 461 |
'tabs' => $tabs, |
| 462 |
'response' => $checkout_data['response'], |
| 463 |
'post_data' => $checkout_data['post_data'], |
| 464 |
'default_val' => $this->get_default_value( $checkout_data['response'], $checkout_data['post_data'] ), |
| 465 |
'letterbox' => $checkout_data['letterbox'], |
| 466 |
'fields' => array( |
| 467 |
array( |
| 468 |
'name' => $this->tab_field, |
| 469 |
'value' => $this->get_tab_field_value( $checkout_data['post_data'] ), |
| 470 |
), |
| 471 |
), |
| 472 |
'pickup_fee' => $pickup_fee, |
| 473 |
'delivery_day_fee' => $delivery_day_fee, |
| 474 |
'carrier_base_cost' => $carrier_base_cost, |
| 475 |
'is_free_shipping' => $is_free_shipping, |
| 476 |
'default_tab' => $default_tab, |
| 477 |
); |
| 478 |
|
| 479 |
wc_get_template( 'checkout/postnl-container.php', $template_args, '', POSTNL_WC_PLUGIN_DIR_PATH . '/templates/' ); |
| 480 |
} |
| 481 |
|
| 482 |
/** |
| 483 |
* Check address and display fields. |
| 484 |
* |
| 485 |
* @return void. |
| 486 |
*/ |
| 487 |
public function postnl_fields() { |
| 488 |
try { |
| 489 |
$post_data = $this->get_checkout_post_data(); |
| 490 |
|
| 491 |
if ( empty( $post_data ) ) { |
| 492 |
return; |
| 493 |
} |
| 494 |
|
| 495 |
$sipping_methods = $this->settings->get_supported_shipping_methods(); |
| 496 |
|
| 497 |
foreach ( $post_data as $post_key => $post_value ) { |
| 498 |
if ( 'shipping_method' === $post_key && ! in_array( Utils::get_cart_shipping_method_id( $post_value[0] ), $sipping_methods ) ) { |
| 499 |
// Clear PostNL session data when shipping method is not supported. |
| 500 |
Utils::clear_postnl_checkout_session(); |
| 501 |
return; |
| 502 |
} |
| 503 |
} |
| 504 |
|
| 505 |
$available_country = Mapping::available_country_for_checkout_feature(); |
| 506 |
$store_country = Utils::get_base_country(); |
| 507 |
|
| 508 |
// To fix cache issues, check billing country if it is the same address for shipping. |
| 509 |
if ( ! empty( $post_data['ship_to_different_address'] ) ) { |
| 510 |
$receiver_country = ! empty( $post_data['shipping_country'] ) ? $post_data['shipping_country'] : ''; |
| 511 |
} else { |
| 512 |
$receiver_country = ! empty( $post_data['billing_country'] ) ? $post_data['billing_country'] : ''; |
| 513 |
} |
| 514 |
|
| 515 |
if ( ! isset( $available_country[ $store_country ][ $receiver_country ] ) ) { |
| 516 |
// Clear PostNL session data when country is not supported. |
| 517 |
Utils::clear_postnl_checkout_session(); |
| 518 |
return; |
| 519 |
} |
| 520 |
|
| 521 |
$post_data = Address_Utils::set_post_data_address( $post_data ); |
| 522 |
|
| 523 |
if ( empty( $post_data['shipping_postcode'] ) ) { |
| 524 |
// Clear PostNL session data when postcode is missing. |
| 525 |
Utils::clear_postnl_checkout_session(); |
| 526 |
return; |
| 527 |
} |
| 528 |
|
| 529 |
// Validate address. |
| 530 |
if ( $this->is_address_validation_required() ) { |
| 531 |
$is_reorder_nl_address_enabled = $this->settings->is_reorder_nl_address_enabled(); |
| 532 |
|
| 533 |
if ( empty( $post_data['shipping_house_number'] ) && $is_reorder_nl_address_enabled ) { |
| 534 |
// Clear PostNL session data when house number is missing. |
| 535 |
Utils::clear_postnl_checkout_session(); |
| 536 |
return; |
| 537 |
} elseif ( empty( $post_data['shipping_house_number'] ) && ! $is_reorder_nl_address_enabled ) { |
| 538 |
throw new \Exception( 'Address does not contain house number!' ); |
| 539 |
} |
| 540 |
|
| 541 |
$this->validated_address( $post_data ); |
| 542 |
} |
| 543 |
|
| 544 |
// Display PostNL Delivery day & Pickup points. |
| 545 |
$this->display_fields( $post_data ); |
| 546 |
|
| 547 |
} catch ( \Exception $e ) { |
| 548 |
wc_add_notice( $e->getMessage(), 'error' ); |
| 549 |
} |
| 550 |
} |
| 551 |
|
| 552 |
/** |
| 553 |
* Check address by PostNL Checkout Rest API. |
| 554 |
* |
| 555 |
* @param Array $post_data Checkout post data. |
| 556 |
*/ |
| 557 |
public function validated_address( $post_data ) { |
| 558 |
$response = $this->service_factory()->postcode_check_service()->check( $post_data ); |
| 559 |
|
| 560 |
if ( empty( $response[0] ) ) { |
| 561 |
// Clear validated address. |
| 562 |
WC()->session->set( POSTNL_SETTINGS_ID . '_validated_address', array() ); |
| 563 |
// Mark the address as invalid in the session: |
| 564 |
WC()->session->set( POSTNL_SETTINGS_ID . '_invalid_address_marker', true ); |
| 565 |
// Add notice without blocking checkout call. |
| 566 |
wc_add_notice( esc_html__( 'This is not a valid address!', 'postnl-for-woocommerce' ), 'notice' ); |
| 567 |
} else { |
| 568 |
// Set validated address. |
| 569 |
WC()->session->set( |
| 570 |
POSTNL_SETTINGS_ID . '_validated_address', |
| 571 |
array( |
| 572 |
'city' => $response[0]['city'], |
| 573 |
'street' => $response[0]['streetName'], |
| 574 |
'house_number' => $response[0]['houseNumber'], |
| 575 |
'ship_to_different_address' => ! empty( $post_data['ship_to_different_address'] ), |
| 576 |
) |
| 577 |
); |
| 578 |
WC()->session->__unset( POSTNL_SETTINGS_ID . '_invalid_address_marker' ); |
| 579 |
} |
| 580 |
} |
| 581 |
|
| 582 |
/** |
| 583 |
* Fill checkout form fields after address validation. |
| 584 |
* |
| 585 |
* @param Array $fragments Cart fragments. |
| 586 |
* |
| 587 |
* @return mixed |
| 588 |
*/ |
| 589 |
public function fill_validated_address( $fragments ) { |
| 590 |
if ( ! $this->settings->is_validate_nl_address_enabled() ) { |
| 591 |
return $fragments; |
| 592 |
} |
| 593 |
|
| 594 |
$validated_address = WC()->session->get( POSTNL_SETTINGS_ID . '_validated_address' ); |
| 595 |
|
| 596 |
if ( ! is_array( $validated_address ) || empty( $validated_address ) ) { |
| 597 |
return $fragments; |
| 598 |
} |
| 599 |
|
| 600 |
if ( $validated_address['ship_to_different_address'] ) { |
| 601 |
$address_type = 'shipping'; |
| 602 |
} else { |
| 603 |
$address_type = 'billing'; |
| 604 |
} |
| 605 |
|
| 606 |
// Fill Address 1 with street name & house number if fields reordering disabled. |
| 607 |
if ( ! $this->settings->is_reorder_nl_address_enabled() ) { |
| 608 |
$address_1 = $validated_address['street'] . ' ' . $validated_address['house_number']; |
| 609 |
} else { |
| 610 |
$address_1 = $validated_address['street']; |
| 611 |
} |
| 612 |
$fragments[ '#' . $address_type . '_address_1' ] = '<input type="text" class="input-text " name="' . $address_type . '_address_1" id="' . $address_type . '_address_1" value="' . $address_1 . '" autocomplete="address-line1">'; |
| 613 |
|
| 614 |
$fragments[ '#' . $address_type . '_city' ] = '<input type="text" class="input-text " name="' . $address_type . '_city" id="' . $address_type . '_city" placeholder="" value="' . $validated_address['city'] . '" autocomplete="address-level2">'; |
| 615 |
|
| 616 |
return $fragments; |
| 617 |
} |
| 618 |
|
| 619 |
/** |
| 620 |
* Add cart fees. |
| 621 |
* |
| 622 |
* Morning/evening delivery fees are folded directly into the shipping rate |
| 623 |
* cost via inject_postnl_base_fees(), so no separate fee line item is needed. |
| 624 |
* |
| 625 |
* @param \WC_Cart $cart Cart object. |
| 626 |
*/ |
| 627 |
public function add_cart_fees( $cart ) { |
| 628 |
return; |
| 629 |
} |
| 630 |
|
| 631 |
/** |
| 632 |
* Collapse every PostNL-linked shipping method into a single canonical letterbox |
| 633 |
* option set when the cart is eligible for automatic letterbox delivery (ALA). |
| 634 |
* |
| 635 |
* This filter is the single source of truth for the letterbox rate set. When ALA |
| 636 |
* succeeds it: |
| 637 |
* - keeps non-linked carriers (e.g. DHL) untouched, including a non-linked |
| 638 |
* Free Shipping method, which stays visible and never affects the letterbox cost; |
| 639 |
* - drops every PostNL-linked rate (the PostNL method's own rate, any linked |
| 640 |
* Flat Rate instances, and a linked Free Shipping method) and emits ONE canonical |
| 641 |
* 24h/48h option set in their place, so the choice appears exactly once regardless |
| 642 |
* of how many linked methods or instances the zone has. A linked Free Shipping |
| 643 |
* method still applies its waiver (the canonical cost drops to 0) but is not shown |
| 644 |
* as a separate row. |
| 645 |
* |
| 646 |
* PostNL::calculate_shipping() deliberately does NOT emit its own letterbox variants |
| 647 |
* — it emits a plain PostNL rate that this filter folds into the canonical set, so |
| 648 |
* there is a single emitter and no duplication. |
| 649 |
* |
| 650 |
* @since 5.9.6 |
| 651 |
* |
| 652 |
* @param array $rates Shipping rates keyed by rate ID. |
| 653 |
* @param array $package Shipping package. |
| 654 |
* @return array |
| 655 |
*/ |
| 656 |
public function inject_letterbox_rates_for_all_methods( $rates, $package ) { |
| 657 |
// Only apply for NL base country. |
| 658 |
if ( 'NL' !== Utils::get_base_country() ) { |
| 659 |
return $rates; |
| 660 |
} |
| 661 |
|
| 662 |
// Check cart eligibility for letterbox. |
| 663 |
if ( ! Utils::is_cart_eligible_auto_letterbox( \WC()->cart ) ) { |
| 664 |
return $rates; |
| 665 |
} |
| 666 |
|
| 667 |
$letterbox_product_type = $this->settings->get_default_automatic_letterboxparcel_product(); |
| 668 |
|
| 669 |
// Only inject when customer can decide or a specific letterbox type is configured. |
| 670 |
if ( ! in_array( $letterbox_product_type, array( 'customer_decide', 'letterbox', 'letterbox_48' ), true ) ) { |
| 671 |
return $rates; |
| 672 |
} |
| 673 |
|
| 674 |
// Idempotency guard: if the canonical letterbox set is already present (this filter |
| 675 |
// ran earlier in the same request), do not rebuild it — that would nest a second |
| 676 |
// ':letterbox' suffix onto an existing variant and duplicate the options. |
| 677 |
foreach ( $rates as $rate ) { |
| 678 |
$rate_meta = $rate->get_meta_data(); |
| 679 |
if ( isset( $rate_meta['letterbox_type'] ) ) { |
| 680 |
return $rates; |
| 681 |
} |
| 682 |
} |
| 683 |
|
| 684 |
$supported = $this->settings->get_supported_shipping_methods(); |
| 685 |
|
| 686 |
// Partition rates: non-linked carriers (including a non-linked Free Shipping method) |
| 687 |
// are kept as-is; every PostNL-linked rate is collapsed into one canonical letterbox |
| 688 |
// option set below. |
| 689 |
$kept_rates = array(); |
| 690 |
$linked_rates = array(); |
| 691 |
$has_free_shipping = false; |
| 692 |
|
| 693 |
foreach ( $rates as $rate_id => $rate ) { |
| 694 |
$method_id = $rate->get_method_id(); |
| 695 |
|
| 696 |
if ( 'free_shipping' === $method_id ) { |
| 697 |
// A Free Shipping method only waives the letterbox cost when the |
| 698 |
// merchant has explicitly linked it to PostNL. When linked, it is |
| 699 |
// collapsed like any other PostNL-linked rate: its waiver effect is |
| 700 |
// applied (the canonical letterbox cost drops to 0) but the method |
| 701 |
// itself is not shown as a separate row alongside the letterbox |
| 702 |
// options. A standalone (non-linked) Free Shipping option is |
| 703 |
// independent: it never zeros out the letterbox prices and is kept |
| 704 |
// visible in checkout. |
| 705 |
if ( in_array( $method_id, $supported, true ) ) { |
| 706 |
$has_free_shipping = true; |
| 707 |
} else { |
| 708 |
$kept_rates[ $rate_id ] = $rate; |
| 709 |
} |
| 710 |
continue; |
| 711 |
} |
| 712 |
|
| 713 |
if ( ! in_array( $method_id, $supported, true ) ) { |
| 714 |
$kept_rates[ $rate_id ] = $rate; |
| 715 |
continue; |
| 716 |
} |
| 717 |
|
| 718 |
$linked_rates[ $rate_id ] = $rate; |
| 719 |
} |
| 720 |
|
| 721 |
// No PostNL-linked rates to collapse — leave the package untouched. |
| 722 |
if ( empty( $linked_rates ) ) { |
| 723 |
return $rates; |
| 724 |
} |
| 725 |
|
| 726 |
// Free-shipping determination drives both variants to 0. It is derived ONLY |
| 727 |
// from PostNL-linked signals: a linked Free Shipping method being present |
| 728 |
// ($has_free_shipping) or a linked carrier rate at cost 0 (handled in the loop |
| 729 |
// below, where the PostNL free-shipping threshold has been met). A non-linked |
| 730 |
// Free Shipping method — even one the customer has selected — must not waive |
| 731 |
// the letterbox cost, so Utils::is_free_shipping_applied() is intentionally not |
| 732 |
// consulted here (it ignores linkage and would re-introduce that regression). |
| 733 |
$is_free = $has_free_shipping; |
| 734 |
|
| 735 |
// Cheapest linked carrier cost is the base-cost fallback when no letterbox_fee is set. |
| 736 |
$cheapest_cost = null; |
| 737 |
foreach ( $linked_rates as $rate ) { |
| 738 |
$cost = (float) $rate->get_cost(); |
| 739 |
if ( 0.0 === $cost ) { |
| 740 |
// A linked rate at cost 0 means the PostNL free-shipping threshold was met. |
| 741 |
$is_free = true; |
| 742 |
} |
| 743 |
if ( null === $cheapest_cost || $cost < $cheapest_cost ) { |
| 744 |
$cheapest_cost = $cost; |
| 745 |
} |
| 746 |
} |
| 747 |
$cheapest_cost = ( null === $cheapest_cost ) ? 0.0 : $cheapest_cost; |
| 748 |
|
| 749 |
$letterbox_fee = $this->settings->get_letterbox_fee(); |
| 750 |
$base_cost = ( null !== $letterbox_fee ) ? (float) $letterbox_fee : $cheapest_cost; |
| 751 |
$base_cost = $is_free ? 0.0 : $base_cost; |
| 752 |
$effective_fee_24h = $is_free ? 0.0 : (float) $this->settings->get_letterbox_24_fee(); |
| 753 |
|
| 754 |
$rep_rate_id = null; |
| 755 |
$rep_rate = null; |
| 756 |
foreach ( $linked_rates as $rate_id => $rate ) { |
| 757 |
if ( POSTNL_SETTINGS_ID === $rate->get_method_id() ) { |
| 758 |
$rep_rate_id = $rate_id; |
| 759 |
$rep_rate = $rate; |
| 760 |
break; |
| 761 |
} |
| 762 |
} |
| 763 |
if ( null === $rep_rate ) { |
| 764 |
$rep_rate_id = array_key_first( $linked_rates ); |
| 765 |
$rep_rate = $linked_rates[ $rep_rate_id ]; |
| 766 |
} |
| 767 |
|
| 768 |
$rep_method_id = $rep_rate->get_method_id(); |
| 769 |
$rep_instance = $rep_rate->get_instance_id(); |
| 770 |
|
| 771 |
// Recalculate shipping taxes for the canonical cost. |
| 772 |
$calc_taxes = function ( $cost ) use ( $rep_rate ) { |
| 773 |
if ( wc_tax_enabled() && 'taxable' === $rep_rate->get_tax_status() ) { |
| 774 |
return \WC_Tax::calc_shipping_tax( $cost, \WC_Tax::get_shipping_tax_rates() ); |
| 775 |
} |
| 776 |
return array(); |
| 777 |
}; |
| 778 |
|
| 779 |
// Build the canonical letterbox option set ONCE. |
| 780 |
$canonical = array(); |
| 781 |
|
| 782 |
if ( 'customer_decide' === $letterbox_product_type || 'letterbox' === $letterbox_product_type ) { |
| 783 |
$cost_24h = $base_cost + $effective_fee_24h; |
| 784 |
|
| 785 |
$rate_24h = new \WC_Shipping_Rate( |
| 786 |
$rep_rate_id . ':letterbox', |
| 787 |
Utils::get_letterbox_label_24h(), |
| 788 |
$cost_24h, |
| 789 |
$calc_taxes( $cost_24h ), |
| 790 |
$rep_method_id, |
| 791 |
$rep_instance |
| 792 |
); |
| 793 |
$rate_24h->add_meta_data( 'letterbox_type', 'letterbox' ); |
| 794 |
$canonical[ $rep_rate_id . ':letterbox' ] = $rate_24h; |
| 795 |
} |
| 796 |
|
| 797 |
if ( 'customer_decide' === $letterbox_product_type || 'letterbox_48' === $letterbox_product_type ) { |
| 798 |
$rate_48h = new \WC_Shipping_Rate( |
| 799 |
$rep_rate_id . ':letterbox_48', |
| 800 |
Utils::get_letterbox_label_48h(), |
| 801 |
$base_cost, |
| 802 |
$calc_taxes( $base_cost ), |
| 803 |
$rep_method_id, |
| 804 |
$rep_instance |
| 805 |
); |
| 806 |
$rate_48h->add_meta_data( 'letterbox_type', 'letterbox_48' ); |
| 807 |
$canonical[ $rep_rate_id . ':letterbox_48' ] = $rate_48h; |
| 808 |
} |
| 809 |
|
| 810 |
// Canonical letterbox option(s) first, then the kept rates (non-linked + free_shipping). |
| 811 |
return $canonical + $kept_rates; |
| 812 |
} |
| 813 |
|
| 814 |
/** |
| 815 |
* Add the shipping option fees to the shipping methods |
| 816 |
* |
| 817 |
* @param array $rates. |
| 818 |
* @return array |
| 819 |
*/ |
| 820 |
public function inject_postnl_base_fees( $rates, $package ) { |
| 821 |
if ( Utils::is_free_shipping_applied() ) { |
| 822 |
return $rates; |
| 823 |
} |
| 824 |
|
| 825 |
// Letterbox-eligible carts are owned by the variant emitters |
| 826 |
if ( Utils::is_cart_eligible_auto_letterbox( \WC()->cart ) ) { |
| 827 |
return $rates; |
| 828 |
} |
| 829 |
|
| 830 |
$option = $package['destination']['postnl_option'] ?? WC()->session->get( 'postnl_option', '' ); |
| 831 |
if ( '' === $option ) { |
| 832 |
return $rates; |
| 833 |
} |
| 834 |
|
| 835 |
$pickup_fee = (float) $this->settings->get_pickup_delivery_fee(); |
| 836 |
$base_day_fee = (float) $this->settings->get_delivery_days_fee(); |
| 837 |
$supported = $this->settings->get_supported_shipping_methods(); |
| 838 |
|
| 839 |
foreach ( $rates as $rate_id => $rate ) { |
| 840 |
if ( ! in_array( $rate->get_method_id(), $supported, true ) ) { |
| 841 |
continue; |
| 842 |
} |
| 843 |
|
| 844 |
// PostNL threshold-based free shipping: the rate was registered with |
| 845 |
// cost = 0 by PostNL::calculate_shipping(). Do not inject any fees. |
| 846 |
if ( 0.0 === (float) $rate->cost ) { |
| 847 |
continue; |
| 848 |
} |
| 849 |
|
| 850 |
$extra = 0; |
| 851 |
if ( 'dropoff_points' === $option && $pickup_fee > 0 ) { |
| 852 |
$extra = $pickup_fee; |
| 853 |
} elseif ( 'delivery_day' === $option ) { |
| 854 |
// Fold both the tab base fee and any morning/evening extra fee into the rate. |
| 855 |
// Prefer the package destination value (set by add_postnl_option_to_package during |
| 856 |
// AJAX calls) and fall back to the session value for order placement, when |
| 857 |
// $_REQUEST['post_data'] is no longer available. |
| 858 |
$extra = $base_day_fee; |
| 859 |
$extra += (float) ( $package['destination']['postnl_delivery_day_price'] ?? WC()->session->get( 'postnl_delivery_day_price', 0 ) ); |
| 860 |
} |
| 861 |
|
| 862 |
if ( $extra <= 0 ) { |
| 863 |
continue; |
| 864 |
} |
| 865 |
|
| 866 |
$rate->cost += $extra; |
| 867 |
|
| 868 |
if ( wc_tax_enabled() && 'taxable' === $rate->get_tax_status() ) { |
| 869 |
$tax_rates = \WC_Tax::get_shipping_tax_rates(); |
| 870 |
$rate->taxes = \WC_Tax::calc_shipping_tax( $rate->cost, $tax_rates ); |
| 871 |
} |
| 872 |
} |
| 873 |
|
| 874 |
return $rates; |
| 875 |
} |
| 876 |
|
| 877 |
|
| 878 |
/** |
| 879 |
* Include the selected PostNL option in the shipping package |
| 880 |
* |
| 881 |
* @param array $packages Shipping packages. |
| 882 |
* @return array |
| 883 |
*/ |
| 884 |
public function add_postnl_option_to_package( $packages ) { |
| 885 |
$post_data = $this->get_checkout_post_data(); |
| 886 |
$option = $post_data['postnl_option'] ?? ''; |
| 887 |
|
| 888 |
// Blocks-checkout fallback: Extend_Block_Core::postnl_store_api_callback |
| 889 |
// writes postnl_delivery_type/postnl_delivery_fee to session before this |
| 890 |
// filter fires. Mirror that state into $option so the destination injection |
| 891 |
// below runs on the blocks path too — without it, the WC_Shipping package |
| 892 |
// hash never changes on tab switch and add_postnl_fees_to_rates() is never |
| 893 |
// re-invoked against fresh rates. WC ≤ 10.4 accidentally masked this via |
| 894 |
// divergent package shapes in CartController::get_shipping_packages(); that |
| 895 |
// divergence was removed, exposing the gap. |
| 896 |
if ( '' === $option && WC()->session ) { |
| 897 |
$session_type = WC()->session->get( 'postnl_delivery_type', '' ); |
| 898 |
if ( 'Pickup' === $session_type ) { |
| 899 |
$option = 'dropoff_points'; |
| 900 |
} elseif ( '' !== $session_type ) { |
| 901 |
$option = 'delivery_day'; |
| 902 |
} |
| 903 |
} |
| 904 |
|
| 905 |
if ( '' === $option ) { |
| 906 |
return $packages; |
| 907 |
} |
| 908 |
|
| 909 |
WC()->session->set( 'postnl_option', $option ); |
| 910 |
|
| 911 |
// Store the morning/evening extra fee in the destination so the shipping |
| 912 |
// rate cache key changes when the selection changes, forcing recalculation. |
| 913 |
// Also persist to session so inject_postnl_base_fees can read it during |
| 914 |
// order placement, when $_REQUEST['post_data'] is no longer available. |
| 915 |
$raw_price = $post_data['postnl_delivery_day_price'] |
| 916 |
?? WC()->session->get( 'postnl_delivery_fee', 0 ); |
| 917 |
|
| 918 |
$delivery_day_price = ( 'delivery_day' === $option ) |
| 919 |
? (string) (float) $raw_price |
| 920 |
: '0'; |
| 921 |
|
| 922 |
WC()->session->set( 'postnl_delivery_day_price', $delivery_day_price ); |
| 923 |
|
| 924 |
foreach ( $packages as $key => $package ) { |
| 925 |
$packages[ $key ]['destination']['postnl_option'] = $option; |
| 926 |
$packages[ $key ]['destination']['postnl_delivery_day_price'] = $delivery_day_price; |
| 927 |
} |
| 928 |
|
| 929 |
return $packages; |
| 930 |
} |
| 931 |
|
| 932 |
/** |
| 933 |
* Check if address validation required. |
| 934 |
* |
| 935 |
* @return bool |
| 936 |
*/ |
| 937 |
public function is_address_validation_required() { |
| 938 |
if ( ! $this->settings->is_validate_nl_address_enabled() ) { |
| 939 |
return false; |
| 940 |
} |
| 941 |
|
| 942 |
if ( 'NL' !== Address_Utils::get_customer_billing_country() && 'NL' !== Address_Utils::get_customer_shipping_country() ) { |
| 943 |
return false; |
| 944 |
} |
| 945 |
|
| 946 |
return true; |
| 947 |
} |
| 948 |
|
| 949 |
/** |
| 950 |
* Replace shipping method title with Icon. |
| 951 |
* |
| 952 |
* @param String $label String of label html. |
| 953 |
* @param \WC_Shipping_Rate $method Shipping method object. |
| 954 |
* |
| 955 |
* @return string |
| 956 |
*/ |
| 957 |
public function add_shipping_method_icon( $label, $method ) { |
| 958 |
if ( POSTNL_SETTINGS_ID === $method->get_method_id() ) { |
| 959 |
$method_title = $method->get_label(); |
| 960 |
$label = '<img src="' . esc_url( trailingslashit( POSTNL_WC_PLUGIN_DIR_URL ) . 'assets/images/postnl-new-brand-logo.png' ) . '" class="postnl_shipping_method_icon" alt="' . $method_title . '" />' . $label; |
| 961 |
} |
| 962 |
|
| 963 |
return $label; |
| 964 |
} |
| 965 |
} |
| 966 |
|