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,
|
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 = {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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()),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user