From 591fe20cef3a8ada97a50233afb01b876a0c8a24 Mon Sep 17 00:00:00 2001 From: Joseph Chrzan Date: Mon, 7 Oct 2024 10:51:37 -0400 Subject: [PATCH] sch-1926 (#86) * init * more form work * kinda finalize payment method styles * lil better * midas touch * api ups * space master * add functions logic --- .../elements/payment-method/PaymentMethod.tsx | 489 ++++++++++-------- .../elements/plan-manager/CheckoutDialog.tsx | 18 +- .../elements/plan-manager/PaymentForm.tsx | 39 +- .../elements/plan-manager/PlanManager.tsx | 139 +++-- .../components/elements/plan-manager/index.ts | 1 + .../elements/upcoming-bill/UpcomingBill.tsx | 2 +- components/src/components/ui/modal/Modal.tsx | 9 +- components/src/components/ui/text/styles.ts | 13 +- 8 files changed, 355 insertions(+), 355 deletions(-) diff --git a/components/src/components/elements/payment-method/PaymentMethod.tsx b/components/src/components/elements/payment-method/PaymentMethod.tsx index e7c5c797..b9a9ef06 100644 --- a/components/src/components/elements/payment-method/PaymentMethod.tsx +++ b/components/src/components/elements/payment-method/PaymentMethod.tsx @@ -1,12 +1,22 @@ -import { forwardRef, useMemo } from "react"; +import { + forwardRef, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useState, +} from "react"; import { createPortal } from "react-dom"; import { useTheme } from "styled-components"; -import { useEmbed } from "../../../hooks"; +import { loadStripe, type Stripe } from "@stripe/stripe-js"; +import { Elements } from "@stripe/react-stripe-js"; +import type { SetupIntentResponseData } from "../../../api"; import { type FontStyle } from "../../../context"; +import { useEmbed } from "../../../hooks"; import type { RecursivePartial, ElementProps } from "../../../types"; -import { Box, Flex, Modal, ModalHeader, Text } from "../../ui"; import { hexToHSL } from "../../../utils"; -import { StyledButton } from "../plan-manager/styles"; +import { Box, Flex, Modal, ModalHeader, Text } from "../../ui"; +import { PaymentForm } from "../plan-manager"; interface DesignProps { header: { @@ -15,6 +25,7 @@ interface DesignProps { }; functions: { allowEdit: boolean; + showExpiration: boolean; }; } @@ -28,10 +39,96 @@ const resolveDesignProps = ( }, functions: { allowEdit: props.functions?.allowEdit ?? true, + showExpiration: props.functions?.showExpiration ?? true, }, }; }; +interface PaymentMethodElementProps extends DesignProps { + size?: "sm" | "md" | "lg"; + cardLast4?: string | null; + monthsToExpiration?: number; + onEdit?: () => void; +} + +const PaymentMethodElement = ({ + size = "md", + cardLast4, + monthsToExpiration, + onEdit, + ...props +}: PaymentMethodElementProps) => { + const theme = useTheme(); + + const isLightBackground = useMemo(() => { + return hexToHSL(theme.card.background).l > 50; + }, [theme.card.background]); + + const sizeFactor = size === "lg" ? 2 : size === "md" ? 1 : 0.5; + + return ( + + {props.header.isVisible && ( + + + Payment Method + + + {props.functions.showExpiration && + typeof monthsToExpiration === "number" && + monthsToExpiration < 4 && ( + + {monthsToExpiration > 0 + ? `Expires in ${monthsToExpiration} mo` + : "Expired"} + + )} + + )} + + + + {cardLast4 + ? `💳 Card ending in ${cardLast4}` + : "Other existing payment method"} + + + {props.functions.allowEdit && onEdit && ( + + Edit + + )} + + + ); +}; + export type PaymentMethodProps = DesignProps; export const PaymentMethod = forwardRef< @@ -45,7 +142,20 @@ export const PaymentMethod = forwardRef< const props = resolveDesignProps(rest); const theme = useTheme(); - const { data, layout } = useEmbed(); + + const { api, data, layout, setLayout } = useEmbed(); + + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(); + const [showPaymentForm, setShowPaymentForm] = useState( + () => typeof data.subscription?.paymentMethod === "undefined", + ); + const [stripe, setStripe] = useState | null>(null); + const [setupIntent, setSetupIntent] = useState(); + + const isLightBackground = useMemo(() => { + return hexToHSL(theme.card.background).l > 50; + }, [theme.card.background]); const paymentMethod = useMemo(() => { const { paymentMethodType, cardLast4, cardExpMonth, cardExpYear } = @@ -72,9 +182,60 @@ export const PaymentMethod = forwardRef< }; }, [data.subscription?.paymentMethod]); - const isLightBackground = useMemo(() => { - return hexToHSL(theme.card.background).l > 50; - }, [theme.card.background]); + const createSetupIntent = useCallback(async () => { + if (!api || !data.component?.id) { + return; + } + + try { + setIsLoading(true); + const { data: setupIntent } = await api.getSetupIntent({ + componentId: data.component.id, + }); + setSetupIntent(setupIntent); + setShowPaymentForm(true); + } catch { + setError("Error initializing payment method change. Please try again."); + } finally { + setIsLoading(false); + } + }, [api, data.component?.id]); + + const updatePaymentMethod = useCallback( + async (id: string) => { + if (!api || !id) { + return; + } + + try { + setIsLoading(true); + await api.updatePaymentMethod({ + updatePaymentMethodRequestBody: { + paymentMethodId: id, + }, + }); + setLayout("success"); + } catch { + setError("Error updating payment method. Please try again."); + } finally { + setIsLoading(false); + } + }, + [api, setLayout], + ); + + useEffect(() => { + if (!stripe && setupIntent?.publishableKey) { + setStripe(loadStripe(setupIntent.publishableKey)); + } + }, [stripe, setupIntent?.publishableKey]); + + useLayoutEffect(() => { + document.body.style.overflow = "hidden"; + return () => { + document.body.style.overflow = ""; + }; + }, []); if (!paymentMethod.paymentMethodType) { return null; @@ -82,242 +243,112 @@ export const PaymentMethod = forwardRef< return (
- {props.header.isVisible && ( - - - Payment Method - + setLayout("payment")} + {...paymentMethod} + {...props} + /> - {typeof paymentMethod.monthsToExpiration === "number" && - paymentMethod.monthsToExpiration < 4 && ( + {layout === "payment" && + createPortal( + setShowPaymentForm(false)}> + - {paymentMethod.monthsToExpiration > 0 - ? `Expires in ${paymentMethod.monthsToExpiration} mo` - : "Expired"} + Edit payment method - )} - - )} - - - - {paymentMethod.cardLast4 - ? `💳 Card ending in ${paymentMethod.cardLast4}` - : "Other existing payment method"} - - - - {layout === "payment" && - createPortal( - - - Edit payment method - - - - - Default - - - - - - - - - - - - - - Visa **** 4242 - - - - - Expires: 3/30 - - - - - - - Edit - - - - - + - - - Others - - - + {showPaymentForm ? ( + - - - - - - - - - - - Visa **** 2929 - - - - - Expires: 3/30 - - - + updatePaymentMethod(value)} + /> + + ) : ( + + + + + + Change payment method + + + )} - - - Make Default - - + - Edit - - - - + {error} + + + )} + , diff --git a/components/src/components/elements/plan-manager/CheckoutDialog.tsx b/components/src/components/elements/plan-manager/CheckoutDialog.tsx index d53c3274..2a2a667e 100644 --- a/components/src/components/elements/plan-manager/CheckoutDialog.tsx +++ b/components/src/components/elements/plan-manager/CheckoutDialog.tsx @@ -643,6 +643,10 @@ export const CheckoutDialog = () => { clientSecret: setupIntent.setupIntentClientSecret, }} > + + Add payment method + + { /> {data.subscription?.paymentMethod && ( - setShowPaymentForm(false)} - $cursor="pointer" - > + setShowPaymentForm(false)} $font={theme.typography.link.fontFamily} $size={theme.typography.link.fontSize} $weight={theme.typography.link.fontWeight} @@ -672,12 +673,9 @@ export const CheckoutDialog = () => { <> - setShowPaymentForm(true)} - $cursor="pointer" - > + setShowPaymentForm(true)} $font={theme.typography.link.fontFamily} $size={theme.typography.link.fontSize} $weight={theme.typography.link.fontWeight} diff --git a/components/src/components/elements/plan-manager/PaymentForm.tsx b/components/src/components/elements/plan-manager/PaymentForm.tsx index c90d7772..bb31ebee 100644 --- a/components/src/components/elements/plan-manager/PaymentForm.tsx +++ b/components/src/components/elements/plan-manager/PaymentForm.tsx @@ -1,21 +1,18 @@ import { useState } from "react"; -import { - LinkAuthenticationElement, - PaymentElement, -} from "@stripe/react-stripe-js"; +import { PaymentElement } from "@stripe/react-stripe-js"; import { useStripe, useElements } from "@stripe/react-stripe-js"; import type { CompanyPlanDetailResponseData } from "../../../api"; import { useEmbed } from "../../../hooks"; -import { Box, Flex, Text } from "../../ui"; +import { Box, Text } from "../../ui"; import { StyledButton } from "./styles"; interface PaymentFormProps { - plan: CompanyPlanDetailResponseData; - period: string; + plan?: CompanyPlanDetailResponseData; + period?: string; onConfirm?: (paymentMethodId: string) => void; } -export const PaymentForm = ({ plan, period, onConfirm }: PaymentFormProps) => { +export const PaymentForm = ({ onConfirm }: PaymentFormProps) => { const stripe = useStripe(); const elements = useElements(); @@ -30,9 +27,7 @@ export const PaymentForm = ({ plan, period, onConfirm }: PaymentFormProps) => { ) => { event.preventDefault(); - const priceId = - period === "month" ? plan.monthlyPrice?.id : plan.yearlyPrice?.id; - if (!api || !stripe || !elements || !priceId) { + if (!api || !stripe || !elements) { return; } @@ -77,28 +72,6 @@ export const PaymentForm = ({ plan, period, onConfirm }: PaymentFormProps) => { overflowY: "auto", }} > - - Add payment method - - - - { - // setEmail(event.value.email); - // }} - // - // Prefill the email field like so: - // options={{defaultValues: {email: 'foo@bar.com'}}} - /> - - diff --git a/components/src/components/elements/plan-manager/PlanManager.tsx b/components/src/components/elements/plan-manager/PlanManager.tsx index cf11c428..50411d2c 100644 --- a/components/src/components/elements/plan-manager/PlanManager.tsx +++ b/components/src/components/elements/plan-manager/PlanManager.tsx @@ -90,85 +90,68 @@ export const PlanManager = forwardRef< }, [data.company, data.activePlans]); return ( -
- - {props.header.isVisible && currentPlan && ( - -
- - - {currentPlan.name} - - - - {props.header.description.isVisible && - currentPlan.description && ( - - {currentPlan.description} - - )} -
+ + {props.header.isVisible && currentPlan && ( + + + + {currentPlan.name} + - {props.header.price.isVisible && - typeof currentPlan.planPrice === "number" && - currentPlan.planPeriod && ( - - {formatCurrency(currentPlan.planPrice)}/ - {currentPlan.planPeriod} - - )} + {props.header.description.isVisible && currentPlan.description && ( + + {currentPlan.description} + + )} - )} - + + {props.header.price.isVisible && + typeof currentPlan.planPrice === "number" && + currentPlan.planPeriod && ( + + {formatCurrency(currentPlan.planPrice)}/{currentPlan.planPeriod} + + )} + + )} {canChangePlan && props.callToAction.isVisible && ( , portal || document.body)} -
+ ); }); diff --git a/components/src/components/elements/plan-manager/index.ts b/components/src/components/elements/plan-manager/index.ts index 686dec89..899905c4 100644 --- a/components/src/components/elements/plan-manager/index.ts +++ b/components/src/components/elements/plan-manager/index.ts @@ -1 +1,2 @@ +export * from "./PaymentForm"; export * from "./PlanManager"; diff --git a/components/src/components/elements/upcoming-bill/UpcomingBill.tsx b/components/src/components/elements/upcoming-bill/UpcomingBill.tsx index 991bb683..d1d6002c 100644 --- a/components/src/components/elements/upcoming-bill/UpcomingBill.tsx +++ b/components/src/components/elements/upcoming-bill/UpcomingBill.tsx @@ -81,7 +81,7 @@ export const UpcomingBill = forwardRef< { $flexDirection="column" $overflow="hidden" {...(size === "auto" - ? { $width: "min-content", $height: "min-content" } + ? { $width: "fit-content", $height: "fit-content" } : { $width: "100%", - $height: "100%", - $maxWidth: "1356px", + ...(size === "lg" + ? { $height: "100%" } + : { $height: "fit-content" }), + $maxWidth: + size === "sm" ? "480px" : size === "md" ? "688px" : "1356px", $maxHeight: "860px", })} $backgroundColor={theme.card.background} diff --git a/components/src/components/ui/text/styles.ts b/components/src/components/ui/text/styles.ts index b61997a3..01fa6400 100644 --- a/components/src/components/ui/text/styles.ts +++ b/components/src/components/ui/text/styles.ts @@ -12,7 +12,9 @@ export interface TextProps extends ComponentProps { $lineHeight?: ComponentProps["$lineHeight"]; } -export const Text = styled.span` +export const Text = styled.span.attrs(({ onClick }) => ({ + ...(onClick && { tabIndex: 0 }), +}))` font-family: ${({ $font = "Inter" }) => `${$font}, sans-serif`}; font-size: ${({ $size = 16 }) => typeof $size === "number" ? `${$size / TEXT_BASE_SIZE}rem` : $size}; @@ -28,4 +30,13 @@ export const Text = styled.span` outline: 2px solid ${({ theme }) => theme.primary}; outline-offset: 2px; } + + ${({ onClick }) => + onClick && + css` + &:hover { + cursor: pointer; + text-decoration: underline; + } + `} `;