Merged in chore/move-enter-details (pull request #2778)
Chore/move enter details Approved-by: Anton Gunnarsson
This commit is contained in:
2
packages/tracking/.env.test
Normal file
2
packages/tracking/.env.test
Normal file
@@ -0,0 +1,2 @@
|
||||
NODE_ENV="development"
|
||||
BRANCH="test"
|
||||
40
packages/tracking/.gitignore
vendored
Normal file
40
packages/tracking/.gitignore
vendored
Normal file
@@ -0,0 +1,40 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
.yarn/install-state.gz
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
#vscode
|
||||
.vscode/
|
||||
|
||||
#cursor
|
||||
.cursorrules
|
||||
|
||||
# Yarn
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
.yarn/releases
|
||||
89
packages/tracking/eslint.config.mjs
Normal file
89
packages/tracking/eslint.config.mjs
Normal file
@@ -0,0 +1,89 @@
|
||||
import { FlatCompat } from "@eslint/eslintrc"
|
||||
import js from "@eslint/js"
|
||||
import typescriptEslint from "@typescript-eslint/eslint-plugin"
|
||||
import tsParser from "@typescript-eslint/parser"
|
||||
import { defineConfig } from "eslint/config"
|
||||
import simpleImportSort from "eslint-plugin-simple-import-sort"
|
||||
import importPlugin from "eslint-plugin-import"
|
||||
|
||||
const compat = new FlatCompat({
|
||||
recommendedConfig: js.configs.recommended,
|
||||
allConfig: js.configs.all,
|
||||
})
|
||||
|
||||
export default defineConfig([
|
||||
{
|
||||
files: ["**/*.ts", "**/*.tsx"],
|
||||
extends: compat.extends("plugin:import/typescript"),
|
||||
plugins: {
|
||||
"simple-import-sort": simpleImportSort,
|
||||
"@typescript-eslint": typescriptEslint,
|
||||
import: importPlugin,
|
||||
},
|
||||
|
||||
linterOptions: {
|
||||
reportUnusedDisableDirectives: true,
|
||||
},
|
||||
|
||||
languageOptions: {
|
||||
parser: tsParser,
|
||||
},
|
||||
|
||||
rules: {
|
||||
"no-unused-vars": "off",
|
||||
"import/no-relative-packages": "error",
|
||||
"simple-import-sort/imports": [
|
||||
"warn",
|
||||
{
|
||||
groups: [
|
||||
["^\\u0000"],
|
||||
["^node:"],
|
||||
["^@?\\w"],
|
||||
["^@scandic-hotels/(?!.*\u0000$).*$"],
|
||||
[
|
||||
"^@/constants/?(?!.*\u0000$).*$",
|
||||
"^@/env/?(?!.*\u0000$).*$",
|
||||
"^@/lib/?(?!.*\u0000$).*$",
|
||||
"^@/server/?(?!.*\u0000$).*$",
|
||||
"^@/stores/?(?!.*\u0000$).*$",
|
||||
],
|
||||
["^@/(?!(types|.*\u0000$)).*$"],
|
||||
[
|
||||
"^\\.\\.(?!/?$)",
|
||||
"^\\.\\./?$",
|
||||
"^\\./(?=.*/)(?!/?$)",
|
||||
"^\\.(?!/?$)",
|
||||
"^\\./?$",
|
||||
],
|
||||
["^(?!\\u0000).+\\.s?css$"],
|
||||
["^node:.*\\u0000$", "^@?\\w.*\\u0000$"],
|
||||
[
|
||||
"^@scandichotels/.*\\u0000$",
|
||||
"^@/types/.*",
|
||||
"^@/.*\\u0000$",
|
||||
"^[^.].*\\u0000$",
|
||||
"^\\..*\\u0000$",
|
||||
],
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
"simple-import-sort/exports": "error",
|
||||
|
||||
"@typescript-eslint/consistent-type-imports": "error",
|
||||
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"error",
|
||||
{
|
||||
args: "all",
|
||||
argsIgnorePattern: "^_",
|
||||
caughtErrors: "all",
|
||||
caughtErrorsIgnorePattern: "^_",
|
||||
destructuredArrayIgnorePattern: "^_",
|
||||
varsIgnorePattern: "^_",
|
||||
ignoreRestSiblings: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
])
|
||||
4
packages/tracking/global.d.ts
vendored
Normal file
4
packages/tracking/global.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
import "@scandic-hotels/common/global.d.ts"
|
||||
import "@scandic-hotels/trpc/types.d.ts"
|
||||
import "@scandic-hotels/trpc/auth.d.ts"
|
||||
import "@scandic-hotels/trpc/jwt.d.ts"
|
||||
60
packages/tracking/lib/TrackingSDK.tsx
Normal file
60
packages/tracking/lib/TrackingSDK.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
"use client"
|
||||
|
||||
import { usePathname } from "next/navigation"
|
||||
|
||||
import { trpc } from "@scandic-hotels/trpc/client"
|
||||
|
||||
import useLang from "./hooks/useLang"
|
||||
import { useTrackHardNavigation } from "./useTrackHardNavigation"
|
||||
import { useTrackSoftNavigation } from "./useTrackSoftNavigation"
|
||||
|
||||
import type {
|
||||
TrackingSDKAncillaries,
|
||||
TrackingSDKHotelInfo,
|
||||
TrackingSDKPageData,
|
||||
TrackingSDKPaymentInfo,
|
||||
TrackingSDKUserData,
|
||||
} from "./types"
|
||||
|
||||
export function TrackingSDK({
|
||||
pageData,
|
||||
hotelInfo,
|
||||
paymentInfo,
|
||||
ancillaries,
|
||||
}: {
|
||||
pageData: TrackingSDKPageData
|
||||
hotelInfo?: TrackingSDKHotelInfo
|
||||
paymentInfo?: TrackingSDKPaymentInfo
|
||||
ancillaries?: TrackingSDKAncillaries
|
||||
}) {
|
||||
const pathName = usePathname()
|
||||
const lang = useLang()
|
||||
|
||||
const { data, isError } = trpc.user.userTrackingInfo.useQuery({
|
||||
lang,
|
||||
})
|
||||
|
||||
const userData: TrackingSDKUserData =
|
||||
!data || isError
|
||||
? ({ loginStatus: "Error" } as const)
|
||||
: { ...data, memberType: "scandic-friends" }
|
||||
|
||||
useTrackHardNavigation({
|
||||
pageData,
|
||||
hotelInfo,
|
||||
paymentInfo,
|
||||
ancillaries,
|
||||
userData,
|
||||
pathName,
|
||||
})
|
||||
useTrackSoftNavigation({
|
||||
pageData,
|
||||
hotelInfo,
|
||||
paymentInfo,
|
||||
ancillaries,
|
||||
userData,
|
||||
pathName,
|
||||
})
|
||||
|
||||
return null
|
||||
}
|
||||
41
packages/tracking/lib/base.ts
Normal file
41
packages/tracking/lib/base.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
"use client"
|
||||
|
||||
import { SESSION_ID_KEY_NAME } from "@scandic-hotels/common/hooks/useSessionId"
|
||||
import { logger } from "@scandic-hotels/common/logger"
|
||||
|
||||
export function trackEvent(data: any) {
|
||||
if (
|
||||
typeof window !== "undefined" &&
|
||||
window.adobeDataLayer &&
|
||||
window.dataLayer
|
||||
) {
|
||||
let sessionId = ""
|
||||
|
||||
try {
|
||||
sessionId = sessionStorage.getItem(SESSION_ID_KEY_NAME) ?? ""
|
||||
} catch (e) {
|
||||
logger.error("Error getting sessionId from sessionStorage", e)
|
||||
}
|
||||
|
||||
data = {
|
||||
...data,
|
||||
pageInfo: { ...data?.pageInfo, siteVersion: "new-web", sessionId },
|
||||
}
|
||||
|
||||
window.adobeDataLayer.push(data)
|
||||
window.dataLayer.push(data)
|
||||
}
|
||||
}
|
||||
|
||||
export function trackClick(
|
||||
name: string,
|
||||
additionalParams?: Record<string, string>
|
||||
) {
|
||||
trackEvent({
|
||||
event: "linkClick",
|
||||
cta: {
|
||||
...additionalParams,
|
||||
name,
|
||||
},
|
||||
})
|
||||
}
|
||||
103
packages/tracking/lib/form.ts
Normal file
103
packages/tracking/lib/form.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { trackEvent } from "./base"
|
||||
|
||||
export type FormType = "checkout" | "signup"
|
||||
|
||||
export function trackFormInputStarted(type: FormType, nameSuffix?: string) {
|
||||
if (type === "checkout") {
|
||||
trackEvent({
|
||||
event: "formStart",
|
||||
form: {
|
||||
action: "checkout form start",
|
||||
name: "checkout enter detail" + nameSuffix,
|
||||
type: type,
|
||||
},
|
||||
})
|
||||
} else if (type === "signup") {
|
||||
trackEvent({
|
||||
event: "formStart",
|
||||
form: {
|
||||
action: "signup form start",
|
||||
name: "member registration" + nameSuffix,
|
||||
type: type,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function trackFormAbandonment(
|
||||
type: FormType,
|
||||
lastAccessedField: string,
|
||||
nameSuffix?: string
|
||||
) {
|
||||
if (type === "checkout") {
|
||||
trackEvent({
|
||||
event: "formAbandonment",
|
||||
form: {
|
||||
action: "checkout form abandonment",
|
||||
name: "checkout enter detail" + nameSuffix,
|
||||
type: type,
|
||||
lastAccessedField,
|
||||
},
|
||||
})
|
||||
} else if (type === "signup") {
|
||||
trackEvent({
|
||||
event: "formAbandonment",
|
||||
form: {
|
||||
action: "signup form abandonment",
|
||||
name: "member registration" + nameSuffix,
|
||||
type: type,
|
||||
lastAccessedField,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function trackFormValidationError(
|
||||
type: FormType,
|
||||
errorMessage: string,
|
||||
nameSuffix?: string
|
||||
) {
|
||||
if (type === "checkout") {
|
||||
trackEvent({
|
||||
event: "formError",
|
||||
form: {
|
||||
action: "checkout form error",
|
||||
name: "checkout enter detail" + nameSuffix,
|
||||
type: type,
|
||||
errorMessage,
|
||||
},
|
||||
})
|
||||
} else if (type === "signup") {
|
||||
trackEvent({
|
||||
event: "formError",
|
||||
form: {
|
||||
action: "signup form error",
|
||||
name: "member registration" + nameSuffix,
|
||||
type: type,
|
||||
errorMessage,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function trackFormCompletion(type: FormType, nameSuffix?: string) {
|
||||
if (type === "checkout") {
|
||||
trackEvent({
|
||||
event: "formCompletion",
|
||||
form: {
|
||||
action: "checkout form completion",
|
||||
name: "checkout enter detail" + nameSuffix,
|
||||
type: type,
|
||||
},
|
||||
})
|
||||
} else if (type === "signup") {
|
||||
trackEvent({
|
||||
event: "formCompletion",
|
||||
form: {
|
||||
action: "signup form completion",
|
||||
name: "member registration" + nameSuffix,
|
||||
type: type,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
17
packages/tracking/lib/hooks/useLang.ts
Normal file
17
packages/tracking/lib/hooks/useLang.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
"use client"
|
||||
import { useParams } from "next/navigation"
|
||||
|
||||
import { Lang } from "@scandic-hotels/common/constants/language"
|
||||
import { languageSchema } from "@scandic-hotels/common/utils/languages"
|
||||
|
||||
/**
|
||||
* A hook to get the current lang from the URL
|
||||
*/
|
||||
export default function useLang() {
|
||||
const { lang } = useParams<{
|
||||
lang: Lang
|
||||
}>()
|
||||
|
||||
const parsedLang = languageSchema.safeParse(lang)
|
||||
return parsedLang.success ? parsedLang.data : Lang.en
|
||||
}
|
||||
37
packages/tracking/lib/pageview.ts
Normal file
37
packages/tracking/lib/pageview.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
205
packages/tracking/lib/types.ts
Normal file
205
packages/tracking/lib/types.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
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" = "campaign",
|
||||
"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
|
||||
}
|
||||
|
||||
type LoggedInScandicUserData = TrackingSDKUserDataBase & {
|
||||
memberType: "scandic-friends"
|
||||
loginType?: LoginType
|
||||
memberId?: string
|
||||
membershipNumber?: string
|
||||
memberLevel?: MembershipLevel
|
||||
noOfNightsStayed?: number
|
||||
totalPointsAvailableToSpend?: number
|
||||
loginAction?: "login success"
|
||||
}
|
||||
|
||||
type LoggedInEurobonusUserData = TrackingSDKUserDataBase & {
|
||||
memberType: "eurobonus"
|
||||
}
|
||||
|
||||
type TrackingSDKUserDataBase = {
|
||||
loginStatus: "logged in"
|
||||
}
|
||||
|
||||
type LoggedInUserData = LoggedInScandicUserData | LoggedInEurobonusUserData
|
||||
export type TrackingSDKUserData =
|
||||
| LoggedInUserData
|
||||
| {
|
||||
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"
|
||||
78
packages/tracking/lib/useFormTracking.ts
Normal file
78
packages/tracking/lib/useFormTracking.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import {
|
||||
type Control,
|
||||
type FieldValues,
|
||||
useFormState,
|
||||
type UseFromSubscribe,
|
||||
} from "react-hook-form"
|
||||
|
||||
import {
|
||||
type FormType,
|
||||
trackFormAbandonment,
|
||||
trackFormCompletion,
|
||||
trackFormInputStarted,
|
||||
} from "./form"
|
||||
|
||||
export function useFormTracking<T extends FieldValues>(
|
||||
formType: FormType,
|
||||
subscribe: UseFromSubscribe<T>,
|
||||
control: Control<T>,
|
||||
nameSuffix: string = ""
|
||||
) {
|
||||
const [formStarted, setFormStarted] = useState(false)
|
||||
const lastAccessedField = useRef<string | undefined>(undefined)
|
||||
const formState = useFormState({ control })
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = subscribe({
|
||||
formState: { dirtyFields: true },
|
||||
callback: (data) => {
|
||||
if ("name" in data) {
|
||||
lastAccessedField.current = data.name as string
|
||||
}
|
||||
|
||||
if (!formStarted) {
|
||||
trackFormInputStarted(formType, nameSuffix)
|
||||
setFormStarted(true)
|
||||
}
|
||||
},
|
||||
})
|
||||
return () => unsubscribe()
|
||||
}, [subscribe, formType, nameSuffix, formStarted])
|
||||
|
||||
useEffect(() => {
|
||||
if (!formStarted || !lastAccessedField.current || formState.isValid) return
|
||||
|
||||
const lastField = lastAccessedField.current
|
||||
|
||||
function handleBeforeUnload() {
|
||||
trackFormAbandonment(formType, lastField, nameSuffix)
|
||||
}
|
||||
|
||||
function handleVisibilityChange() {
|
||||
if (document.visibilityState === "hidden") {
|
||||
trackFormAbandonment(formType, lastField, nameSuffix)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("beforeunload", handleBeforeUnload)
|
||||
window.addEventListener("visibilitychange", handleVisibilityChange)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("beforeunload", handleBeforeUnload)
|
||||
window.removeEventListener("visibilitychange", handleVisibilityChange)
|
||||
}
|
||||
}, [formStarted, formType, nameSuffix, formState.isValid])
|
||||
|
||||
const trackFormSubmit = useCallback(() => {
|
||||
if (formState.isValid) {
|
||||
trackFormCompletion(formType, nameSuffix)
|
||||
}
|
||||
}, [formType, nameSuffix, formState.isValid])
|
||||
|
||||
return {
|
||||
trackFormSubmit,
|
||||
}
|
||||
}
|
||||
167
packages/tracking/lib/useTrackHardNavigation.ts
Normal file
167
packages/tracking/lib/useTrackHardNavigation.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect } from "react"
|
||||
|
||||
import { useSessionId } from "@scandic-hotels/common/hooks/useSessionId"
|
||||
import { logger } from "@scandic-hotels/common/logger"
|
||||
import { promiseWithTimeout } from "@scandic-hotels/common/utils/promiseWithTimeout"
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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 })
|
||||
})
|
||||
}
|
||||
107
packages/tracking/lib/useTrackSoftNavigation.ts
Normal file
107
packages/tracking/lib/useTrackSoftNavigation.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
"use client"
|
||||
|
||||
import { startTransition, useEffect, useRef, useState } from "react"
|
||||
|
||||
import { useSessionId } from "@scandic-hotels/common/hooks/useSessionId"
|
||||
import useRouterTransitionStore from "@scandic-hotels/common/stores/router-transition"
|
||||
import useTrackingStore from "@scandic-hotels/common/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,
|
||||
])
|
||||
}
|
||||
11
packages/tracking/lint-staged.config.js
Normal file
11
packages/tracking/lint-staged.config.js
Normal file
@@ -0,0 +1,11 @@
|
||||
const config = {
|
||||
"*.{ts,tsx}": [
|
||||
() => "yarn lint",
|
||||
() => "tsc -p tsconfig.json --noEmit",
|
||||
"prettier --write",
|
||||
],
|
||||
"*.{json,md}": "prettier --write",
|
||||
"*.{html,js,cjs,mjs,css}": "prettier --write",
|
||||
}
|
||||
|
||||
export default config
|
||||
47
packages/tracking/package.json
Normal file
47
packages/tracking/package.json
Normal file
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "@scandic-hotels/tracking",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"main": "./lib/TrackingSDK.tsx",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"check-types": "tsc --noEmit",
|
||||
"lint": "eslint . --max-warnings 0 && tsc --noEmit"
|
||||
},
|
||||
"exports": {
|
||||
"./*": "./lib/*.ts",
|
||||
"./TrackingSDK": "./lib/TrackingSDK.tsx"
|
||||
},
|
||||
"dependencies": {
|
||||
"@scandic-hotels/common": "workspace:*",
|
||||
"@scandic-hotels/trpc": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/compat": "^1.2.9",
|
||||
"@eslint/eslintrc": "^3.3.1",
|
||||
"@eslint/js": "^9.26.0",
|
||||
"@scandic-hotels/typescript-config": "workspace:*",
|
||||
"@types/lodash-es": "^4",
|
||||
"@typescript-eslint/eslint-plugin": "^8.32.0",
|
||||
"@typescript-eslint/parser": "^8.32.0",
|
||||
"dotenv": "^16.5.0",
|
||||
"eslint": "^9",
|
||||
"eslint-plugin-import": "^2.31.0",
|
||||
"eslint-plugin-simple-import-sort": "^12.1.1",
|
||||
"typescript": "5.8.3",
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
"prettier": {
|
||||
"semi": false,
|
||||
"trailingComma": "es5",
|
||||
"singleQuote": false,
|
||||
"printWidth": 80,
|
||||
"tabWidth": 2,
|
||||
"endOfLine": "lf"
|
||||
}
|
||||
}
|
||||
12
packages/tracking/tsconfig.json
Normal file
12
packages/tracking/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "@scandic-hotels/typescript-config/nextjs.json",
|
||||
"compilerOptions": {
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
]
|
||||
},
|
||||
"include": ["next-env.d.ts", "./**/*.ts", "./**/*.tsx"],
|
||||
"exclude": ["**/node_modules/**"]
|
||||
}
|
||||
3
packages/tracking/vitest-setup.ts
Normal file
3
packages/tracking/vitest-setup.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { config } from "dotenv"
|
||||
|
||||
config({ path: "./.env.test" })
|
||||
17
packages/tracking/vitest.config.ts
Normal file
17
packages/tracking/vitest.config.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
|
||||
export default {
|
||||
test: {
|
||||
globals: true,
|
||||
environment: "jsdom",
|
||||
setupFiles: ["./vitest-setup.ts"],
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "."),
|
||||
},
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user