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:
Tobias Johansson
2024-08-20 15:04:02 +00:00
parent aa9e723cb5
commit 84f5e74f00
30 changed files with 537 additions and 15 deletions

View File

@@ -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>
)
}

View File

@@ -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>

View 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()
}
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View File

@@ -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:

View File

@@ -44,5 +44,5 @@
.white,
.white * {
fill: var(--Scandic-Opacity-White-100);
fill: var(--UI-Opacity-White-100);
}

View File

@@ -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"

View File

@@ -0,0 +1,3 @@
.addCreditCardButton {
justify-self: flex-start;
}

View File

@@ -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>
)
}

View 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
),
}

View 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;
}

View 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
}

View 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,
},
},
})

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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
View File

@@ -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",

View File

@@ -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"

View File

@@ -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(),
})

View File

@@ -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(),
}),
})

View File

@@ -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",

View File

@@ -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",
}

View File

@@ -0,0 +1,3 @@
export type AddCreditCardButtonProps = {
redirectUrl: string
}