Merged in feat/sw-2867-move-user-router-to-trpc-package (pull request #2428)

Move user router to trpc package

* Move more schemas in hotel router

* Fix deps

* fix getNonContentstackUrls

* Fix import error

* Fix entry error handling

* Fix generateMetadata metrics

* Fix alertType enum

* Fix duplicated types

* lint:fix

* Merge branch 'master' into feat/sw-2863-move-contentstack-router-to-trpc-package

* Fix broken imports

* Move booking router to trpc package

* Move partners router to trpc package

* Move autocomplete router to trpc package

* Move booking router to trpc package

* Remove translations from My Pages navigation trpc procedure

* Move navigation router to trpc package

* Move user router to trpc package

* Merge branch 'master' into feat/sw-2862-move-booking-router-to-trpc-package

* Merge branch 'feat/sw-2862-move-booking-router-to-trpc-package' into feat/sw-2865-move-navigation-router-to-trpc-package

* Merge branch 'master' into feat/sw-2865-move-navigation-router-to-trpc-package

* Merge branch 'master' into feat/sw-2865-move-navigation-router-to-trpc-package

* Merge branch 'master' into feat/sw-2865-move-navigation-router-to-trpc-package

* Merge branch 'feat/sw-2865-move-navigation-router-to-trpc-package' into feat/sw-2867-move-user-router-to-trpc-package

* Merge branch 'master' into feat/sw-2867-move-user-router-to-trpc-package


Approved-by: Linus Flood
This commit is contained in:
Anton Gunnarsson
2025-06-27 07:07:49 +00:00
parent 00bcdaaa28
commit 01ca2b4897
49 changed files with 609 additions and 562 deletions

View File

@@ -2,6 +2,7 @@
import { z } from "zod"
import { phoneValidator } from "@scandic-hotels/common/utils/zod/phoneValidator"
import * as api from "@scandic-hotels/trpc/api"
import { ApiLang } from "@scandic-hotels/trpc/constants/apiLang"
import { countriesMap } from "@scandic-hotels/trpc/constants/countries"
@@ -11,7 +12,6 @@ import { protectedServerActionProcedure } from "@/server/trpc"
import { editProfileSchema } from "@/components/Forms/Edit/Profile/schema"
import { getIntl } from "@/i18n"
import { phoneValidator } from "@/utils/zod/phoneValidator"
import { Status } from "@/types/components/myPages/myProfile/edit"

View File

@@ -2,8 +2,8 @@ import { headers } from "next/headers"
import { notFound, redirect } from "next/navigation"
import { overview } from "@scandic-hotels/common/constants/routes/myPages"
import { isSignupPage } from "@scandic-hotels/common/constants/routes/signup"
import { isSignupPage } from "@/constants/routes/signup"
import { env } from "@/env/server"
import ContentPage from "@/components/ContentType/StaticPages/ContentPage"

View File

@@ -4,6 +4,7 @@ import { usePathname } from "next/navigation"
import { useIntl } from "react-intl"
import { dt } from "@scandic-hotels/common/dt"
import { Transactions } from "@scandic-hotels/trpc/enums/transactions"
import { webviews } from "@/constants/routes/webviews"
@@ -15,7 +16,6 @@ import useLang from "@/hooks/useLang"
import AwardPoints from "../../../AwardPoints"
import type { RowProps } from "@/types/components/myPages/myPage/earnAndBurn"
import { Transactions } from "@/types/enums/transactions"
export default function Row({ transaction }: RowProps) {
const intl = useIntl()

View File

@@ -1,3 +1,4 @@
import { getCurrentWebUrl } from "@scandic-hotels/common/utils/url"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { env } from "@/env/server"
@@ -6,7 +7,6 @@ import Link from "@/components/TempDesignSystem/Link"
import Title from "@/components/TempDesignSystem/Text/Title"
import { getIntl } from "@/i18n"
import { getLang } from "@/i18n/serverContext"
import { getCurrentWebUrl } from "@/utils/url"
import styles from "./emptyUpcomingStays.module.css"

View File

@@ -6,6 +6,7 @@ import { useIntl } from "react-intl"
import { findMyBookingCurrentWebPath } from "@scandic-hotels/common/constants/routes/findMyBooking"
import { myPages } from "@scandic-hotels/common/constants/routes/myPages"
import { getCurrentWebUrl } from "@scandic-hotels/common/utils/url"
import { logout } from "@/constants/routes/handleAuth"
import { env } from "@/env/client"
@@ -18,7 +19,6 @@ import SkeletonShimmer from "@/components/SkeletonShimmer"
import Link from "@/components/TempDesignSystem/Link"
import useLang from "@/hooks/useLang"
import { trackClick } from "@/utils/tracking"
import { getCurrentWebUrl } from "@/utils/url"
import BookingButton from "../BookingButton"

View File

@@ -1,5 +1,6 @@
import { signup } from "@scandic-hotels/common/constants/routes/signup"
import { login } from "@/constants/routes/handleAuth"
import { signup } from "@/constants/routes/signup"
import Card from "@/components/TempDesignSystem/Card"
import { getIntl } from "@/i18n"

View File

@@ -1,11 +1,11 @@
import React from "react"
import { signup } from "@scandic-hotels/common/constants/routes/signup"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { isValidSession } from "@scandic-hotels/trpc/utils/session"
import { dtmcLogin } from "@/constants/routes/dtmc"
import { login } from "@/constants/routes/handleAuth"
import { signup } from "@/constants/routes/signup"
import { auth } from "@/auth"
import ButtonLink from "@/components/ButtonLink"

View File

@@ -1,7 +1,7 @@
import { z } from "zod"
import { passwordValidator } from "@/utils/zod/passwordValidator"
import { phoneValidator } from "@/utils/zod/phoneValidator"
import { passwordValidator } from "@scandic-hotels/common/utils/zod/passwordValidator"
import { phoneValidator } from "@scandic-hotels/common/utils/zod/phoneValidator"
export const editProfileErrors = {
COUNTRY_REQUIRED: "COUNTRY_REQUIRED",

View File

@@ -8,6 +8,10 @@ import { useIntl } from "react-intl"
import { Button } from "@scandic-hotels/design-system/Button"
import { Typography } from "@scandic-hotels/design-system/Typography"
import {
type SignUpSchema,
signUpSchema,
} from "@scandic-hotels/trpc/routers/user/schemas"
import { getDefaultCountryFromLang } from "@/constants/languages"
import {
@@ -28,8 +32,7 @@ import { useFormTracking } from "@/components/TrackingSDK/hooks"
import useLang from "@/hooks/useLang"
import { formatPhoneNumber } from "@/utils/phone"
import { type SignUpSchema, signUpSchema } from "./schema"
// import { type SignUpSchema, signUpSchema } from "./schema"
import styles from "./form.module.css"
import type { SignUpFormProps } from "@/types/components/form/signupForm"

View File

@@ -9,6 +9,7 @@ import {
findMyBooking,
findMyBookingCurrentWebPath,
} from "@scandic-hotels/common/constants/routes/findMyBooking"
import { getCurrentWebUrl } from "@scandic-hotels/common/utils/url"
import { customerService } from "@/constants/webHrefs"
import { env } from "@/env/client"
@@ -19,7 +20,6 @@ import LanguageSwitcher from "@/components/LanguageSwitcher"
import { useHandleKeyUp } from "@/hooks/useHandleKeyUp"
import { useIsLangLive } from "@/hooks/useIsLangLive"
import useLang from "@/hooks/useLang"
import { getCurrentWebUrl } from "@/utils/url"
import HeaderLink from "../../HeaderLink"
import TopLink from "../../TopLink"

View File

@@ -2,6 +2,7 @@ import {
findMyBooking,
findMyBookingCurrentWebPath,
} from "@scandic-hotels/common/constants/routes/findMyBooking"
import { getCurrentWebUrl } from "@scandic-hotels/common/utils/url"
import { env } from "@/env/server"
import { getHeader } from "@/lib/trpc/memoizedRequests"
@@ -12,7 +13,6 @@ import SkeletonShimmer from "@/components/SkeletonShimmer"
import { getIntl } from "@/i18n"
import { getLang } from "@/i18n/serverContext"
import { isLoggedInUser } from "@/utils/isLoggedInUser"
import { getCurrentWebUrl } from "@/utils/url"
import HeaderLink from "../HeaderLink"
import TopLink from "../TopLink"

View File

@@ -1,7 +1,8 @@
import { z } from "zod"
import { phoneValidator } from "@scandic-hotels/common/utils/zod/phoneValidator"
import { specialRequestSchema } from "@/components/HotelReservation/EnterDetails/Details/SpecialRequests/schema"
import { phoneValidator } from "@/utils/zod/phoneValidator"
// stringMatcher regex is copied from current web as specified by requirements.
const stringMatcher =

View File

@@ -1,9 +1,9 @@
import { z } from "zod"
import { dt } from "@scandic-hotels/common/dt"
import { phoneValidator } from "@scandic-hotels/common/utils/zod/phoneValidator"
import { specialRequestSchema } from "@/components/HotelReservation/EnterDetails/Details/SpecialRequests/schema"
import { phoneValidator } from "@/utils/zod/phoneValidator"
// stringMatcher regex is copied from current web as specified by requirements.
const stringMatcher =

View File

@@ -3,6 +3,7 @@ import { notFound } from "next/navigation"
import { CurrencyEnum } from "@scandic-hotels/common/constants/currency"
import { dt } from "@scandic-hotels/common/dt"
import * as maskValue from "@scandic-hotels/common/utils/maskValue"
import ScandicLogoIcon from "@scandic-hotels/design-system/Icons/ScandicLogoIcon"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { parseRefId } from "@scandic-hotels/trpc/utils/refId"
@@ -16,7 +17,6 @@ import {
import { getIntl } from "@/i18n"
import { isLoggedInUser } from "@/utils/isLoggedInUser"
import * as maskValue from "@/utils/maskValue"
import AdditionalInfoForm from "../../FindMyBooking/AdditionalInfoForm"
import accessBooking, {

View File

@@ -2,6 +2,8 @@ import { cookies } from "next/headers"
import { notFound } from "next/navigation"
import { dt } from "@scandic-hotels/common/dt"
import * as maskValue from "@scandic-hotels/common/utils/maskValue"
import { getCurrentWebUrl } from "@scandic-hotels/common/utils/url"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { BreakfastPackageEnum } from "@scandic-hotels/trpc/enums/breakfast"
import { parseRefId } from "@scandic-hotels/trpc/utils/refId"
@@ -35,8 +37,6 @@ import Image from "@/components/Image"
import { getIntl } from "@/i18n"
import MyStayProvider from "@/providers/MyStay"
import { isLoggedInUser } from "@/utils/isLoggedInUser"
import * as maskValue from "@/utils/maskValue"
import { getCurrentWebUrl } from "@/utils/url"
import styles from "./index.module.css"

View File

@@ -1,4 +1,4 @@
import { signupErrors } from "@/components/Forms/Signup/schema"
import { signupErrors } from "@scandic-hotels/trpc/routers/user/schemas"
import type { IntlShape } from "react-intl"

View File

@@ -1,10 +1,11 @@
import { phoneErrors } from "@scandic-hotels/common/utils/zod/phoneValidator"
import { signupErrors } from "@scandic-hotels/trpc/routers/user/schemas"
import { bookingWidgetErrors } from "@/components/Forms/BookingWidget/schema"
import { editProfileErrors } from "@/components/Forms/Edit/Profile/schema"
import { signupErrors } from "@/components/Forms/Signup/schema"
import { multiroomErrors } from "@/components/HotelReservation/EnterDetails/Details/Multiroom/schema"
import { roomOneErrors } from "@/components/HotelReservation/EnterDetails/Details/RoomOne/schema"
import { findMyBookingErrors } from "@/components/HotelReservation/FindMyBooking/schema"
import { phoneErrors } from "@/utils/zod/phoneValidator"
import type { IntlShape } from "react-intl"

View File

@@ -5,12 +5,12 @@ import { Text, TextField } from "react-aria-components"
import { Controller, useFormContext } from "react-hook-form"
import { useIntl } from "react-intl"
import { passwordValidators } from "@scandic-hotels/common/utils/zod/passwordValidator"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { Input } from "@scandic-hotels/design-system/Input"
import Button from "@/components/TempDesignSystem/Button"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import { passwordValidators } from "@/utils/zod/passwordValidator"
import { getErrorMessage } from "../Input/errors"

View File

@@ -1,10 +0,0 @@
import type { LangRoute } from "@scandic-hotels/common/constants/routes/langRoute"
export const myBookingPath: LangRoute = {
da: "/hotelreservation/min-booking",
de: "/hotelreservation/my-booking",
en: "/hotelreservation/my-booking",
fi: "/varaa-hotelli/varauksesi",
no: "/hotelreservation/my-booking",
sv: "/hotelreservation/din-bokning",
}

View File

@@ -10,6 +10,7 @@ import { serverClient } from "../server"
import type { Lang } from "@scandic-hotels/common/constants/language"
import type { GetHotelsByCSFilterInput } from "@scandic-hotels/trpc/routers/hotels/input"
import type { GetSavedPaymentCardsInput } from "@scandic-hotels/trpc/routers/user/input"
import type { RoomsAvailabilityExtendedInputSchema } from "@scandic-hotels/trpc/types/availability"
import type { Country } from "@scandic-hotels/trpc/types/country"
import type {
@@ -22,8 +23,6 @@ import type {
PackagesInput,
} from "@scandic-hotels/trpc/types/packages"
import type { GetSavedPaymentCardsInput } from "@/server/routers/user/input"
export const getLocations = cache(async function getMemoizedLocations() {
const lang = await getLang()
const caller = await serverClient()

View File

@@ -6,8 +6,7 @@ import { contentstackRouter } from "@scandic-hotels/trpc/routers/contentstack"
import { hotelsRouter } from "@scandic-hotels/trpc/routers/hotels"
import { navigationRouter } from "@scandic-hotels/trpc/routers/navigation"
import { partnerRouter } from "@scandic-hotels/trpc/routers/partners"
import { userRouter } from "./routers/user"
import { userRouter } from "@scandic-hotels/trpc/routers/user"
export const appRouter = router({
booking: bookingRouter,

View File

@@ -1,134 +0,0 @@
import { z } from "zod"
import { imageSchema } from "@scandic-hotels/trpc/routers/hotels/schemas/image"
// Schema is the same for upcoming and previous stays endpoints
export const getStaysSchema = z.object({
data: z.array(
z.object({
attributes: z.object({
hotelOperaId: z.string(),
hotelInformation: z.object({
hotelContent: z.object({
images: imageSchema,
}),
hotelName: z.string(),
cityName: z.string().nullable(),
}),
confirmationNumber: z.string(),
checkinDate: z.string(),
checkoutDate: z.string(),
isWebAppOrigin: z.boolean(),
bookingUrl: z.string().default(""),
}),
relationships: z.object({
hotel: z.object({
links: z.object({
related: z.string().nullable().optional(),
}),
data: z.object({
id: z.string(),
type: z.string(),
}),
}),
}),
type: z.string(),
id: z.string(),
links: z.object({
self: z.object({
href: z.string(),
meta: z.object({
method: z.string(),
}),
}),
}),
})
),
links: z
.object({
self: z.string(),
offset: z.number(),
limit: z.number(),
totalCount: z.number(),
})
.optional()
.nullable(),
})
type GetStaysData = z.infer<typeof getStaysSchema>
export type Stay = GetStaysData["data"][number]
export const getFriendTransactionsSchema = z.object({
data: z.array(
z.object({
attributes: z.object({
awardPoints: z.number().default(0),
checkinDate: z.string().default(""),
checkoutDate: z.string().default(""),
confirmationNumber: z.string().default(""),
hotelOperaId: z.string().default(""),
nights: z.number().default(1),
pointsCalculated: z.boolean().default(true),
transactionDate: z.string().default(""),
bookingUrl: z.string().default(""),
hotelInformation: z
.object({
city: z.string().default(""),
name: z.string().default(""),
hotelContent: z.object({
images: imageSchema,
}),
})
.optional(),
}),
relationships: z.object({
booking: z.object({
data: z.object({
id: z.string().default(""),
type: z.string().default(""),
}),
links: z.object({
related: z.string().default(""),
}),
}),
hotel: z
.object({
data: z.object({
id: z.string().default(""),
type: z.string().default(""),
}),
links: z.object({
related: z.string().default(""),
}),
})
.optional(),
}),
type: z.string().default(""),
})
),
links: z
.object({
self: z.string(),
})
.nullable(),
})
type GetFriendTransactionsData = z.infer<typeof getFriendTransactionsSchema>
export type FriendTransaction = GetFriendTransactionsData["data"][number]
export const initiateSaveCardSchema = z.object({
data: z.object({
attribute: z.object({
transactionId: z.string(),
link: z.string(),
mobileToken: z.string().optional(),
}),
type: z.string(),
}),
})
export const subscriberIdSchema = z.object({
subscriberId: z.string(),
})

View File

@@ -1,290 +0,0 @@
import { myStay } from "@scandic-hotels/common/constants/routes/myStay"
import { dt } from "@scandic-hotels/common/dt"
import { createCounter } from "@scandic-hotels/common/telemetry"
import * as api from "@scandic-hotels/trpc/api"
import { countries } from "@scandic-hotels/trpc/constants/countries"
import { getFriendsMembership } from "@scandic-hotels/trpc/routers/user/helpers"
import { creditCardsSchema } from "@scandic-hotels/trpc/routers/user/output"
import { getVerifiedUser } from "@scandic-hotels/trpc/routers/user/utils"
import { toApiLang } from "@scandic-hotels/trpc/utils"
import { encrypt } from "@scandic-hotels/trpc/utils/encryption"
import { myBookingPath } from "@/constants/myBooking"
import { env } from "@/env/server"
import { cache } from "@/utils/cache"
import * as maskValue from "@/utils/maskValue"
import { getCurrentWebUrl } from "@/utils/url"
import { type FriendTransaction, getStaysSchema, type Stay } from "./output"
import type { Lang } from "@scandic-hotels/common/constants/language"
import type { User } from "@scandic-hotels/trpc/types/user"
import type { Session } from "next-auth"
export async function getPreviousStays(
accessToken: string,
limit: number = 10,
language: Lang,
cursor?: string
) {
const getPreviousStaysCounter = createCounter("user", "getPreviousStays")
const metricsGetPreviousStays = getPreviousStaysCounter.init({
limit,
cursor,
language,
})
metricsGetPreviousStays.start()
const params: Record<string, string> = {
limit: String(limit),
language: toApiLang(language),
}
if (cursor) {
params.offset = cursor
}
const apiResponse = await api.get(
api.endpoints.v1.Booking.Stays.past,
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
},
params
)
if (!apiResponse.ok) {
await metricsGetPreviousStays.httpError(apiResponse)
return null
}
const apiJson = await apiResponse.json()
const verifiedData = getStaysSchema.safeParse(apiJson)
if (!verifiedData.success) {
metricsGetPreviousStays.validationError(verifiedData.error)
return null
}
metricsGetPreviousStays.success()
return verifiedData.data
}
export async function getUpcomingStays(
accessToken: string,
limit: number = 10,
language: Lang,
cursor?: string
) {
const getUpcomingStaysCounter = createCounter("user", "getUpcomingStays")
const metricsGetUpcomingStays = getUpcomingStaysCounter.init({
limit,
cursor,
language,
})
metricsGetUpcomingStays.start()
const params: Record<string, string> = {
limit: String(limit),
language: toApiLang(language),
}
if (cursor) {
params.offset = cursor
}
const apiResponse = await api.get(
api.endpoints.v1.Booking.Stays.future,
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
},
params
)
if (!apiResponse.ok) {
await metricsGetUpcomingStays.httpError(apiResponse)
return null
}
const apiJson = await apiResponse.json()
const verifiedData = getStaysSchema.safeParse(apiJson)
if (!verifiedData.success) {
metricsGetUpcomingStays.validationError(verifiedData.error)
return null
}
metricsGetUpcomingStays.success()
return verifiedData.data
}
export function parsedUser(data: User, isMFA: boolean) {
const country = countries.find((c) => c.code === data.address?.countryCode)
const user = {
address: {
city: data.address?.city,
country: country?.name ?? "",
countryCode: data.address?.countryCode,
streetAddress: data.address?.streetAddress,
zipCode: data.address?.zipCode,
},
dateOfBirth: data.dateOfBirth,
email: data.email,
firstName: data.firstName,
language: data.language,
lastName: data.lastName,
membershipNumber: data.membershipNumber,
membership: data.loyalty ? getFriendsMembership(data.loyalty) : null,
loyalty: data.loyalty,
name: `${data.firstName} ${data.lastName}`,
phoneNumber: data.phoneNumber,
profileId: data.profileId,
}
if (!isMFA) {
if (user.address.city) {
user.address.city = maskValue.text(user.address.city)
}
if (user.address.streetAddress) {
user.address.streetAddress = maskValue.text(user.address.streetAddress)
}
user.address.zipCode = data.address?.zipCode
? maskValue.text(data.address.zipCode)
: ""
user.dateOfBirth = maskValue.all(user.dateOfBirth)
user.email = maskValue.email(user.email)
user.phoneNumber = user.phoneNumber ? maskValue.phone(user.phoneNumber) : ""
}
return user
}
export const getCreditCards = cache(
async ({
session,
onlyNonExpired,
}: {
session: Session
onlyNonExpired?: boolean
}) => {
const getCreditCardsCounter = createCounter("user", "getCreditCards")
const metricsGetCreditCards = getCreditCardsCounter.init({
onlyNonExpired,
})
metricsGetCreditCards.start()
const apiResponse = await api.get(api.endpoints.v1.Profile.creditCards, {
headers: {
Authorization: `Bearer ${session.token.access_token}`,
},
})
if (!apiResponse.ok) {
await metricsGetCreditCards.httpError(apiResponse)
return null
}
const apiJson = await apiResponse.json()
const verifiedData = creditCardsSchema.safeParse(apiJson)
if (!verifiedData.success) {
metricsGetCreditCards.validationError(verifiedData.error)
return null
}
const result = verifiedData.data.data.filter((card) => {
if (onlyNonExpired) {
try {
const expirationDate = dt(card.expirationDate).startOf("day")
const currentDate = dt().startOf("day")
return expirationDate > currentDate
} catch (_) {
return false
}
}
return true
})
metricsGetCreditCards.success()
return result
}
)
export async function updateStaysBookingUrl(
data: Stay[],
session: Session,
lang: Lang
): Promise<Stay[]>
export async function updateStaysBookingUrl(
data: FriendTransaction[],
session: Session,
lang: Lang
): Promise<FriendTransaction[]>
export async function updateStaysBookingUrl(
data: Stay[] | FriendTransaction[],
session: Session,
lang: Lang
) {
const user = await getVerifiedUser({
session,
})
if (user && !("error" in user)) {
return data.map((d) => {
const originalString =
d.attributes.confirmationNumber.toString() + "," + user.data.lastName
const encryptedBookingValue = encrypt(originalString)
// Get base URL with fallback for ephemeral environments (like deploy previews).
const baseUrl = env.PUBLIC_URL || "https://www.scandichotels.com"
// Construct Booking URL.
const bookingUrl = !env.isLangLive(lang)
? new URL(
getCurrentWebUrl({
path: myBookingPath[lang],
lang,
baseUrl,
})
)
: new URL(myStay[lang], baseUrl)
// Add search parameters.
if (encryptedBookingValue) {
bookingUrl.searchParams.set("RefId", encryptedBookingValue)
} else {
bookingUrl.searchParams.set("lastName", user.data.lastName)
bookingUrl.searchParams.set(
"bookingId",
d.attributes.confirmationNumber.toString()
)
}
return {
...d,
attributes: {
...d.attributes,
bookingUrl: bookingUrl.toString(),
},
}
})
}
return data
}

View File

@@ -1,3 +1,3 @@
import type { passwordValidators } from "@/utils/zod/passwordValidator"
import type { passwordValidators } from "@scandic-hotels/common/utils/zod/passwordValidator"
export type PasswordValidatorKey = keyof typeof passwordValidators

View File

@@ -1,4 +1,4 @@
import type { Stay } from "@/server/routers/user/output"
import type { Stay } from "@scandic-hotels/trpc/routers/user/output"
export type StayCardProps = {
stay: Stay

View File

@@ -1,7 +1,6 @@
import type { userQueryRouter } from "@scandic-hotels/trpc/routers/user/query"
import type { User } from "@scandic-hotels/trpc/types/user"
import type { userQueryRouter } from "@/server/routers/user/query"
export type UserQueryRouter = typeof userQueryRouter
export interface UserProps {

View File

@@ -1,6 +1,5 @@
import { z } from "zod"
import { Lang } from "@scandic-hotels/common/constants/language"
import { BreakfastPackageEnum } from "@scandic-hotels/trpc/enums/breakfast"
import { RoomPackageCodeEnum } from "@scandic-hotels/trpc/enums/roomFilter"
@@ -240,52 +239,3 @@ export function serializeBookingSearchParams(
typeHints,
})
}
/**
* Returns the TLD (top-level domain) for a given language.
* @param lang - The language to get the TLD for
* @returns The TLD for the given language
*/
export function getTldForLanguage(lang: Lang): string {
switch (lang) {
case Lang.sv:
return "se"
case Lang.no:
return "no"
case Lang.da:
return "dk"
case Lang.fi:
return "fi"
case Lang.de:
return "de"
default:
return "com"
}
}
/**
* Constructs a URL with the correct TLD (top-level domain) based on lang, for current web.
* @param params - Object containing path, lang, and baseUrl
* @param params.path - The path to append to the URL
* @param params.lang - The language to use for TLD
* @param params.baseUrl - The base URL to use (e.g. https://www.scandichotels.com)
* @returns The complete URL with language-specific TLD
*/
export function getCurrentWebUrl({
path,
lang,
baseUrl = "https://www.scandichotels.com", // Fallback for ephemeral environments (e.g. deploy previews).
}: {
path: string
lang: Lang
baseUrl?: string
}): string {
const tld = getTldForLanguage(lang)
const url = new URL(path, baseUrl)
if (tld !== "com") {
url.host = url.host.replace(".com", `.${tld}`)
}
return url.toString()
}

View File

@@ -2,23 +2,8 @@ import {
type MembershipLevel,
MembershipLevelEnum,
} from "@scandic-hotels/common/constants/membershipLevels"
import { scandicMembershipTypes } from "@scandic-hotels/trpc/routers/user/helpers"
import type { User, UserLoyalty } from "@scandic-hotels/trpc/types/user"
export function getMembershipCards(userLoyalty: UserLoyalty) {
return userLoyalty.memberships
.filter(
(membership) => membership.type !== scandicMembershipTypes.SCANDIC_NATIVE
)
.map((membership) => ({
currentPoints: 0, // We only have points for Friends so we can't set this for now
expirationDate: membership.tierExpires,
membershipNumber: membership.membershipNumber,
membershipType: membership.type,
memberSince: membership.memberSince,
}))
}
import type { User } from "@scandic-hotels/trpc/types/user"
export function isHighestMembership(
membershipLevel: MembershipLevel | undefined

View File

@@ -22,9 +22,8 @@
"./utils/languages": "./utils/languages.ts",
"./utils/chunk": "./utils/chunk.ts",
"./utils/isDefined": "./utils/isDefined.ts",
"./utils/zod/stringValidator": "./utils/zod/stringValidator.ts",
"./utils/zod/numberValidator": "./utils/zod/numberValidator.ts",
"./utils/zod/arrayValidator": "./utils/zod/arrayValidator.ts",
"./utils/maskValue": "./utils/maskValue.ts",
"./utils/zod/*": "./utils/zod/*.ts",
"./constants/language": "./constants/language.ts",
"./constants/membershipLevels": "./constants/membershipLevels.ts",
"./constants/paymentMethod": "./constants/paymentMethod.ts",

View File

@@ -1,4 +1,4 @@
import { describe, expect, test } from "@jest/globals"
import { describe, expect, test } from "vitest"
import { all, email, phone, text } from "./maskValue"

View File

@@ -1,3 +1,5 @@
import { Lang } from "../constants/language"
export function removeMultipleSlashes(pathname: string) {
return pathname.replaceAll(/\/\/+/g, "/")
}
@@ -9,3 +11,52 @@ export function removeTrailingSlash(pathname: string) {
}
return pathname
}
/**
* Returns the TLD (top-level domain) for a given language.
* @param lang - The language to get the TLD for
* @returns The TLD for the given language
*/
export function getTldForLanguage(lang: Lang): string {
switch (lang) {
case Lang.sv:
return "se"
case Lang.no:
return "no"
case Lang.da:
return "dk"
case Lang.fi:
return "fi"
case Lang.de:
return "de"
default:
return "com"
}
}
/**
* Constructs a URL with the correct TLD (top-level domain) based on lang, for current web.
* @param params - Object containing path, lang, and baseUrl
* @param params.path - The path to append to the URL
* @param params.lang - The language to use for TLD
* @param params.baseUrl - The base URL to use (e.g. https://www.scandichotels.com)
* @returns The complete URL with language-specific TLD
*/
export function getCurrentWebUrl({
path,
lang,
baseUrl = "https://www.scandichotels.com", // Fallback for ephemeral environments (e.g. deploy previews).
}: {
path: string
lang: Lang
baseUrl?: string
}): string {
const tld = getTldForLanguage(lang)
const url = new URL(path, baseUrl)
if (tld !== "com") {
url.host = url.host.replace(".com", `.${tld}`)
}
return url.toString()
}

View File

@@ -1,13 +1,17 @@
import { createEnv } from "@t3-oss/env-nextjs"
import { z } from "zod"
import { isLangLive } from "../lib/DUPLICATED/isLangLive"
import type { Lang } from "@scandic-hotels/common/constants/language"
/*
* ⚠️ Remember to also add environment variables to the corresponding config in sites that uses this package. ⚠️
*/
const TWENTYFOUR_HOURS = 24 * 60 * 60
export const env = createEnv({
const _env = createEnv({
/**
* Due to t3-env only checking typeof window === "undefined"
* and Netlify running Deno, window is never "undefined"
@@ -49,6 +53,18 @@ export const env = createEnv({
.refine((s) => s === "true" || s === "false")
.transform((s) => s === "true")
.default("false"),
/**
* Include the languages that should be hidden for the next release
* Should be in the format of "en,da,de,fi,no,sv" or empty
*/
NEW_SITE_LIVE_FOR_LANGS: z
.string()
.regex(/^([a-z]{2},)*([a-z]{2}){0,1}$/)
.transform((val) => {
return val.split(",")
})
.default(""),
SALESFORCE_PREFERENCE_BASE_URL: z.string(),
},
emptyStringAsUndefined: true,
runtimeEnv: {
@@ -70,5 +86,12 @@ export const env = createEnv({
SENTRY_ENVIRONMENT: process.env.SENTRY_ENVIRONMENT,
PUBLIC_URL: process.env.NEXT_PUBLIC_PUBLIC_URL,
PRINT_QUERY: process.env.PRINT_QUERY,
NEW_SITE_LIVE_FOR_LANGS: process.env.NEXT_PUBLIC_NEW_SITE_LIVE_FOR_LANGS,
SALESFORCE_PREFERENCE_BASE_URL: process.env.SALESFORCE_PREFERENCE_BASE_URL,
},
})
export const env = {
..._env,
isLangLive: (lang: Lang) => isLangLive(lang, _env.NEW_SITE_LIVE_FOR_LANGS),
} as const

View File

@@ -0,0 +1,18 @@
import { describe, expect, it } from "vitest"
import { Lang } from "@scandic-hotels/common/constants/language"
import { isLangLive } from "./isLangLive"
describe("hideForNextRelease", () => {
it("should return true if en is part of live languages", () => {
expect(isLangLive(Lang.en, ["en", "sv"])).toBe(true)
expect(isLangLive(Lang.en, ["en"])).toBe(true)
})
it("should return false if en is not part of live languages", () => {
expect(isLangLive(Lang.en, [])).toBe(false)
expect(isLangLive(Lang.en, ["sv"])).toBe(false)
expect(isLangLive(Lang.en, ["sv,fi"])).toBe(false)
})
})

View File

@@ -0,0 +1,5 @@
import type { Lang } from "@scandic-hotels/common/constants/language"
export function isLangLive(lang: Lang, liveLangs: string[]): boolean {
return liveLangs.includes(lang)
}

View File

@@ -1,4 +1,7 @@
import type { Lang } from "@scandic-hotels/common/constants/language"
import type { MembershipLevel } from "@scandic-hotels/common/constants/membershipLevels"
import type { LoginType } from "../types/loginType"
export type TrackingPageData = {
pageId: string
@@ -25,3 +28,19 @@ type TrackingSDKChannel =
| "hotels"
| "homepage"
| "campaign-overview-page"
export type TrackingUserData =
| {
loginStatus: "logged in"
loginType?: LoginType
memberId?: string
membershipNumber?: string
memberLevel?: MembershipLevel
noOfNightsStayed?: number
totalPointsAvailableToSpend?: number
loginAction?: "login success"
}
| {
loginStatus: "Non-logged in"
}
| { loginStatus: "Error" }

View File

@@ -56,3 +56,17 @@ function isEurobonusMembership(
export function getEurobonusMembership(loyalty: UserLoyalty) {
return loyalty.memberships?.find(isEurobonusMembership)
}
export function getMembershipCards(userLoyalty: UserLoyalty) {
return userLoyalty.memberships
.filter(
(membership) => membership.type !== scandicMembershipTypes.SCANDIC_NATIVE
)
.map((membership) => ({
currentPoints: 0, // We only have points for Friends so we can't set this for now
expirationDate: membership.tierExpires,
membershipNumber: membership.membershipNumber,
membershipType: membership.type,
memberSince: membership.memberSince,
}))
}

View File

@@ -2,7 +2,7 @@ import { z } from "zod"
import { Lang } from "@scandic-hotels/common/constants/language"
import { signUpSchema } from "@/components/Forms/Signup/schema"
import { signUpSchema } from "./schemas"
// Query
export const userTrackingInput = z.object({

View File

@@ -1,25 +1,18 @@
import { signupVerify } from "@scandic-hotels/common/constants/routes/signup"
import { createCounter } from "@scandic-hotels/common/telemetry"
import { router } from "@scandic-hotels/trpc"
import * as api from "@scandic-hotels/trpc/api"
import { serverErrorByStatus } from "@scandic-hotels/trpc/errors"
import {
protectedProcedure,
serviceProcedure,
} from "@scandic-hotels/trpc/procedures"
import { signupVerify } from "@/constants/routes/signup"
import { env } from "@/env/server"
import {
initiateSaveCardSchema,
subscriberIdSchema,
} from "@/server/routers/user/output"
import { env } from "../../../env/server"
import { router } from "../.."
import * as api from "../../api"
import { serverErrorByStatus } from "../../errors"
import { protectedProcedure, serviceProcedure } from "../../procedures"
import {
addCreditCardInput,
deleteCreditCardInput,
saveCreditCardInput,
signupInput,
} from "./input"
import { initiateSaveCardSchema, subscriberIdSchema } from "./output"
export const userMutationRouter = router({
creditCard: router({

View File

@@ -1,6 +1,7 @@
import { z } from "zod"
import { countriesMap } from "../../constants/countries"
import { imageSchema } from "../../routers/hotels/schemas/image"
import { getFriendsMembership } from "./helpers"
const scandicFriendsTier = z.enum(["L1", "L2", "L3", "L4", "L5", "L6", "L7"])
@@ -143,3 +144,134 @@ export const creditCardSchema = z
export const creditCardsSchema = z.object({
data: z.array(creditCardSchema),
})
// Schema is the same for upcoming and previous stays endpoints
export const getStaysSchema = z.object({
data: z.array(
z.object({
attributes: z.object({
hotelOperaId: z.string(),
hotelInformation: z.object({
hotelContent: z.object({
images: imageSchema,
}),
hotelName: z.string(),
cityName: z.string().nullable(),
}),
confirmationNumber: z.string(),
checkinDate: z.string(),
checkoutDate: z.string(),
isWebAppOrigin: z.boolean(),
bookingUrl: z.string().default(""),
}),
relationships: z.object({
hotel: z.object({
links: z.object({
related: z.string().nullable().optional(),
}),
data: z.object({
id: z.string(),
type: z.string(),
}),
}),
}),
type: z.string(),
id: z.string(),
links: z.object({
self: z.object({
href: z.string(),
meta: z.object({
method: z.string(),
}),
}),
}),
})
),
links: z
.object({
self: z.string(),
offset: z.number(),
limit: z.number(),
totalCount: z.number(),
})
.optional()
.nullable(),
})
type GetStaysData = z.infer<typeof getStaysSchema>
export type Stay = GetStaysData["data"][number]
export const getFriendTransactionsSchema = z.object({
data: z.array(
z.object({
attributes: z.object({
awardPoints: z.number().default(0),
checkinDate: z.string().default(""),
checkoutDate: z.string().default(""),
confirmationNumber: z.string().default(""),
hotelOperaId: z.string().default(""),
nights: z.number().default(1),
pointsCalculated: z.boolean().default(true),
transactionDate: z.string().default(""),
bookingUrl: z.string().default(""),
hotelInformation: z
.object({
city: z.string().default(""),
name: z.string().default(""),
hotelContent: z.object({
images: imageSchema,
}),
})
.optional(),
}),
relationships: z.object({
booking: z.object({
data: z.object({
id: z.string().default(""),
type: z.string().default(""),
}),
links: z.object({
related: z.string().default(""),
}),
}),
hotel: z
.object({
data: z.object({
id: z.string().default(""),
type: z.string().default(""),
}),
links: z.object({
related: z.string().default(""),
}),
})
.optional(),
}),
type: z.string().default(""),
})
),
links: z
.object({
self: z.string(),
})
.nullable(),
})
type GetFriendTransactionsData = z.infer<typeof getFriendTransactionsSchema>
export type FriendTransaction = GetFriendTransactionsData["data"][number]
export const initiateSaveCardSchema = z.object({
data: z.object({
attribute: z.object({
transactionId: z.string(),
link: z.string(),
mobileToken: z.string().optional(),
}),
type: z.string(),
}),
})
export const subscriberIdSchema = z.object({
subscriberId: z.string(),
})

View File

@@ -6,13 +6,15 @@ import {
protectedProcedure,
safeProtectedProcedure,
} from "@scandic-hotels/trpc/procedures"
import { getFriendsMembership } from "@scandic-hotels/trpc/routers/user/helpers"
import {
getFriendsMembership,
getMembershipCards,
} from "@scandic-hotels/trpc/routers/user/helpers"
import { getVerifiedUser } from "@scandic-hotels/trpc/routers/user/utils"
import { toApiLang } from "@scandic-hotels/trpc/utils"
import { isValidSession } from "@scandic-hotels/trpc/utils/session"
import { getMembershipCards } from "@/utils/user"
import { Transactions } from "../../enums/transactions"
import {
friendTransactionsInput,
getSavedPaymentCardsInput,
@@ -30,11 +32,7 @@ import {
import type { LoginType } from "@scandic-hotels/trpc/types/loginType"
import type {
// LoginType,
TrackingSDKUserData,
} from "@/types/components/tracking"
import { Transactions } from "@/types/enums/transactions"
import type { TrackingUserData } from "../types"
export const userQueryRouter = router({
get: protectedProcedure
@@ -148,7 +146,7 @@ export const userQueryRouter = router({
metricsUserTrackingInfo.start()
const notLoggedInUserTrackingData: TrackingSDKUserData = {
const notLoggedInUserTrackingData: TrackingUserData = {
loginStatus: "Non-logged in",
}
@@ -190,7 +188,7 @@ export const userQueryRouter = router({
const membership = getFriendsMembership(verifiedUserData.data.loyalty)
const loggedInUserTrackingData: TrackingSDKUserData = {
const loggedInUserTrackingData: TrackingUserData = {
loginStatus: "logged in",
loginType: ctx.session.token.loginType as LoginType,
memberId: verifiedUserData.data.profileId,

View File

@@ -1,7 +1,7 @@
import { z } from "zod"
import { passwordValidator } from "@/utils/zod/passwordValidator"
import { phoneValidator } from "@/utils/zod/phoneValidator"
import { passwordValidator } from "@scandic-hotels/common/utils/zod/passwordValidator"
import { phoneValidator } from "@scandic-hotels/common/utils/zod/phoneValidator"
export const signupErrors = {
COUNTRY_REQUIRED: "COUNTRY_REQUIRED",

View File

@@ -1,12 +1,27 @@
import { myStay } from "@scandic-hotels/common/constants/routes/myStay"
import { dt } from "@scandic-hotels/common/dt"
import { createCounter } from "@scandic-hotels/common/telemetry"
import * as maskValue from "@scandic-hotels/common/utils/maskValue"
import { getCurrentWebUrl } from "@scandic-hotels/common/utils/url"
import { env } from "../../../env/server"
import * as api from "../../api"
import { countries } from "../../constants/countries"
import { cache } from "../../DUPLICATED/cache"
import { getFriendsMembership } from "../../routers/user/helpers"
import { creditCardsSchema } from "../../routers/user/output"
import { toApiLang } from "../../utils"
import { encrypt } from "../../utils/encryption"
import { isValidSession } from "../../utils/session"
import { getUserSchema } from "./output"
import { type FriendTransaction, getStaysSchema, type Stay } from "./output"
import type { Lang } from "@scandic-hotels/common/constants/language"
import type { LangRoute } from "@scandic-hotels/common/constants/routes/langRoute"
import type { Session } from "next-auth"
import type { User } from "../../types/user"
export async function getMembershipNumber(
session: Session | null
): Promise<string | undefined> {
@@ -91,3 +106,279 @@ export const getVerifiedUser = cache(
return verifiedData
}
)
export async function getPreviousStays(
accessToken: string,
limit: number = 10,
language: Lang,
cursor?: string
) {
const getPreviousStaysCounter = createCounter("user", "getPreviousStays")
const metricsGetPreviousStays = getPreviousStaysCounter.init({
limit,
cursor,
language,
})
metricsGetPreviousStays.start()
const params: Record<string, string> = {
limit: String(limit),
language: toApiLang(language),
}
if (cursor) {
params.offset = cursor
}
const apiResponse = await api.get(
api.endpoints.v1.Booking.Stays.past,
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
},
params
)
if (!apiResponse.ok) {
await metricsGetPreviousStays.httpError(apiResponse)
return null
}
const apiJson = await apiResponse.json()
const verifiedData = getStaysSchema.safeParse(apiJson)
if (!verifiedData.success) {
metricsGetPreviousStays.validationError(verifiedData.error)
return null
}
metricsGetPreviousStays.success()
return verifiedData.data
}
export async function getUpcomingStays(
accessToken: string,
limit: number = 10,
language: Lang,
cursor?: string
) {
const getUpcomingStaysCounter = createCounter("user", "getUpcomingStays")
const metricsGetUpcomingStays = getUpcomingStaysCounter.init({
limit,
cursor,
language,
})
metricsGetUpcomingStays.start()
const params: Record<string, string> = {
limit: String(limit),
language: toApiLang(language),
}
if (cursor) {
params.offset = cursor
}
const apiResponse = await api.get(
api.endpoints.v1.Booking.Stays.future,
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
},
params
)
if (!apiResponse.ok) {
await metricsGetUpcomingStays.httpError(apiResponse)
return null
}
const apiJson = await apiResponse.json()
const verifiedData = getStaysSchema.safeParse(apiJson)
if (!verifiedData.success) {
metricsGetUpcomingStays.validationError(verifiedData.error)
return null
}
metricsGetUpcomingStays.success()
return verifiedData.data
}
export function parsedUser(data: User, isMFA: boolean) {
const country = countries.find((c) => c.code === data.address?.countryCode)
const user = {
address: {
city: data.address?.city,
country: country?.name ?? "",
countryCode: data.address?.countryCode,
streetAddress: data.address?.streetAddress,
zipCode: data.address?.zipCode,
},
dateOfBirth: data.dateOfBirth,
email: data.email,
firstName: data.firstName,
language: data.language,
lastName: data.lastName,
membershipNumber: data.membershipNumber,
membership: data.loyalty ? getFriendsMembership(data.loyalty) : null,
loyalty: data.loyalty,
name: `${data.firstName} ${data.lastName}`,
phoneNumber: data.phoneNumber,
profileId: data.profileId,
}
if (!isMFA) {
if (user.address.city) {
user.address.city = maskValue.text(user.address.city)
}
if (user.address.streetAddress) {
user.address.streetAddress = maskValue.text(user.address.streetAddress)
}
user.address.zipCode = data.address?.zipCode
? maskValue.text(data.address.zipCode)
: ""
user.dateOfBirth = maskValue.all(user.dateOfBirth)
user.email = maskValue.email(user.email)
user.phoneNumber = user.phoneNumber ? maskValue.phone(user.phoneNumber) : ""
}
return user
}
export const getCreditCards = cache(
async ({
session,
onlyNonExpired,
}: {
session: Session
onlyNonExpired?: boolean
}) => {
const getCreditCardsCounter = createCounter("user", "getCreditCards")
const metricsGetCreditCards = getCreditCardsCounter.init({
onlyNonExpired,
})
metricsGetCreditCards.start()
const apiResponse = await api.get(api.endpoints.v1.Profile.creditCards, {
headers: {
Authorization: `Bearer ${session.token.access_token}`,
},
})
if (!apiResponse.ok) {
await metricsGetCreditCards.httpError(apiResponse)
return null
}
const apiJson = await apiResponse.json()
const verifiedData = creditCardsSchema.safeParse(apiJson)
if (!verifiedData.success) {
metricsGetCreditCards.validationError(verifiedData.error)
return null
}
const result = verifiedData.data.data.filter((card) => {
if (onlyNonExpired) {
try {
const expirationDate = dt(card.expirationDate).startOf("day")
const currentDate = dt().startOf("day")
return expirationDate > currentDate
} catch (_) {
return false
}
}
return true
})
metricsGetCreditCards.success()
return result
}
)
export async function updateStaysBookingUrl(
data: Stay[],
session: Session,
lang: Lang
): Promise<Stay[]>
export async function updateStaysBookingUrl(
data: FriendTransaction[],
session: Session,
lang: Lang
): Promise<FriendTransaction[]>
export async function updateStaysBookingUrl(
data: Stay[] | FriendTransaction[],
session: Session,
lang: Lang
) {
const user = await getVerifiedUser({
session,
})
if (user && !("error" in user)) {
return data.map((d) => {
const originalString =
d.attributes.confirmationNumber.toString() + "," + user.data.lastName
const encryptedBookingValue = encrypt(originalString)
// Get base URL with fallback for ephemeral environments (like deploy previews).
const baseUrl = env.PUBLIC_URL || "https://www.scandichotels.com"
// Construct Booking URL.
const bookingUrl = !env.isLangLive(lang)
? new URL(
getCurrentWebUrl({
path: myBookingPath[lang],
lang,
baseUrl,
})
)
: new URL(myStay[lang], baseUrl)
// Add search parameters.
if (encryptedBookingValue) {
bookingUrl.searchParams.set("RefId", encryptedBookingValue)
} else {
bookingUrl.searchParams.set("lastName", user.data.lastName)
bookingUrl.searchParams.set(
"bookingId",
d.attributes.confirmationNumber.toString()
)
}
return {
...d,
attributes: {
...d.attributes,
bookingUrl: bookingUrl.toString(),
},
}
})
}
return data
}
export const myBookingPath: LangRoute = {
da: "/hotelreservation/min-booking",
de: "/hotelreservation/my-booking",
en: "/hotelreservation/my-booking",
fi: "/varaa-hotelli/varauksesi",
no: "/hotelreservation/my-booking",
sv: "/hotelreservation/din-bokning",
}