import React, { FC, useCallback, useEffect, useMemo, useState } from 'react';
import classNames from 'classnames';
import { capitalize, find, isEmpty, map, padStart, reduce } from 'lodash';
import {
PaymentRequest as StripePaymentRequest,
PaymentRequestShippingAddress,
PaymentRequestShippingAddressEvent,
PaymentRequestShippingOptionEvent,
} from '@stripe/stripe-js/types/stripe-js/payment-request';
import {
PaymentMethod as StripePaymentMethod,
PaymentRequestOptions,
PaymentRequestPaymentMethodEvent,
StripePaymentRequestButtonElementOptions,
} from '@stripe/stripe-js';
import { PaymentRequestButtonElement, useStripe } from '@stripe/react-stripe-js';
import { useCartContext, usePaymentsContext } from '@app/cart/contexts';
import { useFormatAmountInMinorUnits, useHandleSubmitCart, useUpdateFulfillmentGroup } from '@app/checkout/hooks';
import { MonetaryAmount } from '@broadleaf/commerce-browse';
import { usePaymentAuthState } from '@app/common/hooks';
import {
Address,
Cart,
CheckoutClientCallOptions,
DefaultFulfillmentType,
FulfillmentAddress,
GuestTokenResponse,
PaymentRequest,
PaymentSummary,
Phone,
PricedFulfillmentOption,
UpdateFulfillmentGroupRequest,
CartFulfillmentResponse,
UpdateFulfillmentGroupAddressRequest,
} from '@broadleaf/commerce-cart';
import { useCsrContext } from '@app/csr/context';
import { areAddressesEquivalent, maskCardNumber } from '@broadleaf/payment-js';
import { useSubmitPaymentRequest } from '@broadleaf/payment-react';
import { useCheckoutClient, usePaymentClient, usePreviewOptions, useCartClient } from '@app/common/contexts';
import { useFetchFulfillmentOptionsForCartItems, useSelectFulfillmentOption } from '@app/cart/hooks';
import { useGetCustomerAccessToken } from '@app/auth/hooks';
type Props = {
className?: string;
setCanUseExpressCheckout: (canUseExpressCheckout: boolean) => void;
};
export const StripeWalletExpressCheckout: FC<Props> = ({
className,
setCanUseExpressCheckout,
}) => {
const cartState = useCartContext();
const { resolving: resolvingCart, cart } = cartState;
const [error, setError] = useState(null);
const onShippingAddressChange = useOnShippingAddressChange(setError);
const onShippingOptionChange = useOnShippingOptionChange(setError);
const onPaymentMethod = useOnPaymentMethod(setError);
const formatAmountInMinorUnits = useFormatAmountInMinorUnits();
const fulfillmentGroup = cart?.fulfillmentGroups[0];
const fulfillmentType = fulfillmentGroup?.type;
const isVirtualFulfillment =
fulfillmentType === DefaultFulfillmentType.VIRTUAL;
// Create the Stripe PaymentRequest
const stripePaymentRequest = useStripePaymentRequest({
options: {
country: 'US', // The two-letter country code of your Stripe account (e.g., US).
currency: cart.cartPricing.total.currency.toLowerCase(),
total: {
label: 'Payment Total',
amount: formatAmountInMinorUnits(cart.cartPricing.total), // The amount in the currency's subunit (e.g. cents, yen, etc.)
pending: true, //If this amount can be changed later (for example, after you have calcluated shipping costs).
},
requestPayerName: true, // request the payer name
requestPayerEmail: true, // request the payer email
requestShipping: !isVirtualFulfillment, // Collect shipping address
},
// Stripe.js automatically creates a PaymentMethod after the customer is done interacting with the browser’s payment interface.
// To access the created PaymentMethod, listen for this event.
onPaymentMethod,
// The shippingaddresschange event is emitted from a PaymentRequest whenever the customer selects a new address in the browser's payment interface
onShippingAddressChange,
// The shippingoptionchange event is emitted from a PaymentRequest whenever the customer selects a new shipping option in the browser's payment interface.
onShippingOptionChange,
setCanUseExpressCheckout,
});
const options = useStripePaymentRequestButtonElementOptions(stripePaymentRequest);
const handleStartCheckout = useHandleStartCheckout(stripePaymentRequest);
if (resolvingCart || !cart || !stripePaymentRequest) {
return null;
}
return (
<div className={classNames(className, 'stripe-express-checkout')}>
<PaymentRequestButtonElement
options={options}
onClick={handleStartCheckout}
/>
{error && (
<strong className="block my-4 text-red-600 text-lg font-normal">
Error ...
</strong>
)}
</div>
);
};
const useStripePaymentRequestButtonElementOptions = (
stripePaymentRequest: StripePaymentRequest
): StripePaymentRequestButtonElementOptions => {
return useMemo(
() => ({
paymentRequest: stripePaymentRequest,
style: {
paymentRequestButton: {
// Preferred button type to display. Available types, by wallet:
// Google Pay: default, buy, or donate.
// Apple Pay: default, book, buy, donate, check-out, subscribe, reload, add-money, top-up, order, rent, support, contribute, tip
// When a wallet does not support the provided value, default is used as a fallback.
type: 'check-out',
},
},
}),
[stripePaymentRequest]
);
};
const useHandleStartCheckout = (stripePaymentRequest: StripePaymentRequest) => {
const { cart } = useCartContext();
const formatAmountInMinorUnits = useFormatAmountInMinorUnits();
return () => {
const displayItems = map(cart.cartItems, cartItem => {
const isMerch =
cartItem.internalAttributes.productType === 'MERCHANDISING_PRODUCT';
const itemTotal = isMerch
? cartItem.totalWithDependentItems
: cartItem.total;
const cartItemAmount = formatAmountInMinorUnits(itemTotal);
const label = `${cartItem.name} (${cartItem.quantity})`;
return {
amount: cartItemAmount,
label,
};
});
const paymentAmount = cart.cartPricing.total;
stripePaymentRequest.update({
currency: paymentAmount.currency.toLowerCase(),
total: {
label: 'Payment Total',
amount: formatAmountInMinorUnits(paymentAmount),
pending: true,
},
displayItems,
});
};
};
const useOnShippingAddressChange = setError => {
const { setCart } = useCartContext();
const { updateFulfillmentGroupAddress } = useUpdateFulfillmentGroupAddress();
const buildShippingOptions = useBuildShippingOptions();
const updateContactInfoOrGenerateGuestToken =
useUpdateContactInfoOrGenerateGuestToken(setError);
const formatAmountInMinorUnits = useFormatAmountInMinorUnits();
return async (event: PaymentRequestShippingAddressEvent) => {
let newCart: Cart;
try {
newCart = await updateContactInfoOrGenerateGuestToken();
const shipGroupReferenceNumber = newCart?.fulfillmentGroups[0]?.referenceNumber;
const fulfillmentAddress = buildFulfillmentAddress(event.shippingAddress);
const response = await updateFulfillmentGroupAddress(
fulfillmentAddress,
newCart,
shipGroupReferenceNumber
);
newCart = response?.cart;
if (newCart) {
setCart(newCart);
const fulfillmentOptions =
response.fulfillmentOptionResponse.groupFulfillmentOptions[
shipGroupReferenceNumber
];
const shippingOptions = buildShippingOptions(
fulfillmentOptions,
newCart
);
event.updateWith({
status: 'success',
total: {
label: 'Payment Total',
amount: formatAmountInMinorUnits(newCart.cartPricing.total),
pending: true,
},
shippingOptions,
});
} else {
event.updateWith({
status: 'fail',
});
}
} catch (err) {
event.updateWith({
status: 'fail',
});
setError(err);
}
};
};
const useOnShippingOptionChange = setError => {
const { cart, setCart } = useCartContext();
const formatAmountInMinorUnits = useFormatAmountInMinorUnits();
const { fulfillmentOptions } = useFetchFulfillmentOptionsForCartItems(
isEmpty(cart?.fulfillmentGroups[0]?.address)
);
const { selectFulfillmentOption } = useSelectFulfillmentOption();
const buildShippingOptions = useBuildShippingOptions();
return async (event: PaymentRequestShippingOptionEvent) => {
let newCart: Cart;
try {
const stripeShippingOption = event.shippingOption;
const selectedOption = find(fulfillmentOptions, [
'description',
stripeShippingOption.id,
]);
const response = await selectFulfillmentOption(selectedOption);
newCart = response?.cart;
if (newCart) {
setCart(newCart);
const shipGroupReferenceNumber = newCart?.fulfillmentGroups[0]?.referenceNumber;
const fulfillmentOptions =
response.fulfillmentOptionResponse.groupFulfillmentOptions[
shipGroupReferenceNumber
];
const shippingOptions = buildShippingOptions(
fulfillmentOptions,
newCart
);
event.updateWith({
status: 'success',
total: {
label: 'Payment Total',
amount: formatAmountInMinorUnits(newCart.cartPricing.total),
pending: true,
},
shippingOptions,
});
} else {
event.updateWith({
status: 'fail',
});
}
} catch (err) {
event.updateWith({
status: 'fail',
});
setError(err);
}
};
};
const useOnPaymentMethod = setError => {
const { cart, setCart } = useCartContext();
const { payments } = usePaymentsContext();
const paymentClient = usePaymentClient();
const authState = usePaymentAuthState();
const { handleSubmitPaymentInfo } = useSubmitPaymentRequest({
authState,
payments,
ownerId: cart?.id,
owningUserEmailAddress: cart.emailAddress,
paymentClient,
multiplePaymentsAllowed: true,
rejectOnError: true,
});
const { onSubmit: submitCart } = useHandleSubmitCart();
const updateContactInfoOrGenerateGuestToken =
useUpdateContactInfoOrGenerateGuestToken(setError);
const { updateFulfillmentGroup } = useUpdateFulfillmentGroup();
return async (event: PaymentRequestPaymentMethodEvent) => {
let newCart = cart;
let paymentSummary: PaymentSummary;
const fulfillmentGroup = cart?.fulfillmentGroups[0];
const fulfillmentType = fulfillmentGroup?.type;
const isVirtualFulfillment =
fulfillmentType === DefaultFulfillmentType.VIRTUAL;
try {
newCart = await updateContactInfoOrGenerateGuestToken(event.payerEmail);
if (!isVirtualFulfillment) {
const shipGroupReferenceNumber = newCart?.fulfillmentGroups[0]?.referenceNumber;
const shippingAddress = buildShippingAddress(event.shippingAddress);
const currentShippingAddress = newCart?.fulfillmentGroups[0]?.address;
const addressesEquivalent = areAddressesEquivalent(
shippingAddress,
currentShippingAddress
);
if (!addressesEquivalent) {
const request = {
address: shippingAddress,
} as UpdateFulfillmentGroupRequest;
newCart = await updateFulfillmentGroup(
shipGroupReferenceNumber,
request,
newCart
);
if (newCart) {
setCart(newCart);
} else {
event.complete('fail');
setError(true);
return;
}
}
}
const paymentRequest = buildPaymentRequest(
event,
newCart?.cartPricing.total,
newCart
);
paymentSummary = await handleSubmitPaymentInfo(
paymentRequest
);
if (paymentSummary) {
await submitCart();
event.complete('success');
} else {
event.complete('fail');
}
} catch (err) {
setError(err);
event.complete('fail');
}
};
};
const useBuildShippingOptions = () => {
const formatAmountInMinorUnits = useFormatAmountInMinorUnits();
return (fulfillmentOptions: Array<PricedFulfillmentOption>, cart: Cart) => {
const currentPricedFulfillmentOption = cart?.fulfillmentGroups[0]?.pricedFulfillmentOption;
return reduce(
fulfillmentOptions,
(result, fulfillmentOption) => {
const shippingOption = {
id: fulfillmentOption.description,
label: fulfillmentOption.description,
detail: fulfillmentOption.description,
amount: formatAmountInMinorUnits(fulfillmentOption.price),
};
if (shippingOption.id === currentPricedFulfillmentOption?.description) {
// add the selected fulfillment option to the head
// The first shipping option listed appears in the browser payment interface as the default option
result = [shippingOption, ...result];
} else {
result.push(shippingOption);
}
return result;
},
[]
);
};
};
const buildPaymentRequest = (
event: PaymentRequestPaymentMethodEvent,
paymentAmount: MonetaryAmount,
cart: Cart
): PaymentRequest => {
const paymentMethodProperties = {
PAYMENT_METHOD_ID: event.paymentMethod.id,
CUSTOMER_ID: event.paymentMethod.customer,
};
const stripeBillingDetails: StripePaymentMethod.BillingDetails =
event.paymentMethod.billing_details;
const stripeCard: StripePaymentMethod.Card = event.paymentMethod.card;
const phonePrimary: Phone = stripeBillingDetails?.phone
? {
phoneNumber: stripeBillingDetails.phone,
}
: null;
const billingAddress = {
fullName: stripeBillingDetails?.name,
emailAddress: stripeBillingDetails?.email,
addressLine1: stripeBillingDetails?.address?.line1,
addressLine2: stripeBillingDetails?.address?.line2,
city: stripeBillingDetails?.address?.city,
stateProvinceRegion: stripeBillingDetails?.address?.state,
country: stripeBillingDetails?.address?.country,
postalCode: stripeBillingDetails?.address?.postal_code,
phonePrimary,
} as Address;
const displayAttributes = {
creditCardType: stripeCard.brand.toUpperCase(),
creditCardNumber: maskCardNumber(stripeCard.last4),
creditCardExpDateMonth: padStart(`${stripeCard.exp_month}`, 2, '0'),
creditCardExpDateYear: `${stripeCard.exp_year}`,
};
const paymentName = `${capitalize(event.walletName)} | ${
displayAttributes.creditCardType
} | ${displayAttributes.creditCardNumber} ${
displayAttributes.creditCardExpDateMonth
}/${displayAttributes.creditCardExpDateYear}`;
let paymentType;
if (event.walletName === 'applePay') {
paymentType = 'APPLE_PAY';
} else if (event.walletName === 'googlePay') {
paymentType = 'GOOGLE_PAY';
}
return {
owningUserName: event.payerName,
owningUserEmailAddress: event.payerEmail,
name: paymentName,
type: paymentType,
gatewayType: 'STRIPE',
amount: paymentAmount,
subtotal: cart.cartPricing.subtotal,
adjustmentsTotal: cart.cartPricing.adjustmentsTotal,
fulfillmentTotal: cart.cartPricing.fulfillmentTotal,
taxTotal: cart.cartPricing.totalTax,
isSingleUsePaymentMethod: true,
billingAddress,
paymentMethodProperties,
displayAttributes,
};
};
const useUpdateContactInfoOrGenerateGuestToken = setError => {
const { isAuthenticated, customerEmail } = usePaymentAuthState();
const { cart, setCart, guestToken, setGuestToken } = useCartContext();
const { csrAnonymous } = useCsrContext();
const checkoutClient = useCheckoutClient();
const getCustomerToken = useGetCustomerAccessToken();
const preview = usePreviewOptions();
return async (emailAddress?: string): Promise<Cart> => {
let newCart = cart;
emailAddress = emailAddress || cart?.emailAddress || customerEmail;
try {
if (emailAddress && (guestToken || (isAuthenticated && !csrAnonymous))) {
const accessToken = await getCustomerToken();
newCart = await checkoutClient.updateContactInfoInCart(
cart.id,
{ emailAddress },
{
accessToken,
guestToken: guestToken?.tokenString,
preview,
version: cart.version,
}
);
} else if (!guestToken) {
const accessToken = await getCustomerToken();
const options = {
accessToken,
guestToken: guestToken?.tokenString,
preview,
version: cart.version,
} as CheckoutClientCallOptions;
let response: GuestTokenResponse;
if (emailAddress) {
response = await checkoutClient.checkoutAsGuest(
cart.id,
{ emailAddress },
options
);
} else {
response = await checkoutClient.generateGuestToken(cart.id, options);
}
newCart = response.cart;
setGuestToken(response.token);
}
if (newCart) {
setCart(newCart);
}
} catch (err) {
setError(err);
return Promise.reject(err);
}
return newCart;
};
};
const useUpdateFulfillmentGroupAddress = () => {
const cartClient = useCartClient();
const { guestToken } = useCartContext();
const getCustomerToken = useGetCustomerAccessToken();
const [error, setError] = useState(null);
const updateFulfillmentGroupAddress = useCallback(
async (
fulfillmentAddress: FulfillmentAddress,
cart: Cart,
fulfillmentGroupReference: string
): Promise<CartFulfillmentResponse> => {
try {
const request = {
address: fulfillmentAddress,
fulfillmentGroupReference,
} as UpdateFulfillmentGroupAddressRequest;
const accessToken = await getCustomerToken();
const response = await cartClient.updateFulfillmentGroupAddress(
cart.id,
request,
{
accessToken,
guestToken: guestToken?.tokenString,
version: cart.version,
}
);
setError(null);
return response;
} catch (error) {
setError(error.response?.data);
}
},
[cartClient, guestToken]
);
return { updateFulfillmentGroupAddress, error };
};
type StripePaymentRequestProps = {
options: PaymentRequestOptions;
onPaymentMethod: (event: PaymentRequestPaymentMethodEvent) => void;
onShippingAddressChange: (event: PaymentRequestShippingAddressEvent) => void;
onShippingOptionChange: (event: PaymentRequestShippingOptionEvent) => void;
setCanUseExpressCheckout: (canUseExpressCheckout: boolean) => void;
};
const useStripePaymentRequest = ({
options,
onPaymentMethod,
onShippingAddressChange,
onShippingOptionChange,
setCanUseExpressCheckout,
}: StripePaymentRequestProps) => {
const stripe = useStripe();
const [stripePaymentRequest, setStripePaymentRequest] =
useState<StripePaymentRequest>(null);
const [canMakePayment, setCanMakePayment] = useState(false);
useEffect(() => {
if (stripe && stripePaymentRequest === null) {
const pr = stripe.paymentRequest(options);
setStripePaymentRequest(pr);
} else if (!stripe) {
setCanUseExpressCheckout(false);
}
}, [stripe, options, stripePaymentRequest, setCanUseExpressCheckout]);
useEffect(() => {
let subscribed = true;
if (stripePaymentRequest) {
stripePaymentRequest.canMakePayment().then(res => {
if (subscribed) {
if (res.applePay || res.googlePay) {
setCanUseExpressCheckout(true);
setCanMakePayment(true);
} else {
setCanUseExpressCheckout(false);
}
}
});
}
return () => {
subscribed = false;
};
}, [stripePaymentRequest, setCanUseExpressCheckout]);
useEffect(() => {
if (stripePaymentRequest) {
stripePaymentRequest.on('paymentmethod', onPaymentMethod);
stripePaymentRequest.on('shippingaddresschange', onShippingAddressChange);
stripePaymentRequest.on('shippingoptionchange', onShippingOptionChange);
}
return () => {
if (stripePaymentRequest) {
stripePaymentRequest.off('paymentmethod');
stripePaymentRequest.off('shippingaddresschange');
stripePaymentRequest.off('shippingoptionchange');
}
};
}, [
stripePaymentRequest,
onPaymentMethod,
onShippingAddressChange,
onShippingOptionChange,
]);
return canMakePayment ? stripePaymentRequest : null;
};
const buildFulfillmentAddress = (
stripeShippingAddress: PaymentRequestShippingAddress
): FulfillmentAddress => {
return {
address1: stripeShippingAddress.addressLine[0],
address2:
stripeShippingAddress.addressLine.length > 1
? stripeShippingAddress.addressLine[1]
: undefined,
city: stripeShippingAddress.city,
region: stripeShippingAddress.region,
country: stripeShippingAddress.country,
postalCode: stripeShippingAddress.postalCode,
};
};
const buildShippingAddress = (
stripeShippingAddress: PaymentRequestShippingAddress,
emailAddress?: string
): Address => {
return {
emailAddress,
fullName: stripeShippingAddress.recipient,
addressLine1: stripeShippingAddress.addressLine[0],
addressLine2:
stripeShippingAddress.addressLine.length > 1
? stripeShippingAddress.addressLine[1]
: undefined,
city: stripeShippingAddress.city,
stateProvinceRegion: stripeShippingAddress.region,
country: stripeShippingAddress.country,
postalCode: stripeShippingAddress.postalCode,
phonePrimary: {
phoneNumber: stripeShippingAddress.phone,
},
};
};