From e4b9357dbfe95c477d39177311afedfcc6e25eee Mon Sep 17 00:00:00 2001 From: gintil Date: Wed, 19 Feb 2025 08:54:13 -0500 Subject: [PATCH] Improve accessibility of pricing dialog and checkout --- .../AccessibleNavigationAnnouncer/index.tsx | 10 +- .../ChangeSubscriptionDialog/index.tsx | 151 +++++---- .../client/src/components/FAQ/index.tsx | 4 +- .../PricingDialog/CheckoutSummary.tsx | 237 ------------- .../src/components/PricingDialog/index.tsx | 143 +++----- .../backend-api/client/src/constants/pages.ts | 1 + .../client/src/contexts/PaddleContext.tsx | 59 ++-- .../src/contexts/PricingDialogContext.tsx | 18 +- .../backend-api/client/src/pages/Checkout.tsx | 316 ++++++++++++++++++ .../backend-api/client/src/pages/index.tsx | 20 ++ 10 files changed, 534 insertions(+), 425 deletions(-) delete mode 100644 services/backend-api/client/src/components/PricingDialog/CheckoutSummary.tsx create mode 100644 services/backend-api/client/src/pages/Checkout.tsx diff --git a/services/backend-api/client/src/components/AccessibleNavigationAnnouncer/index.tsx b/services/backend-api/client/src/components/AccessibleNavigationAnnouncer/index.tsx index 17a0513b8..da137a1cf 100644 --- a/services/backend-api/client/src/components/AccessibleNavigationAnnouncer/index.tsx +++ b/services/backend-api/client/src/components/AccessibleNavigationAnnouncer/index.tsx @@ -1,6 +1,7 @@ import React, { useState, useEffect } from "react"; import { useLocation } from "react-router-dom"; import { chakra } from "@chakra-ui/react"; +import { pages } from "../../constants"; export const AccessibleNavigationAnnouncer = () => { // the message that will be announced @@ -14,7 +15,14 @@ export const AccessibleNavigationAnnouncer = () => { if (location.pathname.slice(1)) { // make sure navigation has occurred and screen reader is ready setTimeout(() => { - setMessage(`Navigated to ${location.pathname.slice(1)} page.`); + const checkoutRootPath = pages.checkout(":id").split("/")[0]; + + if (location.pathname.includes(checkoutRootPath)) { + setMessage(`Navigated to checkout page.`); + } else { + setMessage(`Navigated to ${location.pathname.slice(1)} page.`); + } + const h1Element = document.querySelector("h1"); if (h1Element) { diff --git a/services/backend-api/client/src/components/ChangeSubscriptionDialog/index.tsx b/services/backend-api/client/src/components/ChangeSubscriptionDialog/index.tsx index 64de38a2c..a7be6bc50 100644 --- a/services/backend-api/client/src/components/ChangeSubscriptionDialog/index.tsx +++ b/services/backend-api/client/src/components/ChangeSubscriptionDialog/index.tsx @@ -1,9 +1,7 @@ import { Box, Button, - Divider, - HStack, - Heading, + Flex, Link, Modal, ModalBody, @@ -14,7 +12,12 @@ import { ModalOverlay, Skeleton, Stack, + Table, + TableContainer, + Tbody, + Td, Text, + Tr, } from "@chakra-ui/react"; import { useRef } from "react"; import { useTranslation } from "react-i18next"; @@ -147,9 +150,9 @@ export const ChangeSubscriptionDialog = ({ {product?.name} {!data && } {data && price && ( - + {price?.formattedPrice}/{price?.interval} - + )} {!data && } {data && ( @@ -173,64 +176,90 @@ export const ChangeSubscriptionDialog = ({ )} - - - - - Subtotal - - for remaining time on new plan - - - {!data && } - {data && {data.data.immediateTransaction.subtotalFormatted}} - - - - Tax - - Included in plan price - - - {!data && } - {data && {data.data.immediateTransaction.taxFormatted}} - - - - Total - - {!data && } - {data && {data.data.immediateTransaction.totalFormatted}} - - {data && data.data.immediateTransaction.credit !== "0" && ( - - - Credit - - Includes refund for time remaining on current plan - - - {!data && } - {data && ( - - -{data.data.immediateTransaction.creditFormatted} - + + + + + + + + + + + + + + + + {data && data.data.immediateTransaction.credit !== "0" && ( + + + + )} - - )} - - - Due Today - {!data && } - {data && ( - - {data.data.immediateTransaction.grandTotalFormatted} - - )} - - + + + + + +
+ Subtotal + + for remaining time on new plan + + + + {!data && } + {data && ( + {data.data.immediateTransaction.subtotalFormatted} + )} + +
+ Tax + + Included in plan price + + + + {!data && } + {data && {data.data.immediateTransaction.taxFormatted}} + +
+ Total + + + {!data && } + {data && {data.data.immediateTransaction.totalFormatted}} + +
+ Credit + + Includes refund for time remaining on current plan + + + + {!data && } + {data && ( + + -{data.data.immediateTransaction.creditFormatted} + + )} + +
+ Due Today + + + {!data && } + {data && ( + + {data.data.immediateTransaction.grandTotalFormatted} + + )} + +
+
- + By proceeding, you are agreeing to our{" "} terms and conditions diff --git a/services/backend-api/client/src/components/FAQ/index.tsx b/services/backend-api/client/src/components/FAQ/index.tsx index d30dcb6a7..cdc2f78a4 100644 --- a/services/backend-api/client/src/components/FAQ/index.tsx +++ b/services/backend-api/client/src/components/FAQ/index.tsx @@ -8,7 +8,7 @@ import { } from "@chakra-ui/react"; const FAQItem = ({ q, a }: { q: string; a: string | React.ReactNode }) => ( - + {/* */} @@ -22,7 +22,7 @@ const FAQItem = ({ q, a }: { q: string; a: string | React.ReactNode }) => ( ); export const FAQ = ({ items }: { items: Array<{ q: string; a: string | React.ReactNode }> }) => ( - + {items.map(({ q, a }) => ( ))} diff --git a/services/backend-api/client/src/components/PricingDialog/CheckoutSummary.tsx b/services/backend-api/client/src/components/PricingDialog/CheckoutSummary.tsx deleted file mode 100644 index 7b24cf9d3..000000000 --- a/services/backend-api/client/src/components/PricingDialog/CheckoutSummary.tsx +++ /dev/null @@ -1,237 +0,0 @@ -/* eslint-disable no-empty-pattern */ -import { ArrowBackIcon } from "@chakra-ui/icons"; -import { - Badge, - Box, - Button, - CloseButton, - Divider, - Flex, - Heading, - HStack, - Skeleton, - Stack, - Switch, - Table, - TableContainer, - Tbody, - Td, - Text, - Tr, -} from "@chakra-ui/react"; -import { captureException } from "@sentry/react"; -import dayjs from "dayjs"; -import { useEffect, useRef, useState } from "react"; -import { CheckoutSummaryData } from "../../types/CheckoutSummaryData"; - -interface Props { - onGoBack: () => void; - onClose: () => void; - checkoutData?: CheckoutSummaryData; - onChangeInterval: (i: "month" | "year") => void; -} - -const CheckoutSummary = ({ onClose, onGoBack, checkoutData, onChangeInterval }: Props) => { - const closeButtonRef = useRef(null); - const [waitingForUpdate, setWaitingForUpdate] = useState(false); - const isLoaded = !waitingForUpdate && !!checkoutData; - - useEffect(() => { - if (isLoaded && closeButtonRef.current) { - closeButtonRef.current.focus(); - } - }, [isLoaded, closeButtonRef.current]); - - useEffect(() => { - if (!checkoutData) { - return; - } - - setWaitingForUpdate(false); - }, [checkoutData?.item.interval]); - - const formatCurrency = (number?: number) => { - const currency = checkoutData?.currencyCode; - - try { - if (!currency) { - return `${number}`; - } - - const { locale } = new Intl.NumberFormat().resolvedOptions(); - const formatter = new Intl.NumberFormat(locale, { - style: "currency", - currency, - }); - - return formatter.format(number ?? 0); - } catch (err) { - captureException(err, { - extra: { - number, - currency, - }, - }); - - return `${number}`; - } - }; - - const todayFormatted = dayjs().format("D MMM YYYY"); - const expirationFormatted = checkoutData - ? dayjs().add(1, checkoutData.item.interval).format("D MMM YYYY") - : todayFormatted; - - return ( - - - - - - - - - - - - Subscribe to MonitoRSS - - - - - {checkoutData?.item.productName} - - - - - - {formatCurrency(checkoutData?.recurringTotals.total)} - - per {checkoutData?.item.interval} - - - - - { - onChangeInterval(e.target.checked ? "year" : "month"); - setWaitingForUpdate(true); - }} - /> - - - Save 15% - - with annual billing - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- Subtotal - - - {formatCurrency(checkoutData?.totals.subtotal) || "100"} - -
- Tax - - - {formatCurrency(checkoutData?.totals.tax) || "100"} - -
- Credits - - - {formatCurrency(checkoutData?.totals.credit) || "0"} - -
- Total Due Today - - - {formatCurrency(checkoutData?.totals.balance) || "100"} - -
- -
- - Next charge on{" "} - - {expirationFormatted} - - - - - - {formatCurrency(checkoutData?.recurringTotals.total) || "100"} - - -
-
-
- -
-
-
- ); -}; - -export default CheckoutSummary; diff --git a/services/backend-api/client/src/components/PricingDialog/index.tsx b/services/backend-api/client/src/components/PricingDialog/index.tsx index fe6d04add..de5ed5ade 100644 --- a/services/backend-api/client/src/components/PricingDialog/index.tsx +++ b/services/backend-api/client/src/components/PricingDialog/index.tsx @@ -24,24 +24,24 @@ import { ModalCloseButton, Badge, } from "@chakra-ui/react"; -import { ChangeEvent, useEffect, useRef, useState } from "react"; +import { ChangeEvent, useEffect, useState } from "react"; import { captureException } from "@sentry/react"; +import { useNavigate } from "react-router-dom"; import { InlineErrorAlert } from "../InlineErrorAlert"; import { useUserMe } from "../../features/discordUser"; import { FAQ } from "../FAQ"; import { ChangeSubscriptionDialog } from "../ChangeSubscriptionDialog"; -import { ProductKey } from "../../constants"; +import { pages, ProductKey } from "../../constants"; import { useSubscriptionProducts } from "../../features/subscriptionProducts"; import { EXTERNAL_PROPERTIES_MAX_ARTICLES } from "../../constants/externalPropertiesMaxArticles"; -import CheckoutSummary from "./CheckoutSummary"; import { usePaddleContext } from "../../contexts/PaddleContext"; import { PricePreview } from "../../types/PricePreview"; +import { notifyInfo } from "../../utils/notifyInfo"; interface Props { isOpen: boolean; onClose: () => void; onOpen: () => void; - openWithPriceId?: string | null; } enum Feature { @@ -191,24 +191,21 @@ interface ChangeSubscriptionDetails { isDowngrade?: boolean; } -export const PricingDialog = ({ isOpen, onClose, onOpen, openWithPriceId }: Props) => { - const { openCheckout, updateCheckout, getPricePreview, checkoutLoadedData, resetCheckoutData } = - usePaddleContext(); +export const PricingDialog = ({ isOpen, onClose, onOpen }: Props) => { + const { getPricePreview, resetCheckoutData } = usePaddleContext(); const [pricePreviewErrored, setPricePreviewErrored] = useState(false); const [isLoadingPricePreview, setIsLoadingPricePreview] = useState(true); const [products, setProducts] = useState>(); const { status: userStatus, error: userError, data: userData } = useUserMe(); - const [checkingOutPriceId, setCheckingOutPriceId] = useState(); const [interval, setInterval] = useState<"month" | "year">(initialInterval); const { data: subProducts, error: subProductsError } = useSubscriptionProducts(); const [changeSubscriptionDetails, setChangeSubscriptionDetails] = useState(); + const navigate = useNavigate(); const userBillingInterval = userData?.result.subscription.billingInterval; const billingPeriodEndsAt = userData?.result.subscription.billingPeriod?.end; - const initialFocusRef = useRef(null); const onClosePricingModal = () => { - setCheckingOutPriceId(undefined); resetCheckoutData(); onClose(); }; @@ -228,52 +225,25 @@ export const PricingDialog = ({ isOpen, onClose, onOpen, openWithPriceId }: Prop return; } - onClose(); - if (userData.result.subscription.product.key === ProductKey.Free) { - setCheckingOutPriceId(priceId); - openCheckout({ - priceId, - }); + navigate(pages.checkout(priceId)); + onClose(); } else { setChangeSubscriptionDetails({ priceId, productId, isDowngrade, }); + onClose(); } }; - useEffect(() => { - if ( - openWithPriceId && - userData && - userData.result.subscription.product.key === ProductKey.Free - ) { - openCheckout({ - priceId: openWithPriceId, - }); - } - }, [openWithPriceId, !!userData, openCheckout]); - - useEffect(() => { - if (!isLoadingPricePreview) { - initialFocusRef.current?.focus(); - } - }, [isLoadingPricePreview, initialFocusRef.current]); - useEffect(() => { if (userBillingInterval) { setInterval(userBillingInterval); } }, [userBillingInterval]); - useEffect(() => { - if (!checkoutLoadedData) { - setCheckingOutPriceId(undefined); - } - }, [!!checkoutLoadedData]); - useEffect(() => { if (!isOpen) { return; @@ -320,37 +290,6 @@ export const PricingDialog = ({ isOpen, onClose, onOpen, openWithPriceId }: Prop return ( - - { - const product = subProducts?.data.products.find((p) => - p.prices.find((pr) => pr.id === checkingOutPriceId) - ); - - if (!product) { - return; - } - - const price = product.prices.find((pr) => pr.interval === newInterval); - - if (!price) { - return; - } - - updateCheckout({ - priceId: price.id, - }); - }} - checkoutData={checkoutLoadedData} - onClose={() => { - setCheckingOutPriceId(undefined); - }} - onGoBack={() => { - setCheckingOutPriceId(undefined); - onOpen(); - }} - /> - - + - Pricing + + Pricing + MonitoRSS is able to stay open-source and free thanks to its supporters.
@@ -402,7 +342,7 @@ export const PricingDialog = ({ isOpen, onClose, onOpen, openWithPriceId }: Prop {failedToLoadPrices && ( @@ -418,8 +358,8 @@ export const PricingDialog = ({ isOpen, onClose, onOpen, openWithPriceId }: Prop size="lg" colorScheme="green" onChange={onChangeInterval} - ref={initialFocusRef} isChecked={interval === "year"} + aria-label="Switch to yearly pricing" /> Yearly @@ -442,6 +382,7 @@ export const PricingDialog = ({ isOpen, onClose, onOpen, openWithPriceId }: Prop "325px 325px 325px", ]} spacing={4} + role="list" > {tiers.map( ( @@ -480,22 +421,19 @@ export const PricingDialog = ({ isOpen, onClose, onOpen, openWithPriceId }: Prop userSubscription.billingInterval === "year"); return ( - + {associatedProduct?.name || ""} - {/* {highlighted && ( - - Most Popular - - )} */} - {/* - {description} - */} @@ -516,12 +454,17 @@ export const PricingDialog = ({ isOpen, onClose, onOpen, openWithPriceId }: Prop {interval === "month" ? "per month" : "per year"}
- + {features.map((f) => { return ( - + {f.enabled ? ( - + ) : ( @@ -540,15 +483,21 @@ export const PricingDialog = ({ isOpen, onClose, onOpen, openWithPriceId }: Prop diff --git a/services/backend-api/client/src/constants/pages.ts b/services/backend-api/client/src/constants/pages.ts index c235f0d63..efc1179e2 100644 --- a/services/backend-api/client/src/constants/pages.ts +++ b/services/backend-api/client/src/constants/pages.ts @@ -12,6 +12,7 @@ const getConnectionPathByType = (type: FeedConnectionType) => { }; export const pages = { + checkout: (priceId: string) => `/paddle-checkout/${priceId}`, userSettings: () => "/settings", userFeeds: () => "/feeds", notFound: () => "/not-found", diff --git a/services/backend-api/client/src/contexts/PaddleContext.tsx b/services/backend-api/client/src/contexts/PaddleContext.tsx index 6278e77d0..92f164ff1 100644 --- a/services/backend-api/client/src/contexts/PaddleContext.tsx +++ b/services/backend-api/client/src/contexts/PaddleContext.tsx @@ -11,12 +11,11 @@ import { import { initializePaddle, Paddle } from "@paddle/paddle-js"; import { useNavigate } from "react-router-dom"; import { captureException } from "@sentry/react"; -import { Spinner, Stack, Text } from "@chakra-ui/react"; +import { Box, Spinner, Stack, Text } from "@chakra-ui/react"; import { useUserMe } from "../features/discordUser"; import { pages, PRODUCT_NAMES, ProductKey } from "../constants"; import { CheckoutSummaryData } from "../types/CheckoutSummaryData"; import { PricePreview } from "../types/PricePreview"; -import { notifySuccess } from "../utils/notifySuccess"; const pwAuth = import.meta.env.VITE_PADDLE_PW_AUTH; const clientToken = import.meta.env.VITE_PADDLE_CLIENT_TOKEN; @@ -59,8 +58,9 @@ interface ContextProps { updateCheckout: ({ priceId }: { priceId: string }) => void; resetCheckoutData: () => void; isLoaded?: boolean; - openCheckout: ({ priceId }: { priceId: string }) => void; + openCheckout: (p: { priceId: string; frameTarget?: string }) => void; getPricePreview: (priceIdsToGet: string[]) => Promise>; + isSubscriptionCreated: boolean; } export const PaddleContext = createContext({ @@ -71,11 +71,13 @@ export const PaddleContext = createContext({ openCheckout: () => {}, getPricePreview: async () => [], resetCheckoutData: () => {}, + isSubscriptionCreated: false, }); export const PaddleContextProvider = ({ children }: PropsWithChildren<{}>) => { const [paddle, setPaddle] = useState(); const [checkForSubscriptionCreated, setCheckForSubscriptionCreated] = useState(false); + const [isSubscriptionCreated, setIsSubscriptionCreated] = useState(false); const { data: user } = useUserMe({ checkForSubscriptionCreated }); const navigate = useNavigate(); const [checkoutLoadedData, setCheckoutLoadedData] = useState(); @@ -84,7 +86,7 @@ export const PaddleContextProvider = ({ children }: PropsWithChildren<{}>) => { useEffect(() => { if (checkForSubscriptionCreated && paidSubscriptionExists) { setCheckForSubscriptionCreated(false); - notifySuccess(`Your benefits have been provisioned. Thank you for supporting MonitoRSS!`); + setIsSubscriptionCreated(true); } }, [checkForSubscriptionCreated, paidSubscriptionExists]); @@ -240,6 +242,8 @@ export const PaddleContextProvider = ({ children }: PropsWithChildren<{}>) => { const updatePaymentMethod = useCallback( (transactionId: string) => { + setIsSubscriptionCreated(false); + paddle?.Checkout.open({ transactionId, settings: { @@ -254,6 +258,8 @@ export const PaddleContextProvider = ({ children }: PropsWithChildren<{}>) => { const updateCheckout = useCallback( ({ priceId }: { priceId: string }) => { + setIsSubscriptionCreated(false); + if (!paddle) { return; } @@ -266,7 +272,9 @@ export const PaddleContextProvider = ({ children }: PropsWithChildren<{}>) => { ); const openCheckout = useCallback( - ({ priceId }: { priceId: string }) => { + ({ priceId, frameTarget }: { priceId: string; frameTarget?: string }) => { + setIsSubscriptionCreated(false); + if (!user?.result.email) { navigate(pages.userSettings()); @@ -286,7 +294,7 @@ export const PaddleContextProvider = ({ children }: PropsWithChildren<{}>) => { }, settings: { displayMode: "inline", - frameTarget: "checkout-modal", + frameTarget: frameTarget || "checkout-modal", frameInitialHeight: 450, allowLogout: false, variant: "one-page", @@ -303,6 +311,7 @@ export const PaddleContextProvider = ({ children }: PropsWithChildren<{}>) => { ); const resetCheckoutData = useCallback(() => { + setIsSubscriptionCreated(false); setCheckoutLoadedData(undefined); }, []); @@ -315,6 +324,7 @@ export const PaddleContextProvider = ({ children }: PropsWithChildren<{}>) => { openCheckout, getPricePreview, resetCheckoutData, + isSubscriptionCreated, }), [ JSON.stringify(checkoutLoadedData), @@ -324,28 +334,31 @@ export const PaddleContextProvider = ({ children }: PropsWithChildren<{}>) => { openCheckout, getPricePreview, resetCheckoutData, + isSubscriptionCreated, ] ); return ( - {checkForSubscriptionCreated && ( - - - Provisioning benefits... - - )} + + {checkForSubscriptionCreated && ( + + + Provisioning benefits... + + )} + {children} ); diff --git a/services/backend-api/client/src/contexts/PricingDialogContext.tsx b/services/backend-api/client/src/contexts/PricingDialogContext.tsx index 1057394f2..fb6f72450 100644 --- a/services/backend-api/client/src/contexts/PricingDialogContext.tsx +++ b/services/backend-api/client/src/contexts/PricingDialogContext.tsx @@ -1,7 +1,8 @@ -import { PropsWithChildren, createContext, useMemo } from "react"; +import { PropsWithChildren, createContext, useEffect, useMemo } from "react"; import { useDisclosure } from "@chakra-ui/react"; -import { useSearchParams } from "react-router-dom"; +import { useNavigate, useSearchParams } from "react-router-dom"; import { PricingDialog } from "../components/PricingDialog"; +import { pages } from "../constants"; interface ContextProps { onOpen: () => void; @@ -14,9 +15,14 @@ export const PricingDialogContext = createContext({ export const PricingDialogProvider = ({ children }: PropsWithChildren<{}>) => { const [searchParams] = useSearchParams(); const priceId = searchParams.get("priceId"); - const { isOpen, onOpen, onClose } = useDisclosure({ - defaultIsOpen: !!priceId, - }); + const { isOpen, onOpen, onClose } = useDisclosure(); + const navigate = useNavigate(); + + useEffect(() => { + if (priceId) { + navigate(pages.checkout(priceId)); + } + }, [priceId]); const value = useMemo( () => ({ @@ -27,7 +33,7 @@ export const PricingDialogProvider = ({ children }: PropsWithChildren<{}>) => { return ( - + {children} ); diff --git a/services/backend-api/client/src/pages/Checkout.tsx b/services/backend-api/client/src/pages/Checkout.tsx new file mode 100644 index 000000000..354bfbc5e --- /dev/null +++ b/services/backend-api/client/src/pages/Checkout.tsx @@ -0,0 +1,316 @@ +import { + Badge, + Box, + Button, + Divider, + Flex, + Heading, + HStack, + Skeleton, + Stack, + Switch, + Table, + TableContainer, + Tbody, + Td, + Text, + Tr, +} from "@chakra-ui/react"; +import { useLocation, Link as RouterLink } from "react-router-dom"; +import { useEffect, useRef, useState } from "react"; +import { captureException } from "@sentry/react"; +import dayjs from "dayjs"; +import { ChevronLeftIcon } from "@chakra-ui/icons"; +import { FaCircleCheck } from "react-icons/fa6"; +import { BoxConstrained, DashboardContentV2 } from "../components"; +import { pages } from "../constants"; +import { usePaddleContext } from "../contexts/PaddleContext"; +import { useSubscriptionProducts } from "../features/subscriptionProducts"; +import { useUserMe } from "../features/discordUser"; +import getChakraColor from "../utils/getChakraColor"; + +interface Props { + cancelUrl: string; +} + +export const Checkout = ({ cancelUrl }: Props) => { + const location = useLocation(); + const originalPriceId = location.pathname.split("/").pop(); + const [priceId] = useState(originalPriceId); + const { + openCheckout, + updateCheckout, + checkoutLoadedData: checkoutData, + isSubscriptionCreated, + } = usePaddleContext(); + const { data: subProducts, error: subProductsError } = useSubscriptionProducts(); + const [waitingForUpdate, setWaitingForUpdate] = useState(false); + const checkoutRef = useRef(null); + const headingRef = useRef(null); + const { status: userStatus, error: userError } = useUserMe(); + + useEffect(() => { + if (isSubscriptionCreated && headingRef.current) { + headingRef.current.focus(); + } + }, [isSubscriptionCreated, headingRef.current]); + + useEffect(() => { + if (!checkoutData) { + return; + } + + setWaitingForUpdate(false); + }, [checkoutData?.item.interval]); + + useEffect(() => { + if (!priceId || !checkoutRef.current) { + return; + } + + openCheckout({ + priceId, + frameTarget: checkoutRef.current.className, + }); + }, [priceId, openCheckout, checkoutRef.current]); + + const formatCurrency = (number?: number) => { + const currency = checkoutData?.currencyCode; + + try { + if (!currency) { + return `${number}`; + } + + const { locale } = new Intl.NumberFormat().resolvedOptions(); + const formatter = new Intl.NumberFormat(locale, { + style: "currency", + currency, + }); + + return formatter.format(number ?? 0); + } catch (err) { + captureException(err, { + extra: { + number, + currency, + }, + }); + + return `${number}`; + } + }; + + const onChangeInterval = (newInterval: "month" | "year") => { + const product = subProducts?.data.products.find((p) => + p.prices.find((pr) => pr.id === priceId) + ); + + if (!product) { + return; + } + + const price = product.prices.find((pr) => pr.interval === newInterval); + + if (!price) { + return; + } + + updateCheckout({ + priceId: price.id, + }); + }; + + const isLoaded = !waitingForUpdate && !!checkoutData && !!subProducts && userStatus === "success"; + const error = subProductsError || userError; + + const todayFormatted = dayjs().format("D MMM YYYY"); + const expirationFormatted = checkoutData + ? dayjs().add(1, checkoutData.item.interval).format("D MMM YYYY") + : todayFormatted; + + return ( + + + + + + + {isSubscriptionCreated && ( + + + + Your benefits have been provisioned. + + Thank you for supporting MonitoRSS! + + + )} + {!isSubscriptionCreated && ( + + + + + + Checkout Summary + + + + + {checkoutData?.item.productName} + + + + + + {formatCurrency(checkoutData?.recurringTotals.total)} + + per {checkoutData?.item.interval} + + + + + { + if (!isLoaded) { + return; + } + + onChangeInterval(e.target.checked ? "year" : "month"); + setWaitingForUpdate(true); + }} + /> + + + Save 15% + + with annual billing + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Subtotal + + + {formatCurrency(checkoutData?.totals.subtotal) || "100"} + +
+ Tax + + + {formatCurrency(checkoutData?.totals.tax) || "100"} + +
+ Credits + + + {formatCurrency(checkoutData?.totals.credit) || "0"} + +
+ Total Due Today + + + + {formatCurrency(checkoutData?.totals.balance) || "100"} + + +
+ +
+ + Next charge on{" "} + + {expirationFormatted} + + + + + + {formatCurrency(checkoutData?.recurringTotals.total) || "100"} + + +
+
+
+ )} + +
+
+
+
+
+
+ ); +}; diff --git a/services/backend-api/client/src/pages/index.tsx b/services/backend-api/client/src/pages/index.tsx index 5cc04c7cc..083cc7ebf 100644 --- a/services/backend-api/client/src/pages/index.tsx +++ b/services/backend-api/client/src/pages/index.tsx @@ -35,6 +35,12 @@ const UserSettings = lazy(() => })) ); +const Checkout = lazy(() => + import("./Checkout").then(({ Checkout: c }) => ({ + default: c, + })) +); + const SentryRoutes = Sentry.withSentryReactRouterV6Routing(Routes); const Pages: React.FC = () => ( @@ -94,6 +100,20 @@ const Pages: React.FC = () => ( } /> + + + + }> + + + + + + } + />