Broadleaf Microservices
  • v1.0.0-latest-prod

Frontend Integration Example

Product and Cart Page Snippet

Tabby provides JS snippets to be placed on the product and cart pages. Add <script src="https://checkout.tabby.ai/tabby-promo.js"></script> to use it.

Create the following components to be able to add them to your app.

import { flatten, isEmpty, values } from 'lodash';

type ProductSnippetProps = {
  priceInfo: PriceInfo;
  itemChoices?: Record<string, Record<string, SelectItemChoice>>;
  className?: string;
};

export const TabbyPaymentProductSnippet: FC<ProductSnippetProps> = props => {
  const price = useGetProductPrice(props);

  return (
    <TabbyPaymentPromoSnippet
      className={props.className}
      price={price}
      source="product"
    />
  );
};

const useGetProductPrice = ({
  priceInfo,
  itemChoices,
}: ProductSnippetProps) => {
  const priceIncrease = getItemChoicePriceIncreaseTotal(itemChoices);
  const price = priceInfo.price;

  // sums the MonetaryAmount values
  const handleMoneyAddition = useHandleMoneyAddition();

  return {
    amount: handleMoneyAddition(price, {
      amount: priceIncrease,
      currency: price.currency,
    }),
    currency: price.currency,
  } as MonetaryAmount;
};

type CartSnippetProps = {
  cartTotal: MonetaryAmount;
  className?: string;
};

export const TabbyPaymentCartSnippet: FC<CartSnippetProps> = ({
  cartTotal,
  className,
}) => {
  return (
    <TabbyPaymentPromoSnippet
      className={className}
      price={cartTotal}
      source="cart"
    />
  );
};

type Props = {
  price: MonetaryAmount;
  source: 'product' | 'cart';
  className?: string;
};

const TabbyPaymentPromoSnippet: FC<Props> = props => {
  const { price, source, className } = props;

  const publicApiKey = Tabby.PUBLIC_API_KEY;
  const merchantCode = Tabby.MERCHANT_CODE;

  const TabbyPromo = window['TabbyPromo'];

  useEffect(() => {
    if (TabbyPromo && publicApiKey && merchantCode) {
      new TabbyPromo({
        selector: `#TabbyPromoSnippet_${source}`,
        currency: price.currency,
        price: toString(price.amount),
        lang: 'en',
        source,
        publicKey: publicApiKey,
        merchantCode,
      });
    }
  }, [source, price, TabbyPromo, publicApiKey, merchantCode]);

  if (!Tabby.ENABLED || !TabbyPromo) {
    return null;
  }

  if (!publicApiKey || !merchantCode) {
    console.warn('API public key and merchant code required!');
    return null;
  }

  return <div id={`TabbyPromoSnippet_${source}`} className={className}></div>;
};

const getItemChoicePriceIncreaseTotal = (
  itemChoices?: Record<string, Record<string, SelectItemChoice>>
): number => {
  if (isEmpty(itemChoices)) {
    return 0.0;
  }

  const selectedChoices = flatten(
    values(itemChoices).map(
      (val: Record<string, SelectItemChoice>): Array<SelectItemChoice> =>
        values(val)
    )
  ) as Array<SelectItemChoice>;

  return selectedChoices.reduce(
    (totalIncrease, choice) => totalIncrease + choice.priceIncrease,
    0.0
  );
};

Then on your PDP use TabbyPaymentProductSnippet:

<TabbyPaymentProductSnippet
   itemChoices={productState.itemChoices}
   priceInfo={
    productState.activeVariant?.priceInfo || priceInfo
   }
/>

And on the Cart page use TabbyPaymentCartSnippet:

<TabbyPaymentCartSnippet cartTotal={cartTotal} />

Checkout

Checkout Snippet

Tabby provides JS snippets to be placed on the checkout page. Add <script src="https://checkout.tabby.ai/tabby-card.js"></script> to use it.

Payment Form

import { isEmpty, head, map } from 'lodash';
...

const TabbyPaymentForm = () => {
  const { error, onSubmit } = useHandleSubmit();

  const [preScoringResponse, setPreScoringResponse] =
    useState<TabbyPreScoringResponse>();

  const { executePreScoring, error: preScoringError } = useExecutePreScoringRequest();

  const getTabbyPayment = useGetTabbyPayment();

  const TabbyCard = window['TabbyCard'];

  useEffect(() => {
    if (!preScoringResponse && cart && !resolvingCart && TabbyCard) {
      const tabbyPayment = getTabbyPayment(cart);
      const paymentAmount = getPaymentAmount(cart);

      const request = {
        payment: {
          amount: toString(paymentAmount.amount),
          currency: paymentAmount.currency,
          ...tabbyPayment,
        },
        lang: 'en',
      } as TabbyCheckoutSessionRequest;

      executePreScoring(request).then(response => {
        if (response?.status === 'created' && TabbyCard) {
          // Create Tabby Checkout Snippet
          new TabbyCard({
            selector: '#tabbyCard',
            currency: request.payment.currency,
            lang: 'en',
            price: request.payment.amount,
            size: 'wide',
            theme: 'black',
            header: true,
          });
        }
        setPreScoringResponse(response);
      });
    }
  }, [
    cart,
    executePreScoring,
    getPaymentAmount,
    getTabbyPayment,
    preScoringResponse,
    resolvingCart,
    setPreScoringResponse,
    TabbyCard,
  ]);

   if (!preScoringResponse && !preScoringError) {
    return null;
  }

  let preScoringRejectedMessage;

  if (
    preScoringResponse?.status !== 'created' &&
    preScoringResponse?.configuration?.products?.installments?.rejection_reason
  ) {
    const rejectionReason =
      preScoringResponse.configuration?.products?.installments
        ?.rejection_reason;

    if (rejectionReason === 'order_amount_too_high') {
      preScoringRejectedMessage = 'This purchase is above your current spending limit with Tabby, try a smaller cart or use another payment method.';
    } else if (rejectionReason === 'order_amount_too_low') {
      preScoringRejectedMessage = 'The purchase amount is below the minimum amount required to use Tabby, try adding more items or use another payment method.';
    } else {
      preScoringRejectedMessage = 'Sorry, Tabby is unable to approve this purchase. Please use an alternative payment method for your order.';
    }
  }

  return (
    <Formik
      onSubmit={onSubmit}
      validateOnBlur
      validateOnChange={false}
    >
        {() => (
            <FormikForm>
                <div>
                    <img
                      className="mr-2"
                      src="/tabby-badge.png"
                      alt="Tabby payment"
                      role="img"
                      width={60}
                    />
                    <span>Pay in 4. No interest, no fees.</span>
                </div>
                <div id="tabbyCard"></div>

                {(error || preScoringError || preScoringRejectedMessage) && (
                    <strong className="block my-4 text-red-600 text-lg font-normal">
                      {error ? 'There was an error processing your request. Please check your info and try again.' : undefined}
                      {preScoringError
                        ? 'There was an unexpected error. Please check your info and try again our use different payment method.'
                        : undefined}
                      {preScoringRejectedMessage
                        ? preScoringRejectedMessage
                        : undefined}
                    </strong>
                  )}
                <button type="submit">Submit</button>
            </FormikForm>
        )}
    </Formik>
  )
};

const useGetTabbyPayment = () => {
  return useCallback((cart: Cart, billingAddress?: Address) => {
    const shippingAddress = cart?.fulfillmentGroups[0]?.address;

    if (isEmpty(billingAddress)) {
      billingAddress = shippingAddress;
    }

    const orderItems = map(cart.cartItems, cartItem => {
      return {
        title: cartItem.name,
        quantity: cartItem.quantity,
        unit_price: toString(cartItem.unitPrice.amount),
        reference_id: cartItem.productId,
        image_url: cartItem.imageAsset?.contentUrl,
        category: head(cartItem.attributes?.categoryNames),
      } as TabbyOrderItem;
    });

    return {
      buyer: {
        phone: billingAddress.phonePrimary.phoneNumber,
        email: cart.emailAddress,
        name: billingAddress.fullName,
      },
      shipping_address: {
        city: shippingAddress?.city,
        address: shippingAddress?.addressLine1,
        zip: shippingAddress?.postalCode,
      },
      order: {
        reference_id: cart?.id,
        items: orderItems,
      },
    } as TabbyPayment;
  }, []);
};

const useHandleSubmit = () => {
  const { cart } = 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 redirectCallbackUrl = 'https://{storefrontUrl}/api/cart-operations/checkout/${cartId}/payment-callback/TABBY?tenantId=${tenantId}&applicationId=${applicationId}';

  const getTabbyPayment = useGetTabbyPayment();

  const [error, setError] = useState<ApiError>();

  const onSubmit = async (
    data,
    actions
  ): Promise<void> => {
    const { ...billingAddress } = data;

    const tabbyPayment = getTabbyPayment(cart, billingAddress);

    const amount = getPaymentAmount(cart);

    const paymentRequest = {
      name: 'TABBY',
      type: 'BNPL',
      gatewayType: 'TABBY',
      amount,
      billingAddress,
      paymentMethodProperties: {
        bnpl_type: 'PAY_IN_4',
        payment: JSON.stringify(tabbyPayment),
        lang: 'en',
        merchant_urls: JSON.stringify({
          success: redirectCallbackUrl,
          cancel: redirectCallbackUrl,
          failure: redirectCallbackUrl,
        }),
      },
    } as PaymentRequest;

    const existingPayment = payments?.content?.filter(
      p => p.gatewayType === 'TABBY'
    )[0];

    let paymentSummary;

    try {
      setError(null);
      paymentSummary = await handleSubmitPaymentInfo(
        paymentRequest,
        existingPayment?.paymentId
      );
    } catch (err) {
      console.error('There was an error adding payment information', err);
      setError(err);
    } finally {
      actions.setSubmitting(false);
      ...
    }
  };

  return { error, onSubmit };
};