mirror of
https://github.com/woocommerce/woocommerce-paypal-payments.git
synced 2025-09-05 08:59:14 +08:00
Merge pull request #1824 from woocommerce/PCP-2186-apple-pay-button-preview
Apple Pay button preview (2186)
This commit is contained in:
commit
72ae2be1a4
11 changed files with 366 additions and 20 deletions
|
@ -1,11 +1,11 @@
|
|||
#applepay-container {
|
||||
#applepay-container, .ppcp-button-applepay {
|
||||
--apple-pay-button-height: 45px;
|
||||
--apple-pay-button-min-height: 40px;
|
||||
--apple-pay-button-width: 100%;
|
||||
--apple-pay-button-max-width: 750px;
|
||||
--apple-pay-button-border-radius: 4px;
|
||||
--apple-pay-button-overflow: hidden;
|
||||
--apple-pay-button-margin:7px 0;
|
||||
margin:7px 0;
|
||||
&.ppcp-button-pill {
|
||||
--apple-pay-button-border-radius: 50px;
|
||||
}
|
||||
|
@ -17,7 +17,7 @@
|
|||
}
|
||||
|
||||
.woocommerce-checkout {
|
||||
#applepay-container {
|
||||
#applepay-container, .ppcp-button-applepay {
|
||||
margin-top: 0.5em;
|
||||
--apple-pay-button-border-radius: 4px;
|
||||
--apple-pay-button-height: 45px;
|
||||
|
@ -30,7 +30,7 @@
|
|||
.ppcp-has-applepay-block {
|
||||
|
||||
.wp-block-woocommerce-checkout {
|
||||
#applepay-container {
|
||||
#applepay-container, .ppcp-button-applepay {
|
||||
--apple-pay-button-margin: 0;
|
||||
--apple-pay-button-height: 40px;
|
||||
&.ppcp-button-pill {
|
||||
|
@ -40,7 +40,7 @@
|
|||
}
|
||||
|
||||
.wp-block-woocommerce-cart {
|
||||
#applepay-container {
|
||||
#applepay-container, .ppcp-button-applepay {
|
||||
--apple-pay-button-margin: 0;
|
||||
--apple-pay-button-height: 40px;
|
||||
}
|
||||
|
@ -52,5 +52,17 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.wp-admin {
|
||||
.ppcp-button-applepay {
|
||||
pointer-events: none;
|
||||
}
|
||||
&.ppcp-non-ios-device {
|
||||
.ppcp-button-applepay {
|
||||
apple-pay-button {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,6 +4,7 @@ import {setVisible} from '../../../ppcp-button/resources/js/modules/Helper/Hidin
|
|||
import {setEnabled} from '../../../ppcp-button/resources/js/modules/Helper/ButtonDisabler';
|
||||
import FormValidator from "../../../ppcp-button/resources/js/modules/Helper/FormValidator";
|
||||
import ErrorHandler from '../../../ppcp-button/resources/js/modules/ErrorHandler';
|
||||
import widgetBuilder from "../../../ppcp-button/resources/js/modules/Renderer/WidgetBuilder";
|
||||
|
||||
class ApplepayButton {
|
||||
|
||||
|
@ -48,7 +49,7 @@ class ApplepayButton {
|
|||
const isEligible = this.applePayConfig.isEligible;
|
||||
if (isEligible) {
|
||||
this.fetchTransactionInfo().then(() => {
|
||||
const isSubscriptionProduct = this.ppcpConfig.data_client_id.has_subscriptions === true;
|
||||
const isSubscriptionProduct = this.ppcpConfig?.data_client_id?.has_subscriptions === true;
|
||||
if (isSubscriptionProduct) {
|
||||
return;
|
||||
}
|
||||
|
@ -145,13 +146,12 @@ class ApplepayButton {
|
|||
return session;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Add a Apple Pay purchase button
|
||||
*/
|
||||
addButton() {
|
||||
this.log('addButton', this.context);
|
||||
|
||||
const wrapper =
|
||||
(this.context === 'mini-cart')
|
||||
? this.buttonConfig.button.mini_cart_wrapper
|
||||
|
@ -160,7 +160,7 @@ class ApplepayButton {
|
|||
(this.context === 'mini-cart')
|
||||
? this.ppcpConfig.button.mini_cart_style.shape
|
||||
: this.ppcpConfig.button.style.shape;
|
||||
const appleContainer = this.context === 'mini-cart' ? document.getElementById("applepay-container-minicart") : document.getElementById("applepay-container");
|
||||
const appleContainer = document.getElementById(wrapper);
|
||||
const type = this.buttonConfig.button.type;
|
||||
const language = this.buttonConfig.button.lang;
|
||||
const color = this.buttonConfig.button.color;
|
||||
|
@ -195,7 +195,7 @@ class ApplepayButton {
|
|||
try {
|
||||
const formData = new FormData(document.querySelector(checkoutFormSelector));
|
||||
this.form_saved = Object.fromEntries(formData.entries());
|
||||
// This line should be reviewed, the paypal.Applepay().confirmOrder fails if we add it.
|
||||
// This line should be reviewed, the widgetBuilder.paypal.Applepay().confirmOrder fails if we add it.
|
||||
//this.update_request_data_with_form(paymentDataRequest);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
@ -279,7 +279,7 @@ class ApplepayButton {
|
|||
return (applePayValidateMerchantEvent) => {
|
||||
this.log('onvalidatemerchant call');
|
||||
|
||||
paypal.Applepay().validateMerchant({
|
||||
widgetBuilder.paypal.Applepay().validateMerchant({
|
||||
validationUrl: applePayValidateMerchantEvent.validationURL
|
||||
})
|
||||
.then(validateResult => {
|
||||
|
@ -506,7 +506,7 @@ class ApplepayButton {
|
|||
this.log('onpaymentauthorized paypal order ID', id, event.payment.token, event.payment.billingContact);
|
||||
|
||||
try {
|
||||
const confirmOrderResponse = await paypal.Applepay().confirmOrder({
|
||||
const confirmOrderResponse = await widgetBuilder.paypal.Applepay().confirmOrder({
|
||||
orderId: id,
|
||||
token: event.payment.token,
|
||||
billingContact: event.payment.billingContact,
|
||||
|
|
|
@ -4,6 +4,7 @@ import CheckoutHandler from "./CheckoutHandler";
|
|||
import CartBlockHandler from "./CartBlockHandler";
|
||||
import CheckoutBlockHandler from "./CheckoutBlockHandler";
|
||||
import MiniCartHandler from "./MiniCartHandler";
|
||||
import PreviewHandler from "./PreviewHandler";
|
||||
import PayNowHandler from "./PayNowHandler";
|
||||
|
||||
class ContextHandlerFactory {
|
||||
|
@ -24,6 +25,8 @@ class ContextHandlerFactory {
|
|||
return new CartBlockHandler(buttonConfig, ppcpConfig);
|
||||
case 'checkout-block':
|
||||
return new CheckoutBlockHandler(buttonConfig, ppcpConfig);
|
||||
case 'preview':
|
||||
return new PreviewHandler(buttonConfig, ppcpConfig);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
37
modules/ppcp-applepay/resources/js/Context/PreviewHandler.js
Normal file
37
modules/ppcp-applepay/resources/js/Context/PreviewHandler.js
Normal file
|
@ -0,0 +1,37 @@
|
|||
import BaseHandler from "./BaseHandler";
|
||||
|
||||
class PreviewHandler extends BaseHandler {
|
||||
|
||||
constructor(buttonConfig, ppcpConfig, externalHandler) {
|
||||
super(buttonConfig, ppcpConfig, externalHandler);
|
||||
}
|
||||
|
||||
transactionInfo() {
|
||||
// We need to return something as ApplePay button initialization expects valid data.
|
||||
return {
|
||||
countryCode: "US",
|
||||
currencyCode: "USD",
|
||||
totalPrice: "10.00",
|
||||
totalPriceStatus: "FINAL"
|
||||
}
|
||||
}
|
||||
|
||||
createOrder() {
|
||||
throw new Error('Create order fail. This is just a preview.');
|
||||
}
|
||||
|
||||
approveOrder(data, actions) {
|
||||
throw new Error('Approve order fail. This is just a preview.');
|
||||
}
|
||||
|
||||
actionHandler() {
|
||||
throw new Error('Action handler fail. This is just a preview.');
|
||||
}
|
||||
|
||||
errorHandler() {
|
||||
throw new Error('Error handler fail. This is just a preview.');
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default PreviewHandler;
|
148
modules/ppcp-applepay/resources/js/boot-admin.js
Normal file
148
modules/ppcp-applepay/resources/js/boot-admin.js
Normal file
|
@ -0,0 +1,148 @@
|
|||
import {loadCustomScript} from "@paypal/paypal-js";
|
||||
import ApplepayButton from "./ApplepayButton";
|
||||
import widgetBuilder from "../../../ppcp-button/resources/js/modules/Renderer/WidgetBuilder";
|
||||
|
||||
(function ({
|
||||
buttonConfig,
|
||||
jQuery
|
||||
}) {
|
||||
|
||||
let applePayConfig;
|
||||
let buttonQueue = [];
|
||||
let activeButtons = {};
|
||||
let bootstrapped = false;
|
||||
|
||||
// React to PayPal config changes.
|
||||
jQuery(document).on('ppcp_paypal_render_preview', (ev, ppcpConfig) => {
|
||||
if (bootstrapped) {
|
||||
createButton(ppcpConfig);
|
||||
} else {
|
||||
buttonQueue.push({
|
||||
ppcpConfig: JSON.parse(JSON.stringify(ppcpConfig))
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// React to ApplePay config changes.
|
||||
jQuery([
|
||||
'#ppcp-applepay_button_enabled',
|
||||
'#ppcp-applepay_button_type',
|
||||
'#ppcp-applepay_button_color',
|
||||
'#ppcp-applepay_button_language'
|
||||
].join(',')).on('change', () => {
|
||||
for (const [selector, ppcpConfig] of Object.entries(activeButtons)) {
|
||||
createButton(ppcpConfig);
|
||||
}
|
||||
});
|
||||
|
||||
// Maybe we can find a more elegant reload method when transitioning from styling modes.
|
||||
jQuery([
|
||||
'#ppcp-smart_button_enable_styling_per_location'
|
||||
].join(',')).on('change', () => {
|
||||
setTimeout(() => {
|
||||
for (const [selector, ppcpConfig] of Object.entries(activeButtons)) {
|
||||
createButton(ppcpConfig);
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
|
||||
const applyConfigOptions = function (buttonConfig) {
|
||||
buttonConfig.button = buttonConfig.button || {};
|
||||
buttonConfig.button.type = jQuery('#ppcp-applepay_button_type').val();
|
||||
buttonConfig.button.color = jQuery('#ppcp-applepay_button_color').val();
|
||||
buttonConfig.button.lang = jQuery('#ppcp-applepay_button_language').val();
|
||||
}
|
||||
|
||||
const createButton = function (ppcpConfig) {
|
||||
const selector = ppcpConfig.button.wrapper + 'ApplePay';
|
||||
|
||||
if (!jQuery('#ppcp-applepay_button_enabled').is(':checked')) {
|
||||
jQuery(selector).remove();
|
||||
return;
|
||||
}
|
||||
|
||||
buttonConfig = JSON.parse(JSON.stringify(buttonConfig));
|
||||
buttonConfig.button.wrapper = selector.replace('#', '');
|
||||
applyConfigOptions(buttonConfig);
|
||||
|
||||
const wrapperElement = `<div id="${selector.replace('#', '')}" class="ppcp-button-applepay"></div>`;
|
||||
|
||||
if (!jQuery(selector).length) {
|
||||
jQuery(ppcpConfig.button.wrapper).after(wrapperElement);
|
||||
} else {
|
||||
jQuery(selector).replaceWith(wrapperElement);
|
||||
}
|
||||
|
||||
const button = new ApplepayButton(
|
||||
'preview',
|
||||
null,
|
||||
buttonConfig,
|
||||
ppcpConfig,
|
||||
);
|
||||
|
||||
button.init(applePayConfig);
|
||||
|
||||
activeButtons[selector] = ppcpConfig;
|
||||
}
|
||||
|
||||
const bootstrap = async function () {
|
||||
if (!widgetBuilder.paypal) {
|
||||
return;
|
||||
}
|
||||
|
||||
applePayConfig = await widgetBuilder.paypal.Applepay().config();
|
||||
|
||||
// We need to set bootstrapped here otherwise applePayConfig may not be set.
|
||||
bootstrapped = true;
|
||||
|
||||
let options;
|
||||
while (options = buttonQueue.pop()) {
|
||||
createButton(options.ppcpConfig);
|
||||
}
|
||||
|
||||
if (!window.ApplePaySession) {
|
||||
jQuery('body').addClass('ppcp-non-ios-device')
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener(
|
||||
'DOMContentLoaded',
|
||||
() => {
|
||||
|
||||
if (typeof (buttonConfig) === 'undefined') {
|
||||
console.error('PayPal button could not be configured.');
|
||||
return;
|
||||
}
|
||||
|
||||
let paypalLoaded = false;
|
||||
let applePayLoaded = false;
|
||||
|
||||
const tryToBoot = () => {
|
||||
if (!bootstrapped && paypalLoaded && applePayLoaded) {
|
||||
bootstrap();
|
||||
}
|
||||
}
|
||||
|
||||
// Load ApplePay SDK
|
||||
loadCustomScript({ url: buttonConfig.sdk_url }).then(() => {
|
||||
applePayLoaded = true;
|
||||
tryToBoot();
|
||||
});
|
||||
|
||||
// Wait for PayPal to be loaded externally
|
||||
if (typeof widgetBuilder.paypal !== 'undefined') {
|
||||
paypalLoaded = true;
|
||||
tryToBoot();
|
||||
}
|
||||
|
||||
jQuery(document).on('ppcp-paypal-loaded', () => {
|
||||
paypalLoaded = true;
|
||||
tryToBoot();
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
})({
|
||||
buttonConfig: window.wc_ppcp_applepay_admin,
|
||||
jQuery: window.jQuery
|
||||
});
|
|
@ -90,6 +90,8 @@ class ApplepayModule implements ModuleInterface {
|
|||
$module->render_buttons( $c, $apple_payment_method );
|
||||
$apple_payment_method->bootstrap_ajax_request();
|
||||
}
|
||||
|
||||
$module->load_admin_assets( $c, $apple_payment_method );
|
||||
},
|
||||
1
|
||||
);
|
||||
|
@ -182,6 +184,43 @@ class ApplepayModule implements ModuleInterface {
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers and enqueues the assets.
|
||||
*
|
||||
* @param ContainerInterface $c The container.
|
||||
* @param ApplePayButton $button The button.
|
||||
* @return void
|
||||
*/
|
||||
public function load_admin_assets( ContainerInterface $c, ApplePayButton $button ): void {
|
||||
// Enqueue backend scripts.
|
||||
add_action(
|
||||
'admin_enqueue_scripts',
|
||||
static function () use ( $c, $button ) {
|
||||
if ( ! is_admin() ) {
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Should add this to the ButtonInterface.
|
||||
*
|
||||
* @psalm-suppress UndefinedInterfaceMethod
|
||||
*/
|
||||
$button->enqueue_admin();
|
||||
}
|
||||
);
|
||||
|
||||
// Adds ApplePay component to the backend button preview settings.
|
||||
add_action(
|
||||
'woocommerce_paypal_payments_admin_gateway_settings',
|
||||
function( array $settings ) use ( $c ): array {
|
||||
if ( is_array( $settings['components'] ) ) {
|
||||
$settings['components'][] = 'applepay';
|
||||
}
|
||||
return $settings;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the Apple Pay buttons in the enabled places.
|
||||
*
|
||||
|
|
|
@ -942,7 +942,7 @@ class ApplePayButton implements ButtonInterface {
|
|||
}
|
||||
if ( $button_enabled_cart ) {
|
||||
$default_hook_name = 'woocommerce_paypal_payments_cart_button_render';
|
||||
$render_placeholder = apply_filters( 'woocommerce_paypal_payments_googlepay_cart_button_render_hook', $default_hook_name );
|
||||
$render_placeholder = apply_filters( 'woocommerce_paypal_payments_applepay_cart_button_render_hook', $default_hook_name );
|
||||
$render_placeholder = is_string( $render_placeholder ) ? $render_placeholder : $default_hook_name;
|
||||
add_action(
|
||||
$render_placeholder,
|
||||
|
@ -954,7 +954,7 @@ class ApplePayButton implements ButtonInterface {
|
|||
|
||||
if ( $button_enabled_checkout ) {
|
||||
$default_hook_name = 'woocommerce_paypal_payments_checkout_button_render';
|
||||
$render_placeholder = apply_filters( 'woocommerce_paypal_payments_googlepay_checkout_button_render_hook', $default_hook_name );
|
||||
$render_placeholder = apply_filters( 'woocommerce_paypal_payments_applepay_checkout_button_render_hook', $default_hook_name );
|
||||
$render_placeholder = is_string( $render_placeholder ) ? $render_placeholder : $default_hook_name;
|
||||
add_action(
|
||||
$render_placeholder,
|
||||
|
@ -966,7 +966,7 @@ class ApplePayButton implements ButtonInterface {
|
|||
}
|
||||
if ( $button_enabled_payorder ) {
|
||||
$default_hook_name = 'woocommerce_paypal_payments_payorder_button_render';
|
||||
$render_placeholder = apply_filters( 'woocommerce_paypal_payments_googlepay_payorder_button_render_hook', $default_hook_name );
|
||||
$render_placeholder = apply_filters( 'woocommerce_paypal_payments_applepay_payorder_button_render_hook', $default_hook_name );
|
||||
$render_placeholder = is_string( $render_placeholder ) ? $render_placeholder : $default_hook_name;
|
||||
add_action(
|
||||
$render_placeholder,
|
||||
|
@ -979,7 +979,7 @@ class ApplePayButton implements ButtonInterface {
|
|||
|
||||
if ( $button_enabled_minicart ) {
|
||||
$default_hook_name = 'woocommerce_paypal_payments_minicart_button_render';
|
||||
$render_placeholder = apply_filters( 'woocommerce_paypal_payments_googlepay_minicart_button_render_hook', $default_hook_name );
|
||||
$render_placeholder = apply_filters( 'woocommerce_paypal_payments_applepay_minicart_button_render_hook', $default_hook_name );
|
||||
$render_placeholder = is_string( $render_placeholder ) ? $render_placeholder : $default_hook_name;
|
||||
add_action(
|
||||
$render_placeholder,
|
||||
|
@ -1059,6 +1059,34 @@ class ApplePayButton implements ButtonInterface {
|
|||
wp_enqueue_style( 'wc-ppcp-applepay' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueues scripts/styles for admin.
|
||||
*/
|
||||
public function enqueue_admin(): void {
|
||||
wp_register_style(
|
||||
'wc-ppcp-applepay-admin',
|
||||
untrailingslashit( $this->module_url ) . '/assets/css/styles.css',
|
||||
array(),
|
||||
$this->version
|
||||
);
|
||||
wp_enqueue_style( 'wc-ppcp-applepay-admin' );
|
||||
|
||||
wp_register_script(
|
||||
'wc-ppcp-applepay-admin',
|
||||
untrailingslashit( $this->module_url ) . '/assets/js/boot-admin.js',
|
||||
array(),
|
||||
$this->version,
|
||||
true
|
||||
);
|
||||
wp_enqueue_script( 'wc-ppcp-applepay-admin' );
|
||||
|
||||
wp_localize_script(
|
||||
'wc-ppcp-applepay-admin',
|
||||
'wc_ppcp_applepay_admin',
|
||||
$this->script_data_for_admin()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the script data.
|
||||
*
|
||||
|
@ -1068,6 +1096,15 @@ class ApplePayButton implements ButtonInterface {
|
|||
return $this->script_data->apple_pay_script_data();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the admin script data.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function script_data_for_admin(): array {
|
||||
return $this->script_data->apple_pay_script_data_for_admin();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the module is enabled.
|
||||
*
|
||||
|
|
|
@ -69,6 +69,26 @@ class DataToAppleButtonScripts {
|
|||
);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Returns the appropriate admin data to send to ApplePay script
|
||||
*
|
||||
* @return array
|
||||
* @throws NotFoundException When the setting is not found.
|
||||
*/
|
||||
public function apple_pay_script_data_for_admin(): array {
|
||||
$base_location = wc_get_base_location();
|
||||
$shop_country_code = $base_location['country'];
|
||||
$currency_code = get_woocommerce_currency();
|
||||
$total_label = get_bloginfo( 'name' );
|
||||
|
||||
return $this->data_for_admin_page(
|
||||
$shop_country_code,
|
||||
$currency_code,
|
||||
$total_label
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the product needs shipping
|
||||
*
|
||||
|
@ -199,4 +219,47 @@ class DataToAppleButtonScripts {
|
|||
'nonce' => wp_create_nonce( 'woocommerce-process_checkout' ),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares the data for the cart page.
|
||||
* Consider refactoring this method along with data_for_cart_page() and data_for_product_page() methods.
|
||||
*
|
||||
* @param string $shop_country_code The shop country code.
|
||||
* @param string $currency_code The currency code.
|
||||
* @param string $total_label The label for the total amount.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
protected function data_for_admin_page(
|
||||
$shop_country_code,
|
||||
$currency_code,
|
||||
$total_label
|
||||
) {
|
||||
$type = $this->settings->has( 'applepay_button_type' ) ? $this->settings->get( 'applepay_button_type' ) : '';
|
||||
$color = $this->settings->has( 'applepay_button_color' ) ? $this->settings->get( 'applepay_button_color' ) : '';
|
||||
$lang = $this->settings->has( 'applepay_button_language' ) ? $this->settings->get( 'applepay_button_language' ) : '';
|
||||
$lang = apply_filters( 'woocommerce_paypal_payments_applepay_button_language', $lang );
|
||||
|
||||
return array(
|
||||
'sdk_url' => $this->sdk_url,
|
||||
'is_debug' => defined( 'WP_DEBUG' ) && WP_DEBUG ? true : false,
|
||||
'button' => array(
|
||||
'wrapper' => 'applepay-container',
|
||||
'mini_cart_wrapper' => 'applepay-container-minicart',
|
||||
'type' => $type,
|
||||
'color' => $color,
|
||||
'lang' => $lang,
|
||||
),
|
||||
'product' => array(
|
||||
'needShipping' => false,
|
||||
'subtotal' => 0,
|
||||
),
|
||||
'shop' => array(
|
||||
'countryCode' => $shop_country_code,
|
||||
'currencyCode' => $currency_code,
|
||||
'totalLabel' => $total_label,
|
||||
),
|
||||
'ajax_url' => admin_url( 'admin-ajax.php' ),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -11,6 +11,7 @@ module.exports = {
|
|||
entry: {
|
||||
'boot': path.resolve('./resources/js/boot.js'),
|
||||
'boot-block': path.resolve('./resources/js/boot-block.js'),
|
||||
'boot-admin': path.resolve('./resources/js/boot-admin.js'),
|
||||
"styles": path.resolve('./resources/css/styles.scss')
|
||||
},
|
||||
output: {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue