Merged in feat/LOY-497-Flag-Profiling-Consent (pull request #3292)

refactor(LOY-497): hide profiling consent behind feature flag

* refactor(LOY-497): hide profiling consent behind feature flag

* chore(LOY-497): up to date consent readme


Approved-by: Matilda Landström
This commit is contained in:
Chuma Mcphoy (We Ahead)
2025-12-05 05:47:11 +00:00
parent aae5c4d33d
commit 2b9bc8c3ce
8 changed files with 216 additions and 108 deletions

View File

@@ -1,6 +1,7 @@
import { TrackingSDK } from "@scandic-hotels/tracking/TrackingSDK"
import { getEurobonusMembership } from "@scandic-hotels/trpc/routers/user/helpers"
import { env } from "@/env/server"
import {
getProfileSafely,
getProfilingConsent,
@@ -17,12 +18,22 @@ import { ModalTracking } from "@/utils/tracking/profilingConsent"
import styles from "./layout.module.css"
export default async function MyPagesLayout({
type MyPagesLayoutProps = React.PropsWithChildren<{
breadcrumbs: React.ReactNode
}>
export default async function MyPagesLayout(props: MyPagesLayoutProps) {
if (env.ENABLE_PROFILE_CONSENT) {
return <MyPagesLayoutWithConsent {...props} />
}
return <MyPagesLayoutBase {...props} />
}
async function MyPagesLayoutWithConsent({
breadcrumbs,
children,
}: React.PropsWithChildren<{
breadcrumbs: React.ReactNode
}>) {
}: MyPagesLayoutProps) {
const profile = await getProfileSafely()
const eurobonusMembership = profile?.loyalty
? getEurobonusMembership(profile.loyalty)
@@ -37,6 +48,8 @@ export default async function MyPagesLayout({
const lang = await getLang()
const showConsentModal = memberKey && profilingConsent && !hasConsent
return (
<ProfilingConsentAlertProvider>
<div className={styles.container}>
@@ -51,7 +64,7 @@ export default async function MyPagesLayout({
{eurobonusMembership && <SASLevelUpgradeCheck />}
<Surprises />
{memberKey && profilingConsent && !hasConsent ? (
{showConsentModal && (
<>
<ProfilingConsentModal
memberKey={memberKey}
@@ -62,8 +75,30 @@ export default async function MyPagesLayout({
pageData={{ domainLanguage: lang, ...ModalTracking }}
/>
</>
) : null}
)}
</div>
</ProfilingConsentAlertProvider>
)
}
async function MyPagesLayoutBase({
breadcrumbs,
children,
}: MyPagesLayoutProps) {
const profile = await getProfileSafely()
const eurobonusMembership = profile?.loyalty
? getEurobonusMembership(profile.loyalty)
: null
return (
<div className={styles.container}>
<div className={styles.layout}>
{breadcrumbs}
<div className={styles.content}>{children}</div>
</div>
{eurobonusMembership && <SASLevelUpgradeCheck />}
<Surprises />
</div>
)
}

View File

@@ -1,13 +1,24 @@
import { redirect } from "next/navigation"
import { profile } from "@scandic-hotels/common/constants/routes/myPages"
import { TrackingSDK } from "@scandic-hotels/tracking/TrackingSDK"
import { env } from "@/env/server"
import { getProfile } from "@/lib/trpc/memoizedRequests"
import { serverClient } from "@/lib/trpc/server"
import { ProfilingConsent } from "@/components/Forms/ProfilingConsent"
import { getLang } from "@/i18n/serverContext"
import styles from "./page.module.css"
export default async function ProfilingConsentSlot() {
const lang = await getLang()
if (!env.ENABLE_PROFILE_CONSENT) {
redirect(profile[lang])
}
const caller = await serverClient()
const accountPage = await caller.contentstack.accountPage.get()
const user = await getProfile()

View File

@@ -1,3 +1,5 @@
import { env } from "@/env/server"
import SignupForm from "@/components/Forms/Signup"
import type { SignupFormWrapperProps } from "@/types/components/blocks/dynamicContent"
@@ -5,5 +7,10 @@ import type { SignupFormWrapperProps } from "@/types/components/blocks/dynamicCo
export default async function SignupFormWrapper({
dynamic_content,
}: SignupFormWrapperProps) {
return <SignupForm {...dynamic_content} />
return (
<SignupForm
{...dynamic_content}
enableProfileConsent={env.ENABLE_PROFILE_CONSENT}
/>
)
}

View File

@@ -43,9 +43,14 @@ import styles from "./form.module.css"
interface SignUpFormProps {
title: string
enableProfileConsent?: boolean
}
export default function SignupForm({ title }: SignUpFormProps) {
export default function SignupForm({
title,
// Handled as a prop rather than a client env var due to limits in Netlify env var size.
enableProfileConsent = false,
}: SignUpFormProps) {
const intl = useIntl()
const router = useRouter()
const lang = useLang()
@@ -130,7 +135,7 @@ export default function SignupForm({ title }: SignUpFormProps) {
return (
<div className={styles.formWrapper}>
<ProfilingConsentModalReadOnly />
{enableProfileConsent && <ProfilingConsentModalReadOnly />}
{title ? (
<Typography variant="Title/md">
<h2>{title}</h2>
@@ -278,41 +283,43 @@ export default function SignupForm({ title }: SignUpFormProps) {
/>
</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>
<TextLinkButton
typography="Link/sm"
color="Primary"
className={styles.personalizationButton}
onClick={openPersonalizationModal}
>
{intl.formatMessage({
id: "signup.ReadMoreAboutPersonalization",
defaultMessage: "Read more about personalization at Scandic",
})}
</TextLinkButton>
</section>
{enableProfileConsent && (
<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>
<TextLinkButton
typography="Link/sm"
color="Primary"
className={styles.personalizationButton}
onClick={openPersonalizationModal}
>
{intl.formatMessage({
id: "signup.ReadMoreAboutPersonalization",
defaultMessage: "Read more about personalization at Scandic",
})}
</TextLinkButton>
</section>
)}
<section className={styles.terms}>
<header>

View File

@@ -1,3 +1,5 @@
import { env } from "@/env/server"
import { getIntl } from "@/i18n"
import { Section } from "../Section"
@@ -15,7 +17,7 @@ export async function CommunicationSettings() {
})}
>
<EmailSlot />
<PersonalizationSlot />
{env.ENABLE_PROFILE_CONSENT && <PersonalizationSlot />}
</Section>
)
}

View File

@@ -1,5 +1,6 @@
import { Typography } from "@scandic-hotels/design-system/Typography"
import { env } from "@/env/server"
import { getProfile, getProfilingConsent } from "@/lib/trpc/memoizedRequests"
import { GetMainIconByCSIdentifier, userHasConsent } from "../utils"
@@ -8,6 +9,8 @@ import { BannerButton } from "./Button"
import styles from "./profilingConsentBanner.module.css"
export async function ProfilingConsentBanner() {
if (!env.ENABLE_PROFILE_CONSENT) return null
const user = await getProfile()
if (!user || userHasConsent(user?.profilingConsent)) return null

View File

@@ -1,83 +1,115 @@
# Profiling Consent
A full-page modal shown when a user first visits a My Pages route. If the modal is dimissed, a banner is shown on the overview page.
On `/profile`, the user can navigate to `/profile/consent` to update the consent.
On signup, it's also possible to opt in.
Profiling consent allows users to opt in/out of personalized experiences. The feature is controlled by the `ENABLE_PROFILE_CONSENT` environment variable.
## Usage
## User Journey
### 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
### 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
1. **Signup**: Optional consent checkbox in registration form
2. **My Pages Modal**: Prompt shown on first visit (if no decision made)
3. **Banner**: Shown on overview page if modal was dismissed without deciding
4. **Profile Settings**: Dedicated page at `/profile/consent` to update consent at any time
## 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 (`Modal/`)
Full-page modal shown when a user first visits a My Pages route.
- Rendered in `app/[lang]/(live)/(protected)/my-pages/layout.tsx`
- Only shows if user hasn't made a consent decision (`profilingConsent` is `undefined`)
- Uses `memberKey` (from `membershipNumber` or `profileId`) for dismissal tracking
- Close via "X" button only (no overlay click or ESC key)
- Accept/Decline buttons update consent via API
**Sub-components:**
- `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
- `Modal/ReadOnly.tsx` — Informational version without action buttons (used in signup)
## Banner
### Banner (`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.
Shown on the account overview page when consent is pending.
## Local Persistence
- Server component that checks `userHasConsent()` before rendering
- CTA button dispatches `profiling-consent:open` event to reopen the modal
- Added to overview via Contentstack Dynamic Content block
### Alert (`Alert/`)
Feedback alert shown after consent actions in My Pages.
- `ProfilingConsentAlertProvider` — Context provider in My Pages layout
- Shows success/error states after accepting/declining consent
- Success alert includes link to edit preferences
### Consent Form (`components/Forms/ProfilingConsent/`)
Dedicated form on `/profile/consent` page for managing consent.
- Radio button selection for Accept/Decline
- Shows current consent status
- Redirects to profile after saving
- Includes accordion with privacy information
### Signup Integration
The signup form (`components/Forms/Signup/index.tsx`) includes:
- Checkbox to opt-in during registration
- "Read more" link that opens `Modal/ReadOnly.tsx`
### Profile Settings
`PersonalizationSlot` in `components/MyPages/Profile/CommunicationSettings/` provides a link to the consent management page.
### Accordion (`Accordion/`)
Expandable section with privacy and personalization details. Used in both the modal and consent form.
## API
### tRPC Mutations
- `trpc.user.profilingConsent.update` — Update user's consent preference
- `trpc.user.profilingConsentPromptDate.update` — Record when user was prompted
### tRPC Queries
- `trpc.contentstack.profilingConsent.get` — Fetch modal/banner content from CMS
## Hooks
- `useUpdateProfilingConsent` (`hooks/useUpdateProfilingConsent.ts`) — Handles consent update mutation with alert feedback via `ProfilingConsentAlertProvider`
## Local Storage
Modal dismissal is tracked per-member to control auto-open behavior:
- 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 are handled via API and used server-side to decide banner visibility).
- Set when modal is closed via header close button
- Does not reflect Accept/Decline (those are stored via API)
## Utilities
Located at `apps/scandic-web/utils/profilingConsent.ts`:
Located at `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
- `storageKey(memberKey)` — Generate storage key
- `readDismissed(memberKey)` — Check if modal was dismissed
- `setDismissed(memberKey)` — Mark modal as dismissed
- `clearDismissed(memberKey)` — Clear dismissal flag
- `profilingConsentOpenEvent` — CustomEvent name for opening modal
- `requestOpen()` — Dispatch event to open modal
## Testing
To re-show the modal after dismissing:
Re-show modal after dismissing:
```js
// In the browser console:
localStorage.removeItem("profiling-consent:dismissed:<memberKey>")
// Then refresh the page
// Refresh the page
```
To open the modal without clearing the dismissed flag:
Open modal programmatically:
```js
window.dispatchEvent(new CustomEvent("profiling-consent:open"))
@@ -85,13 +117,18 @@ window.dispatchEvent(new CustomEvent("profiling-consent:open"))
Replace `<memberKey>` with the actual `membershipNumber` or `profileId`.
## Contentstack
## Contentstack Setup
Profiling Consent setup in Contentstack:
Required content for the feature:
- Profiling Consent (config)
Config needs to be created and published in respective language.
- /consent (account page)
Page needs to be created and published in respective language.
- /overview (account page)
Need to add Dynamic content: Profiling Consent Banner to respective language, and re-publish the page.
1. **Profiling Consent (config)**
- Config needs to be created and published in each language
2. **/consent (account page)**
- Page needs to be created and published in each language
3. **/overview (account page)**
- Add Dynamic content block: "Profiling Consent Banner"
- Re-publish the page for each language

View File

@@ -107,6 +107,11 @@ export const env = createEnv({
.refine((s) => s === "1" || s === "0")
.transform((s) => s === "1")
.default("0"),
ENABLE_PROFILE_CONSENT: z
.string()
.refine((s) => s === "true" || s === "false")
.transform((s) => s === "true")
.default("false"),
},
emptyStringAsUndefined: true,
runtimeEnv: {
@@ -162,5 +167,6 @@ export const env = createEnv({
CHATBOT_LIVE_LANGS: process.env.CHATBOT_LIVE_LANGS,
NEW_STAYS_ON_MY_PAGES: process.env.NEW_STAYS_ON_MY_PAGES,
SEO_INERT: process.env.SEO_INERT,
ENABLE_PROFILE_CONSENT: process.env.ENABLE_PROFILE_CONSENT,
},
})