feat(SW-245): Delete credit card

This commit is contained in:
Tobias Johansson
2024-08-13 10:20:59 +02:00
committed by Michael Zetterberg
parent 2af17ef4d8
commit e9a6499086
33 changed files with 603 additions and 208 deletions

View File

@@ -1,47 +1,53 @@
"use client"
import { usePathname, useRouter, useSearchParams } from "next/navigation"
import { useEffect } from "react"
import { useEffect, useRef } from "react"
import { useIntl } from "react-intl"
import { toast } from "sonner"
import { trpc } from "@/lib/trpc/client"
import { PlusCircleIcon } from "@/components/Icons"
import Button from "@/components/TempDesignSystem/Button"
import { toast } from "@/components/TempDesignSystem/Toasts"
import useLang from "@/hooks/useLang"
import styles from "./addCreditCardButton.module.css"
let hasRunOnce = false
function useAddCardResultToast() {
const hasRunOnce = useRef(false)
const intl = useIntl()
const router = useRouter()
const pathname = usePathname()
const searchParams = useSearchParams()
useEffect(() => {
if (hasRunOnce) return
if (hasRunOnce.current) return
const success = searchParams.get("success")
const failure = searchParams.get("failure")
const cancel = searchParams.get("cancel")
const error = searchParams.get("error")
if (success) {
// setTimeout is used to make sure DOM is loaded before triggering toast. See documentation for more info: https://sonner.emilkowal.ski/toast#render-toast-on-page-load
setTimeout(() => {
toast.success(
intl.formatMessage({ id: "Your card was successfully saved!" })
)
})
} else if (failure) {
setTimeout(() => {
toast.error(intl.formatMessage({ id: "Something went wrong!" }))
})
toast.success(
intl.formatMessage({ id: "Your card was successfully saved!" })
)
} else if (cancel) {
toast.warning(
intl.formatMessage({
id: "You canceled adding a new credit card.",
})
)
} else if (failure || error) {
toast.error(
intl.formatMessage({
id: "Something went wrong and we couldn't add your card. Please try again later.",
})
)
}
router.replace(pathname)
hasRunOnce = true
hasRunOnce.current = true
}, [intl, pathname, router, searchParams])
}
@@ -51,10 +57,25 @@ export default function AddCreditCardButton() {
const lang = useLang()
useAddCardResultToast()
const initiateAddCard = trpc.user.initiateSaveCard.useMutation({
onSuccess: (result) => (result ? router.push(result.attribute.link) : null),
onError: () =>
toast.error(intl.formatMessage({ id: "Something went wrong!" })),
const initiateAddCard = trpc.user.creditCard.add.useMutation({
onSuccess: (result) => {
if (result?.attribute.link) {
router.push(result.attribute.link)
} else {
toast.error(
intl.formatMessage({
id: "We could not add a card right now, please try again later.",
})
)
}
},
onError: () => {
toast.error(
intl.formatMessage({
id: "An error occurred when adding a credit card, please try again later.",
})
)
},
})
return (

View File

@@ -0,0 +1,4 @@
.cardContainer {
display: grid;
gap: var(--Spacing-x1);
}

View File

@@ -0,0 +1,31 @@
"use client"
import React from "react"
import { trpc } from "@/lib/trpc/client"
import CreditCardRow from "../CreditCardRow"
import styles from "./CreditCardList.module.css"
import type { CreditCard } from "@/types/user"
export default function CreditCardList({
initialData,
}: {
initialData?: CreditCard[] | null
}) {
const creditCards = trpc.user.creditCards.useQuery(undefined, { initialData })
if (!creditCards.data || !creditCards.data.length) {
return null
}
return (
<div className={styles.cardContainer}>
{creditCards.data.map((card) => (
<CreditCardRow key={card.id} card={card} />
))}
</div>
)
}

View File

@@ -0,0 +1,10 @@
.card {
display: grid;
align-items: center;
column-gap: var(--Spacing-x1);
grid-template-columns: auto auto auto 1fr;
justify-items: flex-end;
padding: var(--Spacing-x1) var(--Spacing-x-one-and-half,);
border-radius: var(--Corner-radius-Small);
background-color: var(--Base-Background-Primary-Normal);
}

View File

@@ -0,0 +1,22 @@
import { CreditCard } from "@/components/Icons"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import DeleteCreditCardConfirmation from "../DeleteCreditCardConfirmation"
import styles from "./creditCardRow.module.css"
import type { CreditCardRowProps } from "@/types/components/myPages/myProfile/creditCards"
export default function CreditCardRow({ card }: CreditCardRowProps) {
const maskedCardNumber = `**** ${card.truncatedNumber.slice(-4)}`
return (
<div className={styles.card}>
<CreditCard color="black" />
<Body textTransform="bold">{card.type}</Body>
<Caption color="textMediumContrast">{maskedCardNumber}</Caption>
<DeleteCreditCardConfirmation card={card} />
</div>
)
}

View File

@@ -0,0 +1,40 @@
"use client"
import { useIntl } from "react-intl"
import { trpc } from "@/lib/trpc/client"
import { Delete } from "@/components/Icons"
import LoadingSpinner from "@/components/LoadingSpinner"
import Button from "@/components/TempDesignSystem/Button"
import { toast } from "@/components/TempDesignSystem/Toasts"
export default function DeleteCreditCardButton({
creditCardId,
}: {
creditCardId: string
}) {
const { formatMessage } = useIntl()
const trpcUtils = trpc.useUtils()
const deleteCreditCardMutation = trpc.user.creditCard.delete.useMutation({
onSuccess() {
trpcUtils.user.creditCards.invalidate()
toast.success(formatMessage({ id: "Credit card deleted successfully" }))
},
onError() {
toast.error(
formatMessage({
id: "Failed to delete credit card, please try again later.",
})
)
},
})
async function handleDelete() {
deleteCreditCardMutation.mutate({ creditCardId })
}
return (
<Button variant="icon" theme="base" intent="text" onClick={handleDelete}>
<Delete color="burgundy" />
</Button>
)
}

View File

@@ -0,0 +1,70 @@
.overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: var(--visual-viewport-height);
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
&[data-entering] {
animation: modal-fade 200ms;
}
&[data-exiting] {
animation: modal-fade 150ms reverse ease-in;
}
}
.modal section {
background: var(--Main-Grey-White);
border-radius: var(--Corner-radius-Medium);
padding: var(--Spacing-x4);
padding-bottom: var(--Spacing-x6);
}
.container {
display: flex;
flex-direction: column;
gap: var(--Spacing-x3);
font-family: var(--typography-Body-Regular-fontFamily);
}
.title {
font-family: var(--typography-Subtitle-1-fontFamily);
text-align: center;
margin: 0;
padding-bottom: var(--Spacing-x1);
}
.bodyText {
text-align: center;
max-width: 425px;
margin: 0;
padding: 0;
}
.buttonContainer {
display: flex;
justify-content: space-between;
gap: var(--Spacing-x2);
flex-wrap: wrap;
}
.buttonContainer button {
flex-grow: 1;
justify-content: center;
}
@keyframes modal-fade {
from {
opacity: 0;
}
to {
opacity: 1;
}
}

View File

@@ -0,0 +1,99 @@
"use client"
import {
Dialog,
DialogTrigger,
Heading,
Modal,
ModalOverlay,
} from "react-aria-components"
import { useIntl } from "react-intl"
import { trpc } from "@/lib/trpc/client"
import { Delete } from "@/components/Icons"
import LoadingSpinner from "@/components/LoadingSpinner"
import Button from "@/components/TempDesignSystem/Button"
import { toast } from "@/components/TempDesignSystem/Toasts"
import styles from "./deleteCreditCardConfirmation.module.css"
import type { DeleteCreditCardConfirmationProps } from "@/types/components/myPages/myProfile/creditCards"
export default function DeleteCreditCardConfirmation({
card,
}: DeleteCreditCardConfirmationProps) {
const intl = useIntl()
const trpcUtils = trpc.useUtils()
const deleteCard = trpc.user.creditCard.delete.useMutation({
onSuccess() {
trpcUtils.user.creditCards.invalidate()
toast.success(
intl.formatMessage({ id: "Your card was successfully removed!" })
)
},
onError() {
toast.error(
intl.formatMessage({
id: "Something went wrong and we couldn't remove your card. Please try again later.",
})
)
},
})
const lastFourDigits = card.truncatedNumber.slice(-4)
return (
<div>
<DialogTrigger>
<Button variant="icon" theme="base" intent="text">
<Delete color="burgundy" />
</Button>
<ModalOverlay className={styles.overlay} isDismissable>
<Modal className={styles.modal}>
<Dialog role="alertdialog">
{({ close }) => (
<div className={styles.container}>
<Heading slot="title" className={styles.title}>
{intl.formatMessage({
id: "Remove card from member profile",
})}
</Heading>
<p className={styles.bodyText}>
{`${intl.formatMessage({
id: "Are you sure you want to remove the card ending with",
})} ${lastFourDigits} ${intl.formatMessage({ id: "from your member profile?" })}`}
</p>
{deleteCard.isPending ? (
<LoadingSpinner />
) : (
<div className={styles.buttonContainer}>
<Button intent="secondary" theme="base" onClick={close}>
{intl.formatMessage({ id: "No, keep card" })}
</Button>
<Button
intent="primary"
theme="base"
onClick={() => {
deleteCard.mutate(
{ creditCardId: card.id },
{ onSettled: close }
)
}}
>
{intl.formatMessage({ id: "Yes, remove my card" })}
</Button>
</div>
)}
</div>
)}
</Dialog>
</Modal>
</ModalOverlay>
</DialogTrigger>
</div>
)
}