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