Introduce payment gateway feature check for cart content. (https://github.com/woocommerce/woocommerce-blocks/pull/3719)
* Add supports data to payment methods abstractions. Implement in Stripe. * Add capabilities to the payment gateway. * Payment requirements in cart schema. * Supported features format update. * Formatting. * Check required payment features. * Refactor capabilieties check. * No need for the cart parameter. * Allow external modifiacation of features capability. * Use ExtendRestApi to inject payment requirements into cart endpoint. * Simplify the code. * Enable more integrations. * Enable Stripe payment request. * Move the filter to a more correc location. * Add features check. * Update typedefs and documentation. * Update tests with new functionality. * Style fixes.
This commit is contained in:
parent
7ae87ca367
commit
a0eb52d32b
|
@ -34,6 +34,9 @@ const registerMockPaymentMethods = () => {
|
|||
icons: null,
|
||||
canMakePayment: () => true,
|
||||
ariaLabel: name,
|
||||
supports: {
|
||||
features: [ 'products' ],
|
||||
},
|
||||
} );
|
||||
} );
|
||||
};
|
||||
|
|
|
@ -60,6 +60,9 @@ const registerMockPaymentMethods = () => {
|
|||
edit: <div>A payment method</div>,
|
||||
icons: null,
|
||||
canMakePayment: () => true,
|
||||
supports: {
|
||||
features: [ 'products' ],
|
||||
},
|
||||
ariaLabel: name,
|
||||
} );
|
||||
} );
|
||||
|
@ -74,6 +77,7 @@ const registerMockPaymentMethods = () => {
|
|||
supports: {
|
||||
showSavedCards: true,
|
||||
showSaveOption: true,
|
||||
features: [ 'products' ],
|
||||
},
|
||||
ariaLabel: name,
|
||||
} );
|
||||
|
@ -100,6 +104,9 @@ const registerMockPaymentMethods = () => {
|
|||
edit: <div>An express payment method</div>,
|
||||
canMakePayment: () => true,
|
||||
paymentMethodId: name,
|
||||
supports: {
|
||||
features: [ 'products' ],
|
||||
},
|
||||
} );
|
||||
} );
|
||||
};
|
||||
|
|
|
@ -54,12 +54,17 @@ const usePaymentMethodRegistration = (
|
|||
const { selectedRates, shippingAddress } = useShippingDataContext();
|
||||
const selectedShippingMethods = useShallowEqual( selectedRates );
|
||||
const paymentMethodsOrder = useShallowEqual( paymentMethodsSortOrder );
|
||||
const { cartTotals, cartNeedsShipping } = useStoreCart();
|
||||
const {
|
||||
cartTotals,
|
||||
cartNeedsShipping,
|
||||
paymentRequirements,
|
||||
} = useStoreCart();
|
||||
const canPayArgument = useRef( {
|
||||
cartTotals,
|
||||
cartNeedsShipping,
|
||||
shippingAddress,
|
||||
selectedShippingMethods,
|
||||
paymentRequirements,
|
||||
} );
|
||||
const { addErrorNotice } = useStoreNotices();
|
||||
|
||||
|
@ -69,12 +74,14 @@ const usePaymentMethodRegistration = (
|
|||
cartNeedsShipping,
|
||||
shippingAddress,
|
||||
selectedShippingMethods,
|
||||
paymentRequirements,
|
||||
};
|
||||
}, [
|
||||
cartTotals,
|
||||
cartNeedsShipping,
|
||||
shippingAddress,
|
||||
selectedShippingMethods,
|
||||
paymentRequirements,
|
||||
] );
|
||||
|
||||
const refreshCanMakePayments = useCallback( async () => {
|
||||
|
@ -144,7 +151,12 @@ const usePaymentMethodRegistration = (
|
|||
// Some payment methods (e.g. COD) can be disabled for specific shipping methods.
|
||||
useEffect( () => {
|
||||
refreshCanMakePayments();
|
||||
}, [ refreshCanMakePayments, cartTotals, selectedShippingMethods ] );
|
||||
}, [
|
||||
refreshCanMakePayments,
|
||||
cartTotals,
|
||||
selectedShippingMethods,
|
||||
paymentRequirements,
|
||||
] );
|
||||
|
||||
return isInitialized;
|
||||
};
|
||||
|
|
|
@ -108,6 +108,7 @@ describe( 'useStoreCart', () => {
|
|||
shippingRatesLoading: false,
|
||||
cartHasCalculatedShipping: true,
|
||||
receiveCart: undefined,
|
||||
paymentRequirements: [],
|
||||
};
|
||||
|
||||
const getWrappedComponents = ( Component ) => (
|
||||
|
|
|
@ -51,6 +51,7 @@ export const defaultCartData = {
|
|||
shippingRates: [],
|
||||
shippingRatesLoading: false,
|
||||
cartHasCalculatedShipping: false,
|
||||
paymentRequirements: [],
|
||||
receiveCart: () => {},
|
||||
};
|
||||
|
||||
|
@ -97,6 +98,7 @@ export const useStoreCart = ( options = { shouldSelect: true } ) => {
|
|||
shippingRatesLoading: false,
|
||||
cartHasCalculatedShipping:
|
||||
previewCart.has_calculated_shipping,
|
||||
paymentRequirements: previewCart.paymentRequirements,
|
||||
receiveCart:
|
||||
typeof previewCart?.receiveCart === 'function'
|
||||
? previewCart.receiveCart
|
||||
|
@ -135,6 +137,7 @@ export const useStoreCart = ( options = { shouldSelect: true } ) => {
|
|||
shippingRates: cartData.shippingRates || [],
|
||||
shippingRatesLoading,
|
||||
cartHasCalculatedShipping: cartData.hasCalculatedShipping,
|
||||
paymentRequirements: cartData.paymentRequirements || [],
|
||||
receiveCart,
|
||||
};
|
||||
},
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
* Internal dependencies
|
||||
*/
|
||||
import { assertConfigHasProperties, assertValidElement } from './assertions';
|
||||
import { canMakePaymentWithFeaturesCheck } from './payment-method-config-helper';
|
||||
|
||||
export default class ExpressPaymentMethodConfig {
|
||||
constructor( config ) {
|
||||
|
@ -10,8 +11,14 @@ export default class ExpressPaymentMethodConfig {
|
|||
this.name = config.name;
|
||||
this.content = config.content;
|
||||
this.edit = config.edit;
|
||||
this.canMakePayment = config.canMakePayment;
|
||||
this.paymentMethodId = config.paymentMethodId || this.name;
|
||||
this.supports = {
|
||||
features: config?.supports?.features || [],
|
||||
};
|
||||
this.canMakePayment = canMakePaymentWithFeaturesCheck(
|
||||
config.canMakePayment,
|
||||
this.supports.features
|
||||
);
|
||||
}
|
||||
|
||||
static assertValidConfig = ( config ) => {
|
||||
|
@ -29,6 +36,11 @@ export default class ExpressPaymentMethodConfig {
|
|||
'The paymentMethodId property for the payment method must be a string or undefined (in which case it will be the value of the name property).'
|
||||
);
|
||||
}
|
||||
if ( ! Array.isArray( config.supports?.features ) ) {
|
||||
throw new Error(
|
||||
'The features property for the payment method must be an array.'
|
||||
);
|
||||
}
|
||||
assertValidElement( config.content, 'content' );
|
||||
assertValidElement( config.edit, 'edit' );
|
||||
if ( typeof config.canMakePayment !== 'function' ) {
|
||||
|
|
|
@ -0,0 +1,10 @@
|
|||
// Filter out payment methods by supported features and cart requirement.
|
||||
export const canMakePaymentWithFeaturesCheck = ( canMakePayment, features ) => (
|
||||
canPayArgument
|
||||
) => {
|
||||
const requirements = canPayArgument.paymentRequirements || [];
|
||||
const featuresSupportRequirements = requirements.every( ( requirement ) =>
|
||||
features.includes( requirement )
|
||||
);
|
||||
return featuresSupportRequirements && canMakePayment( canPayArgument );
|
||||
};
|
|
@ -12,6 +12,8 @@ import {
|
|||
assertValidElementOrString,
|
||||
} from './assertions';
|
||||
|
||||
import { canMakePaymentWithFeaturesCheck } from './payment-method-config-helper';
|
||||
|
||||
export default class PaymentMethodConfig {
|
||||
constructor( config ) {
|
||||
// validate config
|
||||
|
@ -23,7 +25,6 @@ export default class PaymentMethodConfig {
|
|||
this.content = config.content;
|
||||
this.icons = config.icons;
|
||||
this.edit = config.edit;
|
||||
this.canMakePayment = config.canMakePayment;
|
||||
this.paymentMethodId = config.paymentMethodId || this.name;
|
||||
this.supports = {
|
||||
showSavedCards:
|
||||
|
@ -31,7 +32,12 @@ export default class PaymentMethodConfig {
|
|||
config?.supports?.savePaymentInfo || // Kept for backward compatibility if methods still pass this when registering.
|
||||
false,
|
||||
showSaveOption: config?.supports?.showSaveOption || false,
|
||||
features: config?.supports?.features || [],
|
||||
};
|
||||
this.canMakePayment = canMakePaymentWithFeaturesCheck(
|
||||
config.canMakePayment,
|
||||
this.supports.features
|
||||
);
|
||||
}
|
||||
|
||||
static assertValidConfig = ( config ) => {
|
||||
|
@ -105,6 +111,11 @@ export default class PaymentMethodConfig {
|
|||
}
|
||||
);
|
||||
}
|
||||
if ( ! Array.isArray( config.supports?.features ) ) {
|
||||
throw new Error(
|
||||
'The features property for the payment method must be an array.'
|
||||
);
|
||||
}
|
||||
if (
|
||||
typeof config.supports?.showSaveOption !== 'undefined' &&
|
||||
typeof config.supports?.showSaveOption !== 'boolean'
|
||||
|
|
|
@ -49,6 +49,9 @@ const bankTransferPaymentMethod = {
|
|||
edit: <Content />,
|
||||
canMakePayment: () => true,
|
||||
ariaLabel: label,
|
||||
supports: {
|
||||
features: settings?.supports ?? [],
|
||||
},
|
||||
};
|
||||
|
||||
registerPaymentMethod( bankTransferPaymentMethod );
|
||||
|
|
|
@ -46,6 +46,9 @@ const offlineChequePaymentMethod = {
|
|||
edit: <Content />,
|
||||
canMakePayment: () => true,
|
||||
ariaLabel: label,
|
||||
supports: {
|
||||
features: settings?.supports ?? [],
|
||||
},
|
||||
};
|
||||
|
||||
registerPaymentMethod( offlineChequePaymentMethod );
|
||||
|
|
|
@ -79,6 +79,9 @@ const cashOnDeliveryPaymentMethod = {
|
|||
edit: <Content />,
|
||||
canMakePayment,
|
||||
ariaLabel: label,
|
||||
supports: {
|
||||
features: settings?.supports ?? [],
|
||||
},
|
||||
};
|
||||
|
||||
registerPaymentMethod( cashOnDeliveryPaymentMethod );
|
||||
|
|
|
@ -45,6 +45,9 @@ const paypalPaymentMethod = {
|
|||
settings.title ||
|
||||
__( 'Payment via PayPal', 'woo-gutenberg-products-block' )
|
||||
),
|
||||
supports: {
|
||||
features: settings.supports ?? [],
|
||||
},
|
||||
};
|
||||
|
||||
registerPaymentMethod( paypalPaymentMethod );
|
||||
|
|
|
@ -58,6 +58,7 @@ const stripeCcPaymentMethod = {
|
|||
supports: {
|
||||
showSavedCards: getStripeServerData().showSavedCards,
|
||||
showSaveOption: getStripeServerData().showSaveOption,
|
||||
features: getStripeServerData()?.supports ?? [],
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
@ -9,7 +9,7 @@ import { getSetting } from '@woocommerce/settings';
|
|||
import { PAYMENT_METHOD_NAME } from './constants';
|
||||
import { PaymentRequestExpress } from './payment-request-express';
|
||||
import { applePayImage } from './apple-pay-preview';
|
||||
import { loadStripe } from '../stripe-utils';
|
||||
import { getStripeServerData, loadStripe } from '../stripe-utils';
|
||||
|
||||
const ApplePayPreview = () => <img src={ applePayImage } alt="" />;
|
||||
|
||||
|
@ -68,6 +68,9 @@ const paymentRequestPaymentMethod = {
|
|||
totalPrice: parseInt( cartData?.cartTotals?.total_price || 0, 10 ),
|
||||
} ),
|
||||
paymentMethodId: 'stripe',
|
||||
supports: {
|
||||
features: getStripeServerData()?.supports ?? [],
|
||||
},
|
||||
};
|
||||
|
||||
export default paymentRequestPaymentMethod;
|
||||
|
|
|
@ -303,6 +303,7 @@
|
|||
* save card can be displayed.
|
||||
* @property {boolean} allowPaymentRequest True if merchant has enabled payment
|
||||
* request (Chrome/Apple Pay).
|
||||
* @property {Object} supports List of features supported by the payment gateway
|
||||
*/
|
||||
/* eslint-enable jsdoc/valid-types */
|
||||
|
||||
|
|
|
@ -40,6 +40,7 @@
|
|||
* shipping rates are
|
||||
* being loaded.
|
||||
* @property {boolean} cartHasCalculatedShipping Whether or not the cart has calculated shipping yet.
|
||||
* @property {Array} paymentRequirements List of features required from payment gateways.
|
||||
* @property {function(Object):any} receiveCart Dispatcher to receive
|
||||
* updated cart.
|
||||
*/
|
||||
|
|
|
@ -13,6 +13,7 @@
|
|||
* @property {Object} edit A react node to display a preview of your payment method in the editor.
|
||||
* @property {Function} canMakePayment A callback to determine whether the payment method should be shown in the checkout.
|
||||
* @property {string} [paymentMethodId] A unique string to represent the payment method server side. If not provided, defaults to name.
|
||||
* @property {Object} supports Object that describes various features provided by the payment method.
|
||||
*/
|
||||
|
||||
/**
|
||||
|
@ -27,6 +28,7 @@
|
|||
* @property {Object} label A react node that will be used as a label for the payment method in the checkout.
|
||||
* @property {string} ariaLabel An accessibility label. Screen readers will output this label when the payment method is selected.
|
||||
* @property {string} [placeOrderButtonLabel] Optionally customise the label text for the checkout submit (`Place Order`) button.
|
||||
* @property {Object} supports Object that describes various features provided by the payment method.
|
||||
*/
|
||||
|
||||
export {};
|
||||
|
|
|
@ -52,7 +52,10 @@ const options = {
|
|||
content: <div>A react node</div>,
|
||||
edit: <div> A react node </div>,
|
||||
canMakePayment: () => true,
|
||||
paymentMethodId: 'new_payment_method',
|
||||
paymentMethodId: 'new_payment_method',
|
||||
supports = {
|
||||
features: [],
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
|
@ -63,6 +66,7 @@ Here's some more details on the configuration options:
|
|||
- `edit` (required): This should be a react node that will be output in the express payment method area when the block is rendered in the editor. It will be cloned in the rendering process. When cloned, this react node will receive props from the payment method interface to checkout (but they will contain preview data).
|
||||
- `canMakePayment` (required): A callback to determine whether the payment method should be available as an option for the shopper. The function will be passed an object containing data about the current order. Return a boolean value - true if payment method is available for use. If your gateway needs to perform async initialisation to determine availability, you can return a promise (resolving to boolean). This allows a payment method to be hidden based on the cart, e.g. if the cart has physical/shippable products (example: `Cash on delivery`); or for payment methods to control whether they are available depending on other conditions. Keep in mind this function could be invoked multiple times in the lifecycle of the checkout and thus any expensive logic in the callback provided on this property should be memoized.
|
||||
- `paymentMethodId`: This is the only optional configuration object. The value of this property is what will accompany the checkout processing request to the server and used to identify what payment method gateway class to load for processing the payment (if the shopper selected the gateway). So for instance if this is `stripe`, then `WC_Gateway_Stripe::process_payment` will be invoked for processing the payment.
|
||||
- `supports:features`: This is an array of payment features supported by the gateway. It is used to crosscheck if the payment method can be used for the content of the cart. By default payment methods should support at least `products` feature.
|
||||
|
||||
### Payment Methods - `registerPaymentMethod( options )`
|
||||
|
||||
|
|
|
@ -61,6 +61,13 @@ final class ExtendRestApi {
|
|||
*/
|
||||
private $extend_data = [];
|
||||
|
||||
/**
|
||||
* Array of payment requirements
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
private $payment_requirements = [];
|
||||
|
||||
/**
|
||||
* An endpoint that validates registration method call
|
||||
*
|
||||
|
@ -135,6 +142,60 @@ final class ExtendRestApi {
|
|||
return (object) $registered_data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers and validates payment requirements callbacks.
|
||||
*
|
||||
* @param array $args {
|
||||
* Array of registration data.
|
||||
*
|
||||
* @type callable $data_callback Callback executed to add payment requirements data.
|
||||
* }
|
||||
*
|
||||
* @throws Exception On failure to register.
|
||||
* @return boolean True on success.
|
||||
*/
|
||||
public function register_payment_requirements( $args ) {
|
||||
if ( ! is_callable( $args['data_callback'] ) ) {
|
||||
$this->throw_exception( '$data_callback must be a callable function.' );
|
||||
}
|
||||
|
||||
$this->payment_requirements[] = $args['data_callback'];
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the additional payment requirements.
|
||||
*
|
||||
* @param array $initial_requirements list of requirements that should be added to the collected requirements.
|
||||
* @return array Returns a list of payment requirements.
|
||||
* @throws Exception If a registered callback throws an error, or silently logs it.
|
||||
*/
|
||||
public function get_payment_requirements( array $initial_requirements = [ 'products' ] ) {
|
||||
$requirements = $initial_requirements;
|
||||
if ( empty( $this->payment_requirements ) ) {
|
||||
return $initial_requirements;
|
||||
}
|
||||
|
||||
foreach ( $this->payment_requirements as $callback ) {
|
||||
$data = [];
|
||||
|
||||
try {
|
||||
$data = $callback();
|
||||
|
||||
if ( ! is_array( $data ) ) {
|
||||
throw new Exception( '$data_callback must return an array.' );
|
||||
}
|
||||
} catch ( Throwable $e ) {
|
||||
$this->throw_exception( $e );
|
||||
continue;
|
||||
}
|
||||
$requirements = array_merge( $requirements, $data );
|
||||
}
|
||||
|
||||
return array_unique( $requirements );
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the registered endpoint schema
|
||||
*
|
||||
|
|
|
@ -70,6 +70,15 @@ abstract class AbstractPaymentMethodType implements PaymentMethodTypeInterface {
|
|||
return $this->get_payment_method_script_handles();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an array of supported features.
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
public function get_supported_features() {
|
||||
return [ 'products' ];
|
||||
}
|
||||
|
||||
/**
|
||||
* An array of key, value pairs of data made available to payment methods
|
||||
* client side.
|
||||
|
|
|
@ -70,6 +70,7 @@ final class BankTransfer extends AbstractPaymentMethodType {
|
|||
return [
|
||||
'title' => $this->get_setting( 'title' ),
|
||||
'description' => $this->get_setting( 'description' ),
|
||||
'supports' => $this->get_supported_features(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
@ -95,6 +95,7 @@ final class CashOnDelivery extends AbstractPaymentMethodType {
|
|||
'description' => $this->get_setting( 'description' ),
|
||||
'enableForVirtual' => $this->get_enable_for_virtual(),
|
||||
'enableForShippingMethods' => $this->get_enable_for_methods(),
|
||||
'supports' => $this->get_supported_features(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
@ -73,6 +73,7 @@ final class Cheque extends AbstractPaymentMethodType {
|
|||
return [
|
||||
'title' => $this->get_setting( 'title' ),
|
||||
'description' => $this->get_setting( 'description' ),
|
||||
'supports' => $this->get_supported_features(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,9 +1,7 @@
|
|||
<?php
|
||||
namespace Automattic\WooCommerce\Blocks\Payments\Integrations;
|
||||
|
||||
use Exception;
|
||||
use WC_Stripe_Payment_Request;
|
||||
use WC_Stripe_Helper;
|
||||
use WC_Gateway_Paypal;
|
||||
use Automattic\WooCommerce\Blocks\Assets\Api;
|
||||
|
||||
/**
|
||||
|
@ -73,6 +71,18 @@ final class PayPal extends AbstractPaymentMethodType {
|
|||
return [
|
||||
'title' => $this->get_setting( 'title' ),
|
||||
'description' => $this->get_setting( 'description' ),
|
||||
'supports' => $this->get_supported_features(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an array of supported features.
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
public function get_supported_features() {
|
||||
$gateway = new WC_Gateway_Paypal();
|
||||
$features = array_filter( $gateway->supports, array( $gateway, 'supports' ) );
|
||||
return apply_filters( '__experimental_woocommerce_blocks_payment_gateway_features_list', $features, $this->get_name() );
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,6 +4,7 @@ namespace Automattic\WooCommerce\Blocks\Payments\Integrations;
|
|||
use Exception;
|
||||
use WC_Stripe_Payment_Request;
|
||||
use WC_Stripe_Helper;
|
||||
use WC_Gateway_Stripe;
|
||||
use Automattic\WooCommerce\Blocks\Assets\Api;
|
||||
use Automattic\WooCommerce\Blocks\Payments\PaymentContext;
|
||||
use Automattic\WooCommerce\Blocks\Payments\PaymentResult;
|
||||
|
@ -96,6 +97,7 @@ final class Stripe extends AbstractPaymentMethodType {
|
|||
'showSavedCards' => $this->get_show_saved_cards(),
|
||||
'allowPaymentRequest' => $this->get_allow_payment_request(),
|
||||
'showSaveOption' => $this->get_show_save_option(),
|
||||
'supports' => $this->get_supported_features(),
|
||||
];
|
||||
}
|
||||
|
||||
|
@ -333,4 +335,14 @@ final class Stripe extends AbstractPaymentMethodType {
|
|||
$order->save();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an array of supported features.
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
public function get_supported_features() {
|
||||
$gateway = new WC_Gateway_Stripe();
|
||||
return array_filter( $gateway->supports, array( $gateway, 'supports' ) );
|
||||
}
|
||||
}
|
||||
|
|
|
@ -45,4 +45,11 @@ interface PaymentMethodTypeInterface {
|
|||
* @return array
|
||||
*/
|
||||
public function get_payment_method_data();
|
||||
|
||||
/**
|
||||
* Get array of supported features.
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
public function get_supported_features();
|
||||
}
|
||||
|
|
|
@ -302,6 +302,12 @@ class CartSchema extends AbstractSchema {
|
|||
'properties' => $this->force_schema_readonly( $this->error_schema->get_properties() ),
|
||||
],
|
||||
],
|
||||
'payment_requirements' => [
|
||||
'description' => __( 'List of required payment gateway features to process the order.', 'woo-gutenberg-products-block' ),
|
||||
'type' => 'array',
|
||||
'context' => [ 'view', 'edit' ],
|
||||
'readonly' => true,
|
||||
],
|
||||
self::EXTENDING_KEY => $this->get_extended_schema( self::IDENTIFIER ),
|
||||
];
|
||||
}
|
||||
|
@ -356,6 +362,7 @@ class CartSchema extends AbstractSchema {
|
|||
]
|
||||
),
|
||||
'errors' => $cart_errors,
|
||||
'payment_requirements' => $this->extend->get_payment_requirements(),
|
||||
self::EXTENDING_KEY => $this->get_extended_data( self::IDENTIFIER ),
|
||||
];
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue