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:
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
.container {
|
||||
padding-top: var(--Space-x3);
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
.container {
|
||||
padding-top: var(--Space-x6);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
205
apps/scandic-web/components/Forms/ProfilingConsent/index.tsx
Normal file
205
apps/scandic-web/components/Forms/ProfilingConsent/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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),
|
||||
]
|
||||
|
||||
49
apps/scandic-web/hooks/useUpdateProfilingConsent.ts
Normal file
49
apps/scandic-web/hooks/useUpdateProfilingConsent.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
24
apps/scandic-web/utils/profilingConsent.ts
Normal file
24
apps/scandic-web/utils/profilingConsent.ts
Normal 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))
|
||||
}
|
||||
67
apps/scandic-web/utils/tracking/profilingConsent.ts
Normal file
67
apps/scandic-web/utils/tracking/profilingConsent.ts
Normal 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",
|
||||
}
|
||||
Reference in New Issue
Block a user