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:
@@ -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 = {
|
||||
|
||||
@@ -218,6 +218,7 @@ export default function ProfilingConsentAccordion({
|
||||
color="Text/Interactive/Secondary"
|
||||
typography="Link/md"
|
||||
className={styles.learnMoreLink}
|
||||
target="_blank"
|
||||
onClick={() =>
|
||||
trackLinkClick({
|
||||
position: component,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()),
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user