+
+/* A Button with the same styling as a TextLink to handle an edge case. */
+export function TextLinkButton({
+ theme,
+ isDisabled,
+ isInline,
+ typography,
+ className,
+ ...props
+}: TextLinkButtonProps) {
+ const classNames = getTextLinkClasses({
+ theme,
+ isDisabled,
+ isInline,
+ typography,
+ className,
+ })
+
+ return (
+
+ )
+}
diff --git a/packages/design-system/lib/components/TextLinkButton/index.tsx b/packages/design-system/lib/components/TextLinkButton/index.tsx
new file mode 100644
index 000000000..9a2a31d47
--- /dev/null
+++ b/packages/design-system/lib/components/TextLinkButton/index.tsx
@@ -0,0 +1 @@
+export { TextLinkButton } from './TextLinkButton'
diff --git a/packages/design-system/lib/components/TextLinkButton/textLinkButton.module.css b/packages/design-system/lib/components/TextLinkButton/textLinkButton.module.css
new file mode 100644
index 000000000..fb5df8950
--- /dev/null
+++ b/packages/design-system/lib/components/TextLinkButton/textLinkButton.module.css
@@ -0,0 +1,6 @@
+.button {
+ justify-self: flex-start;
+ background: none;
+ border: none;
+ cursor: pointer;
+}
diff --git a/packages/design-system/lib/components/TextLinkButton/textLinkStyles.ts b/packages/design-system/lib/components/TextLinkButton/textLinkStyles.ts
new file mode 100644
index 000000000..30cec680d
--- /dev/null
+++ b/packages/design-system/lib/components/TextLinkButton/textLinkStyles.ts
@@ -0,0 +1,19 @@
+import { cx } from 'class-variance-authority'
+import { variants } from '../TextLink/variants'
+import styles from '../TextLink/textLink.module.css'
+import type { TextLinkButtonProps } from './TextLinkButton'
+
+export function getTextLinkClasses({
+ theme,
+ isDisabled,
+ isInline,
+ typography,
+ className,
+}: TextLinkButtonProps) {
+ const variantClasses = variants({ theme, typography, className })
+
+ return cx(variantClasses, styles, {
+ [styles.disabled]: isDisabled,
+ [styles.inline]: isInline,
+ })
+}
diff --git a/packages/design-system/lib/components/Tooltip/index.tsx b/packages/design-system/lib/components/Tooltip/index.tsx
index 5ce126a09..5c355bbd8 100644
--- a/packages/design-system/lib/components/Tooltip/index.tsx
+++ b/packages/design-system/lib/components/Tooltip/index.tsx
@@ -3,7 +3,7 @@ import { type PropsWithChildren, useState } from 'react'
import { tooltipVariants } from './variants'
import styles from './tooltip.module.css'
-import Caption from '../Caption'
+import { Typography } from '../Typography'
type TooltipPosition = 'left' | 'right' | 'top' | 'bottom'
type VerticalArrow = 'top' | 'bottom' | 'center'
@@ -26,6 +26,7 @@ interface TooltipProps {
position: P
arrow: ValidArrow
isTouchable?: boolean
+ isVisible?: boolean
}
export function Tooltip
({
@@ -35,6 +36,7 @@ export function Tooltip
({
arrow,
children,
isTouchable = false,
+ isVisible = true,
}: PropsWithChildren>) {
const className = tooltipVariants({ position, arrow })
const [isActive, setIsActive] = useState(false)
@@ -50,6 +52,10 @@ export function Tooltip({
}
}
+ if (!isVisible) {
+ return <> {children} >
+ }
+
return (
({
data-active={isActive}
>
- {heading && (
-
- {heading}
-
- )}
- {text &&
{text}}
+ {heading ? (
+
+ {heading}
+
+ ) : null}
+ {text ? (
+
+ {text}
+
+ ) : null}
{children}
diff --git a/packages/design-system/lib/components/Tooltip/tooltip.module.css b/packages/design-system/lib/components/Tooltip/tooltip.module.css
index 471e685ef..0b9944697 100644
--- a/packages/design-system/lib/components/Tooltip/tooltip.module.css
+++ b/packages/design-system/lib/components/Tooltip/tooltip.module.css
@@ -8,7 +8,7 @@
background-color: var(--Surface-UI-Fill-Intense);
border: 0.5px solid var(--Border-Interactive-Focus);
border-radius: var(--Corner-radius-md);
- color: var(--Base-Text-Inverted);
+ color: var(--Text-Inverted);
position: absolute;
visibility: hidden;
z-index: 1000;
diff --git a/packages/design-system/package.json b/packages/design-system/package.json
index 44a65e100..01adefbda 100644
--- a/packages/design-system/package.json
+++ b/packages/design-system/package.json
@@ -31,6 +31,7 @@
"./Form/Country": "./lib/components/Form/Country/index.tsx",
"./Form/Date": "./lib/components/Form/Date/index.tsx",
"./Form/ErrorMessage": "./lib/components/Form/ErrorMessage/index.tsx",
+ "./Form/RadioButtonsGroup": "./lib/components/Form/RadioButtonsGroup/index.tsx",
"./Form/PaymentOption": "./lib/components/Form/PaymentOption/PaymentOption.tsx",
"./Form/PaymentOptionsGroup": "./lib/components/Form/PaymentOption/PaymentOptionsGroup.tsx",
"./Form/Phone": "./lib/components/Form/Phone/index.tsx",
@@ -86,6 +87,7 @@
"./Icons/HairdresserIcon": "./lib/components/Icons/Nucleo/Amenities_Facilities/hairdresser-1.tsx",
"./Icons/HairdryerIcon": "./lib/components/Icons/Customised/Amenities_Facilities/Hairdryer.tsx",
"./Icons/HandKeyIcon": "./lib/components/Icons/Illustrations/HandKey.tsx",
+ "./Icons/HandGiftIcon": "./lib/components/Icons/Illustrations/HandGift.tsx",
"./Icons/HandSoapIcon": "./lib/components/Icons/Customised/Amenities_Facilities/HandSoap.tsx",
"./Icons/HaymarketIcon": "./lib/components/Icons/Logos/Haymarket.tsx",
"./Icons/HotelLogoIcon": "./lib/components/Icons/Logos/index.tsx",
@@ -119,6 +121,7 @@
"./Icons/ScandicGoIcon": "./lib/components/Icons/Logos/ScandicGoLogo.tsx",
"./Icons/ScandicLogoIcon": "./lib/components/Icons/Logos/ScandicLogo.tsx",
"./Icons/SlippersIcon": "./lib/components/Icons/Customised/Amenities_Facilities/Slippers.tsx",
+ "./Icons/Spa": "./lib/components/Icons/Illustrations/Spa.tsx",
"./Icons/SurpriseIcon": "./lib/components/Icons/Illustrations/Surprise.tsx",
"./Icons/ToiletIcon": "./lib/components/Icons/Nucleo/Amenities_Facilities/toilet-2.tsx",
"./Icons/TowelIcon": "./lib/components/Icons/Customised/Amenities_Facilities/Towel.tsx",
@@ -172,6 +175,7 @@
"./Switch": "./lib/components/Switch/index.tsx",
"./Table": "./lib/components/Table/index.tsx",
"./TextLink": "./lib/components/TextLink/index.tsx",
+ "./TextLinkButton": "./lib/components/TextLinkButton/index.tsx",
"./Title": "./lib/components/Title/index.tsx",
"./Toast": "./lib/components/Toasts/index.tsx",
"./ToastHandler": "./lib/components/Toasts/ToastHandler.tsx",
diff --git a/packages/tracking/lib/types.ts b/packages/tracking/lib/types.ts
index c13c7ab8b..7e8dab5d4 100644
--- a/packages/tracking/lib/types.ts
+++ b/packages/tracking/lib/types.ts
@@ -34,6 +34,7 @@ export type TrackingSDKPageData = {
type LoggedInScandicUserData = TrackingSDKUserDataBase & {
memberType: "scandic-friends"
+ profileConsent?: boolean
loginType?: LoginType
memberId?: string
membershipNumber?: string
diff --git a/packages/trpc/lib/graphql/Query/ProfilingConsent.graphql.ts b/packages/trpc/lib/graphql/Query/ProfilingConsent.graphql.ts
new file mode 100644
index 000000000..7da926904
--- /dev/null
+++ b/packages/trpc/lib/graphql/Query/ProfilingConsent.graphql.ts
@@ -0,0 +1,27 @@
+import { gql } from "graphql-tag"
+
+export const GetProfilingConsent = gql`
+ query GetProfilingConsent($locale: String!) {
+ all_profiling_consent(limit: 1, locale: $locale) {
+ items {
+ main_icon
+ profiling_consent_banner {
+ button_text
+ header
+ sub_header
+ }
+ profiling_consent_modal {
+ header
+ sub_header
+ cards {
+ card {
+ image_type
+ preamble
+ title
+ }
+ }
+ }
+ }
+ }
+ }
+`
diff --git a/packages/trpc/lib/routers/contentstack/index.ts b/packages/trpc/lib/routers/contentstack/index.ts
index 780bd3165..0b1b82698 100644
--- a/packages/trpc/lib/routers/contentstack/index.ts
+++ b/packages/trpc/lib/routers/contentstack/index.ts
@@ -16,6 +16,7 @@ import { loyaltyPageRouter } from "./loyaltyPage"
import { metadataRouter } from "./metadata"
import { pageSettingsRouter } from "./pageSettings"
import { partnerRouter } from "./partner"
+import { profilingConsentRouter } from "./profilingConsent"
import { promoCampaignPageRouter } from "./promoCampaignPage"
import { rewardRouter } from "./reward"
import { startPageRouter } from "./startPage"
@@ -41,4 +42,5 @@ export const contentstackRouter = router({
startPage: startPageRouter,
partner: partnerRouter,
promoCampaignPage: promoCampaignPageRouter,
+ profilingConsent: profilingConsentRouter,
})
diff --git a/packages/trpc/lib/routers/contentstack/profilingConsent/index.ts b/packages/trpc/lib/routers/contentstack/profilingConsent/index.ts
new file mode 100644
index 000000000..f574abe80
--- /dev/null
+++ b/packages/trpc/lib/routers/contentstack/profilingConsent/index.ts
@@ -0,0 +1,4 @@
+import { mergeRouters } from "../../.."
+import { profilingConsentQueryRouter } from "./query"
+
+export const profilingConsentRouter = mergeRouters(profilingConsentQueryRouter)
diff --git a/packages/trpc/lib/routers/contentstack/profilingConsent/output.ts b/packages/trpc/lib/routers/contentstack/profilingConsent/output.ts
new file mode 100644
index 000000000..0df9d30d6
--- /dev/null
+++ b/packages/trpc/lib/routers/contentstack/profilingConsent/output.ts
@@ -0,0 +1,48 @@
+import { z } from "zod"
+
+export const bannerSchema = z.object({
+ button_text: z.string(),
+ header: z.string(),
+ sub_header: z.string(),
+})
+
+export const modalSchema = z
+ .object({
+ header: z.string(),
+ sub_header: z.string(),
+ cards: z.object({
+ card: z.array(
+ z.object({
+ image_type: z.string(),
+ preamble: z.string(),
+ title: z.string(),
+ })
+ ),
+ }),
+ })
+ .transform(({ header, sub_header, cards }) => ({
+ header,
+ sub_header,
+ cards: cards.card,
+ }))
+
+export const profilingConsentSchema = z
+ .object({
+ all_profiling_consent: z.object({
+ items: z.array(
+ z.object({
+ main_icon: z.string(),
+ profiling_consent_banner: bannerSchema,
+ profiling_consent_modal: modalSchema,
+ })
+ ),
+ }),
+ })
+ .transform((data) => {
+ const profiling_consent = data.all_profiling_consent.items[0]
+ return {
+ icon: profiling_consent.main_icon,
+ banner: profiling_consent.profiling_consent_banner,
+ modal: profiling_consent.profiling_consent_modal,
+ }
+ })
diff --git a/packages/trpc/lib/routers/contentstack/profilingConsent/query.ts b/packages/trpc/lib/routers/contentstack/profilingConsent/query.ts
new file mode 100644
index 000000000..064507856
--- /dev/null
+++ b/packages/trpc/lib/routers/contentstack/profilingConsent/query.ts
@@ -0,0 +1,61 @@
+import { createCounter } from "@scandic-hotels/common/telemetry"
+
+import { router } from "../../.."
+import { notFound } from "../../../errors"
+import { GetProfilingConsent } from "../../../graphql/Query/ProfilingConsent.graphql"
+import { request } from "../../../graphql/request"
+import { contentstackBaseProcedure } from "../../../procedures"
+import { langInput } from "../../../utils"
+import { profilingConsentSchema } from "./output"
+
+import type { GetProfilingConsentData } from "../../../types/profilingConsent"
+
+export const profilingConsentQueryRouter = router({
+ get: contentstackBaseProcedure
+ .input(langInput.optional())
+ .query(async ({ input, ctx }) => {
+ const lang = input?.lang ?? ctx.lang
+
+ const tag = `${lang}:profiling_consent`
+
+ const getProfilingConsentCounter = createCounter(
+ "trpc.contentstack",
+ "profilingConsent.get"
+ )
+ const metricsGetProfilingConsent = getProfilingConsentCounter.init({
+ lang,
+ })
+
+ metricsGetProfilingConsent.start()
+
+ const response = await request(
+ GetProfilingConsent,
+ {
+ locale: lang,
+ },
+ {
+ key: tag,
+ ttl: "max",
+ }
+ )
+ if (!response.data) {
+ const notFoundError = notFound(response)
+ metricsGetProfilingConsent.noDataError()
+ throw notFoundError
+ }
+ const validatedResponse = profilingConsentSchema.safeParse(response.data)
+
+ if (!validatedResponse.success) {
+ metricsGetProfilingConsent.validationError(validatedResponse.error)
+ return null
+ }
+
+ const profiling_consent = validatedResponse.data
+
+ metricsGetProfilingConsent.success()
+
+ return {
+ profiling_consent,
+ }
+ }),
+})
diff --git a/packages/trpc/lib/routers/types.ts b/packages/trpc/lib/routers/types.ts
index 732152f8d..7e2fbb60e 100644
--- a/packages/trpc/lib/routers/types.ts
+++ b/packages/trpc/lib/routers/types.ts
@@ -44,6 +44,7 @@ type UserDataScandicLoggedIn = {
membershipNumber?: string
memberLevel?: MembershipLevel
loginAction?: "login success"
+ profileConsent?: boolean
memberType: "scandic-friends"
}
@@ -58,6 +59,7 @@ type UserDataEurobonusLoggedIn = {
memberId?: string
membershipNumber?: string
memberLevel?: MembershipLevel
+ profileConsent?: boolean
}
export type TrackingUserData =
diff --git a/packages/trpc/lib/routers/user/input.ts b/packages/trpc/lib/routers/user/input.ts
index 73e0ee771..36ae3e299 100644
--- a/packages/trpc/lib/routers/user/input.ts
+++ b/packages/trpc/lib/routers/user/input.ts
@@ -44,7 +44,7 @@ export const signupInput = signUpSchema
language: z.nativeEnum(Lang),
})
.omit({ termsAccepted: true })
- .transform((data) => ({
+ .transform(({ profilingConsent, ...data }) => ({
...data,
phoneNumber: data.phoneNumber.replace(/\s+/g, ""),
address: {
@@ -53,8 +53,13 @@ export const signupInput = signUpSchema
country: "",
streetAddress: "",
},
+ ...(profilingConsent ? { profilingConsent } : {}),
}))
+export const profilingConsentInput = z.object({
+ profilingConsent: z.boolean(),
+})
+
export const getSavedPaymentCardsInput = z.object({
supportedCards: z.array(z.string()),
})
diff --git a/packages/trpc/lib/routers/user/mutation.ts b/packages/trpc/lib/routers/user/mutation.ts
index df77a89d5..65f74781c 100644
--- a/packages/trpc/lib/routers/user/mutation.ts
+++ b/packages/trpc/lib/routers/user/mutation.ts
@@ -11,6 +11,7 @@ import {
addCreditCardInput,
addPromoCampaignInput,
deleteCreditCardInput,
+ profilingConsentInput,
saveCreditCardInput,
signupInput,
} from "./input"
@@ -197,7 +198,7 @@ export const userMutationRouter = router({
const signupCounter = createCounter("trpc.user", "signup")
const metricsSignup = signupCounter.init()
- const apiResponse = await api.post(api.endpoints.v1.Profile.profile, {
+ const apiResponse = await api.post(api.endpoints.v2.Profile.profile, {
body: input,
headers: {
Authorization: `Bearer ${ctx.serviceToken}`,
@@ -217,6 +218,34 @@ export const userMutationRouter = router({
redirectUrl: signupVerify[input.language],
}
}),
+ profilingConsent: router({
+ update: protectedProcedure
+ .input(profilingConsentInput)
+ .mutation(async function ({ ctx, input }) {
+ const profilingConsentCounter = createCounter(
+ "trpc.user",
+ "profilingConsent"
+ )
+ const metricsProfilingConsent = profilingConsentCounter.init()
+
+ const apiResponse = await api.patch(api.endpoints.v2.Profile.profile, {
+ body: input,
+ headers: {
+ Authorization: `Bearer ${ctx.session.token.access_token}`,
+ },
+ })
+
+ if (!apiResponse.ok) {
+ await metricsProfilingConsent.httpError(apiResponse)
+ const text = await apiResponse.text()
+ throw serverErrorByStatus(apiResponse.status, text)
+ }
+
+ metricsProfilingConsent.success()
+
+ return true
+ }),
+ }),
promoCampaign: router({
add: protectedProcedure
.input(addPromoCampaignInput)
diff --git a/packages/trpc/lib/routers/user/output.ts b/packages/trpc/lib/routers/user/output.ts
index f1b805598..b3ac0db68 100644
--- a/packages/trpc/lib/routers/user/output.ts
+++ b/packages/trpc/lib/routers/user/output.ts
@@ -113,6 +113,8 @@ export const getUserSchema = z
.nullable(),
loyalty: userLoyaltySchema.optional(),
employmentDetails: employmentDetailsSchema,
+ profilingConsent: z.boolean().optional(),
+ profilingConsentUpdate: z.string().optional(),
promotions: z.array(z.string()).nullish(),
}),
type: z.string(),
diff --git a/packages/trpc/lib/routers/user/schemas.ts b/packages/trpc/lib/routers/user/schemas.ts
index c72001e3a..cd6dd816e 100644
--- a/packages/trpc/lib/routers/user/schemas.ts
+++ b/packages/trpc/lib/routers/user/schemas.ts
@@ -51,6 +51,7 @@ export const signUpSchema = z.object({
.regex(/^[A-Za-z0-9-\s]{1,9}$/g, signupErrors.ZIP_CODE_INVALID),
}),
password: passwordValidator(signupErrors.PASSWORD_REQUIRED),
+ profilingConsent: z.boolean(),
termsAccepted: z
.boolean()
.refine((value) => value === true, signupErrors.TERMS_REQUIRED),
diff --git a/packages/trpc/lib/routers/user/utils/parsedUser.ts b/packages/trpc/lib/routers/user/utils/parsedUser.ts
index 83c766dea..0e2cb28be 100644
--- a/packages/trpc/lib/routers/user/utils/parsedUser.ts
+++ b/packages/trpc/lib/routers/user/utils/parsedUser.ts
@@ -28,6 +28,8 @@ export function parsedUser(data: User, maskValues: boolean) {
name: `${data.firstName} ${data.lastName}`,
phoneNumber: data.phoneNumber,
profileId: data.profileId,
+ profilingConsent: data.profilingConsent,
+ profilingConsentUpdate: data.profilingConsentUpdate,
promotions: data.promotions || null,
} satisfies User
diff --git a/packages/trpc/lib/types/dynamicContent.ts b/packages/trpc/lib/types/dynamicContent.ts
index a0fc4116f..01cb3e076 100644
--- a/packages/trpc/lib/types/dynamicContent.ts
+++ b/packages/trpc/lib/types/dynamicContent.ts
@@ -18,6 +18,7 @@ export namespace DynamicContentEnum {
overview_table: "overview_table",
points_overview: "points_overview",
previous_stays: "previous_stays",
+ profiling_consent_banner: "profiling_consent_banner",
sign_up_form: "sign_up_form",
sign_up_verification: "sign_up_verification",
soonest_stays: "soonest_stays",
diff --git a/packages/trpc/lib/types/profilingConsent.ts b/packages/trpc/lib/types/profilingConsent.ts
new file mode 100644
index 000000000..8cfdf9ba9
--- /dev/null
+++ b/packages/trpc/lib/types/profilingConsent.ts
@@ -0,0 +1,13 @@
+import type { z } from "zod"
+
+import type { profilingConsentSchema } from "../routers/contentstack/profilingConsent/output"
+
+export interface GetProfilingConsentData
+ extends z.input {}
+
+export interface ProfilingConsent
+ extends z.output {}
+
+export type ProfilingConsentBanner = NonNullable
+
+export type ProfilingConsentModal = NonNullable