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, employmentDetails: undefined,
promotions: [], promotions: [],
profilingConsent: undefined, profilingConsent: undefined,
profilingConsentUpdate: undefined, profilingConsentUpdateDate: undefined,
} }
const badAuthenticatedUser: SafeUser = { const badAuthenticatedUser: SafeUser = {
@@ -198,7 +198,7 @@ const badAuthenticatedUser: SafeUser = {
employmentDetails: undefined, employmentDetails: undefined,
promotions: [], promotions: [],
profilingConsent: undefined, profilingConsent: undefined,
profilingConsentUpdate: undefined, profilingConsentUpdateDate: undefined,
} }
const loggedOutGuest: Guest = { const loggedOutGuest: Guest = {

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
"use client" "use client"
import { AnimatePresence, motion } from "motion/react" 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 { Dialog, Modal, ModalOverlay } from "react-aria-components"
import { useIntl } from "react-intl" 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 { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import ScandicLogoIcon from "@scandic-hotels/design-system/Icons/ScandicLogoIcon" import ScandicLogoIcon from "@scandic-hotels/design-system/Icons/ScandicLogoIcon"
import { Typography } from "@scandic-hotels/design-system/Typography" import { Typography } from "@scandic-hotels/design-system/Typography"
import { trpc } from "@scandic-hotels/trpc/client"
import { useUpdateProfilingConsent } from "@/hooks/useUpdateProfilingConsent" import { useUpdateProfilingConsent } from "@/hooks/useUpdateProfilingConsent"
import { import {
@@ -35,6 +36,52 @@ type ProfilingConsentModalProps = {
const MotionModal = motion.create(Modal) 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({ export default function ProfilingConsentModal({
memberKey, memberKey,
content, content,
@@ -42,22 +89,20 @@ export default function ProfilingConsentModal({
readOnly = false, readOnly = false,
}: ProfilingConsentModalProps) { }: ProfilingConsentModalProps) {
const intl = useIntl() const intl = useIntl()
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [activeChoice, setActiveChoice] = useState<boolean | null>(null)
const shouldOpenInitially = usePromptInitialization(memberKey)
const { initiateUpdateConsent, isLoading, isSuccess } = const { initiateUpdateConsent, isLoading, isSuccess } =
useUpdateProfilingConsent() useUpdateProfilingConsent()
const [activeChoice, setActiveChoice] = useState<boolean | null>(null)
const handleClick = (consent: boolean) => {
setActiveChoice(consent)
initiateUpdateConsent(consent)
}
useEffect(() => { useEffect(() => {
if (!memberKey) return if (shouldOpenInitially) setOpen(true)
setOpen(!readDismissed(memberKey)) }, [shouldOpenInitially])
}, [memberKey])
useModalEvents(setOpen)
const onClose = useCallback(() => { const onClose = useCallback(() => {
if (memberKey) { if (memberKey) {
@@ -66,29 +111,22 @@ export default function ProfilingConsentModal({
setOpen(false) setOpen(false)
}, [memberKey]) }, [memberKey])
useEffect(() => {
const handleOpen: EventListener = () => setOpen(true)
window.addEventListener(profilingConsentOpenEvent, handleOpen)
return () => {
window.removeEventListener(profilingConsentOpenEvent, handleOpen)
}
}, [])
useEffect(() => { useEffect(() => {
if (isSuccess) onClose() if (isSuccess) onClose()
}, [isSuccess, onClose]) }, [isSuccess, onClose])
if (!memberKey && !readOnly) return null if (!memberKey && !readOnly) return null
const handleConsentClick = (consent: boolean) => {
setActiveChoice(consent)
initiateUpdateConsent(consent)
}
return ( return (
<ModalOverlay <ModalOverlay
className={styles.overlay} className={styles.overlay}
isOpen={open} isOpen={open}
onOpenChange={(isOpen) => { onOpenChange={(state) => !state && onClose()}
if (!isOpen) {
onClose()
}
}}
isKeyboardDismissDisabled isKeyboardDismissDisabled
isDismissable={false} isDismissable={false}
> >
@@ -185,7 +223,7 @@ export default function ProfilingConsentModal({
position: "modal", position: "modal",
name: "accept personalization", name: "accept personalization",
}) })
handleClick(true) handleConsentClick(true)
}} }}
> >
{intl.formatMessage({ {intl.formatMessage({
@@ -203,7 +241,7 @@ export default function ProfilingConsentModal({
isPending={isLoading && activeChoice === false} isPending={isLoading && activeChoice === false}
onClick={() => { onClick={() => {
trackConsentAction({ position: "modal", name: "decline" }) trackConsentAction({ position: "modal", name: "decline" })
handleClick(false) handleConsentClick(false)
}} }}
> >
{intl.formatMessage({ {intl.formatMessage({

View File

@@ -1,6 +1,8 @@
# Profiling Consent # 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 ## 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 - Shows Scandic logo, title, lead text, benefit cards and an accordion
- Close via "X" button only (no overlay click or ESC key) - Close via "X" button only (no overlay click or ESC key)
- Dismissal persisted in `localStorage` per member - Dismissal persisted in `localStorage` per member
- Includes Accept/Decline action buttons (currently non-functional) - Includes Accept/Decline action buttons
### Read-Only Version (Signup) ### 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>` - Key: `profiling-consent:dismissed:<memberKey>`
- Set when the modal is closed via the header close button. - 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 ## Utilities
@@ -83,8 +85,13 @@ window.dispatchEvent(new CustomEvent("profiling-consent:open"))
Replace `<memberKey>` with the actual `membershipNumber` or `profileId`. Replace `<memberKey>` with the actual `membershipNumber` or `profileId`.
## Future Work ## Contentstack
- Wire up Antavo/API integration for Accept/Decline actions Profiling Consent setup in Contentstack:
- 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 (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(), profilingConsent: z.boolean(),
}) })
export const profilingConsentPromptDateInput = z.object({
profilingConsentPromptDate: z.string(),
})
export const getSavedPaymentCardsInput = z.object({ export const getSavedPaymentCardsInput = z.object({
supportedCards: z.array(z.string()), supportedCards: z.array(z.string()),
}) })

View File

@@ -12,6 +12,7 @@ import {
addPromoCampaignInput, addPromoCampaignInput,
deleteCreditCardInput, deleteCreditCardInput,
profilingConsentInput, profilingConsentInput,
profilingConsentPromptDateInput,
saveCreditCardInput, saveCreditCardInput,
signupInput, signupInput,
} from "./input" } from "./input"
@@ -246,6 +247,35 @@ export const userMutationRouter = router({
return true 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({ promoCampaign: router({
add: protectedProcedure add: protectedProcedure
.input(addPromoCampaignInput) .input(addPromoCampaignInput)

View File

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

View File

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