Merged in feat/SW-104-add-card (pull request #410)
Feat/SW-104 add card * feat: add api endpoints for adding and removing credit card * feat(SW-104): Added Sonner toast lib * feat(SW-104): Add route handlers for add card flow * feat(SW-104): Added link to route handler and trigger toast when query params from callback is set * feat(SW-104): Added translations for add card success toast * feat(SW-104): Refactored to use client request for initiate save card * fix(SW-104): Return proper status codes when initiating save card fails * fix(SW-104): remove delete card endpoint because it was added in SW-245 * fix(SW-104): remove console.log * fix(SW-104): Use api.post for save card request * fix(SW-104): move function declaration above export * fix(SW-104): handle response of save card and use Lang enum * fix(SW-104): added comment for why setTimeout is needed for toast and also removed lang prop * fix(SW-104): added type for AddCreditCardButton props * feat: add toasts * fix(SW-104): start using toasts from ToastHandler and fix problem with duplicate toasts * fix(SW-104): remove unnecessary wrapping div Approved-by: Michael Zetterberg
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import { env } from "@/env/server"
|
||||
import { serverClient } from "@/lib/trpc/server"
|
||||
|
||||
import { CreditCard, Delete } from "@/components/Icons"
|
||||
@@ -18,6 +19,8 @@ export default async function CreditCardSlot({ params }: PageArgs<LangParams>) {
|
||||
const { formatMessage } = await getIntl()
|
||||
const creditCards = await serverClient().user.creditCards()
|
||||
|
||||
const { lang } = params
|
||||
|
||||
return (
|
||||
<section className={styles.container}>
|
||||
<article className={styles.content}>
|
||||
@@ -41,7 +44,9 @@ export default async function CreditCardSlot({ params }: PageArgs<LangParams>) {
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<AddCreditCardButton />
|
||||
<AddCreditCardButton
|
||||
redirectUrl={`${env.PUBLIC_URL}/api/web/add-card-callback/${lang}`}
|
||||
/>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import TokenRefresher from "@/components/Auth/TokenRefresher"
|
||||
import AdobeSDKScript from "@/components/Current/AdobeSDKScript"
|
||||
import Footer from "@/components/Current/Footer"
|
||||
import VwoScript from "@/components/Current/VwoScript"
|
||||
import { ToastHandler } from "@/components/TempDesignSystem/Toasts"
|
||||
import { preloadUserTracking } from "@/components/TrackingSDK"
|
||||
import { getIntl } from "@/i18n"
|
||||
import ServerIntlProvider from "@/i18n/Provider"
|
||||
@@ -59,6 +60,7 @@ export default async function RootLayout({
|
||||
<TrpcProvider>
|
||||
{header}
|
||||
{children}
|
||||
<ToastHandler />
|
||||
<Footer />
|
||||
<TokenRefresher />
|
||||
</TrpcProvider>
|
||||
|
||||
47
app/api/web/add-card-callback/[lang]/route.ts
Normal file
47
app/api/web/add-card-callback/[lang]/route.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { NextRequest } from "next/server"
|
||||
import { env } from "process"
|
||||
|
||||
import { Lang } from "@/constants/languages"
|
||||
import { serverClient } from "@/lib/trpc/server"
|
||||
import { badRequest, internalServerError } from "@/server/errors/next"
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { lang: string } }
|
||||
) {
|
||||
try {
|
||||
const lang = params.lang as Lang
|
||||
|
||||
const searchParams = request.nextUrl.searchParams
|
||||
const success = searchParams.get("success")
|
||||
const failure = searchParams.get("failure")
|
||||
const trxId = searchParams.get("datatransTrxId")
|
||||
|
||||
const returnUrl = new URL(
|
||||
`${env.PUBLIC_URL}/${lang ?? Lang.en}/scandic-friends/my-pages/profile`
|
||||
)
|
||||
|
||||
if (success) {
|
||||
if (!trxId) {
|
||||
return badRequest("Missing datatransTrxId param")
|
||||
}
|
||||
|
||||
const saveCardSuccess = await serverClient().user.saveCard({
|
||||
transactionId: trxId,
|
||||
})
|
||||
|
||||
if (saveCardSuccess) {
|
||||
returnUrl.searchParams.set("success", "true")
|
||||
} else {
|
||||
returnUrl.searchParams.set("failure", "true")
|
||||
}
|
||||
} else if (failure) {
|
||||
returnUrl.searchParams.set("failure", "true")
|
||||
}
|
||||
|
||||
return Response.redirect(returnUrl, 307)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
return internalServerError()
|
||||
}
|
||||
}
|
||||
40
components/Icons/CloseLarge.tsx
Normal file
40
components/Icons/CloseLarge.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { iconVariants } from "./variants"
|
||||
|
||||
import type { IconProps } from "@/types/components/icon"
|
||||
|
||||
export default function CloseLargeIcon({
|
||||
className,
|
||||
color,
|
||||
...props
|
||||
}: IconProps) {
|
||||
const classNames = iconVariants({ className, color })
|
||||
return (
|
||||
<svg
|
||||
className={classNames}
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<mask
|
||||
id="mask0_1756_2612"
|
||||
style={{ maskType: "alpha" }}
|
||||
maskUnits="userSpaceOnUse"
|
||||
x="0"
|
||||
y="0"
|
||||
width="24"
|
||||
height="24"
|
||||
>
|
||||
<rect width="24" height="24" fill="#D9D9D9" />
|
||||
</mask>
|
||||
<g mask="url(#mask0_1756_2612)">
|
||||
<path
|
||||
d="M12 13.5422L6.34057 19.2016C6.12719 19.415 5.87017 19.5193 5.5695 19.5144C5.26882 19.5096 5.0118 19.4004 4.79842 19.1871C4.59474 18.9737 4.49532 18.7191 4.50017 18.4233C4.50502 18.1274 4.60928 17.8777 4.81297 17.674L10.4578 12L4.81297 6.32606C4.60928 6.12237 4.50744 5.87262 4.50744 5.5768C4.50744 5.28098 4.60928 5.02638 4.81297 4.813C5.01665 4.59961 5.26882 4.4905 5.5695 4.48565C5.87017 4.4808 6.12719 4.58507 6.34057 4.79845L12 10.4579L17.6594 4.79845C17.8728 4.58507 18.1298 4.4808 18.4305 4.48565C18.7312 4.4905 18.9882 4.59961 19.2016 4.813C19.4053 5.02638 19.5047 5.28098 19.4998 5.5768C19.495 5.87262 19.3907 6.12237 19.187 6.32606L13.5422 12L19.187 17.674C19.3907 17.8777 19.4926 18.1274 19.4926 18.4233C19.4926 18.7191 19.3907 18.9737 19.187 19.1871C18.9834 19.4004 18.7312 19.5096 18.4305 19.5144C18.1298 19.5193 17.8728 19.415 17.6594 19.2016L12 13.5422Z"
|
||||
fill="#57514E"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
40
components/Icons/CrossCircle.tsx
Normal file
40
components/Icons/CrossCircle.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { iconVariants } from "./variants"
|
||||
|
||||
import type { IconProps } from "@/types/components/icon"
|
||||
|
||||
export default function CrossCircleIcon({
|
||||
className,
|
||||
color,
|
||||
...props
|
||||
}: IconProps) {
|
||||
const classNames = iconVariants({ className, color })
|
||||
return (
|
||||
<svg
|
||||
className={classNames}
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<mask
|
||||
id="mask0_1756_2637"
|
||||
style={{ maskType: "alpha" }}
|
||||
maskUnits="userSpaceOnUse"
|
||||
x="0"
|
||||
y="0"
|
||||
width="24"
|
||||
height="24"
|
||||
>
|
||||
<rect width="24" height="24" fill="#D9D9D9" />
|
||||
</mask>
|
||||
<g mask="url(#mask0_1756_2637)">
|
||||
<path
|
||||
d="M12 13.3L14.9 16.2C15.075 16.375 15.2917 16.4625 15.55 16.4625C15.8083 16.4625 16.025 16.375 16.2 16.2C16.375 16.025 16.4625 15.8083 16.4625 15.55C16.4625 15.2917 16.375 15.075 16.2 14.9L13.3 12L16.2 9.1C16.375 8.925 16.4625 8.70833 16.4625 8.45C16.4625 8.19167 16.375 7.975 16.2 7.8C16.025 7.625 15.8083 7.5375 15.55 7.5375C15.2917 7.5375 15.075 7.625 14.9 7.8L12 10.7L9.1 7.8C8.925 7.625 8.70833 7.5375 8.45 7.5375C8.19167 7.5375 7.975 7.625 7.8 7.8C7.625 7.975 7.5375 8.19167 7.5375 8.45C7.5375 8.70833 7.625 8.925 7.8 9.1L10.7 12L7.8 14.9C7.625 15.075 7.5375 15.2917 7.5375 15.55C7.5375 15.8083 7.625 16.025 7.8 16.2C7.975 16.375 8.19167 16.4625 8.45 16.4625C8.70833 16.4625 8.925 16.375 9.1 16.2L12 13.3ZM12 21.75C10.6516 21.75 9.38434 21.4936 8.19838 20.9809C7.01239 20.4682 5.98075 19.7724 5.10345 18.8934C4.22615 18.0145 3.53125 16.9826 3.01875 15.7978C2.50625 14.613 2.25 13.3471 2.25 12C2.25 10.6516 2.50636 9.38434 3.01908 8.19838C3.53179 7.01239 4.22762 5.98075 5.10658 5.10345C5.98553 4.22615 7.01739 3.53125 8.20218 3.01875C9.38698 2.50625 10.6529 2.25 12 2.25C13.3484 2.25 14.6157 2.50636 15.8016 3.01908C16.9876 3.53179 18.0193 4.22762 18.8966 5.10658C19.7739 5.98553 20.4688 7.01739 20.9813 8.20217C21.4938 9.38697 21.75 10.6529 21.75 12C21.75 13.3484 21.4936 14.6157 20.9809 15.8016C20.4682 16.9876 19.7724 18.0193 18.8934 18.8966C18.0145 19.7739 16.9826 20.4688 15.7978 20.9813C14.613 21.4938 13.3471 21.75 12 21.75Z"
|
||||
fill="white"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
40
components/Icons/WarningTriangle.tsx
Normal file
40
components/Icons/WarningTriangle.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { iconVariants } from "./variants"
|
||||
|
||||
import type { IconProps } from "@/types/components/icon"
|
||||
|
||||
export default function WarningTriangleIcon({
|
||||
className,
|
||||
color,
|
||||
...props
|
||||
}: IconProps) {
|
||||
const classNames = iconVariants({ className, color })
|
||||
return (
|
||||
<svg
|
||||
className={classNames}
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<mask
|
||||
id="mask0_1756_2606"
|
||||
style={{ maskType: "alpha" }}
|
||||
maskUnits="userSpaceOnUse"
|
||||
x="0"
|
||||
y="0"
|
||||
width="24"
|
||||
height="24"
|
||||
>
|
||||
<rect width="24" height="24" fill="#D9D9D9" />
|
||||
</mask>
|
||||
<g mask="url(#mask0_1756_2606)">
|
||||
<path
|
||||
d="M2.95563 20.775C2.7768 20.775 2.61564 20.7315 2.47216 20.6444C2.32869 20.5573 2.2171 20.4425 2.13738 20.3C2.05405 20.1583 2.0103 20.0063 2.00613 19.8438C2.00196 19.6813 2.04571 19.5208 2.13738 19.3625L11.1874 3.75001C11.279 3.59167 11.3986 3.47501 11.546 3.40001C11.6934 3.32501 11.8447 3.28751 11.9999 3.28751C12.155 3.28751 12.3063 3.32501 12.4538 3.40001C12.6012 3.47501 12.7207 3.59167 12.8124 3.75001L21.8624 19.3625C21.954 19.5208 21.9978 19.6813 21.9936 19.8438C21.9895 20.0063 21.9457 20.1583 21.8624 20.3C21.779 20.4417 21.6663 20.5563 21.524 20.6438C21.3818 20.7313 21.2237 20.775 21.0499 20.775H2.95563ZM11.9973 17.875C12.2657 17.875 12.4915 17.7842 12.6749 17.6026C12.8582 17.4211 12.9499 17.1961 12.9499 16.9276C12.9499 16.6592 12.8591 16.4333 12.6775 16.25C12.4959 16.0667 12.2709 15.975 12.0025 15.975C11.7341 15.975 11.5082 16.0658 11.3249 16.2474C11.1415 16.4289 11.0499 16.6539 11.0499 16.9224C11.0499 17.1908 11.1407 17.4167 11.3223 17.6C11.5038 17.7833 11.7288 17.875 11.9973 17.875ZM12.0124 15C12.2707 15 12.4915 14.9083 12.6749 14.725C12.8582 14.5417 12.9499 14.3208 12.9499 14.0625V11.0125C12.9499 10.7542 12.8582 10.5333 12.6749 10.35C12.4915 10.1667 12.2707 10.075 12.0124 10.075C11.754 10.075 11.5332 10.1667 11.3499 10.35C11.1665 10.5333 11.0749 10.7542 11.0749 11.0125V14.0625C11.0749 14.3208 11.1665 14.5417 11.3499 14.725C11.5332 14.9083 11.754 15 12.0124 15Z"
|
||||
fill="white"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -14,8 +14,10 @@ import {
|
||||
ChevronDownIcon,
|
||||
ChevronRightIcon,
|
||||
CloseIcon,
|
||||
CloseLarge,
|
||||
CoffeeIcon,
|
||||
ConciergeIcon,
|
||||
CrossCircle,
|
||||
DoorOpenIcon,
|
||||
ElectricBikeIcon,
|
||||
EmailIcon,
|
||||
@@ -34,6 +36,7 @@ import {
|
||||
PlusCircleIcon,
|
||||
RestaurantIcon,
|
||||
TshirtWashIcon,
|
||||
WarningTriangle,
|
||||
WifiIcon,
|
||||
} from "."
|
||||
|
||||
@@ -59,6 +62,8 @@ export function getIconByIconName(icon?: IconName): FC<IconProps> | null {
|
||||
return CellphoneIcon
|
||||
case IconName.Check:
|
||||
return CheckIcon
|
||||
case IconName.CrossCircle:
|
||||
return CrossCircle
|
||||
case IconName.CheckCircle:
|
||||
return CheckCircleIcon
|
||||
case IconName.ChevronDown:
|
||||
@@ -67,6 +72,8 @@ export function getIconByIconName(icon?: IconName): FC<IconProps> | null {
|
||||
return ChevronRightIcon
|
||||
case IconName.Close:
|
||||
return CloseIcon
|
||||
case IconName.CloseLarge:
|
||||
return CloseLarge
|
||||
case IconName.Coffee:
|
||||
return CoffeeIcon
|
||||
case IconName.Concierge:
|
||||
@@ -107,6 +114,8 @@ export function getIconByIconName(icon?: IconName): FC<IconProps> | null {
|
||||
return RestaurantIcon
|
||||
case IconName.TshirtWash:
|
||||
return TshirtWashIcon
|
||||
case IconName.WarningTriangle:
|
||||
return WarningTriangle
|
||||
case IconName.Wifi:
|
||||
return WifiIcon
|
||||
default:
|
||||
|
||||
@@ -44,5 +44,5 @@
|
||||
|
||||
.white,
|
||||
.white * {
|
||||
fill: var(--Scandic-Opacity-White-100);
|
||||
fill: var(--UI-Opacity-White-100);
|
||||
}
|
||||
|
||||
@@ -11,9 +11,11 @@ export { default as CheckCircleIcon } from "./CheckCircle"
|
||||
export { default as ChevronDownIcon } from "./ChevronDown"
|
||||
export { default as ChevronRightIcon } from "./ChevronRight"
|
||||
export { default as CloseIcon } from "./Close"
|
||||
export { default as CloseLarge } from "./CloseLarge"
|
||||
export { default as CoffeeIcon } from "./Coffee"
|
||||
export { default as ConciergeIcon } from "./Concierge"
|
||||
export { default as CreditCard } from "./CreditCard"
|
||||
export { default as CrossCircle } from "./CrossCircle"
|
||||
export { default as Delete } from "./Delete"
|
||||
export { default as DoorOpenIcon } from "./DoorOpen"
|
||||
export { default as ElectricBikeIcon } from "./ElectricBike"
|
||||
@@ -34,4 +36,5 @@ export { default as PlusCircleIcon } from "./PlusCircle"
|
||||
export { default as RestaurantIcon } from "./Restaurant"
|
||||
export { default as ScandicLogoIcon } from "./ScandicLogo"
|
||||
export { default as TshirtWashIcon } from "./TshirtWash"
|
||||
export { default as WarningTriangle } from "./WarningTriangle"
|
||||
export { default as WifiIcon } from "./Wifi"
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
.addCreditCardButton {
|
||||
justify-self: flex-start;
|
||||
}
|
||||
@@ -1,31 +1,83 @@
|
||||
"use client"
|
||||
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation"
|
||||
import { useEffect } 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 useLang from "@/hooks/useLang"
|
||||
|
||||
export default function AddCreditCardButton() {
|
||||
const { formatMessage } = useIntl()
|
||||
import styles from "./addCreditCardButton.module.css"
|
||||
|
||||
import { type AddCreditCardButtonProps } from "@/types/components/myPages/myProfile/addCreditCardButton"
|
||||
|
||||
let hasRunOnce = false
|
||||
|
||||
function useAddCardResultToast() {
|
||||
const intl = useIntl()
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
const searchParams = useSearchParams()
|
||||
|
||||
useEffect(() => {
|
||||
if (hasRunOnce) return
|
||||
|
||||
const success = searchParams.get("success")
|
||||
const failure = searchParams.get("failure")
|
||||
|
||||
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!" }))
|
||||
})
|
||||
}
|
||||
|
||||
router.replace(pathname)
|
||||
hasRunOnce = true
|
||||
}, [intl, pathname, router, searchParams])
|
||||
}
|
||||
|
||||
export default function AddCreditCardButton({
|
||||
redirectUrl,
|
||||
}: AddCreditCardButtonProps) {
|
||||
const intl = useIntl()
|
||||
const router = useRouter()
|
||||
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!" })),
|
||||
})
|
||||
|
||||
async function handleAddCreditCard() {
|
||||
// TODO: initiate add credit card flow and redirect user to planet:
|
||||
// const { url } = trpc.user.creditCard.add.useMutation()
|
||||
// router.redirect(url)
|
||||
console.log("Credit card added!")
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
className={styles.addCreditCardButton}
|
||||
variant="icon"
|
||||
theme="base"
|
||||
intent="text"
|
||||
onClick={handleAddCreditCard}
|
||||
onClick={() =>
|
||||
initiateAddCard.mutate({
|
||||
language: lang,
|
||||
mobileToken: false,
|
||||
redirectUrl,
|
||||
})
|
||||
}
|
||||
wrapping
|
||||
>
|
||||
<PlusCircleIcon color="burgundy" />
|
||||
{formatMessage({ id: "Add new card" })}
|
||||
{intl.formatMessage({ id: "Add new card" })}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
96
components/TempDesignSystem/Toasts/index.tsx
Normal file
96
components/TempDesignSystem/Toasts/index.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import { ExternalToast, toast as sonnerToast, Toaster } from "sonner"
|
||||
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
CloseLarge,
|
||||
CrossCircle,
|
||||
InfoCircleIcon,
|
||||
WarningTriangle,
|
||||
} from "@/components/Icons"
|
||||
|
||||
import Button from "../Button"
|
||||
import Body from "../Text/Body"
|
||||
import { ToastsProps } from "./toasts"
|
||||
import { toastVariants } from "./variants"
|
||||
|
||||
import styles from "./toasts.module.css"
|
||||
|
||||
export function ToastHandler() {
|
||||
return <Toaster />
|
||||
}
|
||||
|
||||
function getIcon(variant: ToastsProps["variant"]) {
|
||||
switch (variant) {
|
||||
case "error":
|
||||
return CrossCircle
|
||||
case "info":
|
||||
return InfoCircleIcon
|
||||
case "success":
|
||||
return CheckCircleIcon
|
||||
case "warning":
|
||||
return WarningTriangle
|
||||
}
|
||||
}
|
||||
|
||||
export function Toast({ message, onClose, variant }: ToastsProps) {
|
||||
const className = toastVariants({ variant })
|
||||
const Icon = getIcon(variant)
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className={styles.iconContainer}>
|
||||
{Icon && <Icon color="white" height={24} width={24} />}
|
||||
</div>
|
||||
<Body className={styles.message}>{message}</Body>
|
||||
<Button onClick={onClose} variant="icon" intent="text">
|
||||
<CloseLarge />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const toast = {
|
||||
success: (message: string, options?: ExternalToast) =>
|
||||
sonnerToast.custom(
|
||||
(t) => (
|
||||
<Toast
|
||||
variant="success"
|
||||
message={message}
|
||||
onClose={() => sonnerToast.dismiss(t)}
|
||||
/>
|
||||
),
|
||||
options
|
||||
),
|
||||
info: (message: string, options?: ExternalToast) =>
|
||||
sonnerToast.custom(
|
||||
(t) => (
|
||||
<Toast
|
||||
variant="info"
|
||||
message={message}
|
||||
onClose={() => sonnerToast.dismiss(t)}
|
||||
/>
|
||||
),
|
||||
options
|
||||
),
|
||||
error: (message: string, options?: ExternalToast) =>
|
||||
sonnerToast.custom(
|
||||
(t) => (
|
||||
<Toast
|
||||
variant="error"
|
||||
message={message}
|
||||
onClose={() => sonnerToast.dismiss(t)}
|
||||
/>
|
||||
),
|
||||
options
|
||||
),
|
||||
warning: (message: string, options?: ExternalToast) =>
|
||||
sonnerToast.custom(
|
||||
(t) => (
|
||||
<Toast
|
||||
variant="warning"
|
||||
message={message}
|
||||
onClose={() => sonnerToast.dismiss(t)}
|
||||
/>
|
||||
),
|
||||
options
|
||||
),
|
||||
}
|
||||
38
components/TempDesignSystem/Toasts/toasts.module.css
Normal file
38
components/TempDesignSystem/Toasts/toasts.module.css
Normal file
@@ -0,0 +1,38 @@
|
||||
.toast {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
border-radius: var(--Corner-radius-Large);
|
||||
overflow: hidden;
|
||||
background: var(--Base-Surface-Primary-light-Normal);
|
||||
box-shadow: 0px 0px 8px 2px rgba(0, 0, 0, 0.08);
|
||||
align-items: center;
|
||||
width: var(--width);
|
||||
}
|
||||
|
||||
.message {
|
||||
padding: var(--Spacing-x2) var(--Spacing-x-one-and-half);
|
||||
}
|
||||
|
||||
.success {
|
||||
--icon-background-color: var(--UI-Semantic-Success);
|
||||
}
|
||||
|
||||
.error {
|
||||
--icon-background-color: var(--UI-Semantic-Error);
|
||||
}
|
||||
|
||||
.warning {
|
||||
--icon-background-color: var(--UI-Semantic-Warning);
|
||||
}
|
||||
|
||||
.info {
|
||||
--icon-background-color: var(--UI-Semantic-Information);
|
||||
}
|
||||
|
||||
.iconContainer {
|
||||
display: flex;
|
||||
background-color: var(--icon-background-color);
|
||||
padding: var(--Spacing-x2);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
10
components/TempDesignSystem/Toasts/toasts.ts
Normal file
10
components/TempDesignSystem/Toasts/toasts.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { toastVariants } from "./variants"
|
||||
|
||||
import type { VariantProps } from "class-variance-authority"
|
||||
|
||||
export interface ToastsProps
|
||||
extends Omit<React.AnchorHTMLAttributes<HTMLDivElement>, "color">,
|
||||
VariantProps<typeof toastVariants> {
|
||||
message: string
|
||||
onClose: () => void
|
||||
}
|
||||
14
components/TempDesignSystem/Toasts/variants.ts
Normal file
14
components/TempDesignSystem/Toasts/variants.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { cva } from "class-variance-authority"
|
||||
|
||||
import styles from "./toasts.module.css"
|
||||
|
||||
export const toastVariants = cva(styles.toast, {
|
||||
variants: {
|
||||
variant: {
|
||||
success: styles.success,
|
||||
info: styles.info,
|
||||
warning: styles.warning,
|
||||
error: styles.error,
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -128,6 +128,7 @@
|
||||
"Year": "År",
|
||||
"You have no previous stays.": "Du har ingen tidligere ophold.",
|
||||
"You have no upcoming stays.": "Du har ingen kommende ophold.",
|
||||
"Your card was successfully saved!": "Dit kort blev gemt!",
|
||||
"Your Challenges Conquer & Earn!": "Dine udfordringer Overvind og tjen!",
|
||||
"Your level": "Dit niveau",
|
||||
"Zip code": "Postnummer",
|
||||
|
||||
@@ -122,6 +122,7 @@
|
||||
"Year": "Jahr",
|
||||
"You have no previous stays.": "Sie haben keine vorherigen Aufenthalte.",
|
||||
"You have no upcoming stays.": "Sie haben keine bevorstehenden Aufenthalte.",
|
||||
"Your card was successfully saved!": "Ihre Karte wurde erfolgreich gespeichert!",
|
||||
"Your Challenges Conquer & Earn!": "Meistern Sie Ihre Herausforderungen und verdienen Sie Geld!",
|
||||
"Your level": "Dein level",
|
||||
"Zip code": "PLZ",
|
||||
|
||||
@@ -133,6 +133,7 @@
|
||||
"Year": "Year",
|
||||
"You have no previous stays.": "You have no previous stays.",
|
||||
"You have no upcoming stays.": "You have no upcoming stays.",
|
||||
"Your card was successfully saved!": "Your card was successfully saved!",
|
||||
"Your Challenges Conquer & Earn!": "Your Challenges Conquer & Earn!",
|
||||
"Your level": "Your level",
|
||||
"Zip code": "Zip code",
|
||||
|
||||
@@ -127,7 +127,8 @@
|
||||
"Which room class suits you the best?": "Mikä huoneluokka sopii sinulle parhaiten?",
|
||||
"Year": "Vuosi",
|
||||
"You have no previous stays.": "Sinulla ei ole aiempaa oleskelua.",
|
||||
"You have no upcoming stays.": "Sinulla ei ole tulevia majoituksia.",
|
||||
"You have no upcoming stays.": "Sinulla ei ole tulevia oleskeluja.",
|
||||
"Your card was successfully saved!": "Korttisi tallennettu onnistuneesti!",
|
||||
"Your Challenges Conquer & Earn!": "Voita ja ansaitse haasteesi!",
|
||||
"Your level": "Tasosi",
|
||||
"Zip code": "Postinumero",
|
||||
|
||||
@@ -128,6 +128,7 @@
|
||||
"Year": "År",
|
||||
"You have no previous stays.": "Du har ingen tidligere opphold.",
|
||||
"You have no upcoming stays.": "Du har ingen kommende opphold.",
|
||||
"Your card was successfully saved!": "Kortet ditt ble lagret!",
|
||||
"Your Challenges Conquer & Earn!": "Dine utfordringer Erobre og tjen!",
|
||||
"Your level": "Ditt nivå",
|
||||
"Zip code": "Post kode",
|
||||
|
||||
@@ -130,6 +130,7 @@
|
||||
"Year": "År",
|
||||
"You have no previous stays.": "Du har inga tidigare vistelser.",
|
||||
"You have no upcoming stays.": "Du har inga planerade resor.",
|
||||
"Your card was successfully saved!": "Ditt kort har sparats!",
|
||||
"Your Challenges Conquer & Earn!": "Dina utmaningar Erövra och tjäna!",
|
||||
"Your level": "Din nivå",
|
||||
"Zip code": "Postnummer",
|
||||
|
||||
@@ -8,6 +8,7 @@ export namespace endpoints {
|
||||
export const enum v1 {
|
||||
profile = "profile/v1/Profile",
|
||||
creditCards = `${profile}/creditCards`,
|
||||
initiateSaveCard = `${creditCards}/initiateSaveCard`,
|
||||
friendTransactions = "profile/v1/Transaction/friendTransactions",
|
||||
upcomingStays = "booking/v1/Stays/future",
|
||||
previousStays = "booking/v1/Stays/past",
|
||||
|
||||
@@ -53,7 +53,7 @@ export async function patch(
|
||||
}
|
||||
|
||||
export async function post(
|
||||
endpoint: Endpoint,
|
||||
endpoint: Endpoint | `${Endpoint}/${string}`,
|
||||
options: RequestOptionsWithJSONBody
|
||||
) {
|
||||
const { body, ...requestOptions } = options
|
||||
|
||||
10
package-lock.json
generated
10
package-lock.json
generated
@@ -41,6 +41,7 @@
|
||||
"react-international-phone": "^4.2.6",
|
||||
"react-intl": "^6.6.8",
|
||||
"server-only": "^0.0.1",
|
||||
"sonner": "^1.5.0",
|
||||
"superjson": "^2.2.1",
|
||||
"zod": "^3.22.4",
|
||||
"zustand": "^4.5.2"
|
||||
@@ -16538,6 +16539,15 @@
|
||||
"tslib": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/sonner": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/sonner/-/sonner-1.5.0.tgz",
|
||||
"integrity": "sha512-FBjhG/gnnbN6FY0jaNnqZOMmB73R+5IiyYAw8yBj7L54ER7HB3fOSE5OFiQiE2iXWxeXKvg6fIP4LtVppHEdJA==",
|
||||
"peerDependencies": {
|
||||
"react": "^18.0.0",
|
||||
"react-dom": "^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map": {
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
"react-international-phone": "^4.2.6",
|
||||
"react-intl": "^6.6.8",
|
||||
"server-only": "^0.0.1",
|
||||
"sonner": "^1.5.0",
|
||||
"superjson": "^2.2.1",
|
||||
"zod": "^3.22.4",
|
||||
"zustand": "^4.5.2"
|
||||
|
||||
@@ -18,3 +18,14 @@ export const soonestUpcomingStaysInput = z
|
||||
limit: z.number().int().positive(),
|
||||
})
|
||||
.default({ limit: 3 })
|
||||
|
||||
export const initiateSaveCardInput = z.object({
|
||||
language: z.string(),
|
||||
mobileToken: z.boolean(),
|
||||
redirectUrl: z.string(),
|
||||
})
|
||||
|
||||
export const saveCardInput = z.object({
|
||||
transactionId: z.string(),
|
||||
merchantId: z.string().optional(),
|
||||
})
|
||||
|
||||
@@ -180,6 +180,8 @@ export const getCreditCardsSchema = z.object({
|
||||
expirationDate: z.string(),
|
||||
cardType: z.string(),
|
||||
}),
|
||||
id: z.string(),
|
||||
type: z.string(),
|
||||
})
|
||||
),
|
||||
})
|
||||
@@ -193,3 +195,14 @@ export const getMembershipCardsSchema = z.array(
|
||||
membershipType: z.string(),
|
||||
})
|
||||
)
|
||||
|
||||
export const initiateSaveCardSchema = z.object({
|
||||
data: z.object({
|
||||
attribute: z.object({
|
||||
transactionId: z.string(),
|
||||
link: z.string(),
|
||||
mobileToken: z.string().optional(),
|
||||
}),
|
||||
type: z.string(),
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { Lang } from "@/constants/languages"
|
||||
import { env } from "@/env/server"
|
||||
import * as api from "@/lib/api"
|
||||
import { internalServerError } from "@/server/errors/next"
|
||||
import {
|
||||
badRequestError,
|
||||
forbiddenError,
|
||||
unauthorizedError,
|
||||
} from "@/server/errors/trpc"
|
||||
import {
|
||||
protectedProcedure,
|
||||
router,
|
||||
@@ -12,13 +18,19 @@ import * as maskValue from "@/utils/maskValue"
|
||||
import { getMembership, getMembershipCards } from "@/utils/user"
|
||||
|
||||
import encryptValue from "../utils/encryptValue"
|
||||
import { getUserInputSchema, staysInput } from "./input"
|
||||
import {
|
||||
getUserInputSchema,
|
||||
initiateSaveCardInput,
|
||||
saveCardInput,
|
||||
staysInput,
|
||||
} from "./input"
|
||||
import {
|
||||
getCreditCardsSchema,
|
||||
getFriendTransactionsSchema,
|
||||
getMembershipCardsSchema,
|
||||
getStaysSchema,
|
||||
getUserSchema,
|
||||
initiateSaveCardSchema,
|
||||
Stay,
|
||||
} from "./output"
|
||||
import { benefits, extendedUser, nextLevelPerks } from "./temp"
|
||||
@@ -517,6 +529,69 @@ export const userQueryRouter = router({
|
||||
return verifiedData.data.data
|
||||
}),
|
||||
|
||||
initiateSaveCard: protectedProcedure
|
||||
.input(initiateSaveCardInput)
|
||||
.mutation(async function ({ ctx, input }) {
|
||||
const apiResponse = await api.post(api.endpoints.v1.initiateSaveCard, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${ctx.session.token.access_token}`,
|
||||
},
|
||||
body: {
|
||||
language: input.language,
|
||||
mobileToken: input.mobileToken,
|
||||
redirectUrl: input.redirectUrl,
|
||||
},
|
||||
})
|
||||
|
||||
if (!apiResponse.ok) {
|
||||
switch (apiResponse.status) {
|
||||
case 400:
|
||||
throw badRequestError(apiResponse)
|
||||
case 401:
|
||||
throw unauthorizedError(apiResponse)
|
||||
case 403:
|
||||
throw forbiddenError(apiResponse)
|
||||
default:
|
||||
throw internalServerError(apiResponse)
|
||||
}
|
||||
}
|
||||
|
||||
const apiJson = await apiResponse.json()
|
||||
const verifiedData = initiateSaveCardSchema.safeParse(apiJson)
|
||||
if (!verifiedData.success) {
|
||||
console.error(`Failed to initiate save card data`)
|
||||
console.error(`User: (${JSON.stringify(ctx.session.user)})`)
|
||||
console.error(verifiedData.error)
|
||||
return null
|
||||
}
|
||||
|
||||
return verifiedData.data.data
|
||||
}),
|
||||
|
||||
saveCard: protectedProcedure.input(saveCardInput).mutation(async function ({
|
||||
ctx,
|
||||
input,
|
||||
}) {
|
||||
const apiResponse = await api.post(
|
||||
`${api.endpoints.v1.creditCards}/${input.transactionId}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${ctx.session.token.access_token}`,
|
||||
},
|
||||
body: {},
|
||||
}
|
||||
)
|
||||
|
||||
if (!apiResponse.ok) {
|
||||
console.error(`API Response Failed - Save card`)
|
||||
console.error(`User: (${JSON.stringify(ctx.session.user)})`)
|
||||
console.error(apiResponse)
|
||||
return null
|
||||
}
|
||||
|
||||
return true
|
||||
}),
|
||||
|
||||
membershipCards: protectedProcedure.query(async function ({ ctx }) {
|
||||
const apiResponse = await api.get(api.endpoints.v1.profile, {
|
||||
cache: "no-store",
|
||||
|
||||
@@ -16,10 +16,12 @@ export enum IconName {
|
||||
Camera = "Camera",
|
||||
Cellphone = "Cellphone",
|
||||
Check = "Check",
|
||||
CrossCircle = "CrossCircle",
|
||||
CheckCircle = "CheckCircle",
|
||||
ChevronDown = "ChevronDown",
|
||||
ChevronRight = "ChevronRight",
|
||||
Close = "Close",
|
||||
CloseLarge = "CloseLarge",
|
||||
Coffee = "Coffee",
|
||||
Concierge = "Concierge",
|
||||
DoorOpen = "DoorOpen",
|
||||
@@ -41,4 +43,5 @@ export enum IconName {
|
||||
Restaurant = "Restaurant",
|
||||
TshirtWash = "TshirtWash",
|
||||
Wifi = "Wifi",
|
||||
WarningTriangle = "WarningTriangle",
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export type AddCreditCardButtonProps = {
|
||||
redirectUrl: string
|
||||
}
|
||||
Reference in New Issue
Block a user