Merged in feat/LOY-486-consent-prompt-date (pull request #3221)

Feat/LOY-486 consent prompt date

* chore(LOY-486): update name on date

* chore(LOY-486): open links in new tab

* chore(LOY-486): send prompt date + refactor modal

* chore(LOY-486): update README


Approved-by: Chuma Mcphoy (We Ahead)
This commit is contained in:
Matilda Landström
2025-11-26 12:11:57 +00:00
parent f70c431a5e
commit 086319e8b1
9 changed files with 119 additions and 37 deletions

View File

@@ -163,7 +163,7 @@ const authenticatedUser: SafeUser = {
employmentDetails: undefined,
promotions: [],
profilingConsent: undefined,
profilingConsentUpdate: undefined,
profilingConsentUpdateDate: undefined,
}
const badAuthenticatedUser: SafeUser = {
@@ -198,7 +198,7 @@ const badAuthenticatedUser: SafeUser = {
employmentDetails: undefined,
promotions: [],
profilingConsent: undefined,
profilingConsentUpdate: undefined,
profilingConsentUpdateDate: undefined,
}
const loggedOutGuest: Guest = {

View File

@@ -218,6 +218,7 @@ export default function ProfilingConsentAccordion({
color="Text/Interactive/Secondary"
typography="Link/md"
className={styles.learnMoreLink}
target="_blank"
onClick={() =>
trackLinkClick({
position: component,

View File

@@ -31,6 +31,7 @@ export function getLegalText(intl: IntlShape, lang: Lang) {
href={partnerLink}
color="Text/Interactive/Secondary"
typography="Link/md"
target="_blank"
isInline
>
{linkText}
@@ -41,6 +42,7 @@ export function getLegalText(intl: IntlShape, lang: Lang) {
href={privacyPolicyLink}
color="Text/Interactive/Secondary"
typography="Link/md"
target="_blank"
isInline
>
{linkText}

View File

@@ -1,7 +1,7 @@
"use client"
import { AnimatePresence, motion } from "motion/react"
import { useCallback, useEffect, useState } from "react"
import { useCallback, useEffect, useRef, useState } from "react"
import { Dialog, Modal, ModalOverlay } from "react-aria-components"
import { useIntl } from "react-intl"
@@ -9,6 +9,7 @@ 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 { trpc } from "@scandic-hotels/trpc/client"
import { useUpdateProfilingConsent } from "@/hooks/useUpdateProfilingConsent"
import {
@@ -35,6 +36,52 @@ type ProfilingConsentModalProps = {
const MotionModal = motion.create(Modal)
function usePromptInitialization(memberKey: string | undefined) {
const utils = trpc.useUtils()
const updateConsentPromptDate =
trpc.user.profilingConsentPromptDate.update.useMutation({
onSuccess: () => utils.user.get.invalidate(),
})
const mutationRef = useRef(updateConsentPromptDate)
mutationRef.current = updateConsentPromptDate
const [shouldOpenInitially, setShouldOpenInitially] = useState(false)
const hasSentPromptDate = useRef(false)
const sendPromptDate = useCallback((date: string) => {
mutationRef.current.mutate({
profilingConsentPromptDate: date,
})
}, [])
useEffect(() => {
if (!memberKey) return
const dismissed = readDismissed(memberKey)
const firstOpen = !dismissed
setShouldOpenInitially(firstOpen)
if (firstOpen && !hasSentPromptDate.current) {
hasSentPromptDate.current = true
sendPromptDate(new Date().toISOString())
}
}, [memberKey, sendPromptDate])
return shouldOpenInitially
}
function useModalEvents(setOpen: (s: boolean) => void) {
useEffect(() => {
const handleOpen: EventListener = () => setOpen(true)
window.addEventListener(profilingConsentOpenEvent, handleOpen)
return () =>
window.removeEventListener(profilingConsentOpenEvent, handleOpen)
}, [setOpen])
}
export default function ProfilingConsentModal({
memberKey,
content,
@@ -42,22 +89,20 @@ export default function ProfilingConsentModal({
readOnly = false,
}: ProfilingConsentModalProps) {
const intl = useIntl()
const [open, setOpen] = useState(false)
const [activeChoice, setActiveChoice] = useState<boolean | null>(null)
const shouldOpenInitially = usePromptInitialization(memberKey)
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])
if (shouldOpenInitially) setOpen(true)
}, [shouldOpenInitially])
useModalEvents(setOpen)
const onClose = useCallback(() => {
if (memberKey) {
@@ -66,29 +111,22 @@ export default function ProfilingConsentModal({
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
const handleConsentClick = (consent: boolean) => {
setActiveChoice(consent)
initiateUpdateConsent(consent)
}
return (
<ModalOverlay
className={styles.overlay}
isOpen={open}
onOpenChange={(isOpen) => {
if (!isOpen) {
onClose()
}
}}
onOpenChange={(state) => !state && onClose()}
isKeyboardDismissDisabled
isDismissable={false}
>
@@ -185,7 +223,7 @@ export default function ProfilingConsentModal({
position: "modal",
name: "accept personalization",
})
handleClick(true)
handleConsentClick(true)
}}
>
{intl.formatMessage({
@@ -203,7 +241,7 @@ export default function ProfilingConsentModal({
isPending={isLoading && activeChoice === false}
onClick={() => {
trackConsentAction({ position: "modal", name: "decline" })
handleClick(false)
handleConsentClick(false)
}}
>
{intl.formatMessage({

View File

@@ -1,6 +1,8 @@
# Profiling Consent
A full-page modal shown when a user first visits a My Pages route.
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.
## Usage
@@ -25,7 +27,7 @@ A read-only version (`Modal/ReadOnly.tsx`) is integrated into the signup form (`
- 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)
- Includes Accept/Decline action buttons
### Read-Only Version (Signup)
@@ -52,7 +54,7 @@ A read-only version (`Modal/ReadOnly.tsx`) is integrated into the signup form (`
- 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).
- 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).
## Utilities
@@ -83,8 +85,13 @@ window.dispatchEvent(new CustomEvent("profiling-consent:open"))
Replace `<memberKey>` with the actual `membershipNumber` or `profileId`.
## Future Work
## Contentstack
- 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
Profiling Consent setup in Contentstack:
- 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.

View File

@@ -60,6 +60,10 @@ export const profilingConsentInput = z.object({
profilingConsent: z.boolean(),
})
export const profilingConsentPromptDateInput = z.object({
profilingConsentPromptDate: z.string(),
})
export const getSavedPaymentCardsInput = z.object({
supportedCards: z.array(z.string()),
})

View File

@@ -12,6 +12,7 @@ import {
addPromoCampaignInput,
deleteCreditCardInput,
profilingConsentInput,
profilingConsentPromptDateInput,
saveCreditCardInput,
signupInput,
} from "./input"
@@ -246,6 +247,35 @@ export const userMutationRouter = router({
return true
}),
}),
profilingConsentPromptDate: router({
update: protectedProcedure
.input(profilingConsentPromptDateInput)
.mutation(async function ({ ctx, input }) {
const profilingConsentPromptDateCounter = createCounter(
"trpc.user",
"profilingConsentPromptDate"
)
const metricsProfilingConsentPromptDate =
profilingConsentPromptDateCounter.init()
const apiResponse = await api.patch(api.endpoints.v2.Profile.profile, {
body: input,
headers: {
Authorization: `Bearer ${ctx.session.token.access_token}`,
},
})
if (!apiResponse.ok) {
await metricsProfilingConsentPromptDate.httpError(apiResponse)
const text = await apiResponse.text()
throw serverErrorByStatus(apiResponse.status, text)
}
metricsProfilingConsentPromptDate.success()
return true
}),
}),
promoCampaign: router({
add: protectedProcedure
.input(addPromoCampaignInput)

View File

@@ -114,7 +114,7 @@ export const getUserSchema = z
loyalty: userLoyaltySchema.optional(),
employmentDetails: employmentDetailsSchema,
profilingConsent: z.boolean().optional(),
profilingConsentUpdate: z.string().optional(),
profilingConsentUpdateDate: z.string().optional(),
promotions: z.array(z.string()).nullish(),
}),
type: z.string(),

View File

@@ -29,7 +29,7 @@ export function parsedUser(data: User, maskValues: boolean) {
phoneNumber: data.phoneNumber,
profileId: data.profileId,
profilingConsent: data.profilingConsent,
profilingConsentUpdate: data.profilingConsentUpdate,
profilingConsentUpdateDate: data.profilingConsentUpdateDate,
promotions: data.promotions || null,
} satisfies User