diff --git a/apps/scandic-web/components/HotelReservation/MyStay/accessBooking.test.ts b/apps/scandic-web/components/HotelReservation/MyStay/accessBooking.test.ts index 503575996..bb99926cf 100644 --- a/apps/scandic-web/components/HotelReservation/MyStay/accessBooking.test.ts +++ b/apps/scandic-web/components/HotelReservation/MyStay/accessBooking.test.ts @@ -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 = { diff --git a/apps/scandic-web/components/MyPages/ProfilingConsent/Accordion/index.tsx b/apps/scandic-web/components/MyPages/ProfilingConsent/Accordion/index.tsx index 790bef505..2dc171a8a 100644 --- a/apps/scandic-web/components/MyPages/ProfilingConsent/Accordion/index.tsx +++ b/apps/scandic-web/components/MyPages/ProfilingConsent/Accordion/index.tsx @@ -218,6 +218,7 @@ export default function ProfilingConsentAccordion({ color="Text/Interactive/Secondary" typography="Link/md" className={styles.learnMoreLink} + target="_blank" onClick={() => trackLinkClick({ position: component, diff --git a/apps/scandic-web/components/MyPages/ProfilingConsent/Accordion/personalizationLegalText.tsx b/apps/scandic-web/components/MyPages/ProfilingConsent/Accordion/personalizationLegalText.tsx index 60c0295ac..363b59300 100644 --- a/apps/scandic-web/components/MyPages/ProfilingConsent/Accordion/personalizationLegalText.tsx +++ b/apps/scandic-web/components/MyPages/ProfilingConsent/Accordion/personalizationLegalText.tsx @@ -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} diff --git a/apps/scandic-web/components/MyPages/ProfilingConsent/Modal/index.tsx b/apps/scandic-web/components/MyPages/ProfilingConsent/Modal/index.tsx index 62d1b0c86..7119e608b 100644 --- a/apps/scandic-web/components/MyPages/ProfilingConsent/Modal/index.tsx +++ b/apps/scandic-web/components/MyPages/ProfilingConsent/Modal/index.tsx @@ -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(null) + + const shouldOpenInitially = usePromptInitialization(memberKey) const { initiateUpdateConsent, isLoading, isSuccess } = useUpdateProfilingConsent() - const [activeChoice, setActiveChoice] = useState(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 ( { - 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({ diff --git a/apps/scandic-web/components/MyPages/ProfilingConsent/README.md b/apps/scandic-web/components/MyPages/ProfilingConsent/README.md index bd8020180..f42ec5bd5 100644 --- a/apps/scandic-web/components/MyPages/ProfilingConsent/README.md +++ b/apps/scandic-web/components/MyPages/ProfilingConsent/README.md @@ -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:` - 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 `` 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. diff --git a/packages/trpc/lib/routers/user/input.ts b/packages/trpc/lib/routers/user/input.ts index 36ae3e299..7a9f58106 100644 --- a/packages/trpc/lib/routers/user/input.ts +++ b/packages/trpc/lib/routers/user/input.ts @@ -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()), }) diff --git a/packages/trpc/lib/routers/user/mutation.ts b/packages/trpc/lib/routers/user/mutation.ts index 65f74781c..503f4c476 100644 --- a/packages/trpc/lib/routers/user/mutation.ts +++ b/packages/trpc/lib/routers/user/mutation.ts @@ -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) diff --git a/packages/trpc/lib/routers/user/output.ts b/packages/trpc/lib/routers/user/output.ts index b3ac0db68..09aff0e48 100644 --- a/packages/trpc/lib/routers/user/output.ts +++ b/packages/trpc/lib/routers/user/output.ts @@ -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(), diff --git a/packages/trpc/lib/routers/user/utils/parsedUser.ts b/packages/trpc/lib/routers/user/utils/parsedUser.ts index 0e2cb28be..8fe461bcd 100644 --- a/packages/trpc/lib/routers/user/utils/parsedUser.ts +++ b/packages/trpc/lib/routers/user/utils/parsedUser.ts @@ -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