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 @@
+
+
+
Payment in progress...
+
+
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