Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Payments add card update with setup intent #1584

Merged
merged 16 commits into from
Oct 4, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/files-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
"@lingui/react": "^3.7.2",
"@material-ui/core": "^4.12.3",
"@sentry/react": "^5.28.0",
"@stripe/react-stripe-js": "^1.4.1",
"@stripe/stripe-js": "^1.18.0",
"@tkey/default": "3.14.2",
"@tkey/security-questions": "3.14.2",
"@tkey/web-storage": "3.14.2",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
import { Button, Grid, TextInput, Typography, useToasts } from "@chainsafe/common-components"
import { createStyles, makeStyles } from "@chainsafe/common-theme"
import React, { useState, useCallback } from "react"
import React, { FormEvent, useState } from "react"
import { Button, Grid, Typography, useToasts } from "@chainsafe/common-components"
import { createStyles, makeStyles, useTheme } from "@chainsafe/common-theme"
import { CSFTheme } from "../../../../Themes/types"
import CustomModal from "../../../Elements/CustomModal"
import CustomButton from "../../../Elements/CustomButton"
import { t, Trans } from "@lingui/macro"
import CardInputs from "../../../Elements/CardInputs"
import { getCardNumberError, getCVCError, getExpiryDateError } from "../../../Elements/CardInputs/utils/validator"
import { useStripe, useElements, CardNumberElement, CardExpiryElement, CardCvcElement } from "@stripe/react-stripe-js"
import { useFilesApi } from "../../../../Contexts/FilesApiContext"
import { useBilling } from "../../../../Contexts/BillingContext"
import clsx from "clsx"

const useStyles = makeStyles(
({ breakpoints, constants, typography, zIndex }: CSFTheme) => {
({ breakpoints, constants, typography, zIndex, palette, animation }: CSFTheme) => {
return createStyles({
root: {
padding: constants.generalUnit * 4,
Expand Down Expand Up @@ -55,71 +56,105 @@ const useStyles = makeStyles(
},
footer: {
marginTop: constants.generalUnit * 4
},
cardNumberInputs: {
marginBottom: constants.generalUnit * 2,
[breakpoints.down("md")]: {
marginTop: constants.generalUnit * 2,
marginBottom: constants.generalUnit * 2
}
},
cardInputs: {
border: `1px solid ${palette.additional["gray"][6]}`,
borderRadius: 2,
padding: constants.generalUnit * 1.5,
transitionDuration: `${animation.transform}ms`,
"&:hover": {
borderColor: palette.primary.border
Tbaut marked this conversation as resolved.
Show resolved Hide resolved
}
},
cardInputsFocus: {
borderColor: palette.primary.border,
boxShadow: constants.addCard.shadow
},
expiryCvcContainer: {
display: "grid",
gridTemplateColumns: "1fr 1fr",
marginTop: constants.generalUnit,
gridColumnGap: constants.generalUnit,
[breakpoints.down("md")]: {
gridTemplateColumns: "1fr",
gridRowGap: constants.generalUnit * 2
}
},
error: {
marginTop: constants.generalUnit * 2,
color: palette.error.main
}
})
}
)

interface ICreateFolderModalProps {
interface IAddCardModalProps {
isModalOpen: boolean
onClose: () => void
}

const CreateFolderModal = ({ isModalOpen, onClose }: ICreateFolderModalProps) => {
const AddCardModal = ({ isModalOpen, onClose }: IAddCardModalProps) => {
const classes = useStyles()
const [cardInputs, setCardInputs] = useState({
cardNumber: "",
cardExpiry: "",
cardCvc: ""
})
const [cardName, setCardName] = useState("")
const [error, setError] = useState("")
const [loading, setLoading] = useState(false)
const { addCard, getCardTokenFromStripe } = useBilling()
const stripe = useStripe()
const elements = useElements()
const { addToast } = useToasts()
const { filesApiClient } = useFilesApi()
const { refreshDefaultCard } = useBilling()
const [focusElement, setFocusElement] = useState<"number" | "expiry" | "cvc" | undefined>(undefined)
const [cardAddError, setCardAddError] = useState<string | undefined>(undefined)
const theme: CSFTheme = useTheme()

const onCloseModal = useCallback(() => {
setCardInputs({ cardNumber: "", cardExpiry: "", cardCvc: "" })
setCardName("")
setError("")
onClose()
}, [onClose])
const [loadingPaymentMethodAdd, setLoadingPaymentMethodAdd] = useState(false)

const onSubmitCard = useCallback((e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
const error = !cardName
? t`Name on card is required`
: getCardNumberError(cardInputs.cardNumber) ||
getExpiryDateError(cardInputs.cardExpiry) ||
getCVCError(cardInputs.cardCvc, cardInputs.cardNumber)
const handleSubmitPaymentMethod = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
setCardAddError(undefined)
if (!stripe || !elements) return
try {
const cardNumberElement = elements.getElement(CardNumberElement)
if (!cardNumberElement) return

if (error) {
setError(error)
return
}
setError("")
setLoadingPaymentMethodAdd(true)
const { error, paymentMethod } = await stripe.createPaymentMethod({
type: "card",
card: cardNumberElement
})

setLoading(true)
// get token from stripe
getCardTokenFromStripe(cardInputs).then((resp) => {
// send stripe token to API
addCard(resp.data.id)
.then(() => {
onCloseModal()
addToast({
title: t`Card added successfully`,
type: "success"
})
}).catch((e) => {
setError(t`Something went wrong, please try again`)
console.error(e)
}).finally(() => setLoading(false))
}).catch((err) => {
console.error(err)
setError(t`Card details could not be validated`)
setLoading(false)
})
}, [cardName, cardInputs, getCardTokenFromStripe, addCard, onCloseModal, addToast])
if (error || !paymentMethod) {
console.error(error)
setLoadingPaymentMethodAdd(false)
setCardAddError(t`Card inputs invalid`)
return
}

const setupIntent = await filesApiClient.createSetupIntent()
const setupIntentResult = await stripe.confirmCardSetup(setupIntent.secret, {
payment_method: paymentMethod.id
})

if (setupIntentResult.error || !setupIntentResult.setupIntent) {
console.error(error)
setLoadingPaymentMethodAdd(false)
setCardAddError(t`Failed to add payment method`)
return
}
refreshDefaultCard()
onClose()
setLoadingPaymentMethodAdd(false)
addToast({ title: "Payment method added", type: "success" })
} catch (error) {
console.error(error)
setLoadingPaymentMethodAdd(false)
setCardAddError(t`Failed to add payment method`)
}
}

return (
<CustomModal
Expand All @@ -131,10 +166,10 @@ const CreateFolderModal = ({ isModalOpen, onClose }: ICreateFolderModalProps) =>
closePosition="none"
maxWidth="sm"
>
<form onSubmit={onSubmitCard}>
<form onSubmit={handleSubmitPaymentMethod}>
<div
className={classes.root}
data-cy="modal-create-folder"
data-cy="modal-add-card"
>
<Grid
item
Expand All @@ -149,30 +184,60 @@ const CreateFolderModal = ({ isModalOpen, onClose }: ICreateFolderModalProps) =>
<Trans>Add a credit card</Trans>
</Typography>
</Grid>
<Grid
item
xs={12}
sm={12}
>
<TextInput
value={cardName}
onChange={(val) =>
setCardName(val?.toString() || "")
<CardNumberElement
className={clsx(
classes.cardInputs,
classes.cardNumberInputs,
focusElement === "number" && classes.cardInputsFocus
)}
options={{ showIcon: true, style: {
base: {
color: theme.constants.addCard.color
}
size="large"
placeholder={t`Name on card`}
label={t`Name on card`}
} }}
onFocus={() => setFocusElement("number")}
Tbaut marked this conversation as resolved.
Show resolved Hide resolved
onBlur={() => setFocusElement(undefined)}
onChange={() => setCardAddError(undefined)}
/>
<div className={classes.expiryCvcContainer}>
<CardExpiryElement
className={clsx(
classes.cardInputs,
focusElement === "expiry" && classes.cardInputsFocus
)}
onFocus={() => setFocusElement("expiry")}
onBlur={() => setFocusElement(undefined)}
onChange={() => setCardAddError(undefined)}
options={{ style: {
base: {
color: theme.constants.addCard.color
}
} }}
/>
<CardInputs
cardNumber={cardInputs.cardNumber}
cardExpiry={cardInputs.cardExpiry}
cardCvc={cardInputs.cardCvc}
handleChangeCardNumber={(value) => setCardInputs({ ...cardInputs, cardNumber: value })}
handleChangeCardExpiry={(value) => setCardInputs({ ...cardInputs, cardExpiry: value })}
handleChangeCardCvc={(value) => setCardInputs({ ...cardInputs, cardCvc: value })}
error={error}
<CardCvcElement
className={clsx(
classes.cardInputs,
focusElement === "cvc" && classes.cardInputsFocus
)}
onFocus={() => setFocusElement("cvc")}
onBlur={() => setFocusElement(undefined)}
onChange={() => setCardAddError(undefined)}
options={{ style: {
base: {
color: theme.constants.addCard.color
}
} }}
/>
</Grid>
</div>
{cardAddError &&
<Typography
component="p"
variant="body1"
className={classes.error}
>
{cardAddError}
</Typography>
}
<Grid
item
flexDirection="row"
Expand All @@ -181,12 +246,12 @@ const CreateFolderModal = ({ isModalOpen, onClose }: ICreateFolderModalProps) =>
>
<CustomButton
data-cy="button-cancel-create-folder"
onClick={onCloseModal}
onClick={onClose}
size="medium"
className={classes.cancelButton}
variant="outline"
type="button"
disabled={loading}
disabled={loadingPaymentMethodAdd}
>
<Trans>Cancel</Trans>
</CustomButton>
Expand All @@ -196,8 +261,8 @@ const CreateFolderModal = ({ isModalOpen, onClose }: ICreateFolderModalProps) =>
variant="primary"
type="submit"
className={classes.okButton}
loading={loading}
disabled={loading}
loading={loadingPaymentMethodAdd}
disabled={loadingPaymentMethodAdd}
>
<Trans>Add card</Trans>
</Button>
Expand All @@ -208,4 +273,4 @@ const CreateFolderModal = ({ isModalOpen, onClose }: ICreateFolderModalProps) =>
)
}

export default CreateFolderModal
export default AddCardModal
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import CurrentCard from "./CurrentCard"
import { Divider, Typography } from "@chainsafe/common-components"
import { makeStyles, createStyles, ITheme } from "@chainsafe/common-theme"
import { Trans } from "@lingui/macro"
import { Elements } from "@stripe/react-stripe-js"
import { loadStripe } from "@stripe/stripe-js"

const useStyles = makeStyles(({ constants }: ITheme) =>
createStyles({
Expand All @@ -13,22 +15,26 @@ const useStyles = makeStyles(({ constants }: ITheme) =>
})
)

const stripePromise = loadStripe(process.env.REACT_APP_STRIPE_PK || "")

const PlanView: React.FC = () => {
const classes = useStyles()

return (
<div>
<Typography
variant="h3"
component="h3"
className={classes.heading}
>
<Trans>Payment and Subscriptions</Trans>
</Typography>
<Divider />
<CurrentProduct />
<CurrentCard />
</div>
<Elements stripe={stripePromise}>
<div>
<Typography
variant="h3"
component="h3"
className={classes.heading}
>
<Trans>Payment and Subscriptions</Trans>
</Typography>
<Divider />
<CurrentProduct />
<CurrentCard />
</div>
</Elements>
)
}

Expand Down
Loading