From 73727e87c5b822cd95248e56f3dd7a7e09efc562 Mon Sep 17 00:00:00 2001 From: Alex Brazier Date: Wed, 6 Mar 2024 20:38:04 +0000 Subject: [PATCH 1/4] feat: Split out card button to allow custom buttons --- src/components/credit-card/credit-card.tsx | 145 +++++++++--------- .../credit-card/credit-card.types.ts | 21 +++ src/components/payment-form/payment-form.tsx | 1 + src/contexts/form/form.tsx | 5 + src/contexts/form/form.types.ts | 2 + 5 files changed, 102 insertions(+), 72 deletions(-) diff --git a/src/components/credit-card/credit-card.tsx b/src/components/credit-card/credit-card.tsx index 181b59bb..1035b909 100644 --- a/src/components/credit-card/credit-card.tsx +++ b/src/components/credit-card/credit-card.tsx @@ -48,10 +48,7 @@ function CreditCard({ style, ...props }: CreditCardProps) { - const [card, setCard] = React.useState(() => undefined); - const [isSubmitting, setIsSubmitting] = React.useState(false); - const buttonRef = React.useRef(null); - const { cardTokenizeResponseReceived, payments } = useForm(); + const { payments, card, setCard } = useForm(); const options: Square.CardOptions = React.useMemo(() => { const baseOptions = { @@ -70,48 +67,6 @@ function CreditCard({ }, {}); }, [includeInputLabels, postalCode, style]); - /** - * Handle the on click of the Credit Card button click - * - * @param e An event which takes place in the DOM. - * @returns The data be sended to `cardTokenizeResponseReceived()` function, or an error - */ - const handlePayment = async (e: Event) => { - e.stopPropagation(); - - if (buttonProps?.isLoading) return; - - if (!card) { - console.warn('Credit Card button was clicked, but no Credit Card instance was found.'); - - return; - } - - setIsSubmitting(true); - - try { - const result = await card.tokenize(); - - if (result.status === 'OK') { - const tokenizedResult = await cardTokenizeResponseReceived(result); - return tokenizedResult; - } - - let message = `Tokenization failed with status: ${result.status}`; - if (result?.errors) { - message += ` and errors: ${JSON.stringify(result?.errors)}`; - - throw new Error(message); - } - - console.warn(message); - } catch (error) { - console.error(error); - } finally { - setIsSubmitting(false); - } - }; - React.useEffect(() => { const abortController = new AbortController(); const { signal } = abortController; @@ -162,6 +117,68 @@ function CreditCard({ recalculateSize(card?.recalculateSize); } + return ( + <> +
+ {!card && } +
+ + {props.hideButton ? null : typeof render === 'function' ? ( + render(CreditCardButton) + ) : ( + {children ?? 'Pay'} + )} + + ); +} + +export const CreditCardButton = ({ children, isLoading, render, ...props }: CreditCardPayButtonProps) => { + const [isSubmitting, setIsSubmitting] = React.useState(false); + const buttonRef = React.useRef(null); + const { cardTokenizeResponseReceived, card } = useForm(); + + /** + * Handle the on click of the Credit Card button click + * + * @param e An event which takes place in the DOM. + * @returns The data be sended to `cardTokenizeResponseReceived()` function, or an error + */ + const handlePayment = async (e: Event) => { + e.stopPropagation(); + + if (isLoading) return; + + if (!card) { + console.warn('Credit Card button was clicked, but no Credit Card instance was found.'); + + return; + } + + setIsSubmitting(true); + + try { + const result = await card.tokenize(); + + if (result.status === 'OK') { + const tokenizedResult = await cardTokenizeResponseReceived(result); + return tokenizedResult; + } + + let message = `Tokenization failed with status: ${result.status}`; + if (result?.errors) { + message += ` and errors: ${JSON.stringify(result?.errors)}`; + + throw new Error(message); + } + + console.warn(message); + } catch (error) { + console.error(error); + } finally { + setIsSubmitting(false); + } + }; + useEventListener({ listener: handlePayment, type: 'click', @@ -171,35 +188,19 @@ function CreditCard({ }, }); - const Button = ({ children, isLoading, ...props }: CreditCardPayButtonProps) => { - const id = 'rswp-card-button'; - const disabled = isLoading || !card || isSubmitting; - - return ( - - {children ?? 'Pay'} - - ); - }; + const id = 'rswp-card-button'; + const disabled = isLoading || !card || isSubmitting; - return ( - <> -
- {!card && } -
+ if (render) { + return render({ isSubmitting, handlePayment, buttonRef }); + } - {typeof render === 'function' ? render(Button) : } - + return ( + + {children ?? 'Pay'} + ); -} +}; export default CreditCard; export * from './credit-card.types'; diff --git a/src/components/credit-card/credit-card.types.ts b/src/components/credit-card/credit-card.types.ts index 48efd88f..7a475159 100644 --- a/src/components/credit-card/credit-card.types.ts +++ b/src/components/credit-card/credit-card.types.ts @@ -27,6 +27,21 @@ export type CreditCardPayButtonProps = Omit< css?: Stitches.ComponentProps['css']; /** Control the loading state of the button a.k.a disabling the button. */ isLoading?: boolean; + /** Render a custom button with full control. */ + render?: (props: { + /** If handlePayment is currently running. Use this to disable the button */ + isSubmitting: boolean; + /** + * Call this function onClick. Alternatively just set the `ref={buttonRef}` + * and it will be handled for you + */ + handlePayment: (e: Event) => void; + /** + * Set `ref={buttonRef}` for it to handle the payment on click. You may want + * to use `handlePayment` instead for more control, but don't use both. + */ + buttonRef: React.RefObject; + }) => React.ReactElement; }; export type CreditCardFunctionChildrenProps = { @@ -159,4 +174,10 @@ export interface CreditCardProps extends CreditCardBase { * @param Button - The button component */ render?(Button: (props: CreditCardPayButtonProps) => React.ReactElement | null): React.ReactNode; + /** + * Make it possible to render the button outside of the component using + * `` If `hideButton` is set to `true` then the button + * will not be rendered and `render`, `children` and `buttonProps` will be ignored. + */ + hideButton?: boolean; } diff --git a/src/components/payment-form/payment-form.tsx b/src/components/payment-form/payment-form.tsx index 430c3886..baa26766 100644 --- a/src/components/payment-form/payment-form.tsx +++ b/src/components/payment-form/payment-form.tsx @@ -38,4 +38,5 @@ function RenderPaymentForm( const PaymentForm = React.forwardRef(RenderPaymentForm); export default PaymentForm; +export { useForm } from '~/contexts/form'; export * from './payment-form.types'; diff --git a/src/contexts/form/form.tsx b/src/contexts/form/form.tsx index 92cff495..8450a29d 100644 --- a/src/contexts/form/form.tsx +++ b/src/contexts/form/form.tsx @@ -16,6 +16,8 @@ const FormContext = React.createContext({ cardTokenizeResponseReceived: null as unknown as () => Promise, createPaymentRequest: null as unknown as Square.PaymentRequestOptions, payments: null as unknown as Square.Payments, + card: undefined, + setCard: () => undefined, }); function FormProvider({ applicationId, locationId, children, overrides, ...props }: FormProviderProps) { @@ -23,6 +25,7 @@ function FormProvider({ applicationId, locationId, children, overrides, ...props const [createPaymentRequest] = React.useState(() => props.createPaymentRequest?.() ); + const [card, setCard] = React.useState(() => undefined); React.useEffect(() => { const abortController = new AbortController(); @@ -78,6 +81,8 @@ function FormProvider({ applicationId, locationId, children, overrides, ...props cardTokenizeResponseReceived: cardTokenizeResponseReceivedCallback, createPaymentRequest, payments: instance, + card, + setCard, }; return {children}; diff --git a/src/contexts/form/form.types.ts b/src/contexts/form/form.types.ts index 69a3ea6f..c425f2e1 100644 --- a/src/contexts/form/form.types.ts +++ b/src/contexts/form/form.types.ts @@ -30,6 +30,8 @@ export type FormContextType = { * Invoked when a digital wallet payment button is clicked. */ createPaymentRequest?: Square.PaymentRequestOptions; + card?: Square.Card; + setCard: React.Dispatch>; }; export type FormProviderProps = { From 310ca3a8142410cd52e0cda51dab7168a408f52f Mon Sep 17 00:00:00 2001 From: Alex Brazier Date: Thu, 7 Mar 2024 09:17:53 +0000 Subject: [PATCH 2/4] fix: CreditCardButton export --- src/components/credit-card/credit-card.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/components/credit-card/credit-card.tsx b/src/components/credit-card/credit-card.tsx index 1035b909..0af5c63f 100644 --- a/src/components/credit-card/credit-card.tsx +++ b/src/components/credit-card/credit-card.tsx @@ -132,7 +132,7 @@ function CreditCard({ ); } -export const CreditCardButton = ({ children, isLoading, render, ...props }: CreditCardPayButtonProps) => { +function CreditCardButton({ children, isLoading, render, ...props }: CreditCardPayButtonProps) { const [isSubmitting, setIsSubmitting] = React.useState(false); const buttonRef = React.useRef(null); const { cardTokenizeResponseReceived, card } = useForm(); @@ -200,7 +200,8 @@ export const CreditCardButton = ({ children, isLoading, render, ...props }: Cred {children ?? 'Pay'} ); -}; +} export default CreditCard; +export { CreditCardButton }; export * from './credit-card.types'; From b979359d5e410fd39287ef0393dc1ee12069cdbc Mon Sep 17 00:00:00 2001 From: Alex Brazier Date: Thu, 7 Mar 2024 09:18:56 +0000 Subject: [PATCH 3/4] feat: pass payments instance to tokenize response - this allows you to recall verify buyer to save a card --- src/components/payment-form/payment-form.types.ts | 3 ++- src/contexts/form/form.tsx | 2 +- src/contexts/form/form.types.ts | 3 ++- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/components/payment-form/payment-form.types.ts b/src/components/payment-form/payment-form.types.ts index 82849e37..603e5467 100644 --- a/src/components/payment-form/payment-form.types.ts +++ b/src/components/payment-form/payment-form.types.ts @@ -14,7 +14,8 @@ export type PaymentFormProps = { */ cardTokenizeResponseReceived: ( props: Square.TokenResult, - verifiedBuyer?: Square.VerifyBuyerResponseDetails | null + verifiedBuyer?: Square.VerifyBuyerResponseDetails | null, + payments?: Square.Payments ) => void; children: React.ReactNode; /** diff --git a/src/contexts/form/form.tsx b/src/contexts/form/form.tsx index 8450a29d..c5ca51f2 100644 --- a/src/contexts/form/form.tsx +++ b/src/contexts/form/form.tsx @@ -64,7 +64,7 @@ function FormProvider({ applicationId, locationId, children, overrides, ...props const verifyBuyerResults = await instance?.verifyBuyer(String(rest.token), props.createVerificationDetails()); - await props.cardTokenizeResponseReceived(rest, verifyBuyerResults); + await props.cardTokenizeResponseReceived(rest, verifyBuyerResults, instance); }; // Fixes stale closure issue with using React Hooks & SqPaymentForm callback functions diff --git a/src/contexts/form/form.types.ts b/src/contexts/form/form.types.ts index c425f2e1..1e8d726a 100644 --- a/src/contexts/form/form.types.ts +++ b/src/contexts/form/form.types.ts @@ -46,7 +46,8 @@ export type FormProviderProps = { */ cardTokenizeResponseReceived: ( token: Square.TokenResult, - verifiedBuyer?: Square.VerifyBuyerResponseDetails | null + verifiedBuyer?: Square.VerifyBuyerResponseDetails | null, + payments?: Square.Payments ) => void | Promise; children: React.ReactNode; /** From f5d8317742646c5598a07ee26f4b98c7c1f46a1e Mon Sep 17 00:00:00 2001 From: Alex Brazier Date: Fri, 8 Mar 2024 10:00:56 +0000 Subject: [PATCH 4/4] fix: types --- src/components/credit-card/credit-card.tsx | 2 ++ src/components/credit-card/credit-card.types.ts | 9 +++++++++ 2 files changed, 11 insertions(+) diff --git a/src/components/credit-card/credit-card.tsx b/src/components/credit-card/credit-card.tsx index 0af5c63f..fa643d39 100644 --- a/src/components/credit-card/credit-card.tsx +++ b/src/components/credit-card/credit-card.tsx @@ -10,6 +10,7 @@ import type { CreditCardBase, CreditCardChildren, CreditCardFunctionChildren, + CreditCardNoButton, CreditCardPayButtonProps, CreditCardProps, } from './credit-card.types'; @@ -35,6 +36,7 @@ import type { function CreditCard(props: CreditCardBase): JSX.Element; function CreditCard(props: CreditCardChildren): JSX.Element; function CreditCard(props: CreditCardFunctionChildren): JSX.Element; +function CreditCard(props: CreditCardNoButton): JSX.Element; function CreditCard({ buttonProps, callbacks, diff --git a/src/components/credit-card/credit-card.types.ts b/src/components/credit-card/credit-card.types.ts index 7a475159..62171c50 100644 --- a/src/components/credit-card/credit-card.types.ts +++ b/src/components/credit-card/credit-card.types.ts @@ -152,6 +152,15 @@ export interface CreditCardFunctionChildren extends CreditCardBase { render?(Button: (props: CreditCardPayButtonProps) => React.ReactElement): React.ReactNode; } +export interface CreditCardNoButton extends CreditCardBase { + /** + * Make it possible to render the button outside of the component using + * `` If `hideButton` is set to `true` then the button + * will not be rendered and `render`, `children` and `buttonProps` will be ignored. + */ + hideButton?: boolean; +} + export interface CreditCardProps extends CreditCardBase { /** * Props to be passed to the `