PluginProbe
MONEI Payments for WooCommerce / trunk
MONEI Payments for WooCommerce vtrunk
7.3.3 7.3.2 7.3.1 7.3.0 7.2.4 7.2.3 7.2.2 7.2.0 7.2.1 7.1.3 2.1.0 3.0.0 3.1.0 3.1.1 4.0.0 4.1.0 4.1.1 4.2.0 4.2.1 5.0 5.1.0 5.1.1 5.1.2 5.2.2 5.2.3 All 87 releases
monei / src / Services / express / ExpressCheckoutAssets.php

ExpressCheckoutAssets.php in MONEI Payments for WooCommerce trunk, at src/Services/express/ExpressCheckoutAssets.php

475 lines 14.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Express checkout asset loading and classic markup.
4 *
5 * @package Monei
6 */
7
8 namespace Monei\Services\express;
9
10 use Monei\Core\ContainerProvider;
11 use Monei\Gateways\Abstracts\WCMoneiPaymentGateway;
12 use Monei\Gateways\PaymentMethods\WCGatewayMoneiAppleGoogle;
13 use Monei\Gateways\PaymentMethods\WCGatewayMoneiPaypal;
14 use Monei\Services\PaymentMethodsService;
15 use WC_AJAX;
16 use WC_Product;
17 use WP_Post;
18 use Exception;
19
20 if ( ! defined( 'ABSPATH' ) ) {
21 exit;
22 }
23
24 /**
25 * Loads the express checkout button assets and prints the mount containers on the
26 * classic (non-block) product, cart and checkout pages.
27 *
28 * The express surfaces span three pages, while `WCGatewayMoneiAppleGoogle::monei_scripts()`
29 * deliberately only loads on checkout. Widening that method would change when the
30 * regular Apple/Google Pay checkout assets load, so express gets its own service
31 * instead — the same shape as ExpressCheckoutAjaxHandler, bootstrapped from the
32 * plugin's `init()` because the container is lazy.
33 *
34 * Block surfaces load nothing from here: the Cart and Checkout blocks pull their
35 * script through the payment method registry, in MoneiAppleGoogleBlocksSupport.
36 */
37 class ExpressCheckoutAssets {
38
39 /**
40 * Handle of the classic express script.
41 */
42 const SCRIPT_HANDLE = 'monei-express-checkout';
43
44 /**
45 * Handle of the express stylesheet, shared by the classic and blocks paths.
46 */
47 const STYLE_HANDLE = 'monei-express-checkout';
48
49 /**
50 * Handle of the blocks express registration script.
51 */
52 const BLOCKS_SCRIPT_HANDLE = 'wc-monei-express-blocks-integration';
53
54 /**
55 * Name the blocks express method registers under. It must differ from the
56 * `monei_apple_google` entry the regular payment method already occupies, or the
57 * second registration silently replaces the first.
58 */
59 const BLOCKS_METHOD_NAME = 'monei_apple_google_express';
60
61 /**
62 * The wallet component each express gateway renders. These keys are the contract
63 * with the JavaScript: they name the component factory and the mount container.
64 */
65 const METHOD_PAYMENT_REQUEST = 'payment_request';
66 const METHOD_PAYPAL = 'paypal';
67
68 /**
69 * Express gateways by wallet component, or null before they are resolved.
70 *
71 * @var array<string, WCMoneiPaymentGateway>|null
72 */
73 private static $gateways = null;
74
75 /**
76 * Register the frontend hooks.
77 *
78 * @return void
79 */
80 public function init() {
81 add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_classic_assets' ) );
82 add_action( 'woocommerce_before_checkout_form', array( $this, 'render_checkout_buttons' ), 5 );
83 add_action( 'woocommerce_proceed_to_checkout', array( $this, 'render_cart_buttons' ), 5 );
84 add_action( 'woocommerce_after_add_to_cart_form', array( $this, 'render_product_buttons' ) );
85 }
86
87 /**
88 * Loads the classic express bundle on the surfaces the merchant enabled.
89 *
90 * @return void
91 */
92 public function enqueue_classic_assets() {
93 $location = $this->get_classic_location();
94
95 if ( null === $location ) {
96 return;
97 }
98
99 self::enqueue_style();
100
101 if ( ! wp_script_is( 'monei', 'registered' ) ) {
102 wp_register_script( 'monei', 'https://js.monei.com/v3/monei.js', '', '3.0', true );
103 }
104 wp_enqueue_script( 'monei' );
105
106 wp_register_script(
107 self::SCRIPT_HANDLE,
108 plugins_url( 'public/js/monei-express-checkout.min.js', MONEI_MAIN_FILE ),
109 // `jquery-blockui` is WooCommerce's own loading treatment, and the express
110 // script uses it to cover the gap between the wallet sheet closing and the
111 // redirect. WooCommerce loads it on cart and checkout anyway, but not
112 // reliably on a product page, so it is declared rather than assumed.
113 array( 'jquery', 'jquery-blockui', 'monei' ),
114 MONEI_VERSION,
115 true
116 );
117
118 wp_localize_script(
119 self::SCRIPT_HANDLE,
120 'wc_monei_express_params',
121 array_merge(
122 self::get_script_data(),
123 array(
124 'location' => $location,
125 'product' => 'product' === $location ? $this->get_product_context() : null,
126 )
127 )
128 );
129
130 wp_enqueue_script( self::SCRIPT_HANDLE );
131 }
132
133 /**
134 * Express button container above the classic checkout form.
135 *
136 * @return void
137 */
138 public function render_checkout_buttons() {
139 if ( 'checkout' !== $this->get_classic_location() ) {
140 return;
141 }
142
143 $this->render_container( 'checkout' );
144 }
145
146 /**
147 * Express button container above the classic cart's checkout button.
148 *
149 * @return void
150 */
151 public function render_cart_buttons() {
152 if ( 'cart' !== $this->get_classic_location() ) {
153 return;
154 }
155
156 $this->render_container( 'cart' );
157 }
158
159 /**
160 * Express button container below the add-to-cart form.
161 *
162 * @return void
163 */
164 public function render_product_buttons() {
165 if ( 'product' !== $this->get_classic_location() ) {
166 return;
167 }
168
169 $this->render_container( 'product' );
170 }
171
172 /**
173 * Prints the mount containers, one per wallet the merchant enabled at this surface.
174 *
175 * Everything starts hidden and the script reveals it only once a wallet reports
176 * itself supported, so an unavailable wallet leaves no gap and no dead control.
177 *
178 * @param string $location One of the express location keys.
179 *
180 * @return void
181 */
182 private function render_container( $location ) {
183 $methods = array_keys( self::get_enabled_methods( $location ) );
184
185 if ( empty( $methods ) ) {
186 return;
187 }
188 ?>
189 <div class="monei-express-checkout is-loading" data-monei-express-location="<?php echo esc_attr( $location ); ?>">
190 <div class="monei-express-checkout__title"><?php esc_html_e( 'Express checkout', 'monei' ); ?></div>
191 <?php foreach ( $methods as $method ) : ?>
192 <div class="monei-express-checkout__button" data-monei-express-method="<?php echo esc_attr( $method ); ?>"></div>
193 <?php endforeach; ?>
194 <div class="monei-express-checkout__error" role="alert"></div>
195 </div>
196 <?php
197 }
198
199 /**
200 * Everything the express scripts need that is identical on classic and blocks.
201 *
202 * Both wallets are described in one payload, and both blocks payment methods carry
203 * the same copy of it, so the express script works whichever gateway happened to
204 * put it on the page.
205 *
206 * @return array<string, mixed>
207 */
208 public static function get_script_data() {
209 $methods = array();
210 $account_id = '';
211
212 foreach ( self::get_express_gateways() as $method => $gateway ) {
213 $locations = array();
214
215 foreach ( array_keys( WCMoneiPaymentGateway::get_express_location_options() ) as $location ) {
216 $locations[ $location ] = $gateway->is_express_enabled_at( (string) $location );
217 }
218
219 $methods[ $method ] = array(
220 'locations' => $locations,
221 // PayPal takes different style keys from PaymentRequest — color, layout,
222 // size, shape, label — so each wallet carries its own.
223 'style' => json_decode( self::get_button_style( $gateway ) ),
224 // Whether the MONEI account offers this wallet at all. The blocks
225 // registry reserves a grid column per registered express method before
226 // any component mounts, so a wallet the account cannot serve has to be
227 // refused at registration; discovering it later through `onLoad` leaves
228 // an empty column behind that halves the width of its neighbour.
229 'available' => self::account_offers( $method ),
230 );
231
232 if ( '' === $account_id ) {
233 $account_id = (string) $gateway->getAccountId();
234 }
235 }
236
237 return array(
238 // The `%%endpoint%%` placeholder is how WooCommerce core itself hands a
239 // wc-ajax URL template to the browser, see wc_cart_fragments_params.
240 'ajaxUrl' => WC_AJAX::get_endpoint( '%%endpoint%%' ),
241 'accountId' => $account_id,
242 'currency' => get_woocommerce_currency(),
243 'language' => locale_iso_639_1_code(),
244 'methods' => $methods,
245 'i18n' => array(
246 'genericError' => __( 'Express checkout is unavailable right now. Please use the regular checkout.', 'monei' ),
247 ),
248 );
249 }
250
251 /**
252 * Registers the express script for the Cart and Checkout blocks.
253 *
254 * Called by both blocks payment methods, so express still loads when only one of
255 * the two gateways is on. Registering an existing handle twice is a no-op, and the
256 * handle is deduplicated when WooCommerce merges it into the block bundle.
257 *
258 * @return string Handle, or an empty string when no wallet has express on a block
259 * surface.
260 */
261 public static function register_blocks_script() {
262 if ( empty( self::get_enabled_methods( 'cart' ) ) && empty( self::get_enabled_methods( 'checkout' ) ) ) {
263 return '';
264 }
265
266 $handle = self::BLOCKS_SCRIPT_HANDLE;
267
268 self::enqueue_style();
269
270 wp_register_script(
271 $handle,
272 WC_Monei()->plugin_url() . '/public/js/monei-block-express-checkout.min.js',
273 array(
274 'wc-blocks-checkout',
275 'wc-blocks-registry',
276 'wc-settings',
277 'wp-data',
278 'wp-element',
279 'wp-i18n',
280 'monei',
281 ),
282 WC_Monei()->version,
283 true
284 );
285
286 if ( function_exists( 'wp_set_script_translations' ) ) {
287 wp_set_script_translations( $handle, 'monei', WC_Monei()->plugin_path() . '/languages' );
288 }
289
290 return $handle;
291 }
292
293 /**
294 * Express gateways that are enabled at a surface, keyed by wallet component.
295 *
296 * @param string $location One of the express location keys.
297 *
298 * @return array<string, WCMoneiPaymentGateway>
299 */
300 private static function get_enabled_methods( $location ) {
301 $enabled = array();
302
303 foreach ( self::get_express_gateways() as $method => $gateway ) {
304 if ( $gateway->is_express_enabled_at( $location ) ) {
305 $enabled[ $method ] = $gateway;
306 }
307 }
308
309 return $enabled;
310 }
311
312 /**
313 * @param WCMoneiPaymentGateway $gateway Express gateway.
314 *
315 * @return string
316 */
317 private static function get_button_style( WCMoneiPaymentGateway $gateway ) {
318 return (string) $gateway->get_option( 'express_button_style', $gateway::DEFAULT_EXPRESS_BUTTON_STYLE );
319 }
320
321 /**
322 * The gateways that expose express checkout, keyed by the wallet they render.
323 *
324 * Resolved on demand rather than injected, for the same reason the AJAX handler
325 * does it: this service is built during `init`, before WooCommerce assembles its
326 * payment gateways.
327 *
328 * @return array<string, WCMoneiPaymentGateway>
329 */
330 /**
331 * Whether the MONEI account can serve the wallet behind an express method.
332 *
333 * ⚠️ Fails open. A false negative hides a wallet the merchant has switched on and
334 * paid to enable; a false positive costs an empty column. So an unreadable answer
335 * — the API down, the account response cached empty — registers the method and
336 * lets `onLoad` sort it out, which is the behaviour this replaced.
337 *
338 * @param string $method Express method key.
339 *
340 * @return bool
341 */
342 private static function account_offers( $method ) {
343 try {
344 $methods = ContainerProvider::getContainer()->get( PaymentMethodsService::class );
345
346 if ( self::METHOD_PAYPAL === $method ) {
347 return $methods->isPaypalEnabled();
348 }
349
350 return $methods->isGoogleEnabled() || $methods->isAppleEnabled();
351 } catch ( Exception $e ) {
352 return true;
353 }
354 }
355
356 /**
357 * Loads the express stylesheet, on whichever surface asked for it.
358 *
359 * ⚠️ Blocks needs this too. It used to be registered only on the classic path,
360 * so a Cart or Checkout block got the express markup with none of its styling —
361 * including `.monei-express-checkout__error`, which is how a failed express
362 * payment tells the shopper what went wrong. Unstyled, that message inherited
363 * whatever the theme did with a bare div, and `:empty` never hid it.
364 *
365 * @return void
366 */
367 public static function enqueue_style() {
368 if ( ! wp_style_is( self::STYLE_HANDLE, 'registered' ) ) {
369 wp_register_style(
370 self::STYLE_HANDLE,
371 plugins_url( 'public/css/monei-express-checkout.css', MONEI_MAIN_FILE ),
372 array(),
373 MONEI_VERSION,
374 'all'
375 );
376 }
377
378 wp_enqueue_style( self::STYLE_HANDLE );
379 }
380
381 private static function get_express_gateways() {
382 if ( null !== self::$gateways ) {
383 return self::$gateways;
384 }
385
386 $container = ContainerProvider::getContainer();
387 self::$gateways = array();
388
389 $classes = array(
390 self::METHOD_PAYMENT_REQUEST => WCGatewayMoneiAppleGoogle::class,
391 self::METHOD_PAYPAL => WCGatewayMoneiPaypal::class,
392 );
393
394 foreach ( $classes as $method => $class_name ) {
395 $gateway = $container->get( $class_name );
396
397 if ( $gateway instanceof WCMoneiPaymentGateway ) {
398 self::$gateways[ $method ] = $gateway;
399 }
400 }
401
402 return self::$gateways;
403 }
404
405 /**
406 * The product the page is showing, for the product page express flow.
407 *
408 * @return array<string, mixed>|null
409 */
410 private function get_product_context() {
411 $product = wc_get_product();
412
413 if ( ! $product instanceof WC_Product ) {
414 return null;
415 }
416
417 return array(
418 'id' => $product->get_id(),
419 'isVariable' => $product->is_type( 'variable' ),
420 'purchasable' => $product->is_purchasable() && $product->is_in_stock(),
421 );
422 }
423
424 /**
425 * Express location of the current request, or null when express must not render.
426 *
427 * Block-rendered cart and checkout pages return null: their buttons come from the
428 * payment method registry, and loading the classic bundle there would mount a
429 * second set.
430 *
431 * @return string|null
432 */
433 private function get_classic_location() {
434 if ( is_checkout() && ! is_checkout_pay_page() && ! is_add_payment_method_page() ) {
435 if ( self::current_page_has_block( 'woocommerce/checkout' ) ) {
436 return null;
437 }
438
439 return empty( self::get_enabled_methods( 'checkout' ) ) ? null : 'checkout';
440 }
441
442 if ( is_product() ) {
443 return empty( self::get_enabled_methods( 'product' ) ) ? null : 'product';
444 }
445
446 if ( is_cart() ) {
447 if ( self::current_page_has_block( 'woocommerce/cart' ) ) {
448 return null;
449 }
450
451 return empty( self::get_enabled_methods( 'cart' ) ) ? null : 'cart';
452 }
453
454 return null;
455 }
456
457 /**
458 * Whether the page being rendered right now contains a block.
459 *
460 * Deliberately not `WC_Blocks_Utils::has_block_in_page( wc_get_page_id( ... ) )`:
461 * that asks about the page configured in WooCommerce settings, so a store whose
462 * configured checkout is a block would answer "block" for a second, shortcode-based
463 * checkout page too, and the classic buttons would never load there.
464 *
465 * @param string $block Block name.
466 *
467 * @return bool
468 */
469 private static function current_page_has_block( $block ) {
470 $post = get_post();
471
472 return $post instanceof WP_Post && has_block( $block, $post );
473 }
474 }
475