From 6f5563fb64c40a3d407ae152b84274d88a923883 Mon Sep 17 00:00:00 2001 From: Fabian Gerke <124085586+FabianGerke@users.noreply.github.com> Date: Wed, 30 Oct 2024 16:45:08 +0100 Subject: [PATCH] feat: apple pay & google pay --- apps/web/components/PayPal/ApplePayButton.vue | 146 ++-------- .../web/components/PayPal/GooglePayButton.vue | 275 +++++------------- apps/web/composables/useApplePay/index.ts | 1 + .../composables/useApplePay/useApplePay.ts | 167 +++++++++++ apps/web/composables/useGooglePay/index.ts | 2 + apps/web/composables/useGooglePay/types.ts | 25 ++ .../composables/useGooglePay/useGooglePay.ts | 177 +++++++++++ apps/web/composables/usePayPal/usePayPal.ts | 9 + apps/web/pages/checkout.vue | 13 + package.json | 2 +- yarn.lock | 10 +- 11 files changed, 485 insertions(+), 342 deletions(-) create mode 100644 apps/web/composables/useApplePay/index.ts create mode 100644 apps/web/composables/useApplePay/useApplePay.ts create mode 100644 apps/web/composables/useGooglePay/index.ts create mode 100644 apps/web/composables/useGooglePay/types.ts create mode 100644 apps/web/composables/useGooglePay/useGooglePay.ts diff --git a/apps/web/components/PayPal/ApplePayButton.vue b/apps/web/components/PayPal/ApplePayButton.vue index 1d49b7161..3d452ebce 100644 --- a/apps/web/components/PayPal/ApplePayButton.vue +++ b/apps/web/components/PayPal/ApplePayButton.vue @@ -3,145 +3,35 @@ diff --git a/apps/web/components/PayPal/GooglePayButton.vue b/apps/web/components/PayPal/GooglePayButton.vue index 9d7837185..f3ae35e0a 100644 --- a/apps/web/components/PayPal/GooglePayButton.vue +++ b/apps/web/components/PayPal/GooglePayButton.vue @@ -1,232 +1,91 @@ diff --git a/apps/web/composables/useApplePay/index.ts b/apps/web/composables/useApplePay/index.ts new file mode 100644 index 000000000..38d03fe90 --- /dev/null +++ b/apps/web/composables/useApplePay/index.ts @@ -0,0 +1 @@ +export * from './useApplePay'; diff --git a/apps/web/composables/useApplePay/useApplePay.ts b/apps/web/composables/useApplePay/useApplePay.ts new file mode 100644 index 000000000..f91b26191 --- /dev/null +++ b/apps/web/composables/useApplePay/useApplePay.ts @@ -0,0 +1,167 @@ +import { cartGetters, orderGetters } from '@plentymarkets/shop-api'; +import { ApplepayType, ConfigResponse } from '~/components/PayPal/types'; + +const loadExternalScript = async () => { + return new Promise((resolve, reject) => { + const scriptElement = document.createElement('script'); + scriptElement.src = 'https://applepay.cdn-apple.com/jsapi/v1/apple-pay-sdk.js'; + scriptElement.type = 'text/javascript'; + scriptElement.addEventListener('error', reject); + scriptElement.addEventListener('load', resolve); + document.head.append(scriptElement); + }); +}; + +const showErrorNotification = (message: string) => { + useNotification().send({ + type: 'negative', + message, + }); +}; + +export const useApplePay = () => { + const state = useState(`useApplePay`, () => ({ + scriptLoaded: false, + script: {} as ApplepayType, + paymentSession: null as ApplePaySession | null, + config: {} as ConfigResponse, + })); + + const initialize = async () => { + const { data: cart } = useCart(); + const currency = computed(() => cartGetters.getCurrency(cart.value) || (useAppConfig().fallbackCurrency as string)); + const { getScript } = usePayPal(); + const script = await getScript(currency.value); + + if (!script) return false; + + if (!state.value.scriptLoaded) { + await loadExternalScript(); + state.value.scriptLoaded = true; + } + + state.value.script = (script as any).Applepay() as ApplepayType; + state.value.config = await state.value.script.config(); + + return true; + }; + + const createPaymentRequest = () => { + const { data: cart } = useCart(); + return { + countryCode: state.value.config.countryCode, + merchantCapabilities: state.value.config.merchantCapabilities, + supportedNetworks: state.value.config.supportedNetworks, + currencyCode: state.value.config.currencyCode, + requiredShippingContactFields: [], + requiredBillingContactFields: ['postalAddress'], + total: { + type: 'final', + label: useRuntimeConfig().public.storename ?? 'plentyshop PWA', + amount: cartGetters.getTotals(cart.value).total.toString(), + }, + } as ApplePayJS.ApplePayPaymentRequest; + }; + + const processPayment = () => { + const { createOrder } = useMakeOrder(); + const { createCreditCardTransaction, captureOrder, executeOrder } = usePayPal(); + const { data: cart, clearCartItems } = useCart(); + const localePath = useLocalePath(); + const { shippingPrivacyAgreement } = useAdditionalInformation(); + + try { + const paymentRequest = createPaymentRequest(); + const paymentSession = new ApplePaySession(14, paymentRequest); + + paymentSession.onvalidatemerchant = async (event: ApplePayJS.ApplePayValidateMerchantEvent) => { + try { + const validationData = await state.value.script.validateMerchant({ + validationUrl: event.validationURL, + }); + paymentSession.completeMerchantValidation(validationData.merchantSession); + } catch (error) { + console.error(error); + paymentSession.abort(); + } + }; + + paymentSession.onpaymentauthorized = async (event: ApplePayJS.ApplePayPaymentAuthorizedEvent) => { + try { + const transaction = await createCreditCardTransaction(); + if (!transaction || !transaction.id) { + showErrorNotification('Transaction creation failed'); + return; + } + + const order = await createOrder({ + paymentId: cart.value.methodOfPaymentId, + shippingPrivacyHintAccepted: shippingPrivacyAgreement.value, + }); + if (!order || !order.order || !order.order.id) { + showErrorNotification('Order creation failed'); + return; + } + + try { + await state.value.script.confirmOrder({ + orderId: transaction.id, + token: event.payment.token, + billingContact: event.payment.billingContact, + }); + } catch (error) { + showErrorNotification(error?.toString() ?? 'Error during order confirmation'); + return; + } + + await executeOrder({ + mode: 'paypal', + plentyOrderId: Number.parseInt(orderGetters.getId(order)), + paypalTransactionId: transaction.id, + }); + + paymentSession.completePayment(ApplePaySession.STATUS_SUCCESS); + + clearCartItems(); + + navigateTo(localePath(paths.confirmation + '/' + order.order.id + '/' + order.order.accessKey)); + } catch (error: unknown) { + showErrorNotification(error?.toString() ?? 'Error during payment process'); + paymentSession.completePayment(ApplePaySession.STATUS_FAILURE); + } + }; + + paymentSession.addEventListener('cancel', () => { + console.error('Apple pay cancel'); + }); + + paymentSession.begin(); + } catch (error) { + console.error(error); + } + }; + + const checkIsEligible = async () => { + if ( + (await initialize()) && + typeof ApplePaySession !== 'undefined' && + state.value.script && + ApplePaySession && + ApplePaySession.canMakePayments() && + state.value.config.isEligible + ) { + await useSdk().plentysystems.doHandleAllowPaymentApplePay({ + canMakePayments: true, + }); + return true; + } + return false; + }; + + return { + initialize, + checkIsEligible, + processPayment, + ...toRefs(state.value), + }; +}; diff --git a/apps/web/composables/useGooglePay/index.ts b/apps/web/composables/useGooglePay/index.ts new file mode 100644 index 000000000..76067aba6 --- /dev/null +++ b/apps/web/composables/useGooglePay/index.ts @@ -0,0 +1,2 @@ +export * from './types'; +export * from './useGooglePay'; diff --git a/apps/web/composables/useGooglePay/types.ts b/apps/web/composables/useGooglePay/types.ts new file mode 100644 index 000000000..83aa8e61c --- /dev/null +++ b/apps/web/composables/useGooglePay/types.ts @@ -0,0 +1,25 @@ +export interface GooglePayConfig { + merchantInfo: google.payments.api.MerchantInfo; + isEligible: boolean; + countryCode: string; + apiVersion: number; + apiVersionMinor: number; + allowedPaymentMethods: google.payments.api.PaymentMethodSpecification[]; + transactionInfo: google.payments.api.TransactionInfo; + callbackIntents: google.payments.api.CallbackIntent[]; +} + +export interface GooglePayConfirmOrderParams { + orderId: string; + paymentMethodData: google.payments.api.PaymentMethodData; +} + +export interface GooglePayConfirmOrderResponse { + status: string; +} + +export interface GooglePayPayPal { + config(): Promise; + confirmOrder(params: GooglePayConfirmOrderParams): Promise; + initiatePayerAction(params: { orderId: string }): Promise; +} diff --git a/apps/web/composables/useGooglePay/useGooglePay.ts b/apps/web/composables/useGooglePay/useGooglePay.ts new file mode 100644 index 000000000..dabc27811 --- /dev/null +++ b/apps/web/composables/useGooglePay/useGooglePay.ts @@ -0,0 +1,177 @@ +import { GooglePayConfig, GooglePayPayPal } from '~/composables/useGooglePay/types'; +import { cartGetters, orderGetters, paypalGetters, PayPalGooglePayAllowedPaymentMethod } from '@plentymarkets/shop-api'; + +const loadExternalScript = async () => { + return new Promise((resolve, reject) => { + const scriptElement = document.createElement('script'); + scriptElement.src = 'https://pay.google.com/gp/p/js/pay.js'; + scriptElement.type = 'text/javascript'; + scriptElement.addEventListener('error', reject); + scriptElement.addEventListener('load', resolve); + document.head.append(scriptElement); + }); +}; + +const getPaymentsClient = () => { + const { config } = usePayPal(); + + return new google.payments.api.PaymentsClient({ + environment: config.value ? (paypalGetters.isProduction(config.value) ? 'PRODUCTION' : 'TEST') : 'TEST', + }); +}; + +export const useGooglePay = () => { + const state = useState(`useGooglePay`, () => ({ + scriptLoaded: false, + script: null as GooglePayPayPal | null, + googleConfig: {} as GooglePayConfig, + paymentsClient: {} as google.payments.api.PaymentsClient, + paymentLoading: false, + })); + + const initialize = async () => { + const { data: cart } = useCart(); + const currency = computed(() => cartGetters.getCurrency(cart.value) || (useAppConfig().fallbackCurrency as string)); + const { getScript } = usePayPal(); + const script = await getScript(currency.value); + + if (!script) return false; + + if (!state.value.scriptLoaded) { + await loadExternalScript(); + state.value.scriptLoaded = true; + } + + state.value.script = (script as any).Googlepay() as GooglePayPayPal; + state.value.googleConfig = await state.value.script.config(); + state.value.paymentsClient = getPaymentsClient(); + + return true; + }; + + const getGoogleTransactionInfo = () => { + const { data: cart } = useCart(); + const currency = computed(() => cartGetters.getCurrency(cart.value) || (useAppConfig().fallbackCurrency as string)); + return { + countryCode: state.value.googleConfig.countryCode, + currencyCode: currency.value, + totalPriceStatus: 'FINAL', + totalPrice: cartGetters.getTotals(cart.value).total.toString(), + } as google.payments.api.TransactionInfo; + }; + + const getGooglePaymentDataRequest = () => { + return { + apiVersion: 2, + apiVersionMinor: 0, + allowedPaymentMethods: JSON.parse(JSON.stringify(state.value.googleConfig.allowedPaymentMethods)), + transactionInfo: getGoogleTransactionInfo(), + merchantInfo: JSON.parse(JSON.stringify(state.value.googleConfig.merchantInfo)), + } as google.payments.api.PaymentDataRequest; + }; + + const showErrorNotification = (message: string) => { + useNotification().send({ + type: 'negative', + message, + }); + state.value.paymentLoading = false; + }; + + const processPayment = async (paymentData: google.payments.api.PaymentData) => { + if (!state.value.script) return; + const localePath = useLocalePath(); + const { createCreditCardTransaction, getOrder, captureOrder, executeOrder } = usePayPal(); + const { data: cart, clearCartItems } = useCart(); + const { shippingPrivacyAgreement } = useAdditionalInformation(); + const { createOrder } = useMakeOrder(); + + state.value.paymentLoading = true; + + const transaction = await createCreditCardTransaction(); + if (!transaction || !transaction.id) { + showErrorNotification('Failed to create transaction'); + return; + } + + let { status } = await state.value.script.confirmOrder({ + orderId: transaction.id, + paymentMethodData: paymentData.paymentMethodData, + }); + + if (status === 'PAYER_ACTION_REQUIRED') { + await state.value.script.initiatePayerAction({ orderId: transaction.id }); + const paypalOrder = (await getOrder({ + paypalOrderId: transaction.id, + payPalPayerId: transaction.payPalPayerId, + })) as any; + status = paypalOrder?.result?.status || 'ERROR'; + } + + if (status === 'APPROVED') { + await captureOrder({ + paypalOrderId: transaction.id, + paypalPayerId: transaction.payPalPayerId, + }); + + const order = await createOrder({ + paymentId: cart.value.methodOfPaymentId, + shippingPrivacyHintAccepted: shippingPrivacyAgreement.value, + }); + + if (!order || !order.order || !order.order.id) { + showErrorNotification('Failed to create plenty order'); + return; + } + + await executeOrder({ + mode: 'paypal', + plentyOrderId: Number.parseInt(orderGetters.getId(order)), + paypalTransactionId: transaction.id, + }); + + clearCartItems(); + navigateTo(localePath(paths.confirmation + '/' + order.order.id + '/' + order.order.accessKey)); + state.value.paymentLoading = false; + + return { transactionState: 'SUCCESS' }; + } else { + showErrorNotification('Payment failed'); + return { transactionState: 'ERROR' }; + } + }; + + const getIsReadyToPayRequest = (): google.payments.api.IsReadyToPayRequest => { + return { + apiVersion: 2, + apiVersionMinor: 0, + allowedPaymentMethods: JSON.parse(JSON.stringify(state.value.googleConfig.allowedPaymentMethods)), + } as google.payments.api.IsReadyToPayRequest; + }; + + const checkIsEligible = async () => { + if (await initialize()) { + const request = getIsReadyToPayRequest(); + const response = await toRaw(state.value.paymentsClient).isReadyToPay(request); + + if (response.result) { + await useSdk().plentysystems.doHandleAllowPaymentGooglePay({ + allowedPaymentMethods: toRaw( + state.value.googleConfig.allowedPaymentMethods, + ) as PayPalGooglePayAllowedPaymentMethod[], + }); + return true; + } + } + return false; + }; + + return { + ...toRefs(state.value), + initialize, + getGooglePaymentDataRequest, + processPayment, + getIsReadyToPayRequest, + checkIsEligible, + }; +}; diff --git a/apps/web/composables/usePayPal/usePayPal.ts b/apps/web/composables/usePayPal/usePayPal.ts index 57e035619..35edd37ff 100644 --- a/apps/web/composables/usePayPal/usePayPal.ts +++ b/apps/web/composables/usePayPal/usePayPal.ts @@ -4,6 +4,7 @@ import type { PayPalConfigResponse, PayPalCreateOrder, PayPalExecuteParams, + PayPalGetOrderDetailsParams, } from '@plentymarkets/shop-api'; import { paypalGetters } from '@plentymarkets/shop-api'; import { PayPalLoadScript, PayPalScript } from '~/composables'; @@ -11,6 +12,13 @@ import { PayPalLoadScript, PayPalScript } from '~/composables'; const localeMap: Record = { de: 'de_DE' }; const getLocaleForPayPal = (locale: string): string => localeMap[locale] || 'en_US'; +const getOrder = async (params: PayPalGetOrderDetailsParams) => { + const { data, error } = await useAsyncData(() => useSdk().plentysystems.getPayPalOrderDetails(params)); + useHandleError(error.value); + + return data.value?.data ?? null; +}; + /** * @description Composable for managing PayPal interaction. * @example @@ -248,6 +256,7 @@ export const usePayPal = () => { createCreditCardTransaction, captureOrder, getScript, + getOrder, ...toRefs(state.value), }; }; diff --git a/apps/web/pages/checkout.vue b/apps/web/pages/checkout.vue index 03cfefc99..d44dd4aa7 100644 --- a/apps/web/pages/checkout.vue +++ b/apps/web/pages/checkout.vue @@ -152,6 +152,17 @@ const { handlePaymentMethodUpdate, } = useCheckoutPagePaymentAndShipping(); +const checkPayPalPaymentsEligible = async () => { + if (import.meta.client) { + const googlePayAvailable = await useGooglePay().checkIsEligible(); + const applePayAvailable = await useApplePay().checkIsEligible(); + + if (googlePayAvailable || applePayAvailable) { + await usePaymentMethods().fetchPaymentMethods(); + } + } +}; + onNuxtReady(async () => { useFetchAddress(AddressType.Shipping) .fetchServer() @@ -162,6 +173,8 @@ onNuxtReady(async () => { .fetchServer() .then(() => persistBillingAddress()) .catch((error) => useHandleError(error)); + + await checkPayPalPaymentsEligible(); }); await getCart().then( diff --git a/package.json b/package.json index d33ee86a4..865a8d768 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "lhci:mobile": "lhci autorun" }, "dependencies": { - "@plentymarkets/shop-api": "^0.69.2", + "@plentymarkets/shop-api": "^0.69.3", "@types/applepayjs": "^14.0.8", "@types/googlepay": "^0.7.6", "@vee-validate/nuxt": "^4.13.2", diff --git a/yarn.lock b/yarn.lock index da6061fc2..18f4af5d2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4237,7 +4237,7 @@ __metadata: "@nuxt/test-utils": ^3.13.1 "@nuxtjs/turnstile": ^0.8.0 "@paypal/paypal-js": 8.1.0 - "@plentymarkets/shop-api": ^0.69.2 + "@plentymarkets/shop-api": ^0.69.3 "@types/applepayjs": ^14.0.8 "@types/googlepay": ^0.7.6 "@types/uuid": ^9.0.8 @@ -4271,14 +4271,14 @@ __metadata: languageName: unknown linkType: soft -"@plentymarkets/shop-api@npm:^0.69.2": - version: 0.69.2 - resolution: "@plentymarkets/shop-api@npm:0.69.2::__archiveUrl=https%3A%2F%2Fnpm.pkg.github.com%2Fdownload%2F%40plentymarkets%2Fshop-api%2F0.69.2%2F30647f7862c0944605b240584e041457ca19fba4" +"@plentymarkets/shop-api@npm:^0.69.3": + version: 0.69.3 + resolution: "@plentymarkets/shop-api@npm:0.69.3::__archiveUrl=https%3A%2F%2Fnpm.pkg.github.com%2Fdownload%2F%40plentymarkets%2Fshop-api%2F0.69.3%2Fc0bce148083a5a9bb9b8dfd524e93d47cb7d49de" dependencies: "@vue-storefront/middleware": ^3.10.0 axios: ^1.7.7 consola: ^3.2.3 - checksum: fa5e4a0156d84365069dead6138c838cfca5fbcce9b4b0b45b627610883445653b645469352d4b8a8728062f525e543922d35dbfb1e90634e5e59584179cf8ec + checksum: fde41e5e5250e57fb612405c7c7e628ec46fa070a0c14719aba481fcd17f33e19c229102ad961ccbcaccc1d0e09faebc1931f0e9b1f8e2783a5c319f9119ba88 languageName: node linkType: hard