Merged in feat/profile-consent-feature-branch (pull request #2900)

feat(LOY-268): Feature branch for profiling consent work

* feat: Add feature branch for profile and consent work

* Merged in feat/LOY-268-profile-consent-banner-comp (pull request #2908)

Feat/LOY-358 profile consent banner component

* feat: Add feature branch for profile and consent work

* feat(LOY-268): create banner

* feat(LOY-268): Create personalization banner component


Approved-by: Chuma Mcphoy (We Ahead)

* feat(LOY-268): create banner

* feat(LOY-268): Create personalization banner component

* Merged in feat/profile-consent-contentstack (pull request #2921)

Feat(LOY-389): Profile consent in Contentstack

* feat(LOY-268): create banner

* feat(LOY-268): Create personalization banner component

* chore(LOY-348): add profiling consent as CS entry

* chore(LOY-348): add banner as dynamic content

Approved-by: Chuma Mcphoy (We Ahead)

* Merged in feat/LOY-347-Profile-consent-modal-phase-1 (pull request #2901)

Feat(LOY-347): Profiling Consent Modal (phase 1)

* feat(LOY-347): Profile Consent Modal base functionality

* feat(LOY-347): Add Icon

* feat(LOY-347): Add Benefit Cards to Profile consent modal

* feat(LOY-347): Add accordion to profile consent modal

* fix(LOY-347): scroll behaviour

* Fix(LOY-347): fade in/out animations of profile consent modal

* fix(LOY-347): White Ellipsis Around Icons

* feat(LOY-347): Implement ability to open modal from banner

* fix(LOY-347): minor fixes

* fix(LOY-347): replace old spa icon

* fix(LOY-347): re-add env vars

* fix(LOY-347): File renaming and cleanup

* chore(LOY-347): Update readme

* fix(LOY-347): use correct space var

* fix(LOY-347): Add TODO comment for adding link to accordion

Approved-by: Matilda Landström

* Merged in fix/LOY-386-profiling-consent-modal-contentstack (pull request #2930)

Fix(LOY-386): profiling consent modal contentstack

* feat(LOY-347): Profile Consent Modal base functionality

* feat(LOY-347): Add Icon

* feat(LOY-347): Add Benefit Cards to Profile consent modal

* feat(LOY-347): Add accordion to profile consent modal

* fix(LOY-347): scroll behaviour

* Fix(LOY-347): fade in/out animations of profile consent modal

* fix(LOY-347): White Ellipsis Around Icons

* feat(LOY-347): Implement ability to open modal from banner

* fix(LOY-347): minor fixes

* fix(LOY-347): replace old spa icon

* fix(LOY-347): re-add env vars

* fix(LOY-347): File renaming and cleanup

* fix(LOY-386): Use contentstack content for profile consent modal

* fix(LOY-386): beneift cards schema transform

* chore(LOY-386): remove usememo

* fix(LOY-386): fix modalcontent check

* fix(LOY-386): remove uneeded vars


Approved-by: Matilda Landström

* Merged in feat/LOY-412-profiling-consent-in-signup (pull request #2976)

Feat(LOY-412): profiling consent in signup

* feat(LOY-268): create banner

* feat(LOY-268): Create personalization banner component

* Merged in feat/profile-consent-contentstack (pull request #2921)

Feat(LOY-389): Profile consent in Contentstack

* feat(LOY-268): create banner

* feat(LOY-268): Create personalization banner component

* chore(LOY-348): add profiling consent as CS entry

* chore(LOY-348): add banner as dynamic content

Approved-by: Chuma Mcphoy (We Ahead)

* Merged in feat/LOY-347-Profile-consent-modal-phase-1 (pull request #2901)

Feat(LOY-347): Profiling Consent Modal (phase 1)

* feat(LOY-347): Profile Consent Modal base functionality

* feat(LOY-347): Add Icon

* feat(LOY-347): Add Benefit Cards to Profile consent modal

* feat(LOY-347): Add accordion to profile consent modal

* fix(LOY-347): scroll behaviour

* Fix(LOY-347): fade in/out animations of profile consent modal

* fix(LOY-347): White Ellipsis Around Ico…
* Merged in fix/lokalise-ids (pull request #3013)

fix: add ids to translations in Profiling Consent

* fix: add ids to translations


Approved-by: Erik Tiekstra
Approved-by: Chuma Mcphoy (We Ahead)

* Merged in LOY-436-my-pages-profiling-consent (pull request #3011)

LOY-436: Profiling Consent on My Profile, no api

Approved-by: Chuma Mcphoy (We Ahead)

* Merged in feat/LOY-418-profiling-consent-ui-text-update (pull request #3080)

Feat/LOY-418: Profiling consent ui and text update

* chore(LOY-418): update /consent buttons

* chore(LOY-418): update legal texts


Approved-by: Chuma Mcphoy (We Ahead)

* Merged in feat/LOY-268-profiling-consent-api (pull request #3088)

Approved-by: Chuma Mcphoy (We Ahead)

* Merged in feat/LOY-413-Signup-API-Profiling-Consent (pull request #3105)

Feat/LOY-413 Signup API Profiling Consent

* feat(LOY-413): signup profiling consent

* chore(LOY-413): remove todo

* fix(LOY-413): only pass in profilingConsent if true

* fix(LOY-413): proper spread of profilingConsent in signup input


Approved-by: Christel Westerberg

* Merged in fix/LOY-413-use-v2-for-signup-call (pull request #3112)

fix(LOY-413): use v2 endpoint for profile POST in signup

* fix(LOY-413): use v2 endpoint for profile POST in signup


Approved-by: Erik Tiekstra

* Merged in feat/LOY-268-profiling-consent-improvements (pull request #3094)

Feat/LOY-268: Profiling consent improvements

* Merged in feat/profile-consent-contentstack (pull request #2921)

Feat(LOY-389): Profile consent in Contentstack

* feat(LOY-268): create banner

* feat(LOY-268): Create personalization banner component

* chore(LOY-348): add profiling consent as CS entry

* chore(LOY-348): add banner as dynamic content

Approved-by: Chuma Mcphoy (We Ahead)

* Merged in feat/LOY-347-Profile-consent-modal-phase-1 (pull request #2901)

Feat(LOY-347): Profiling Consent Modal (phase 1)

* feat(LOY-347): Profile Consent Modal base functionality

* feat(LOY-347): Add Icon

* feat(LOY-347): Add Benefit Cards to Profile consent modal

* feat(LOY-347): Add accordion to profile consent modal

* fix(LOY-347): scroll behaviour

* Fix(LOY-347): fade in/out animations of profile consent modal

* fix(LOY-347): White Ellipsis Around Icons

* feat(LOY-347): Implement ability to open modal from banner

* fix(LOY-347): min…
* Merged in fix/update-graphql (pull request #3130)

fix: update graphql

* fix: update graphql


Approved-by: Chuma Mcphoy (We Ahead)

* Merged in feat/LOY-414-prof-consent-tracking (pull request #3127)

Feat/LOY-414 profile consent tracking + credit card ui update

* chore(LOY-414): create track link function

* chore(LOY-414): add cta tracking

* chore(LOY-414): add profileConsent to userInfo datalayer

* chore(LOY-414): update credit card ui

* chore(LOY-414): update tracking specs

* chore(LOY-414): add pageView tracking to modal

Approved-by: Chuma Mcphoy (We Ahead)

* fix: remove old flag

* Merged in fix/LOY-268-prof-consent-button-fix (pull request #3162)

fix(LOY-268): add button as link

* fix(LOY-268): add button as link


Approved-by: Chuma Mcphoy (We Ahead)


Approved-by: Matilda Landström
This commit is contained in:
Chuma Mcphoy (We Ahead)
2025-11-14 13:28:56 +00:00
parent 9260cc0f81
commit 494bfe10f3
79 changed files with 9332 additions and 268 deletions

View File

@@ -10,8 +10,6 @@ import { getIntl } from "@/i18n"
import styles from "./page.module.css"
export { generateMetadata } from "@/utils/metadata/generateMetadata"
export default async function MyPages() {
const caller = await serverClient()
const accountPageRes = await caller.contentstack.accountPage.get()

View File

@@ -1,9 +1,17 @@
import { TrackingSDK } from "@scandic-hotels/tracking/TrackingSDK"
import { getEurobonusMembership } from "@scandic-hotels/trpc/routers/user/helpers"
import { getProfileSafely } from "@/lib/trpc/memoizedRequests"
import {
getProfileSafely,
getProfilingConsent,
} from "@/lib/trpc/memoizedRequests"
import ProfilingConsentModal from "@/components/MyPages/ProfilingConsent/Modal"
import { userHasConsent } from "@/components/MyPages/ProfilingConsent/utils"
import { SASLevelUpgradeCheck } from "@/components/MyPages/SASLevelUpgradeCheck"
import Surprises from "@/components/MyPages/Surprises"
import { getLang } from "@/i18n/serverContext"
import { ModalTracking } from "@/utils/tracking/profilingConsent"
import styles from "./layout.module.css"
@@ -18,6 +26,15 @@ export default async function MyPagesLayout({
? getEurobonusMembership(profile.loyalty)
: null
const memberKey =
profile?.membership?.membershipNumber || profile?.profileId || ""
const profilingConsentData = await getProfilingConsent()
const profilingConsent = profilingConsentData?.profiling_consent
const hasConsent = userHasConsent(profile?.profilingConsent)
const lang = await getLang()
return (
<div className={styles.container}>
<div className={styles.layout}>
@@ -27,6 +44,16 @@ export default async function MyPagesLayout({
{eurobonusMembership && <SASLevelUpgradeCheck />}
<Surprises />
{memberKey && profilingConsent && !hasConsent ? (
<>
<ProfilingConsentModal
memberKey={memberKey}
content={profilingConsent.modal}
iconIdentifier={profilingConsent.icon}
/>
<TrackingSDK pageData={{ domainLanguage: lang, ...ModalTracking }} />
</>
) : null}
</div>
)
}

View File

@@ -0,0 +1,9 @@
.container {
padding-top: var(--Space-x3);
}
@media screen and (min-width: 768px) {
.container {
padding-top: var(--Space-x6);
}
}

View File

@@ -0,0 +1,34 @@
import { TrackingSDK } from "@scandic-hotels/tracking/TrackingSDK"
import { getProfile } from "@/lib/trpc/memoizedRequests"
import { serverClient } from "@/lib/trpc/server"
import { ProfilingConsent } from "@/components/Forms/ProfilingConsent"
import styles from "./page.module.css"
export default async function ProfilingConsentSlot() {
const caller = await serverClient()
const accountPage = await caller.contentstack.accountPage.get()
const user = await getProfile()
if (!user || "error" in user || !accountPage) {
return null
}
const page = accountPage.accountPage
const { heading, preamble } = page
return (
<>
<div className={styles.container}>
<ProfilingConsent
heading={heading}
preamble={preamble}
consent={user.profilingConsent}
/>
{accountPage && <TrackingSDK pageData={accountPage.tracking} />}
</div>
</>
)
}

View File

@@ -22,6 +22,7 @@ import SignupFormWrapper from "@/components/Blocks/DynamicContent/SignupFormWrap
import NextStay from "@/components/Blocks/DynamicContent/Stays/NextStay"
import PreviousStays from "@/components/Blocks/DynamicContent/Stays/Previous"
import UpcomingStays from "@/components/Blocks/DynamicContent/Stays/UpcomingStays"
import { ProfilingConsentBanner } from "@/components/MyPages/ProfilingConsent/Banner"
import { SJWidget } from "@/components/SJWidget"
import JobylonFeed from "./JobylonFeed"
@@ -85,6 +86,9 @@ function DynamicContentBlocks(props: DynamicContentProps) {
preamble={dynamic_content.subtitle}
/>
)
case DynamicContentEnum.Blocks.components.profiling_consent_banner:
return <ProfilingConsentBanner />
case DynamicContentEnum.Blocks.components.sj_widget:
return <SJWidget />
default:

View File

@@ -11,6 +11,14 @@
grid-area: title;
}
.welcome {
color: var(--Text-Accent-Primary);
}
.name {
color: var(--Text-Interactive-Default);
}
.form {
display: grid;
gap: var(--Space-x5);

View File

@@ -13,9 +13,9 @@ import {
formatPhoneNumber,
getDefaultCountryFromLang,
} from "@scandic-hotels/common/utils/phone"
import { OldDSButton as Button } from "@scandic-hotels/design-system/OldDSButton"
import Title from "@scandic-hotels/design-system/Title"
import { Button } from "@scandic-hotels/design-system/Button"
import { toast } from "@scandic-hotels/design-system/Toast"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { trpc } from "@scandic-hotels/trpc/client"
import { langToApiLang } from "@scandic-hotels/trpc/constants/apiLang"
@@ -124,21 +124,19 @@ export default function Form({ user }: EditFormProps) {
<>
<section className={styles.container}>
<div className={styles.title}>
<Title as="h4" color="red" level="h1" textTransform="capitalize">
{intl.formatMessage({
id: "common.welcome",
defaultMessage: "Welcome",
})}
</Title>
<Title
data-hj-suppress
as="h4"
color="burgundy"
level="h2"
textTransform="capitalize"
>
{user.name}
</Title>
<Typography variant="Title/sm">
<span>
<h1 className={styles.welcome}>
{intl.formatMessage({
id: "common.welcome",
defaultMessage: "Welcome",
})}
</h1>
<h2 data-hj-suppress className={styles.name}>
{user.name}
</h2>
</span>
</Typography>
</div>
<div className={styles.btnContainer}>
<Dialog
@@ -160,7 +158,7 @@ export default function Form({ user }: EditFormProps) {
defaultMessage: "Discard unsaved changes?",
})}
trigger={
<Button intent="secondary" size="small" theme="base">
<Button variant="Secondary" size="Small" color="Primary">
{intl.formatMessage({
id: "editProfile.discardChanges",
defaultMessage: "Discard changes",
@@ -169,11 +167,11 @@ export default function Form({ user }: EditFormProps) {
}
/>
<Button
disabled={!isValid || methods.formState.isSubmitting}
isDisabled={!isValid || methods.formState.isSubmitting}
form={formId}
intent="primary"
size="small"
theme="base"
variant="Primary"
size="Small"
color="Primary"
type="submit"
>
{intl.formatMessage({

View File

@@ -0,0 +1,58 @@
.container {
display: grid;
gap: var(--Space-x4);
}
.form {
background-color: var(--Surface-Primary-Default);
padding: var(--Space-x4) var(--Space-x2) 0;
display: grid;
gap: var(--Space-x6);
}
.buttons {
position: sticky;
bottom: 0;
display: flex;
flex-direction: column;
gap: var(--Space-x1);
justify-content: center;
padding: var(--Space-x2) var(--Space-x3) var(--Space-x3) var(--Space-x3);
border-top: 1px solid var(--Border-Divider-Subtle);
background: var(--Base-Surface-Primary-light-Normal);
}
.title {
color: var(--Text-Interactive-Default);
}
.fieldset {
border: none;
padding: 0;
margin: 0;
min-inline-size: 0;
}
.legend {
display: flex;
flex-direction: column;
gap: var(--Space-x1);
padding-bottom: var(--Space-x4);
}
@media (min-width: 768px) {
.container {
gap: var(--Space-x6);
}
.form {
padding: var(--Space-x4) var(--Space-x3) 0;
gap: var(--Space-x6);
}
.buttons {
flex-direction: row-reverse;
gap: var(--Space-x2);
padding: var(--Space-x6) var(--Space-x3) var(--Space-x4);
}
}

View File

@@ -0,0 +1,205 @@
"use client"
import { useRouter } from "next/navigation"
import { useCallback, useEffect } from "react"
import { Controller, FormProvider, useForm } from "react-hook-form"
import { useIntl } from "react-intl"
import { profile } from "@scandic-hotels/common/constants/routes/myPages"
import { Button } from "@scandic-hotels/design-system/Button"
import { RadioButtonsGroup } from "@scandic-hotels/design-system/Form/RadioButtonsGroup"
import { Tooltip } from "@scandic-hotels/design-system/Tooltip"
import { Typography } from "@scandic-hotels/design-system/Typography"
import ProfilingConsentAccordion from "@/components/MyPages/ProfilingConsent/Accordion"
import useLang from "@/hooks/useLang"
import { useUpdateProfilingConsent } from "@/hooks/useUpdateProfilingConsent"
import {
trackConsentAction,
trackConsentChange,
} from "@/utils/tracking/profilingConsent"
import styles from "./form.module.css"
interface ProfilingConsentProps {
heading: string | null
preamble: string | null
consent?: boolean
}
export function ProfilingConsent({
preamble,
heading,
consent,
}: ProfilingConsentProps) {
const intl = useIntl()
const router = useRouter()
const lang = useLang()
const options = [
{
consent: true,
value: intl.formatMessage({
id: "profilingConsent.accept",
defaultMessage: "Accept",
}),
title: intl.formatMessage({
id: "profilingConsent.yesIAccept",
defaultMessage: "Yes, I would like to accept profiling!",
}),
text: intl.formatMessage({
id: "profilingConsent.iConsentToScandicUsingMyInformation",
defaultMessage:
"I consent to Scandic using my information to give me even more personalized travel inspiration and offers from Scandic and trusted Scandic Friends partners. This means Scandic may use information about my interactions with Scandic Friends partners, and share details of my interactions with Scandic with those partners, to make the experience even more relevant to me.",
}),
},
{
consent: false,
value: intl.formatMessage({
id: "profilingConsent.decline",
defaultMessage: "Decline",
}),
title: intl.formatMessage({
id: "profilingConsent.noDeclineProfiling",
defaultMessage: "No, decline profiling",
}),
text: intl.formatMessage({
id: "profilingConsent.receiveGeneralInformationMarketingOnly",
defaultMessage: "Receive general information & marketing only.",
}),
},
]
type AcceptPersonalizationForm = { consent: string }
const form = useForm<AcceptPersonalizationForm>({
defaultValues: {
consent: options.find((option) => option.consent === consent)?.value,
},
})
const {
control,
handleSubmit,
formState: { isDirty },
} = form
const { initiateUpdateConsent, isLoading, isSuccess } =
useUpdateProfilingConsent()
const onSubmit = (data: AcceptPersonalizationForm) => {
if (isDirty) {
const cons = options.find(
(option) => option.value === data.consent
)?.consent
if (cons !== undefined) {
initiateUpdateConsent(cons)
trackConsentChange({ from: consent, to: cons })
}
}
}
const backToProfile = useCallback(() => {
router.push(profile[lang])
}, [router, lang])
useEffect(() => {
if (isSuccess) backToProfile()
}, [isSuccess, backToProfile])
return (
<FormProvider {...form}>
<form onSubmit={handleSubmit(onSubmit)}>
<div className={styles.container}>
<Typography variant="Title/md" className={styles.title}>
<h1>{heading}</h1>
</Typography>
<Typography variant="Body/Lead text">
<p>{preamble}</p>
</Typography>
<div className={styles.form}>
<fieldset className={styles.fieldset}>
<legend className={styles.legend}>
<Typography variant="Title/Subtitle/lg">
<p>
{intl.formatMessage({
id: "profilingConsent.profilingConsent",
defaultMessage: "Profiling consent",
})}
</p>
</Typography>
<Typography>
<p>
{intl.formatMessage({
id: "profilingConsent.updateAtAnyTime",
defaultMessage:
"You can update your preferences at any time. We respect your choices and will only use and store your data in accordance with your consent.",
})}
</p>
</Typography>
</legend>
<Controller
name="consent"
control={control}
render={({ field }) => (
<RadioButtonsGroup
options={options}
ariaLabel={intl.formatMessage({
id: "profilingConsent.acceptOrDecline",
defaultMessage: "Accept or decline personalization",
})}
onChange={(value: string) => field.onChange(value)}
defaultOption={options.find(
(option) => option.consent === consent
)}
/>
)}
/>
</fieldset>
<ProfilingConsentAccordion component="profile" />
</div>
</div>
<div className={styles.buttons}>
<Tooltip
text={intl.formatMessage({
id: "profilingConsent.tooltip.noChangesInPreferences",
defaultMessage: "No changes in preferences have been made",
})}
position="top"
arrow="center"
isVisible={!isDirty}
>
<Button
type="submit"
typography="Body/Paragraph/mdBold"
variant="Primary"
color="Primary"
size="Medium"
isDisabled={!isDirty}
isPending={isLoading}
>
{intl.formatMessage({
id: "profilingConsent.save&update",
defaultMessage: "Save & update preferences",
})}
</Button>
</Tooltip>
<Button
type="button"
typography="Body/Paragraph/mdBold"
variant="Secondary"
color="Primary"
size="Medium"
onClick={() => {
backToProfile()
trackConsentAction({
position: "profile",
name: "cancel",
})
}}
>
{intl.formatMessage({
id: "common.cancel",
defaultMessage: "Cancel",
})}
</Button>
</div>
</form>
</FormProvider>
)
}

View File

@@ -11,7 +11,8 @@
}
.container,
.terms {
.terms,
.personalization {
display: grid;
gap: var(--Space-x2);
}
@@ -26,6 +27,16 @@
gap: var(--Space-x1);
}
.personalizationMoreInfo {
display: grid;
grid-template-columns: auto 1fr;
align-items: start;
gap: var(--Space-x2);
}
.personalizationButton {
width: fit-content;
}
@media screen and (min-width: 1367px) {
.formWrapper {
gap: var(--Space-x5);

View File

@@ -18,6 +18,7 @@ import Checkbox from "@scandic-hotels/design-system/Form/Checkbox"
import CountrySelect from "@scandic-hotels/design-system/Form/Country"
import DateSelect from "@scandic-hotels/design-system/Form/Date"
import Phone from "@scandic-hotels/design-system/Form/Phone"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import Link from "@scandic-hotels/design-system/OldDSLink"
import { toast } from "@scandic-hotels/design-system/Toast"
import { Typography } from "@scandic-hotels/design-system/Typography"
@@ -29,11 +30,14 @@ import {
signUpSchema,
} from "@scandic-hotels/trpc/routers/user/schemas"
import ProfilingConsentModalReadOnly from "@/components/MyPages/ProfilingConsent/Modal/ReadOnly"
import Input from "@/components/TempDesignSystem/Form/Input"
import PasswordInput from "@/components/TempDesignSystem/Form/PasswordInput"
import useLang from "@/hooks/useLang"
import { getFormattedCountryList } from "@/utils/countries"
import { getErrorMessage } from "@/utils/getErrorMessage"
import { requestOpen } from "@/utils/profilingConsent"
import { trackLinkClick } from "@/utils/tracking/profilingConsent"
// import { type SignUpSchema, signUpSchema } from "./schema"
import styles from "./form.module.css"
@@ -92,6 +96,7 @@ export default function SignupForm({ title }: SignUpFormProps) {
},
password: "",
termsAccepted: false,
profilingConsent: false,
},
mode: "all",
criteriaMode: "all",
@@ -114,8 +119,17 @@ export default function SignupForm({ title }: SignUpFormProps) {
trackFormSubmit()
}
function openPersonalizationModal() {
trackLinkClick({
position: "signup",
name: "read more about personalization at scandic",
})
requestOpen()
}
return (
<div className={styles.formWrapper}>
<ProfilingConsentModalReadOnly />
{title ? (
<Typography variant="Title/md">
<h2>{title}</h2>
@@ -262,6 +276,67 @@ export default function SignupForm({ title }: SignUpFormProps) {
isNewPassword
/>
</section>
<section className={styles.personalization}>
<header>
<Typography variant="Title/Subtitle/md">
<h3>
{intl.formatMessage({
id: "signup.UnlockYourPersonalizedExperience",
defaultMessage: "Unlock your personalized experience!",
})}
</h3>
</Typography>
</header>
<Checkbox
name="profilingConsent"
registerOptions={{
required: false,
}}
>
{intl.formatMessage({
id: "signup.yesConsent",
defaultMessage:
"I consent to Scandic using my information to give me even more personalized travel inspiration and offers from Scandic and trusted Scandic Friends partners. This means Scandic may use information about my interactions with Scandic Friends partners, and share details of my interactions with Scandic with those partners, to make the experience even more relevant to me.",
})}
</Checkbox>
<div className={styles.personalizationMoreInfo}>
<MaterialIcon icon="person" color="Icon/Interactive/Default" />
<p>
{intl.formatMessage({
id: "signup.GetATailoredProfile",
defaultMessage:
"Get a tailored profile that adapts to your interests.",
})}
</p>
</div>
<div className={styles.personalizationMoreInfo}>
<MaterialIcon
icon="featured_seasonal_and_gifts"
color="Icon/Interactive/Default"
/>
<p>
{intl.formatMessage({
id: "signup.PersonalizedOffersEarlyAccess",
defaultMessage:
"Personalized offers, early access to campaigns, and deals you won't find anywhere else!",
})}
</p>
</div>
<Button
variant="Text"
typography="Link/sm"
color="Primary"
className={styles.personalizationButton}
onClick={openPersonalizationModal}
>
{intl.formatMessage({
id: "signup.ReadMoreAboutPersonalization",
defaultMessage: "Read more about personalization at Scandic",
})}
</Button>
</section>
<section className={styles.terms}>
<header>
<Typography variant="Title/Subtitle/md">

View File

@@ -162,6 +162,8 @@ const authenticatedUser: SafeUser = {
profileId: "",
employmentDetails: undefined,
promotions: [],
profilingConsent: undefined,
profilingConsentUpdate: undefined,
}
const badAuthenticatedUser: SafeUser = {
@@ -195,6 +197,8 @@ const badAuthenticatedUser: SafeUser = {
profileId: "",
employmentDetails: undefined,
promotions: [],
profilingConsent: undefined,
profilingConsentUpdate: undefined,
}
const loggedOutGuest: Guest = {

View File

@@ -1,34 +1,35 @@
import { Typography } from "@scandic-hotels/design-system/Typography"
import ManagePreferencesButton from "@/components/Profile/ManagePreferencesButton"
import ManageEmailPreferencesButton from "@/components/Profile/ManageEmailPreferencesButton"
import { getIntl } from "@/i18n"
import styles from "./communication.module.css"
import styles from "./slots.module.css"
export default async function CommunicationSlot() {
export default async function EmailSlot() {
const intl = await getIntl()
return (
<section className={styles.container}>
<div className={styles.content}>
<Typography variant="Title/Subtitle/md">
<h3>
{intl.formatMessage({
id: "myPages.myCommunicationPreferences",
defaultMessage: "My communication preferences",
id: "profile.emailPreferences",
defaultMessage: "E-mail preferences",
})}
</h3>
</Typography>
<Typography variant="Body/Paragraph/mdRegular">
<p>
{intl.formatMessage({
id: "myPages.tellUsWhatInfoYouWouldLikeToReceive",
id: "profile.manageEmailsAndUpdates",
defaultMessage:
"Tell us what information and updates you'd like to receive, and how, by clicking the link below.",
"Manage what e-mails and updates you'd like to receive, and how, by clicking the link below.",
})}
</p>
</Typography>
</div>
<ManagePreferencesButton />
<ManageEmailPreferencesButton />
</section>
)
}

View File

@@ -0,0 +1,59 @@
"use client"
import { useIntl } from "react-intl"
import { profileConsent } from "@scandic-hotels/common/constants/routes/myPages"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { TextLink } from "@scandic-hotels/design-system/TextLink"
import { Typography } from "@scandic-hotels/design-system/Typography"
import useLang from "@/hooks/useLang"
import { trackLinkClick } from "@/utils/tracking/profilingConsent"
import styles from "./slots.module.css"
export default function PersonalizationSlot() {
const intl = useIntl()
const lang = useLang()
return (
<section className={styles.container}>
<div className={styles.content}>
<Typography variant="Title/Subtitle/md">
<h3>
{intl.formatMessage({
id: "profile.personalization&profiling",
defaultMessage: "Personalization & Profiling",
})}
</h3>
</Typography>
<Typography variant="Body/Paragraph/mdRegular">
<p>
{intl.formatMessage({
id: "profile.reviewUpdateConsent",
defaultMessage:
"Review and update your consent settings to control how we personalize your experience.",
})}
</p>
</Typography>
</div>
<TextLink
href={profileConsent[lang]}
color="Text/Interactive/Secondary"
typography="Link/md"
onClick={() =>
trackLinkClick({
position: "profile",
name: "manage profiling consent",
})
}
>
<MaterialIcon size={24} icon="arrow_forward" color="CurrentColor" />
{intl.formatMessage({
id: "profile.mangeProfilingConsent",
defaultMessage: "Manage profiling consent",
})}
</TextLink>
</section>
)
}

View File

@@ -0,0 +1,15 @@
.content {
display: grid;
gap: var(--Space-x1);
}
.container {
justify-items: left;
}
@media screen and (min-width: 768px) {
.container {
display: grid;
gap: var(--Space-x15);
}
}

View File

@@ -0,0 +1,21 @@
import { getIntl } from "@/i18n"
import { Section } from "../Section"
import EmailSlot from "./Slots/Email"
import PersonalizationSlot from "./Slots/Personalization"
export async function CommunicationSettings() {
const intl = await getIntl()
return (
<Section
title={intl.formatMessage({
id: "profile.communicationSettings",
defaultMessage: "Communication settings",
})}
>
<EmailSlot />
<PersonalizationSlot />
</Section>
)
}

View File

@@ -1,22 +0,0 @@
.container {
display: grid;
gap: var(--Space-x2);
justify-items: flex-start;
max-width: 510px;
}
.content {
display: grid;
gap: var(--Space-x1);
}
.cardContainer {
display: grid;
gap: var(--Space-x1);
}
@media screen and (min-width: 768px) {
.container {
gap: var(--Space-x3);
}
}

View File

@@ -1,41 +0,0 @@
import { Typography } from "@scandic-hotels/design-system/Typography"
import { serverClient } from "@/lib/trpc/server"
import AddCreditCardButton from "@/components/Profile/AddCreditCardButton"
import CreditCardList from "@/components/Profile/CreditCardList"
import { getIntl } from "@/i18n"
import styles from "./creditCards.module.css"
export default async function CreditCardSlot() {
const intl = await getIntl()
const caller = await serverClient()
const creditCards = await caller.user.creditCards()
return (
<section className={styles.container}>
<div className={styles.content}>
<Typography variant="Title/Subtitle/md">
<h3>
{intl.formatMessage({
id: "myPages.myPaymentCards",
defaultMessage: "My payment cards",
})}
</h3>
</Typography>
<Typography variant="Body/Paragraph/mdRegular">
<p>
{intl.formatMessage({
id: "myPages.checkOutCardsSavedToProfile",
defaultMessage:
"Check out the credit cards saved to your profile. Pay with a saved card when signed in for a smoother web experience.",
})}
</p>
</Typography>
</div>
<CreditCardList initialData={creditCards} />
<AddCreditCardButton />
</section>
)
}

View File

@@ -0,0 +1,50 @@
import { Typography } from "@scandic-hotels/design-system/Typography"
import { serverClient } from "@/lib/trpc/server"
import AddCreditCardButton from "@/components/Profile/AddCreditCardButton"
import CreditCardList from "@/components/Profile/CreditCardList"
import { getIntl } from "@/i18n"
import { Section } from "../Section"
import styles from "./payment.module.css"
export async function Payment() {
const intl = await getIntl()
const caller = await serverClient()
const creditCards = await caller.user.creditCards()
return (
<Section
title={intl.formatMessage({
id: "profile.payment",
defaultMessage: "Payment",
})}
>
<div className={styles.container}>
<span className={styles.content}>
<Typography variant="Title/Subtitle/md">
<h3>
{intl.formatMessage({
id: "profile.myPaymentCards",
defaultMessage: "My payment cards",
})}
</h3>
</Typography>
<Typography variant="Body/Paragraph/mdRegular">
<p>
{intl.formatMessage({
id: "profile.checkOutSavedCreditCards",
defaultMessage:
"Check out the credit cards saved to your profile. Pay with a saved card when signed in for a smoother web experience.",
})}
</p>
</Typography>
</span>
<CreditCardList initialData={creditCards} />
<AddCreditCardButton />
</div>
</Section>
)
}

View File

@@ -1,10 +1,9 @@
.container {
display: grid;
gap: var(--Space-x3);
max-width: 510px;
}
.content {
display: grid;
gap: var(--Space-x1);
}
.container {
display: grid;
gap: var(--Space-x3);
}

View File

@@ -0,0 +1,19 @@
import { Typography } from "@scandic-hotels/design-system/Typography"
import styles from "./section.module.css"
import type { PropsWithChildren } from "react"
interface SectionProps extends PropsWithChildren {
title: string
}
export function Section({ children, title }: SectionProps) {
return (
<section className={styles.section}>
<Typography variant="Title/smLowCase">
<h2>{title}</h2>
</Typography>
<div className={styles.content}>{children}</div>
</section>
)
}

View File

@@ -0,0 +1,19 @@
.content {
display: grid;
grid-auto-flow: row;
gap: var(--Space-x5);
}
.section {
display: grid;
gap: var(--Space-x3);
}
@media screen and (min-width: 950px) {
.content {
grid-auto-flow: column;
grid-auto-columns: calc(50% - var(--Space-x9) / 2)
calc(50% - var(--Space-x9) / 2);
gap: var(--Space-x9);
}
}

View File

@@ -3,20 +3,23 @@ import { profileEdit } from "@scandic-hotels/common/constants/routes/myPages"
import { isValidLang } from "@scandic-hotels/common/utils/languages"
import ButtonLink from "@scandic-hotels/design-system/ButtonLink"
import { Divider } from "@scandic-hotels/design-system/Divider"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import {
MaterialIcon,
type MaterialIconProps,
} from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { countriesMap } from "@scandic-hotels/trpc/constants/countries"
import { getProfile } from "@/lib/trpc/memoizedRequests"
import CommunicationSlot from "@/components/MyPages/Profile/Communication"
import CreditCardSlot from "@/components/MyPages/Profile/CreditCards"
import Header from "@/components/Profile/Header"
import { getIntl } from "@/i18n"
import { getLang } from "@/i18n/serverContext"
import { isValidCountry } from "@/utils/countries"
import ChangeNameDisclaimer from "./ChangeNameDisclaimer"
import { CommunicationSettings } from "./CommunicationSettings"
import { Payment } from "./Payment"
import styles from "./profile.module.css"
@@ -67,6 +70,61 @@ export default async function Profile() {
? localizedLanguage.charAt(0).toUpperCase() + localizedLanguage.slice(1)
: languages[userLang]
const userDataItems: {
icon: MaterialIconProps["icon"]
label: string
value: string
}[] = [
{
icon: "calendar_month",
label: intl.formatMessage({
id: "profile.dateOfBirth",
defaultMessage: "Date of birth",
}),
value: user.dateOfBirth,
},
{
icon: "phone",
label: intl.formatMessage({
id: "common.phoneNumber",
defaultMessage: "Phone number",
}),
value: user.phoneNumber ?? "",
},
{
icon: "globe",
label: intl.formatMessage({
id: "profile.language",
defaultMessage: "Language",
}),
value: normalizedLanguage,
},
{
icon: "mail",
label: intl.formatMessage({
id: "common.email",
defaultMessage: "Email",
}),
value: user.email,
},
{
icon: "location_on",
label: intl.formatMessage({
id: "common.address",
defaultMessage: "Address",
}),
value: addressOutput,
},
{
icon: "lock",
label: intl.formatMessage({
id: "profile.password",
defaultMessage: "Password",
}),
value: "**********",
},
]
return (
<section className={styles.container}>
<Header>
@@ -95,102 +153,25 @@ export default async function Profile() {
</ButtonLink>
</Header>
<div className={styles.info}>
<div className={styles.item}>
<MaterialIcon
icon="calendar_month"
color="Icon/Interactive/Default"
/>
<Typography variant="Body/Paragraph/mdBold">
<p>
{intl.formatMessage({
id: "myPages.dateOfBirth",
defaultMessage: "Date of birth",
})}
</p>
</Typography>
<Typography variant="Body/Paragraph/mdRegular">
<p>{user.dateOfBirth}</p>
</Typography>
</div>
<div className={styles.item}>
<MaterialIcon icon="phone" color="Icon/Interactive/Default" />
<Typography variant="Body/Paragraph/mdBold">
<p>
{intl.formatMessage({
id: "common.phoneNumber",
defaultMessage: "Phone number",
})}
</p>
</Typography>
<Typography variant="Body/Paragraph/mdRegular">
<p>{user.phoneNumber}</p>
</Typography>
</div>
<div className={styles.item}>
<MaterialIcon icon="globe" color="Icon/Interactive/Default" />
<Typography variant="Body/Paragraph/mdBold">
<p>
{intl.formatMessage({
id: "common.language",
defaultMessage: "Language",
})}
</p>
</Typography>
<Typography variant="Body/Paragraph/mdRegular">
<p>{normalizedLanguage}</p>
</Typography>
</div>
<div className={styles.item}>
<MaterialIcon icon="mail" color="Icon/Interactive/Default" />
<Typography variant="Body/Paragraph/mdBold">
<p>
{intl.formatMessage({
id: "common.email",
defaultMessage: "Email",
})}
</p>
</Typography>
<Typography variant="Body/Paragraph/mdRegular">
<p>{user.email}</p>
</Typography>
</div>
<div className={styles.item}>
<MaterialIcon icon="location_on" color="Icon/Interactive/Default" />
<Typography variant="Body/Paragraph/mdBold">
<p>
{intl.formatMessage({
id: "common.address",
defaultMessage: "Address",
})}
</p>
</Typography>
<Typography variant="Body/Paragraph/mdRegular">
<p>{addressOutput}</p>
</Typography>
</div>
<div className={styles.item}>
<MaterialIcon icon="lock" color="Icon/Interactive/Default" />
<Typography variant="Body/Paragraph/mdBold">
<p>
{intl.formatMessage({
id: "common.password",
defaultMessage: "Password",
})}
</p>
</Typography>
<Typography variant="Body/Paragraph/mdRegular">
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
<p>**********</p>
</Typography>
</div>
{userDataItems.map(({ icon, label, value }) => (
<div className={styles.item} key={label}>
<MaterialIcon icon={icon} color="Icon/Interactive/Default" />
<Typography variant="Body/Paragraph/mdBold">
<p>{label}</p>
</Typography>
<Typography variant="Body/Paragraph/mdRegular">
<p>{value}</p>
</Typography>
</div>
))}
</div>
<ChangeNameDisclaimer />
<Divider />
<CreditCardSlot />
<CommunicationSettings />
<Payment />
{/* <MembershipCardSlot /> */}
<CommunicationSlot />
</section>
)
}

View File

@@ -0,0 +1,239 @@
"use client"
import { useIntl } from "react-intl"
import { privacy } from "@scandic-hotels/common/constants/routes/customerService"
import Accordion from "@scandic-hotels/design-system/Accordion"
import AccordionItem from "@scandic-hotels/design-system/Accordion/AccordionItem"
import { IconByIconName } from "@scandic-hotels/design-system/Icons/IconByIconName"
import { IconName } from "@scandic-hotels/design-system/Icons/iconName"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { TextLink } from "@scandic-hotels/design-system/TextLink"
import { Typography } from "@scandic-hotels/design-system/Typography"
import useLang from "@/hooks/useLang"
import { trackLinkClick } from "@/utils/tracking/profilingConsent"
import styles from "./profilingConsentAccordion.module.css"
interface ProfilingConsentAccordionProps {
component: "modal" | "profile"
}
export default function ProfilingConsentAccordion({
component,
}: ProfilingConsentAccordionProps) {
const intl = useIntl()
const lang = useLang()
const dataWeProcess = [
{
icon: IconName.Person,
title: intl.formatMessage({
id: "profilingConsent.NameAndContactInformation",
defaultMessage: "Name and contact information",
}),
subtitle: intl.formatMessage({
id: "profilingConsent.NameEmailPhoneAddress",
defaultMessage:
"Your name, email address, phone number and postal address",
}),
},
{
icon: IconName.Luggage,
title: intl.formatMessage({
id: "profilingConsent.YourBookingsWithScandic",
defaultMessage: "Your bookings with Scandic",
}),
subtitle: intl.formatMessage({
id: "profilingConsent.BookingsDatesDestinationsPreferences",
defaultMessage: "Hotel bookings, dates, destinations and preferences",
}),
},
{
icon: IconName.Diamond,
title: intl.formatMessage({
id: "profilingConsent.YourMembershipInformation",
defaultMessage: "Your membership information",
}),
subtitle: intl.formatMessage({
id: "profilingConsent.ScandicFriendsPointsBenefits",
defaultMessage: "Scandic Friends status, points and member benefits",
}),
},
{
icon: IconName.Business,
title: intl.formatMessage({
id: "profilingConsent.CompanyInformation",
defaultMessage: "Company information",
}),
subtitle: intl.formatMessage({
id: "profilingConsent.InformationAboutYourCompany",
defaultMessage:
"Information about the company you work for (if available)",
}),
},
{
icon: IconName.Globe,
title: intl.formatMessage({
id: "profilingConsent.UserGeneratedData",
defaultMessage: "User-generated data",
}),
subtitle: intl.formatMessage({
id: "profilingConsent.ClicksBrowsingPurchasingInteractions",
defaultMessage:
"Clicks, browsing, purchase history and website interactions",
}),
},
]
const rights = [
intl.formatMessage({
id: "profilingConsent.GetInformation",
defaultMessage: "Get information about how we process your data",
}),
intl.formatMessage({
id: "profilingConsent.RequestDeletion",
defaultMessage: "Request deletion of your personal data",
}),
intl.formatMessage({
id: "profilingConsent.WithdrawConsent",
defaultMessage: "Withdraw your consent at any time",
}),
]
const security = [
intl.formatMessage({
id: "profilingConsent.EncryptedSecureDataStorage",
defaultMessage: "Encrypted & secure data storage",
}),
intl.formatMessage({
id: "profilingConsent.WeWillNeverSellYourData",
defaultMessage: "We will never sell your data",
}),
intl.formatMessage({
id: "profilingConsent.RegularSecurityAudits",
defaultMessage: "Regular security audits",
}),
intl.formatMessage({
id: "profilingConsent.GDPRCompliantProcessing",
defaultMessage: "GDPR-compliant processing",
}),
]
return (
<Accordion type="sidepeek" className={styles.accordion}>
<AccordionItem
title={intl.formatMessage({
id: "profilingConsent.whatDoWeProcess",
defaultMessage: "What data do we process?",
})}
iconName={IconName.InfoCircle}
className={styles.accordionItem}
openedOnRender
>
<ul className={styles.list}>
{dataWeProcess.map((item, i) => (
<li key={i} className={styles.row}>
<span className={styles.rowIcon} aria-hidden>
<IconByIconName
iconName={item.icon}
color="Icon/Interactive/Default"
size={24}
/>
</span>
<span className={styles.rowText}>
<Typography variant="Body/Paragraph/mdBold">
<p>{item.title}</p>
</Typography>
<Typography variant="Body/Supporting text (caption)/smRegular">
<p>{item.subtitle}</p>
</Typography>
</span>
</li>
))}
</ul>
</AccordionItem>
<AccordionItem
title={intl.formatMessage({
id: "profilingConsent.transparency&processing",
defaultMessage: "Transparency & data processing",
})}
iconName={IconName.EyeShow}
className={styles.accordionItem}
openedOnRender
>
<div className={styles.columns}>
<div className={styles.column}>
<Typography variant="Title/Subtitle/md">
<h4>
{intl.formatMessage({
id: "profilingConsent.youHaveTheRightTo",
defaultMessage: "You have the right to",
})}
</h4>
</Typography>
<ul className={styles.bullets}>
{rights.map((text, i) => (
<li key={i} className={styles.bulletRow}>
<MaterialIcon
icon="favorite"
size={20}
color="Icon/Accent"
isFilled
/>
<Typography variant="Body/Paragraph/mdRegular">
<p>{text}</p>
</Typography>
</li>
))}
</ul>
</div>
<div className={styles.column}>
<Typography variant="Title/Subtitle/md">
<h4>
{intl.formatMessage({
id: "profilingConsent.dataSecurity",
defaultMessage: "Data security",
})}
</h4>
</Typography>
<ul className={styles.bullets}>
{security.map((text, i) => (
<li key={i} className={styles.bulletRow}>
<MaterialIcon icon="lock" size={20} color="Icon/Accent" />
<Typography variant="Body/Paragraph/mdRegular">
<p>{text}</p>
</Typography>
</li>
))}
</ul>
</div>
</div>
<TextLink
href={privacy[lang]}
color="Text/Interactive/Secondary"
typography="Link/md"
className={styles.learnMoreLink}
onClick={() =>
trackLinkClick({
position: component,
name: "learn more about how we process your data",
})
}
>
{intl.formatMessage({
id: "profilingConsent.learnMoreAboutProcessing",
defaultMessage: "Learn more about how we process your data",
})}
<MaterialIcon
icon="open_in_new"
size={20}
color="Icon/Interactive/Secondary"
/>
</TextLink>
</AccordionItem>
</Accordion>
)
}

View File

@@ -0,0 +1,77 @@
.divider {
margin: var(--Space-x1) 0;
}
.accordion {
width: 100%;
}
.list {
display: grid;
gap: var(--Space-x15);
}
.row {
display: grid;
grid-template-columns: 40px 1fr;
gap: var(--Space-x2);
align-items: start;
border: 0.696px solid var(--Border-Divider-Subtle);
border-radius: var(--Corner-radius-md);
background: var(--Surface-Primary-OnSurface-Default);
padding: var(--Space-x1);
}
.accordionItem:first-child {
border-top: 1px solid var(--Border-Default);
}
.rowIcon {
display: flex;
align-items: center;
justify-content: center;
place-self: center;
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--Surface-Primary-Default);
}
.rowText {
display: grid;
gap: 2px;
}
.columns {
display: grid;
gap: var(--Space-x3);
margin-bottom: var(--Space-x3);
}
.column {
display: grid;
gap: var(--Space-x1);
}
.bullets {
display: grid;
gap: var(--Space-x1);
}
.bulletRow {
display: grid;
grid-template-columns: 20px 1fr;
gap: var(--Space-x1);
align-items: start;
}
.learnMoreLink {
display: flex;
gap: var(--Space-x05);
}
@media (min-width: 768px) {
.columns {
grid-template-columns: 1fr 1fr;
}
}

View File

@@ -0,0 +1,28 @@
"use client"
import { Button } from "@scandic-hotels/design-system/Button"
import { requestOpen } from "@/utils/profilingConsent"
import { trackConsentAction } from "@/utils/tracking/profilingConsent"
interface BannerButtonProps {
cta: string
}
export function BannerButton({ cta }: BannerButtonProps) {
function handleOpenModal() {
trackConsentAction({ position: "banner", name: cta })
requestOpen()
}
return (
<Button
variant="Secondary"
color="Primary"
size="Small"
typography="Body/Supporting text (caption)/smBold"
onClick={handleOpenModal}
>
{cta}
</Button>
)
}

View File

@@ -0,0 +1,33 @@
import { Typography } from "@scandic-hotels/design-system/Typography"
import { getProfile, getProfilingConsent } from "@/lib/trpc/memoizedRequests"
import { GetMainIconByCSIdentifier, userHasConsent } from "../utils"
import { BannerButton } from "./Button"
import styles from "./profilingConsentBanner.module.css"
export async function ProfilingConsentBanner() {
const user = await getProfile()
if (!user || userHasConsent(user?.profilingConsent)) return null
const data = await getProfilingConsent()
if (!data) return null
const { icon, banner } = data.profiling_consent
return (
<div className={styles.banner}>
<GetMainIconByCSIdentifier identifier={icon} className={styles.icon} />
<span className={styles.text}>
<Typography variant="Title/sm" className={styles.header}>
<h4>{banner.header}</h4>
</Typography>
<Typography variant="Body/Lead text">
<p>{banner.sub_header}</p>
</Typography>
</span>
<BannerButton cta={banner.button_text} />
</div>
)
}

View File

@@ -0,0 +1,33 @@
.banner {
background-color: var(--Background-Secondary);
padding: var(--Space-x4) var(--Space-x3);
display: grid;
align-items: center;
border-radius: var(--Corner-radius-Large);
gap: var(--Space-x4);
grid-auto-flow: row;
text-align: center;
}
.text {
display: grid;
gap: var(--Space-x15);
}
.icon {
justify-self: center;
}
.header {
color: var(--Text-Brand-OnPrimary-1-Heading);
}
@media screen and (min-width: 950px) {
.banner {
padding: var(--Space-x2) var(--Space-x4);
grid-template-columns: auto 1fr auto;
grid-auto-flow: column;
text-align: left;
}
}

View File

@@ -0,0 +1,37 @@
"use client"
import { Typography } from "@scandic-hotels/design-system/Typography"
import styles from "./benefitCards.module.css"
type BenefitCardProps = {
Icon: React.ComponentType<{
width?: number
height?: number
className?: string
}>
title: string
description: string
}
export default function BenefitCard({
Icon,
title,
description,
}: BenefitCardProps) {
return (
<div className={styles.card}>
<div className={styles.iconPane} aria-hidden="true">
<Icon width={140} height={120} />
</div>
<div className={styles.copy}>
<Typography variant="Title/Subtitle/md">
<h3>{title}</h3>
</Typography>
<Typography variant="Body/Supporting text (caption)/smRegular">
<p>{description}</p>
</Typography>
</div>
</div>
)
}

View File

@@ -0,0 +1,29 @@
.benefitsCarousel {
width: 100%;
}
.card {
display: grid;
gap: var(--Space-x3);
}
.iconPane {
background: var(--Surface-Brand-Primary-1-Default);
border-radius: var(--Corner-radius-xLarge);
display: grid;
place-items: center;
padding: var(--Space-x2) var(--Space-x15);
min-height: 180px;
}
.copy {
text-align: center;
display: grid;
gap: var(--Space-x1);
}
@media (min-width: 1024px) {
.dots {
display: none;
}
}

View File

@@ -0,0 +1,38 @@
"use client"
import { Carousel } from "@/components/Carousel"
import { GetBenefitIconByCSIdentifier } from "../../utils"
import BenefitCard from "./BenefitCard"
import styles from "./benefitCards.module.css"
import type { ProfilingConsentModal as ProfilingConsentModalType } from "@scandic-hotels/trpc/types/profilingConsent"
type BenefitCardsProps = {
cards?: ProfilingConsentModalType["cards"]
}
export default function BenefitCards({ cards }: BenefitCardsProps) {
if (!cards?.length) return null
return (
<Carousel className={styles.benefitsCarousel}>
<Carousel.Content>
{cards.map((card, index) => {
const Icon = GetBenefitIconByCSIdentifier(card.image_type)
return (
<Carousel.Item key={`${card.title}-${index}`}>
<BenefitCard
Icon={Icon}
title={card.title}
description={card.preamble}
/>
</Carousel.Item>
)
})}
</Carousel.Content>
<Carousel.Dots className={styles.dots} />
</Carousel>
)
}

View File

@@ -0,0 +1,28 @@
"use client"
import { trpc } from "@scandic-hotels/trpc/client"
import ProfilingConsentModal from "@/components/MyPages/ProfilingConsent/Modal"
import useLang from "@/hooks/useLang"
/**
* A read-only version of the Profiling Consent Modal
* without any of the profiling consent actions.
**/
export default function ProfilingConsentModalReadOnly() {
const lang = useLang()
const { data } = trpc.contentstack.profilingConsent.get.useQuery({ lang })
const modal = data?.profiling_consent?.modal
const icon = data?.profiling_consent?.icon
if (!modal || !icon) return null
return (
<ProfilingConsentModal
memberKey={undefined}
content={modal}
iconIdentifier={icon}
readOnly
/>
)
}

View File

@@ -0,0 +1,222 @@
"use client"
import { AnimatePresence, motion } from "motion/react"
import { useCallback, useEffect, useState } from "react"
import { Dialog, Modal, ModalOverlay } from "react-aria-components"
import { useIntl } from "react-intl"
import { Button } from "@scandic-hotels/design-system/Button"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import ScandicLogoIcon from "@scandic-hotels/design-system/Icons/ScandicLogoIcon"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { useUpdateProfilingConsent } from "@/hooks/useUpdateProfilingConsent"
import {
profilingConsentOpenEvent,
readDismissed,
setDismissed as persistDismissed,
} from "@/utils/profilingConsent"
import { trackConsentAction } from "@/utils/tracking/profilingConsent"
import ProfilingConsentAccordion from "../Accordion"
import { GetMainIconByCSIdentifier } from "../utils"
import BenefitCards from "./BenefitCards"
import styles from "./profilingConsentModal.module.css"
import type { ProfilingConsentModal as ProfilingConsentModalType } from "@scandic-hotels/trpc/types/profilingConsent"
type ProfilingConsentModalProps = {
memberKey?: string
content: ProfilingConsentModalType
iconIdentifier: string
readOnly?: boolean
}
const MotionModal = motion.create(Modal)
export default function ProfilingConsentModal({
memberKey,
content,
iconIdentifier,
readOnly = false,
}: ProfilingConsentModalProps) {
const intl = useIntl()
const [open, setOpen] = useState(false)
const { initiateUpdateConsent, isLoading, isSuccess } =
useUpdateProfilingConsent()
const [activeChoice, setActiveChoice] = useState<boolean | null>(null)
const handleClick = (consent: boolean) => {
setActiveChoice(consent)
initiateUpdateConsent(consent)
}
useEffect(() => {
if (!memberKey) return
setOpen(!readDismissed(memberKey))
}, [memberKey])
const onClose = useCallback(() => {
if (memberKey) {
persistDismissed(memberKey)
}
setOpen(false)
}, [memberKey])
useEffect(() => {
const handleOpen: EventListener = () => setOpen(true)
window.addEventListener(profilingConsentOpenEvent, handleOpen)
return () => {
window.removeEventListener(profilingConsentOpenEvent, handleOpen)
}
}, [])
useEffect(() => {
if (isSuccess) onClose()
}, [isSuccess, onClose])
if (!memberKey && !readOnly) return null
return (
<ModalOverlay
className={styles.overlay}
isOpen={open}
onOpenChange={(isOpen) => {
if (!isOpen) {
onClose()
}
}}
isKeyboardDismissDisabled
isDismissable={false}
>
<AnimatePresence mode="wait">
{open && (
<MotionModal
className={styles.modal}
initial={{ y: 32, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: 32, opacity: 0 }}
transition={{
y: { duration: 0.4, ease: "easeInOut" },
opacity: { duration: 0.4, ease: "easeInOut" },
}}
>
<Dialog
aria-label={intl.formatMessage({
id: "common.profilingConsent",
defaultMessage: "Profiling consent",
})}
className={styles.dialog}
>
<header className={styles.header}>
<div className={styles.logoWrap}>
<ScandicLogoIcon height={20} width={94} />
</div>
<Button
type="button"
variant="Text"
size="Large"
color="Primary"
className={styles.closeBtn}
onClick={() => {
trackConsentAction({ position: "modal", name: "close" })
onClose()
}}
aria-label={intl.formatMessage({
id: "common.close",
defaultMessage: "Close",
})}
>
<MaterialIcon color="CurrentColor" icon="close" />
</Button>
</header>
<main className={styles.content}>
<GetMainIconByCSIdentifier identifier={iconIdentifier} />
<div className={styles.textContent}>
<Typography className={styles.heading} variant="Title/md">
<h2>{content.header}</h2>
</Typography>
<Typography className={styles.text} variant="Body/Lead text">
<p>{content.sub_header}</p>
</Typography>
</div>
<BenefitCards cards={content.cards} />
<section className={styles.container}>
<header className={styles.header}>
<Typography variant="Title/Subtitle/lg">
<h3>
{intl.formatMessage({
id: "profilingConsent.personalization&privacy",
defaultMessage: "Personalization & privacy",
})}
</h3>
</Typography>
<Typography variant="Body/Paragraph/mdRegular">
<p>
{intl.formatMessage({
id: "profilingConsent.byAcceptingThisIConsent",
defaultMessage:
"By accepting this I consent to Scandic using my information to give me even more personalized travel inspiration and offers from Scandic and trusted Scandic Friends partners. This means Scandic may use information about my interactions with Scandic Friends partners, and share details of my interactions with Scandic with those partners, to make the experience even more relevant to me.",
})}
</p>
</Typography>
</header>
<ProfilingConsentAccordion component="modal" />
</section>
</main>
{!readOnly && (
<footer className={styles.actions}>
<Button
variant="Primary"
color="Primary"
size="Large"
typography="Body/Supporting text (caption)/smRegular"
type="button"
isDisabled={isLoading}
isPending={isLoading && activeChoice === true}
onClick={() => {
trackConsentAction({
position: "modal",
name: "accept personalization",
})
handleClick(true)
}}
>
{intl.formatMessage({
id: "profilingConsent.acceptPersonalization",
defaultMessage: "Accept Personalization",
})}
</Button>
<Button
variant="Secondary"
size="Large"
color="Primary"
typography="Body/Supporting text (caption)/smRegular"
type="button"
isDisabled={isLoading}
isPending={isLoading && activeChoice === false}
onClick={() => {
trackConsentAction({ position: "modal", name: "decline" })
handleClick(false)
}}
>
{intl.formatMessage({
id: "common.decline",
defaultMessage: "Decline",
})}
</Button>
</footer>
)}
</Dialog>
</MotionModal>
)}
</AnimatePresence>
</ModalOverlay>
)
}

View File

@@ -0,0 +1,136 @@
@keyframes fade {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.overlay {
background: rgba(0, 0, 0, 0.5);
position: fixed;
inset: 0;
height: var(--visual-viewport-height);
z-index: var(--default-modal-overlay-z-index);
display: flex;
justify-content: center;
align-items: flex-end;
&[data-entering] {
animation: fade 400ms ease-in;
}
&[data-exiting] {
animation: fade 400ms reverse ease-in;
}
}
.modal {
background: var(--Background-Primary);
margin: 0 auto;
box-shadow: var(--modal-box-shadow);
width: 100%;
max-width: 100%;
border-radius: var(--Corner-radius-xLarge) var(--Corner-radius-xLarge) 0 0;
}
.dialog {
display: flex;
flex-direction: column;
max-height: 95dvh;
outline: 0 none;
overflow-y: auto;
overflow-x: hidden;
}
.header {
position: relative;
padding: var(--Space-x3);
}
.logoWrap {
display: flex;
justify-content: center;
}
.closeBtn {
position: absolute;
right: var(--Space-x2);
top: 50%;
transform: translateY(-50%);
}
.content {
padding: var(--Space-x4);
text-align: center;
display: grid;
place-items: center;
place-self: center;
gap: var(--Space-x5);
}
.textContent {
display: flex;
flex-direction: column;
gap: var(--Space-x15);
}
.heading {
color: var(--Text-Interactive-Default);
}
.actions {
position: sticky;
bottom: 0;
display: flex;
flex-direction: column;
gap: var(--Space-x1);
justify-content: center;
padding: var(--Space-x2) var(--Space-x3) var(--Space-x3) var(--Space-x3);
border-top: 1px solid var(--Border-Divider-Subtle);
background: var(--Base-Surface-Primary-light-Normal);
border-bottom-right-radius: var(--Corner-radius-xLarge);
border-bottom-left-radius: var(--Corner-radius-xLarge);
}
.container {
border-radius: var(--Corner-radius-md);
background: var(--Base-Surface-Primary-light-Normal);
display: grid;
gap: var(--Space-x5);
padding: var(--Space-x4) var(--Space-x2);
width: 100%;
text-align: left;
}
.header {
display: grid;
gap: var(--Space-x1);
text-align: center;
color: var(--Text-Default);
}
@media (min-width: 768px) {
.overlay {
align-items: center;
}
.modal {
width: 95%;
max-width: 95%;
border-radius: var(--Corner-radius-xLarge);
}
.actions {
flex-direction: row-reverse;
gap: var(--Space-x2);
box-shadow: 0 0 8px 3px rgba(0, 0, 0, 0.1);
}
}
@media (min-width: 1367px) {
.content {
max-width: 984px;
}
}

View File

@@ -0,0 +1,90 @@
# Profiling Consent
A full-page modal shown when a user first visits a My Pages route.
## Usage
### My Pages Modal
Rendered in `app/[lang]/(live)/(protected)/my-pages/layout.tsx` so it is available across all My Pages routes. The layout passes `memberKey` (derived from `membershipNumber` or `profileId`) to enable per-member dismissal tracking.
### Signup Form Integration
A read-only version (`Modal/ReadOnly.tsx`) is integrated into the signup form (`components/Forms/Signup/index.tsx`) to provide users with information about personalization benefits during registration. This version:
- Has no action buttons (Accept/Decline)
- Can be opened via the "Read more about personalization at Scandic" button in the signup form
- Uses the same Contentstack content as the main modal
- Does not require a `memberKey` since it's for non-authenticated users
## Features
### My Pages Modal
- Displays upon landing on any My Pages route (if not previously dismissed)
- Shows Scandic logo, title, lead text, benefit cards and an accordion
- Close via "X" button only (no overlay click or ESC key)
- Dismissal persisted in `localStorage` per member
- Includes Accept/Decline action buttons (currently non-functional)
### Read-Only Version (Signup)
- Same visual content as main modal but without action buttons
- Accessible during signup process via "Read more" button
- Uses Contentstack content fetched via `trpc.contentstack.profilingConsent.get.useQuery`
- No localStorage persistence since it's informational only
## Components
- `Modal/` — Main modal shell with header, content, and action buttons
- `Modal/ReadOnly.tsx` — Read-only version without action buttons, used in signup form
- `Modal/BenefitCards/` — Cards showcasing personalization benefits
- `Accordion/` — Privacy and personalization information
- `Banner/` — A banner shown on the account overview page that can reopen the modal
## Banner
- Purpose: Offer a way to reopen the Profiling Consent modal later when the user is ready to decide.
- Visibility: Decided server-side in `Banner/index.tsx` (render only when consent status is pending once API is available).
- Behavior: The client CTA dispatches a `profiling-consent:open` event to reopen the modal.
## Local Persistence
- Key: `profiling-consent:dismissed:<memberKey>`
- Set when the modal is closed via the header close button.
- This flag only controls auto-open behavior; it does not reflect Accept/Decline (those will be handled via API and used server-side to decide banner visibility).
## Utilities
Located at `apps/scandic-web/utils/profilingConsent.ts`:
- `storageKey(memberKey)`
- `readDismissed(memberKey)`
- `setDismissed(memberKey)`
- `clearDismissed(memberKey)`
- `profilingConsentOpenEvent` — CustomEvent name used to request the modal to open
- `requestOpen()` — Dispatches the open event
## Testing
To re-show the modal after dismissing:
```js
// In the browser console:
localStorage.removeItem("profiling-consent:dismissed:<memberKey>")
// Then refresh the page
```
To open the modal without clearing the dismissed flag:
```js
window.dispatchEvent(new CustomEvent("profiling-consent:open"))
```
Replace `<memberKey>` with the actual `membershipNumber` or `profileId`.
## Future Work
- Wire up Antavo/API integration for Accept/Decline actions
- Consider default-open Accordion items support in DS and open relevant items by default here
- Connect signup form personalization checkbox with profiling consent acceptance

View File

@@ -0,0 +1,49 @@
import HandGiftIcon from "@scandic-hotels/design-system/Icons/HandGiftIcon"
import MagicWandIcon from "@scandic-hotels/design-system/Icons/MagicWandIcon"
import SpaIcon from "@scandic-hotels/design-system/Icons/Spa"
import VoucherIcon from "@scandic-hotels/design-system/Icons/VoucherIcon"
import type { User } from "@scandic-hotels/trpc/types/user"
interface GetMainIconByCSIdentifierProps {
identifier: string
className?: string
}
// Main (modal/banner) icon mapping — default to Spa icon
export function GetMainIconByCSIdentifier({
identifier,
className,
}: GetMainIconByCSIdentifierProps) {
switch (identifier) {
case "SpaIcon":
default:
return <SpaIcon width={140} height={128} className={className} />
}
}
export function GetBenefitIconByCSIdentifier(
identifier: string
): React.ComponentType<{
width?: number
height?: number
className?: string
}> {
switch (identifier) {
case "VoucherIcon":
return VoucherIcon
case "MagicWandIcon":
return MagicWandIcon
case "HandGiftIcon":
default:
return HandGiftIcon
}
}
/**
* If profilingConsent is undefined, it means that the user has not yet made an active decision to accept or decline.
* Returns true if the user has taken an action, otherwise false.
*/
export function userHasConsent(consent: User["profilingConsent"]) {
return typeof consent === "boolean"
}

View File

@@ -4,8 +4,8 @@ import { usePathname, useRouter, useSearchParams } from "next/navigation"
import { useEffect, useRef } from "react"
import { useIntl } from "react-intl"
import { Button } from "@scandic-hotels/design-system/Button"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { OldDSButton as Button } from "@scandic-hotels/design-system/OldDSButton"
import { toast } from "@scandic-hotels/design-system/Toast"
import { trpc } from "@scandic-hotels/trpc/client"
@@ -98,9 +98,10 @@ export default function AddCreditCardButton() {
return (
<Button
className={styles.addCreditCardButton}
variant="icon"
theme="base"
intent="text"
variant="Text"
color="Primary"
size="Medium"
typography="Body/Paragraph/mdBold"
onClick={() =>
initiateAddCard.mutate({
language: lang,

View File

@@ -5,6 +5,11 @@
grid-template-columns: auto auto auto 1fr;
justify-items: flex-end;
padding: var(--Space-x1) var(--Space-x15);
border-radius: var(--Corner-radius-sm);
background-color: var(--Background-Primary);
border-radius: var(--Corner-radius-Medium);
background-color: var(--Base-Surface-Secondary-light-Normal);
}
.cardNumber {
margin-left: var(--Space-x1);
color: var(--Text-Secondary);
}

View File

@@ -1,6 +1,5 @@
import Body from "@scandic-hotels/design-system/Body"
import Caption from "@scandic-hotels/design-system/Caption"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { Typography } from "@scandic-hotels/design-system/Typography"
import DeleteCreditCardConfirmation from "../DeleteCreditCardConfirmation"
@@ -9,13 +8,20 @@ import styles from "./creditCardRow.module.css"
import type { CreditCardRowProps } from "@/types/components/myPages/myProfile/creditCards"
export default function CreditCardRow({ card }: CreditCardRowProps) {
const maskedCardNumber = `**** ${card.truncatedNumber.slice(-4)}`
const maskedCardNumber = `•••• ${card.truncatedNumber.slice(-4)}`
return (
<div className={styles.card}>
<MaterialIcon icon="credit_card" color="Icon/Intense" />
<Body textTransform="bold">{card.type}</Body>
<Caption color="textMediumContrast">{maskedCardNumber}</Caption>
<Typography variant="Body/Paragraph/mdBold">
<span>{card.type}</span>
</Typography>
<Typography
variant="Body/Paragraph/mdRegular"
className={styles.cardNumber}
>
<span>{maskedCardNumber}</span>
</Typography>
<DeleteCreditCardConfirmation card={card} />
</div>
)

View File

@@ -1,8 +1,8 @@
"use client"
import { useIntl } from "react-intl"
import { IconButton } from "@scandic-hotels/design-system/IconButton"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { OldDSButton as Button } from "@scandic-hotels/design-system/OldDSButton"
import { toast } from "@scandic-hotels/design-system/Toast"
import { trpc } from "@scandic-hotels/trpc/client"
@@ -70,9 +70,15 @@ export default function DeleteCreditCardConfirmation({
defaultMessage: "Remove card from member profile",
})}
trigger={
<Button intent="secondary" size="small" theme="base">
<IconButton
theme="Black"
aria-label={intl.formatMessage({
id: "profile.creditCard.deleteCard",
defaultMessage: "Delete card",
})}
>
<MaterialIcon icon="delete" color="Icon/Interactive/Default" />
</Button>
</IconButton>
}
/>
)

View File

@@ -3,13 +3,13 @@
import { useIntl } from "react-intl"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { OldDSButton as Button } from "@scandic-hotels/design-system/OldDSButton"
import { TextLinkButton } from "@scandic-hotels/design-system/TextLinkButton"
import { toast } from "@scandic-hotels/design-system/Toast"
import { trpc } from "@scandic-hotels/trpc/client"
import styles from "./managePreferencesButton.module.css"
import styles from "./manageEmailPreferencesButton.module.css"
export default function ManagePreferencesButton() {
export default function ManageEmailPreferencesButton() {
const intl = useIntl()
const generatePreferencesLink = trpc.user.generatePreferencesLink.useMutation(
{
@@ -19,7 +19,7 @@ export default function ManagePreferencesButton() {
} else {
toast.error(
intl.formatMessage({
id: "profile.managePreferences.communicationPreferencesUnavailable",
id: "profile.manageEmailPreferences.communicationPreferencesUnavailable",
defaultMessage:
"It's not possible to manage your communication preferences right now. Please try again later or contact support if the problem persists.",
})
@@ -29,7 +29,7 @@ export default function ManagePreferencesButton() {
onError: () => {
toast.error(
intl.formatMessage({
id: "profile.managePreferences.error",
id: "profile.manageEmailPreferences.anErrorOccured",
defaultMessage:
"An error occurred trying to manage your preferences, please try again later.",
})
@@ -39,19 +39,18 @@ export default function ManagePreferencesButton() {
)
return (
<Button
<TextLinkButton
theme="Primary"
className={styles.managePreferencesButton}
variant="icon"
theme="base"
intent="text"
onClick={() => generatePreferencesLink.mutate()}
wrapping
typography="Link/md"
color="Text/Interactive/Secondary"
>
<MaterialIcon icon="arrow_forward" color="CurrentColor" />
{intl.formatMessage({
id: "profile.managePreferences.title",
defaultMessage: "Manage preferences",
id: "profile.manageEmailPreferences",
defaultMessage: "Manage e-mail preferences",
})}
</Button>
</TextLinkButton>
)
}

View File

@@ -4,6 +4,7 @@ import {
overview,
points,
profile,
profileConsent,
profileEdit,
stays,
} from "@scandic-hotels/common/constants/routes/myPages"
@@ -19,6 +20,7 @@ export const authRequired = [
...Object.values(overview),
...Object.values(profile),
...Object.values(profileEdit),
...Object.values(profileConsent),
...Object.values(stays),
...Object.values(points),
]

View File

@@ -0,0 +1,49 @@
"use client"
import { useRouter } from "next/navigation"
import { useIntl } from "react-intl"
import { toast } from "@scandic-hotels/design-system/Toast"
import { trpc } from "@scandic-hotels/trpc/client"
export function useUpdateProfilingConsent() {
const intl = useIntl()
const utils = trpc.useUtils()
const router = useRouter()
const updateConsent = trpc.user.profilingConsent.update.useMutation({
onSuccess: async () => {
await utils.user.get.invalidate()
router.refresh()
setTimeout(() => {
toast.success(
intl.formatMessage({
id: "profilingConsent.alert.updateConsentSuccessful",
defaultMessage: "Preference saved!",
})
)
})
},
onError: () => {
setTimeout(() => {
toast.error(
intl.formatMessage({
id: "profilingConsent.alert.updateConsentFailed",
defaultMessage:
"An error occurred when updating preferences, please try again later.",
})
)
})
},
})
const initiateUpdateConsent = (consent: boolean) => {
updateConsent.mutate({ profilingConsent: consent })
}
return {
initiateUpdateConsent,
isLoading: updateConsent.isPending,
isSuccess: updateConsent.isSuccess,
isError: updateConsent.isError,
}
}

View File

@@ -244,3 +244,10 @@ export const getPromoCampaignPage = cache(
return caller.contentstack.promoCampaignPage.get()
}
)
export const getProfilingConsent = cache(
async function getMemoizedProfilingConsent() {
const caller = await serverClient()
return caller.contentstack.profilingConsent.get()
}
)

View File

@@ -3,8 +3,6 @@ import { type NextMiddleware, NextResponse } from "next/server"
import {
myPages,
overview,
profile,
profileEdit,
} from "@scandic-hotels/common/constants/routes/myPages"
import { logger } from "@scandic-hotels/common/logger"
import { findLang } from "@scandic-hotels/common/utils/languages"
@@ -37,6 +35,7 @@ export const middleware: NextMiddleware = async (request) => {
pathNameWithoutLang,
lang
)
if (error) {
throw internalServerError(error)
}
@@ -50,24 +49,6 @@ export const middleware: NextMiddleware = async (request) => {
headers.set("x-uid", uid)
headers.set("x-contenttype", contentType)
// Handle profile and profile edit routes, which are not CMS entries
if (profile[lang].startsWith(nextUrl.pathname)) {
return NextResponse.rewrite(new URL(`/${lang}/my-pages/profile`, nextUrl), {
request: {
headers,
},
})
} else if (profileEdit[lang].startsWith(nextUrl.pathname)) {
return NextResponse.rewrite(
new URL(`/${lang}/my-pages/profile/edit`, nextUrl),
{
request: {
headers,
},
}
)
}
return NextResponse.next({
request: {
headers,

View File

@@ -0,0 +1,24 @@
export const profilingConsentOpenEvent = "profiling-consent:open"
export const storageKey = (memberKey: string) =>
`profiling-consent:dismissed:${memberKey}`
export function readDismissed(memberKey: string): boolean {
if (!memberKey || typeof window === "undefined") return false
return localStorage.getItem(storageKey(memberKey)) === "1"
}
export function setDismissed(memberKey: string): void {
if (!memberKey || typeof window === "undefined") return
localStorage.setItem(storageKey(memberKey), "1")
}
export function clearDismissed(memberKey: string): void {
if (!memberKey || typeof window === "undefined") return
localStorage.removeItem(storageKey(memberKey))
}
export function requestOpen(): void {
if (typeof window === "undefined") return
window.dispatchEvent(new CustomEvent(profilingConsentOpenEvent))
}

View File

@@ -0,0 +1,67 @@
import { trackEvent } from "@scandic-hotels/tracking/base"
import type { TrackingSDKPageData } from "@scandic-hotels/tracking/types"
interface trackLinkClickProps {
position: "modal" | "banner" | "signup" | "profile"
name:
| "learn more about how we process your data"
| "edit preference in my profile"
| "manage profiling consent"
| "read more about personalization at scandic"
}
export function trackLinkClick({ position, name }: trackLinkClickProps) {
trackEvent({
event: "profileConsent",
profile: {
position: position,
},
link: {
name: name,
},
})
}
interface trackConsentChangeProps {
to: boolean
from?: boolean
}
export function trackConsentChange({ to, from }: trackConsentChangeProps) {
trackEvent({
event: "profileConsentUpdate",
profile: {
fromConsent: from,
toConsent: to,
},
cta: {
name: "save & update the preferences",
},
})
}
interface trackConsentActionProps {
position: "modal" | "banner" | "profile"
name: string
}
export function trackConsentAction({
position,
name,
}: trackConsentActionProps) {
trackEvent({
event: "profileConsent",
profile: {
position: position,
},
cta: {
name: name,
},
})
}
export const ModalTracking: Omit<TrackingSDKPageData, "domainLanguage"> = {
pageType: "profileconsentmodalpage",
channel: "scandic-friends",
siteVersion: "new-web",
pageName: "profile consent modal",
siteSections: "profile consent modal",
}