Merged in feat/SW-618-payment-non-happy-path (pull request #874)
Feat/SW-618 payment non happy path * feat(SW-618): filter out expired saved cards * feat(SW-618): Added payment error codes and way of showing messages based on code * feat(SW-618): show error message if max retries has been reached and remove search param after showing toast * fix(SW-618): move fallback error codes * fix(SW-618): remove ref from stopping useEffect to run twice * fix(SW-618): refactored logic for toast message and minor fixes * fix(SW-618): remove error message enum due to static analysis problems Approved-by: Christian Andolf Approved-by: Arvid Norlin
This commit is contained in:
committed by
Joakim Jäderberg
parent
5fb70866ea
commit
293800f024
@@ -1,11 +1,15 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server"
|
import { NextRequest, NextResponse } from "next/server"
|
||||||
|
|
||||||
import { BOOKING_CONFIRMATION_NUMBER } from "@/constants/booking"
|
import {
|
||||||
|
BOOKING_CONFIRMATION_NUMBER,
|
||||||
|
PaymentErrorCodeEnum,
|
||||||
|
} from "@/constants/booking"
|
||||||
import { Lang } from "@/constants/languages"
|
import { Lang } from "@/constants/languages"
|
||||||
import {
|
import {
|
||||||
bookingConfirmation,
|
bookingConfirmation,
|
||||||
payment,
|
payment,
|
||||||
} from "@/constants/routes/hotelReservation"
|
} from "@/constants/routes/hotelReservation"
|
||||||
|
import { serverClient } from "@/lib/trpc/server"
|
||||||
import { getPublicURL } from "@/server/utils"
|
import { getPublicURL } from "@/server/utils"
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
@@ -35,12 +39,35 @@ export async function GET(
|
|||||||
const returnUrl = new URL(`${publicURL}/${payment[lang]}`)
|
const returnUrl = new URL(`${publicURL}/${payment[lang]}`)
|
||||||
returnUrl.search = queryParams.toString()
|
returnUrl.search = queryParams.toString()
|
||||||
|
|
||||||
if (status === "cancel") {
|
if (confirmationNumber) {
|
||||||
returnUrl.searchParams.set("cancel", "true")
|
try {
|
||||||
}
|
const bookingStatus = await serverClient().booking.status({
|
||||||
|
confirmationNumber,
|
||||||
|
})
|
||||||
|
if (bookingStatus.metadata) {
|
||||||
|
returnUrl.searchParams.set(
|
||||||
|
"errorCode",
|
||||||
|
bookingStatus.metadata.errorCode?.toString() ?? ""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
`[payment-callback] failed to get booking status for ${confirmationNumber}, status: ${status}`
|
||||||
|
)
|
||||||
|
|
||||||
if (status === "error") {
|
if (status === "cancel") {
|
||||||
returnUrl.searchParams.set("error", "true")
|
returnUrl.searchParams.set(
|
||||||
|
"errorCode",
|
||||||
|
PaymentErrorCodeEnum.Cancelled.toString()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (status === "error") {
|
||||||
|
returnUrl.searchParams.set(
|
||||||
|
"errorCode",
|
||||||
|
PaymentErrorCodeEnum.Failed.toString()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[payment-callback] redirecting to: ${returnUrl}`)
|
console.log(`[payment-callback] redirecting to: ${returnUrl}`)
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import Body from "@/components/TempDesignSystem/Text/Body"
|
|||||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||||
import { toast } from "@/components/TempDesignSystem/Toasts"
|
import { toast } from "@/components/TempDesignSystem/Toasts"
|
||||||
import { useHandleBookingStatus } from "@/hooks/booking/useHandleBookingStatus"
|
import { useHandleBookingStatus } from "@/hooks/booking/useHandleBookingStatus"
|
||||||
|
import { usePaymentFailedToast } from "@/hooks/booking/usePaymentFailedToast"
|
||||||
import useLang from "@/hooks/useLang"
|
import useLang from "@/hooks/useLang"
|
||||||
|
|
||||||
import { bedTypeMap } from "../../SelectRate/RoomSelection/utils"
|
import { bedTypeMap } from "../../SelectRate/RoomSelection/utils"
|
||||||
@@ -41,7 +42,7 @@ import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectR
|
|||||||
import { PaymentProps } from "@/types/components/hotelReservation/selectRate/section"
|
import { PaymentProps } from "@/types/components/hotelReservation/selectRate/section"
|
||||||
import { BreakfastPackageEnum } from "@/types/enums/breakfast"
|
import { BreakfastPackageEnum } from "@/types/enums/breakfast"
|
||||||
|
|
||||||
const maxRetries = 40
|
const maxRetries = 4
|
||||||
const retryInterval = 2000
|
const retryInterval = 2000
|
||||||
|
|
||||||
export const formId = "submit-booking"
|
export const formId = "submit-booking"
|
||||||
@@ -87,6 +88,8 @@ export default function Payment({
|
|||||||
const [availablePaymentOptions, setAvailablePaymentOptions] =
|
const [availablePaymentOptions, setAvailablePaymentOptions] =
|
||||||
useState(otherPaymentOptions)
|
useState(otherPaymentOptions)
|
||||||
|
|
||||||
|
usePaymentFailedToast()
|
||||||
|
|
||||||
const methods = useForm<PaymentFormData>({
|
const methods = useForm<PaymentFormData>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
paymentMethod: savedCreditCards?.length
|
paymentMethod: savedCreditCards?.length
|
||||||
@@ -105,23 +108,29 @@ export default function Payment({
|
|||||||
if (result?.confirmationNumber) {
|
if (result?.confirmationNumber) {
|
||||||
setConfirmationNumber(result.confirmationNumber)
|
setConfirmationNumber(result.confirmationNumber)
|
||||||
} else {
|
} else {
|
||||||
// TODO: add proper error message
|
toast.error(
|
||||||
toast.error("Failed to create booking")
|
intl.formatMessage({
|
||||||
|
id: "payment.error.failed",
|
||||||
|
})
|
||||||
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
console.error("Error", error)
|
console.error("Error", error)
|
||||||
// TODO: add proper error message
|
toast.error(
|
||||||
toast.error("Failed to create booking")
|
intl.formatMessage({
|
||||||
|
id: "payment.error.failed",
|
||||||
|
})
|
||||||
|
)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const bookingStatus = useHandleBookingStatus(
|
const bookingStatus = useHandleBookingStatus({
|
||||||
confirmationNumber,
|
confirmationNumber,
|
||||||
BookingStatusEnum.PaymentRegistered,
|
expectedStatus: BookingStatusEnum.BookingCompleted,
|
||||||
maxRetries,
|
maxRetries,
|
||||||
retryInterval
|
retryInterval,
|
||||||
)
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (window.ApplePaySession) {
|
if (window.ApplePaySession) {
|
||||||
@@ -138,8 +147,14 @@ export default function Payment({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (bookingStatus?.data?.paymentUrl) {
|
if (bookingStatus?.data?.paymentUrl) {
|
||||||
router.push(bookingStatus.data.paymentUrl)
|
router.push(bookingStatus.data.paymentUrl)
|
||||||
|
} else if (bookingStatus.isTimeout) {
|
||||||
|
toast.error(
|
||||||
|
intl.formatMessage({
|
||||||
|
id: "payment.error.failed",
|
||||||
|
})
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}, [bookingStatus, router])
|
}, [bookingStatus, router, intl])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsSubmittingDisabled(
|
setIsSubmittingDisabled(
|
||||||
|
|||||||
@@ -46,6 +46,12 @@ export enum PaymentMethodEnum {
|
|||||||
discover = "discover",
|
discover = "discover",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum PaymentErrorCodeEnum {
|
||||||
|
Abandoned = 5,
|
||||||
|
Cancelled = 6,
|
||||||
|
Failed = 7,
|
||||||
|
}
|
||||||
|
|
||||||
export const PAYMENT_METHOD_TITLES: Record<
|
export const PAYMENT_METHOD_TITLES: Record<
|
||||||
keyof typeof PaymentMethodEnum,
|
keyof typeof PaymentMethodEnum,
|
||||||
string
|
string
|
||||||
|
|||||||
@@ -1,19 +1,30 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
|
import { useRef } from "react"
|
||||||
|
|
||||||
import { BookingStatusEnum } from "@/constants/booking"
|
import { BookingStatusEnum } from "@/constants/booking"
|
||||||
import { trpc } from "@/lib/trpc/client"
|
import { trpc } from "@/lib/trpc/client"
|
||||||
|
|
||||||
export function useHandleBookingStatus(
|
export function useHandleBookingStatus({
|
||||||
confirmationNumber: string | null,
|
confirmationNumber,
|
||||||
expectedStatus: BookingStatusEnum,
|
expectedStatus,
|
||||||
maxRetries: number,
|
maxRetries,
|
||||||
|
retryInterval,
|
||||||
|
}: {
|
||||||
|
confirmationNumber: string | null
|
||||||
|
expectedStatus: BookingStatusEnum
|
||||||
|
maxRetries: number
|
||||||
retryInterval: number
|
retryInterval: number
|
||||||
) {
|
}) {
|
||||||
|
const retries = useRef(0)
|
||||||
|
|
||||||
const query = trpc.booking.status.useQuery(
|
const query = trpc.booking.status.useQuery(
|
||||||
{ confirmationNumber: confirmationNumber ?? "" },
|
{ confirmationNumber: confirmationNumber ?? "" },
|
||||||
{
|
{
|
||||||
enabled: !!confirmationNumber,
|
enabled: !!confirmationNumber,
|
||||||
refetchInterval: (query) => {
|
refetchInterval: (query) => {
|
||||||
|
retries.current = query.state.dataUpdateCount
|
||||||
|
|
||||||
if (query.state.error || query.state.dataUpdateCount >= maxRetries) {
|
if (query.state.error || query.state.dataUpdateCount >= maxRetries) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -31,5 +42,8 @@ export function useHandleBookingStatus(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
return query
|
return {
|
||||||
|
...query,
|
||||||
|
isTimeout: retries.current >= maxRetries,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
48
hooks/booking/usePaymentFailedToast.ts
Normal file
48
hooks/booking/usePaymentFailedToast.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { usePathname, useRouter, useSearchParams } from "next/navigation"
|
||||||
|
import { useCallback, useEffect } from "react"
|
||||||
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
|
import { PaymentErrorCodeEnum } from "@/constants/booking"
|
||||||
|
|
||||||
|
import { toast } from "@/components/TempDesignSystem/Toasts"
|
||||||
|
|
||||||
|
export function usePaymentFailedToast() {
|
||||||
|
const intl = useIntl()
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
const pathname = usePathname()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const getErrorMessage = useCallback(
|
||||||
|
(errorCode: PaymentErrorCodeEnum) => {
|
||||||
|
switch (errorCode) {
|
||||||
|
case PaymentErrorCodeEnum.Cancelled:
|
||||||
|
return intl.formatMessage({ id: "payment.error.cancelled" })
|
||||||
|
default:
|
||||||
|
return intl.formatMessage({ id: "payment.error.failed" })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[intl]
|
||||||
|
)
|
||||||
|
|
||||||
|
const errorCodeString = searchParams.get("errorCode")
|
||||||
|
const errorCode = Number(errorCodeString) as PaymentErrorCodeEnum
|
||||||
|
const errorMessage = getErrorMessage(errorCode)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!errorCode) return
|
||||||
|
|
||||||
|
// setTimeout is needed to show toasts on page load: https://sonner.emilkowal.ski/toast#render-toast-on-page-load
|
||||||
|
setTimeout(() => {
|
||||||
|
const toastType =
|
||||||
|
errorCode === PaymentErrorCodeEnum.Cancelled ? "warning" : "error"
|
||||||
|
|
||||||
|
toast[toastType](errorMessage)
|
||||||
|
})
|
||||||
|
|
||||||
|
const queryParams = new URLSearchParams(searchParams.toString())
|
||||||
|
queryParams.delete("errorCode")
|
||||||
|
router.replace(`${pathname}?${queryParams.toString()}`)
|
||||||
|
}, [searchParams, router, pathname, errorCode, errorMessage])
|
||||||
|
}
|
||||||
@@ -444,6 +444,9 @@
|
|||||||
"number": "nummer",
|
"number": "nummer",
|
||||||
"or": "eller",
|
"or": "eller",
|
||||||
"paying": "betaler ",
|
"paying": "betaler ",
|
||||||
|
"payment.error.abandoned": "Vi havde et problem med at behandle din booking. Prøv venligst igen. Ingen gebyrer er blevet opkrævet.",
|
||||||
|
"payment.error.cancelled": "Du har nu annulleret din betaling.",
|
||||||
|
"payment.error.failed": "Vi havde et problem med at behandle din booking. Prøv venligst igen. Ingen gebyrer er blevet opkrævet.",
|
||||||
"points": "Point",
|
"points": "Point",
|
||||||
"room type": "værelsestype",
|
"room type": "værelsestype",
|
||||||
"room types": "værelsestyper",
|
"room types": "værelsestyper",
|
||||||
|
|||||||
@@ -443,6 +443,9 @@
|
|||||||
"number": "nummer",
|
"number": "nummer",
|
||||||
"or": "oder",
|
"or": "oder",
|
||||||
"paying": "bezahlt",
|
"paying": "bezahlt",
|
||||||
|
"payment.error.abandoned": "Wir hatten ein Problem beim Verarbeiten Ihrer Buchung. Bitte versuchen Sie es erneut. Es wurden keine Gebühren erhoben.",
|
||||||
|
"payment.error.cancelled": "Sie haben jetzt Ihre Zahlung abgebrochen.",
|
||||||
|
"payment.error.failed": "Wir hatten ein Problem beim Verarbeiten Ihrer Buchung. Bitte versuchen Sie es erneut. Es wurden keine Gebühren erhoben.",
|
||||||
"points": "Punkte",
|
"points": "Punkte",
|
||||||
"room type": "zimmerart",
|
"room type": "zimmerart",
|
||||||
"room types": "zimmerarten",
|
"room types": "zimmerarten",
|
||||||
|
|||||||
@@ -482,6 +482,9 @@
|
|||||||
"number": "number",
|
"number": "number",
|
||||||
"or": "or",
|
"or": "or",
|
||||||
"paying": "paying",
|
"paying": "paying",
|
||||||
|
"payment.error.abandoned": "We had an issue processing your booking. Please try again. No charges have been made.",
|
||||||
|
"payment.error.cancelled": "You have now cancelled your payment.",
|
||||||
|
"payment.error.failed": "We had an issue processing your booking. Please try again. No charges have been made.",
|
||||||
"points": "Points",
|
"points": "Points",
|
||||||
"room type": "room type",
|
"room type": "room type",
|
||||||
"room types": "room types",
|
"room types": "room types",
|
||||||
|
|||||||
@@ -443,6 +443,9 @@
|
|||||||
"number": "määrä",
|
"number": "määrä",
|
||||||
"or": "tai",
|
"or": "tai",
|
||||||
"paying": "maksaa",
|
"paying": "maksaa",
|
||||||
|
"payment.error.abandoned": "Meillä oli ongelma varauksen käsittelyssä. Yritä uudelleen. Ei maksuja on tehty.",
|
||||||
|
"payment.error.cancelled": "Sinut nyt peruutit maksun.",
|
||||||
|
"payment.error.failed": "Meillä oli ongelma varauksen käsittelyssä. Yritä uudelleen. Ei maksuja on tehty.",
|
||||||
"points": "pistettä",
|
"points": "pistettä",
|
||||||
"room type": "huonetyyppi",
|
"room type": "huonetyyppi",
|
||||||
"room types": "huonetyypit",
|
"room types": "huonetyypit",
|
||||||
|
|||||||
@@ -441,6 +441,9 @@
|
|||||||
"number": "antall",
|
"number": "antall",
|
||||||
"or": "eller",
|
"or": "eller",
|
||||||
"paying": "betaler",
|
"paying": "betaler",
|
||||||
|
"payment.error.abandoned": "Vi hadde et problem med å behandle din bestilling. Vær så snill å prøv igjen. Ingen gebyrer er blevet belastet.",
|
||||||
|
"payment.error.cancelled": "Du har nå annullerer din betaling.",
|
||||||
|
"payment.error.failed": "Vi hadde et problem med å behandle din bestilling. Vær så snill å prøv igjen. Ingen gebyrer er blevet belastet.",
|
||||||
"points": "poeng",
|
"points": "poeng",
|
||||||
"room type": "romtype",
|
"room type": "romtype",
|
||||||
"room types": "romtyper",
|
"room types": "romtyper",
|
||||||
|
|||||||
@@ -442,6 +442,9 @@
|
|||||||
"number": "nummer",
|
"number": "nummer",
|
||||||
"or": "eller",
|
"or": "eller",
|
||||||
"paying": "betalar",
|
"paying": "betalar",
|
||||||
|
"payment.error.abandoned": "Vi hade et problem med att bearbeta din bokning. Vänligen försök igen. Inga avgifter har debiterats.",
|
||||||
|
"payment.error.cancelled": "Du har nu avbrutit din betalning.",
|
||||||
|
"payment.error.failed": "Vi hade ett problem med att bearbeta din bokning. Vänligen försök igen. Inga avgifter har debiterats.",
|
||||||
"points": "poäng",
|
"points": "poäng",
|
||||||
"room type": "rumtyp",
|
"room type": "rumtyp",
|
||||||
"room types": "rumstyper",
|
"room types": "rumstyper",
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ export const createBookingSchema = z
|
|||||||
cancellationNumber: d.data.attributes.cancellationNumber,
|
cancellationNumber: d.data.attributes.cancellationNumber,
|
||||||
reservationStatus: d.data.attributes.reservationStatus,
|
reservationStatus: d.data.attributes.reservationStatus,
|
||||||
paymentUrl: d.data.attributes.paymentUrl,
|
paymentUrl: d.data.attributes.paymentUrl,
|
||||||
|
metadata: d.data.attributes.metadata,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// QUERY
|
// QUERY
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { metrics } from "@opentelemetry/api"
|
|||||||
import { cache } from "react"
|
import { cache } from "react"
|
||||||
|
|
||||||
import * as api from "@/lib/api"
|
import * as api from "@/lib/api"
|
||||||
|
import { dt } from "@/lib/dt"
|
||||||
import {
|
import {
|
||||||
protectedProcedure,
|
protectedProcedure,
|
||||||
router,
|
router,
|
||||||
@@ -208,7 +209,13 @@ export function parsedUser(data: User, isMFA: boolean) {
|
|||||||
return user
|
return user
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getCreditCards(session: Session) {
|
async function getCreditCards({
|
||||||
|
session,
|
||||||
|
onlyNonExpired,
|
||||||
|
}: {
|
||||||
|
session: Session
|
||||||
|
onlyNonExpired?: boolean
|
||||||
|
}) {
|
||||||
getCreditCardsCounter.add(1)
|
getCreditCardsCounter.add(1)
|
||||||
console.info("api.profile.creditCards start", JSON.stringify({}))
|
console.info("api.profile.creditCards start", JSON.stringify({}))
|
||||||
const apiResponse = await api.get(api.endpoints.v1.Profile.creditCards, {
|
const apiResponse = await api.get(api.endpoints.v1.Profile.creditCards, {
|
||||||
@@ -255,7 +262,19 @@ async function getCreditCards(session: Session) {
|
|||||||
}
|
}
|
||||||
getCreditCardsSuccessCounter.add(1)
|
getCreditCardsSuccessCounter.add(1)
|
||||||
console.info("api.profile.creditCards success", JSON.stringify({}))
|
console.info("api.profile.creditCards success", JSON.stringify({}))
|
||||||
return verifiedData.data.data
|
|
||||||
|
return verifiedData.data.data.filter((card) => {
|
||||||
|
if (onlyNonExpired) {
|
||||||
|
try {
|
||||||
|
const expirationDate = dt(card.expirationDate).startOf("day")
|
||||||
|
const currentDate = dt().startOf("day")
|
||||||
|
return expirationDate > currentDate
|
||||||
|
} catch (error) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export const userQueryRouter = router({
|
export const userQueryRouter = router({
|
||||||
@@ -492,7 +511,7 @@ export const userQueryRouter = router({
|
|||||||
)
|
)
|
||||||
const nextCursor =
|
const nextCursor =
|
||||||
verifiedData.data.links &&
|
verifiedData.data.links &&
|
||||||
verifiedData.data.links.offset < verifiedData.data.links.totalCount
|
verifiedData.data.links.offset < verifiedData.data.links.totalCount
|
||||||
? verifiedData.data.links.offset
|
? verifiedData.data.links.offset
|
||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
@@ -585,7 +604,7 @@ export const userQueryRouter = router({
|
|||||||
})
|
})
|
||||||
const nextCursor =
|
const nextCursor =
|
||||||
verifiedData.data.links &&
|
verifiedData.data.links &&
|
||||||
verifiedData.data.links.offset < verifiedData.data.links.totalCount
|
verifiedData.data.links.offset < verifiedData.data.links.totalCount
|
||||||
? verifiedData.data.links.offset
|
? verifiedData.data.links.offset
|
||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
@@ -730,14 +749,14 @@ export const userQueryRouter = router({
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
creditCards: protectedProcedure.query(async function ({ ctx }) {
|
creditCards: protectedProcedure.query(async function ({ ctx }) {
|
||||||
return await getCreditCards(ctx.session)
|
return await getCreditCards({ session: ctx.session })
|
||||||
}),
|
}),
|
||||||
safeCreditCards: safeProtectedProcedure.query(async function ({ ctx }) {
|
safeCreditCards: safeProtectedProcedure.query(async function ({ ctx }) {
|
||||||
if (!ctx.session) {
|
if (!ctx.session) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
return await getCreditCards(ctx.session)
|
return await getCreditCards({ session: ctx.session, onlyNonExpired: true })
|
||||||
}),
|
}),
|
||||||
|
|
||||||
membershipCards: protectedProcedure.query(async function ({ ctx }) {
|
membershipCards: protectedProcedure.query(async function ({ ctx }) {
|
||||||
|
|||||||
Reference in New Issue
Block a user