Merged in feat/sw-2873-move-selecthotel-to-booking-flow (pull request #2727)

feat(SW-2873): Move select-hotel to booking flow

* crude setup of select-hotel in partner-sas

* wip

* Fix linting

* restructure tracking files

* Remove dependency on trpc in tracking hooks

* Move pageview tracking to common

* Fix some lint and import issues

* Add AlternativeHotelsPage

* Add SelectHotelMapPage

* Add AlternativeHotelsMapPage

* remove next dependency in tracking store

* Remove dependency on react in tracking hooks

* move isSameBooking to booking-flow

* Inject searchParamsComparator into tracking store

* Move useTrackHardNavigation to common

* Move useTrackSoftNavigation to common

* Add TrackingSDK to partner-sas

* call serverclient in layout

* Remove unused css

* Update types

* Move HotelPin type

* Fix todos

* Merge branch 'master' into feat/sw-2873-move-selecthotel-to-booking-flow

* Merge branch 'master' into feat/sw-2873-move-selecthotel-to-booking-flow

* Fix component


Approved-by: Joakim Jäderberg
This commit is contained in:
Anton Gunnarsson
2025-09-01 08:37:00 +00:00
parent 93a90bef9d
commit 87402a2092
157 changed files with 2026 additions and 1376 deletions

View File

@@ -0,0 +1 @@
export const FamilyAndFriendsCodes = ["D000029555", "D000029271", "D000029195"]

View File

@@ -0,0 +1,7 @@
export enum LoginTypeEnum {
email = "email",
"membership number" = "membership number",
"email link" = "email link",
"dtmc" = "dtmc",
}
export type LoginType = keyof typeof LoginTypeEnum

View File

@@ -0,0 +1,5 @@
export enum RateEnum {
change = "change",
flex = "flex",
save = "save",
}

View File

@@ -11,40 +11,48 @@
"lint": "eslint . --max-warnings 0 && tsc --noEmit"
},
"exports": {
"./global.d.ts": "./global.d.ts",
"./dataCache": "./dataCache/index.ts",
"./telemetry": "./telemetry/index.ts",
"./tokenManager": "./tokenManager/index.ts",
"./tracking/base": "./tracking/base.ts",
"./dt": "./dt/dt.ts",
"./logger": "./logger/index.ts",
"./logger/*": "./logger/*.ts",
"./utils/isEdge": "./utils/isEdge.ts",
"./utils/safeTry": "./utils/safeTry.ts",
"./utils/url": "./utils/url.ts",
"./utils/languages": "./utils/languages.ts",
"./utils/chunk": "./utils/chunk.ts",
"./utils/isDefined": "./utils/isDefined.ts",
"./utils/maskValue": "./utils/maskValue.ts",
"./utils/dateFormatting": "./utils/dateFormatting.ts",
"./utils/numberFormatting": "./utils/numberFormatting.ts",
"./utils/rangeArray": "./utils/rangeArray.ts",
"./utils/zod/*": "./utils/zod/*.ts",
"./utils/debounce": "./utils/debounce.ts",
"./utils/isValidJson": "./utils/isValidJson.ts",
"./hooks/*": "./hooks/*.ts",
"./stores/*": "./stores/*.ts",
"./constants/alert": "./constants/alert.ts",
"./constants/currency": "./constants/currency.ts",
"./constants/dateFormats": "./constants/dateFormats.ts",
"./constants/facilities": "./constants/facilities.ts",
"./constants/familyAndFriends": "./constants/familyAndFriends.ts",
"./constants/hotelType": "./constants/hotelType.ts",
"./constants/language": "./constants/language.ts",
"./constants/loginType": "./constants/loginType.ts",
"./constants/membershipLevels": "./constants/membershipLevels.ts",
"./constants/paymentMethod": "./constants/paymentMethod.ts",
"./constants/rate": "./constants/rate.ts",
"./constants/rateType": "./constants/rateType.ts",
"./constants/routes/*": "./constants/routes/*.ts",
"./constants/signatureHotels": "./constants/signatureHotels.ts"
"./constants/signatureHotels": "./constants/signatureHotels.ts",
"./dataCache": "./dataCache/index.ts",
"./dt": "./dt/dt.ts",
"./global.d.ts": "./global.d.ts",
"./hooks/*": "./hooks/*.ts",
"./logger": "./logger/index.ts",
"./logger/*": "./logger/*.ts",
"./stores/*": "./stores/*.ts",
"./telemetry": "./telemetry/index.ts",
"./tokenManager": "./tokenManager/index.ts",
"./tracking/base": "./tracking/base.ts",
"./tracking/pageview": "./tracking/pageview.ts",
"./tracking/types": "./tracking/types.ts",
"./tracking/useTrackHardNavigation": "./tracking/useTrackHardNavigation.ts",
"./tracking/useTrackSoftNavigation": "./tracking/useTrackSoftNavigation.ts",
"./utils/chunk": "./utils/chunk.ts",
"./utils/dateFormatting": "./utils/dateFormatting.ts",
"./utils/debounce": "./utils/debounce.ts",
"./utils/isDefined": "./utils/isDefined.ts",
"./utils/isEdge": "./utils/isEdge.ts",
"./utils/isValidJson": "./utils/isValidJson.ts",
"./utils/languages": "./utils/languages.ts",
"./utils/maskValue": "./utils/maskValue.ts",
"./utils/numberFormatting": "./utils/numberFormatting.ts",
"./utils/rangeArray": "./utils/rangeArray.ts",
"./utils/safeTry": "./utils/safeTry.ts",
"./utils/url": "./utils/url.ts",
"./utils/promiseWithTimeout": "./utils/promiseWithTimeout.ts",
"./utils/zod/*": "./utils/zod/*.ts"
},
"dependencies": {
"@opentelemetry/api": "^1.9.0",

View File

@@ -0,0 +1,17 @@
"use client"
import { create } from "zustand"
interface RouterTransitionState {
isTransitioning: boolean
startRouterTransition: () => void
stopRouterTransition: () => void
}
const useRouterTransitionStore = create<RouterTransitionState>((set) => ({
isTransitioning: false,
startRouterTransition: () => set(() => ({ isTransitioning: true })),
stopRouterTransition: () => set(() => ({ isTransitioning: false })),
}))
export default useRouterTransitionStore

View File

@@ -0,0 +1,81 @@
"use client"
import { create } from "zustand"
interface TrackingStoreState {
initialStartTime: number
setInitialPageLoadTime: (time: number) => void
getPageLoadTime: () => number
currentParams: URLSearchParams | null
previousParams: URLSearchParams | null
currentPath: string | null
previousPath: string | null
currentLang: string | null
previousLang: string | null
updateRouteInfo: (path: string, lang: string, params: URLSearchParams) => void
hasPathOrLangChanged: () => boolean
hasBookingFlowParamsChanged: (
searchParamsComparator: (input: {
previousParams: URLSearchParams
currentParams: URLSearchParams
}) => boolean
) => boolean
}
const useTrackingStore = create<TrackingStoreState>((set, get) => ({
initialStartTime: Date.now(),
setInitialPageLoadTime: (time) => set({ initialStartTime: time }),
getPageLoadTime: () => {
const { initialStartTime } = get()
return (Date.now() - initialStartTime) / 1000
},
currentParams: null,
previousParams: null,
currentPath: null,
previousPath: null,
currentLang: null,
previousLang: null,
updateRouteInfo: (path, lang, params) =>
set((state) => {
if (!path || !lang) return state
if (!state.currentPath || !state.currentLang) {
return {
currentParams: params,
currentPath: path,
currentLang: lang,
previousParams: null,
previousPath: null,
previousLang: null,
}
}
return {
previousParams: state.currentParams,
previousPath: state.currentPath,
previousLang: state.currentLang,
currentParams: params,
currentPath: path,
currentLang: lang,
}
}),
hasPathOrLangChanged: () => {
const { currentPath, previousPath, currentLang, previousLang } = get()
if (!previousPath || !previousLang) return false
return currentPath !== previousPath || currentLang !== previousLang
},
hasBookingFlowParamsChanged: (searchParamsComparator) => {
const { currentPath, currentParams, previousParams } = get()
if (!previousParams || !currentParams) return false
if (!currentPath?.match(/^\/(da|de|en|fi|no|sv)\/(hotelreservation)/))
return false
return searchParamsComparator({ previousParams, currentParams })
},
}))
export default useTrackingStore

View File

@@ -0,0 +1,37 @@
import { trackEvent } from "./base"
import type { TrackingSDKData } from "./types"
function convertSlashToPipe(url: string) {
const formattedUrl = url.startsWith("/") ? url.slice(1) : url
return formattedUrl.replaceAll("/", "|")
}
export function trackPageViewStart() {
trackEvent({
event: "pageViewStart",
})
}
export function trackPageView(data: any) {
trackEvent(data)
}
export function createSDKPageObject(
trackingData: TrackingSDKData
): TrackingSDKData {
let pageName = convertSlashToPipe(trackingData.pageName)
let siteSections = convertSlashToPipe(trackingData.siteSections)
if (trackingData.pathName.indexOf("/webview/") > -1) {
pageName = "webview|" + pageName
siteSections = "webview|" + siteSections
}
return {
...trackingData,
domain: typeof window !== "undefined" ? window.location.host : "",
pageName: pageName,
siteSections: siteSections,
}
}

View File

@@ -0,0 +1,195 @@
import type { Lang } from "@scandic-hotels/common/constants/language"
import type { LoginType } from "@scandic-hotels/common/constants/loginType"
import type { MembershipLevel } from "@scandic-hotels/common/constants/membershipLevels"
import type { RateEnum } from "@scandic-hotels/common/constants/rate"
export enum TrackingChannelEnum {
"scandic-friends" = "scandic-friends",
"static-content-page" = "static-content-page",
"hotelreservation" = "hotelreservation",
"collection-page" = "collection-page",
"campaign-page" = "campaign-page",
"campaign-overview-page" = "campaign-overview-page",
"hotels" = "hotels",
"homepage" = "homepage",
}
export type TrackingChannel = keyof typeof TrackingChannelEnum
export type TrackingSDKPageData = {
pageId: string
createDate?: string
publishDate?: string
domainLanguage: Lang
pageType: string
channel: TrackingChannel
siteVersion: "new-web"
pageName: string
domain?: string
siteSections: string
pageLoadTime?: number // Page load time in seconds
lcpTime?: number // Largest contentful paint time in seconds
sessionId?: string | null
}
export type TrackingSDKUserData =
| {
loginStatus: "logged in"
loginType?: LoginType
memberId?: string
membershipNumber?: string
memberLevel?: MembershipLevel
noOfNightsStayed?: number
totalPointsAvailableToSpend?: number
loginAction?: "login success"
}
| {
loginStatus: "Non-logged in"
}
| { loginStatus: "Error" }
export type TrackingSDKAncillaries = Ancillary[]
export type TrackingSDKHotelInfo = {
ageOfChildren?: string // "10", "2,5,10"
analyticsRateCode?: RateEnum | string
arrivalDate?: string
availableResults?: number // Number of hotels to choose from after a city search
bedType?: string
bedTypePosition?: number // Which position the bed type had in the list of available bed types
bnr?: string // Booking number
breakfastOption?: string // "no breakfast" or "breakfast buffet"
//bonuscheque?: boolean
bookingCode?: string
bookingCodeAvailability?: string
bookingTypeofDay?: "weekend" | "weekday"
childBedPreference?: string
country?: string // Country of the hotel
departureDate?: string
discount?: number | string
duration?: number // Number of nights to stay
hotelID?: string
leadTime?: number // Number of days from booking date until arrivalDate
lowestRoomPrice?: number
multiroomRateIdentity?: string
//modifyValues?: string // <price:<value>,roomtype:value>,bed:<value,<breakfast:value>
noOfAdults?: number | string // multiroom support, "2,1,3"
noOfChildren?: number | string // multiroom support, "2,1,3"
noOfRooms?: number
rateCode?: string
rateCodeCancellationRule?: string
rateCodeName?: string // Scandic Friends - full flex inkl. frukost
rateCodeType?: string // regular, promotion etc
region?: string // Region of the hotel
revenueCurrencyCode?: string // SEK, DKK, NOK, EUR
rewardNight?: string
rewardNightAvailability?: string
points?: number | string // Should be sent on confirmation page and enter-details page
roomPrice?: number | string
roomTypeCode?: string
roomTypeName?: string
roomTypePosition?: number // Which position the room had in the list of available rooms
searchTerm?: string
searchType?: "destination" | "hotel"
specialRoomType?: string // allergy room, pet-friendly, accesibillity room
totalPrice?: number | string
lateArrivalGuarantee?: string
guaranteedProduct?: string
emailId?: string // Encrypted hash value on booking confirmation page
mobileNumber?: string // Encrypted hash value on booking confirmation page
}
export type Ancillary = {
productId: string
productUnits?: number
hotelid?: string
productPoints: number
productPrice: number
productType: string
productName: string
productCategory: string
}
export type TrackingSDKPaymentInfo = {
edccCurrencyFrom?: string
edccCurrencyTo?: string
isedcc?: string
isSavedCard?: boolean
isCreditCard?: boolean
paymentStatus?: "confirmed" | "glacardsaveconfirmed"
paymentType?: string
type?: string
status?: string
}
export type TrackingSDKData = TrackingSDKPageData & {
pathName: string
}
type BasePaymentEvent = {
event: string
hotelId: string | undefined
method?: string | null
isSavedCreditCard?: boolean
smsEnable?: boolean
status: "attempt" | "cancelled" | "failed"
}
export type PaymentAttemptStartEvent = BasePaymentEvent
export type PaymentCancelEvent = BasePaymentEvent
export type PaymentFailEvent = BasePaymentEvent & {
errorMessage?: string
}
export type PaymentEvent =
| PaymentAttemptStartEvent
| PaymentCancelEvent
| PaymentFailEvent
export type LowestRoomPriceEvent = {
hotelId: string | null
arrivalDate: string | null
departureDate: string | null
lowestPrice: string
currency?: string
}
// Old tracking setup types:
// TODO: Remove this when we delete "current site"
export type TrackingProps = {
pageData: {
pageId: string
createdDate: string
publishedDate: string
englishUrl?: string
lang: Lang
}
}
export type TrackingData = {
lang: Lang
englishUrl?: string
pathName: string
queryString: string
pageId: string
publishedDate: string
createdDate: string
}
export type SiteSectionObject = {
sitesection1: string
sitesection2: string
sitesection3: string
sitesection4: string
sitesection5: string
sitesection6: string
}
export type TrackingPosition =
| "top menu"
| "hamburger menu"
| "join scandic friends sidebar"
| "enter details"
| "my stay"

View File

@@ -0,0 +1,166 @@
"use client"
import { useEffect } from "react"
import { useSessionId } from "../hooks/useSessionId"
import { logger } from "../logger"
import { createSDKPageObject, trackPageView } from "../tracking/pageview"
import { promiseWithTimeout } from "../utils/promiseWithTimeout"
import type {
TrackingSDKAncillaries,
TrackingSDKHotelInfo,
TrackingSDKPageData,
TrackingSDKPaymentInfo,
TrackingSDKUserData,
} from "../tracking/types"
type TrackingSDKProps = {
pageData: TrackingSDKPageData
hotelInfo?: TrackingSDKHotelInfo
paymentInfo?: TrackingSDKPaymentInfo
ancillaries?: TrackingSDKAncillaries
userData: TrackingSDKUserData | undefined
pathName: string
}
let hasTrackedHardNavigation = false
export const useTrackHardNavigation = ({
pageData,
hotelInfo,
paymentInfo,
ancillaries,
userData,
pathName,
}: TrackingSDKProps) => {
const sessionId = useSessionId()
useEffect(() => {
if (!userData) {
return
}
if (hasTrackedHardNavigation) {
return
}
hasTrackedHardNavigation = true
const track = () => {
trackPerformance({
pathName,
sessionId,
paymentInfo,
hotelInfo,
userData,
pageData,
ancillaries,
})
}
if (document.readyState === "complete") {
track()
return
}
window.addEventListener("load", track)
return () => window.removeEventListener("load", track)
}, [
pathName,
hotelInfo,
pageData,
sessionId,
paymentInfo,
userData,
ancillaries,
])
}
const trackPerformance = async ({
pathName,
sessionId,
paymentInfo,
hotelInfo,
userData,
pageData,
ancillaries,
}: {
pathName: string
sessionId: string | null
paymentInfo: TrackingSDKProps["paymentInfo"]
hotelInfo: TrackingSDKProps["hotelInfo"]
userData: TrackingSDKUserData
pageData: TrackingSDKProps["pageData"]
ancillaries: TrackingSDKProps["ancillaries"]
}) => {
let pageLoadTime: number | undefined = undefined
let lcpTime: number | undefined = undefined
try {
pageLoadTime = await promiseWithTimeout(getPageLoadTimeEntry(), 3000)
} catch (error) {
logger.error("Error obtaining pageLoadTime:", error)
}
try {
lcpTime = await promiseWithTimeout(getLCPTimeEntry(), 3000)
} catch (error) {
logger.error("Error obtaining lcpTime:", error)
}
const trackingData = {
...pageData,
sessionId,
pathName,
pageLoadTime,
lcpTime,
}
const pageObject = createSDKPageObject(trackingData)
trackPageView({
event: "pageView",
pageInfo: pageObject,
userInfo: userData,
hotelInfo,
paymentInfo,
ancillaries,
})
}
const getLCPTimeEntry = () => {
return new Promise<number | undefined>((resolve) => {
const observer = new PerformanceObserver((entries) => {
const lastEntry = entries.getEntries().at(-1)
if (lastEntry) {
observer.disconnect()
resolve(lastEntry.startTime / 1000)
}
})
const lcpSupported = PerformanceObserver.supportedEntryTypes?.includes(
"largest-contentful-paint"
)
if (lcpSupported) {
observer.observe({
type: "largest-contentful-paint",
buffered: true,
})
} else {
resolve(undefined)
}
})
}
const getPageLoadTimeEntry = () => {
return new Promise<number>((resolve) => {
const observer = new PerformanceObserver((entries) => {
const navEntry = entries.getEntriesByType("navigation")[0]
if (navEntry) {
observer.disconnect()
resolve(navEntry.duration / 1000)
}
})
observer.observe({ type: "navigation", buffered: true })
})
}

View File

@@ -0,0 +1,106 @@
"use client"
import { startTransition, useEffect, useRef, useState } from "react"
import { useSessionId } from "../hooks/useSessionId"
import useRouterTransitionStore from "../stores/router-transition"
import useTrackingStore from "../stores/tracking"
import { createSDKPageObject, trackPageView } from "./pageview"
import type {
TrackingSDKAncillaries,
TrackingSDKHotelInfo,
TrackingSDKPageData,
TrackingSDKPaymentInfo,
TrackingSDKUserData,
} from "./types"
type TrackingSDKProps = {
pageData: TrackingSDKPageData
hotelInfo?: TrackingSDKHotelInfo
paymentInfo?: TrackingSDKPaymentInfo
ancillaries?: TrackingSDKAncillaries
userData: TrackingSDKUserData | undefined
pathName: string
}
enum TransitionStatusEnum {
NotRun = "NotRun",
Running = "Running",
Done = "Done",
}
type TransitionStatus = keyof typeof TransitionStatusEnum
export const useTrackSoftNavigation = ({
pageData,
hotelInfo,
paymentInfo,
ancillaries,
userData,
pathName,
}: TrackingSDKProps) => {
const [status, setStatus] = useState<TransitionStatus>(
TransitionStatusEnum.NotRun
)
const { getPageLoadTime } = useTrackingStore()
const sessionId = useSessionId()
const { isTransitioning, stopRouterTransition } = useRouterTransitionStore()
const previousPathname = useRef<string | null>(null)
useEffect(() => {
if (!userData) {
return
}
if (isTransitioning && status === TransitionStatusEnum.NotRun) {
startTransition(() => {
setStatus(TransitionStatusEnum.Running)
})
return
}
if (isTransitioning && status === TransitionStatusEnum.Running) {
setStatus(TransitionStatusEnum.Done)
stopRouterTransition()
return
}
if (!isTransitioning && status === TransitionStatusEnum.Done) {
const pageLoadTime = getPageLoadTime()
const trackingData = {
...pageData,
sessionId,
pathName,
pageLoadTime: pageLoadTime,
}
const pageObject = createSDKPageObject(trackingData)
trackPageView({
event: "pageView",
pageInfo: pageObject,
userInfo: userData,
hotelInfo: hotelInfo,
paymentInfo,
ancillaries,
})
setStatus(TransitionStatusEnum.NotRun) // Reset status
previousPathname.current = pathName // Update for next render
}
}, [
isTransitioning,
status,
stopRouterTransition,
pageData,
pathName,
hotelInfo,
getPageLoadTime,
sessionId,
paymentInfo,
userData,
ancillaries,
])
}

View File

@@ -0,0 +1,12 @@
export const promiseWithTimeout = <T>(
promise: Promise<T>,
timeoutMs: number,
fallbackValue: T | undefined = undefined
) => {
return Promise.race([
promise,
new Promise<T | undefined>((resolve) =>
setTimeout(() => resolve(fallbackValue), timeoutMs)
),
])
}