Merged in feat/profile-consent-feature-branch (pull request #2900)
feat(LOY-268): Feature branch for profiling consent work * feat: Add feature branch for profile and consent work * Merged in feat/LOY-268-profile-consent-banner-comp (pull request #2908) Feat/LOY-358 profile consent banner component * feat: Add feature branch for profile and consent work * feat(LOY-268): create banner * feat(LOY-268): Create personalization banner component Approved-by: Chuma Mcphoy (We Ahead) * feat(LOY-268): create banner * feat(LOY-268): Create personalization banner component * Merged in feat/profile-consent-contentstack (pull request #2921) Feat(LOY-389): Profile consent in Contentstack * feat(LOY-268): create banner * feat(LOY-268): Create personalization banner component * chore(LOY-348): add profiling consent as CS entry * chore(LOY-348): add banner as dynamic content Approved-by: Chuma Mcphoy (We Ahead) * Merged in feat/LOY-347-Profile-consent-modal-phase-1 (pull request #2901) Feat(LOY-347): Profiling Consent Modal (phase 1) * feat(LOY-347): Profile Consent Modal base functionality * feat(LOY-347): Add Icon * feat(LOY-347): Add Benefit Cards to Profile consent modal * feat(LOY-347): Add accordion to profile consent modal * fix(LOY-347): scroll behaviour * Fix(LOY-347): fade in/out animations of profile consent modal * fix(LOY-347): White Ellipsis Around Icons * feat(LOY-347): Implement ability to open modal from banner * fix(LOY-347): minor fixes * fix(LOY-347): replace old spa icon * fix(LOY-347): re-add env vars * fix(LOY-347): File renaming and cleanup * chore(LOY-347): Update readme * fix(LOY-347): use correct space var * fix(LOY-347): Add TODO comment for adding link to accordion Approved-by: Matilda Landström * Merged in fix/LOY-386-profiling-consent-modal-contentstack (pull request #2930) Fix(LOY-386): profiling consent modal contentstack * feat(LOY-347): Profile Consent Modal base functionality * feat(LOY-347): Add Icon * feat(LOY-347): Add Benefit Cards to Profile consent modal * feat(LOY-347): Add accordion to profile consent modal * fix(LOY-347): scroll behaviour * Fix(LOY-347): fade in/out animations of profile consent modal * fix(LOY-347): White Ellipsis Around Icons * feat(LOY-347): Implement ability to open modal from banner * fix(LOY-347): minor fixes * fix(LOY-347): replace old spa icon * fix(LOY-347): re-add env vars * fix(LOY-347): File renaming and cleanup * fix(LOY-386): Use contentstack content for profile consent modal * fix(LOY-386): beneift cards schema transform * chore(LOY-386): remove usememo * fix(LOY-386): fix modalcontent check * fix(LOY-386): remove uneeded vars Approved-by: Matilda Landström * Merged in feat/LOY-412-profiling-consent-in-signup (pull request #2976) Feat(LOY-412): profiling consent in signup * feat(LOY-268): create banner * feat(LOY-268): Create personalization banner component * Merged in feat/profile-consent-contentstack (pull request #2921) Feat(LOY-389): Profile consent in Contentstack * feat(LOY-268): create banner * feat(LOY-268): Create personalization banner component * chore(LOY-348): add profiling consent as CS entry * chore(LOY-348): add banner as dynamic content Approved-by: Chuma Mcphoy (We Ahead) * Merged in feat/LOY-347-Profile-consent-modal-phase-1 (pull request #2901) Feat(LOY-347): Profiling Consent Modal (phase 1) * feat(LOY-347): Profile Consent Modal base functionality * feat(LOY-347): Add Icon * feat(LOY-347): Add Benefit Cards to Profile consent modal * feat(LOY-347): Add accordion to profile consent modal * fix(LOY-347): scroll behaviour * Fix(LOY-347): fade in/out animations of profile consent modal * fix(LOY-347): White Ellipsis Around Ico… * Merged in fix/lokalise-ids (pull request #3013) fix: add ids to translations in Profiling Consent * fix: add ids to translations Approved-by: Erik Tiekstra Approved-by: Chuma Mcphoy (We Ahead) * Merged in LOY-436-my-pages-profiling-consent (pull request #3011) LOY-436: Profiling Consent on My Profile, no api Approved-by: Chuma Mcphoy (We Ahead) * Merged in feat/LOY-418-profiling-consent-ui-text-update (pull request #3080) Feat/LOY-418: Profiling consent ui and text update * chore(LOY-418): update /consent buttons * chore(LOY-418): update legal texts Approved-by: Chuma Mcphoy (We Ahead) * Merged in feat/LOY-268-profiling-consent-api (pull request #3088) Approved-by: Chuma Mcphoy (We Ahead) * Merged in feat/LOY-413-Signup-API-Profiling-Consent (pull request #3105) Feat/LOY-413 Signup API Profiling Consent * feat(LOY-413): signup profiling consent * chore(LOY-413): remove todo * fix(LOY-413): only pass in profilingConsent if true * fix(LOY-413): proper spread of profilingConsent in signup input Approved-by: Christel Westerberg * Merged in fix/LOY-413-use-v2-for-signup-call (pull request #3112) fix(LOY-413): use v2 endpoint for profile POST in signup * fix(LOY-413): use v2 endpoint for profile POST in signup Approved-by: Erik Tiekstra * Merged in feat/LOY-268-profiling-consent-improvements (pull request #3094) Feat/LOY-268: Profiling consent improvements * Merged in feat/profile-consent-contentstack (pull request #2921) Feat(LOY-389): Profile consent in Contentstack * feat(LOY-268): create banner * feat(LOY-268): Create personalization banner component * chore(LOY-348): add profiling consent as CS entry * chore(LOY-348): add banner as dynamic content Approved-by: Chuma Mcphoy (We Ahead) * Merged in feat/LOY-347-Profile-consent-modal-phase-1 (pull request #2901) Feat(LOY-347): Profiling Consent Modal (phase 1) * feat(LOY-347): Profile Consent Modal base functionality * feat(LOY-347): Add Icon * feat(LOY-347): Add Benefit Cards to Profile consent modal * feat(LOY-347): Add accordion to profile consent modal * fix(LOY-347): scroll behaviour * Fix(LOY-347): fade in/out animations of profile consent modal * fix(LOY-347): White Ellipsis Around Icons * feat(LOY-347): Implement ability to open modal from banner * fix(LOY-347): min… * Merged in fix/update-graphql (pull request #3130) fix: update graphql * fix: update graphql Approved-by: Chuma Mcphoy (We Ahead) * Merged in feat/LOY-414-prof-consent-tracking (pull request #3127) Feat/LOY-414 profile consent tracking + credit card ui update * chore(LOY-414): create track link function * chore(LOY-414): add cta tracking * chore(LOY-414): add profileConsent to userInfo datalayer * chore(LOY-414): update credit card ui * chore(LOY-414): update tracking specs * chore(LOY-414): add pageView tracking to modal Approved-by: Chuma Mcphoy (We Ahead) * fix: remove old flag * Merged in fix/LOY-268-prof-consent-button-fix (pull request #3162) fix(LOY-268): add button as link * fix(LOY-268): add button as link Approved-by: Chuma Mcphoy (We Ahead) Approved-by: Matilda Landström
This commit is contained in:
@@ -10,8 +10,6 @@ import { getIntl } from "@/i18n"
|
||||
|
||||
import styles from "./page.module.css"
|
||||
|
||||
export { generateMetadata } from "@/utils/metadata/generateMetadata"
|
||||
|
||||
export default async function MyPages() {
|
||||
const caller = await serverClient()
|
||||
const accountPageRes = await caller.contentstack.accountPage.get()
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import { TrackingSDK } from "@scandic-hotels/tracking/TrackingSDK"
|
||||
import { getEurobonusMembership } from "@scandic-hotels/trpc/routers/user/helpers"
|
||||
|
||||
import { getProfileSafely } from "@/lib/trpc/memoizedRequests"
|
||||
import {
|
||||
getProfileSafely,
|
||||
getProfilingConsent,
|
||||
} from "@/lib/trpc/memoizedRequests"
|
||||
|
||||
import ProfilingConsentModal from "@/components/MyPages/ProfilingConsent/Modal"
|
||||
import { userHasConsent } from "@/components/MyPages/ProfilingConsent/utils"
|
||||
import { SASLevelUpgradeCheck } from "@/components/MyPages/SASLevelUpgradeCheck"
|
||||
import Surprises from "@/components/MyPages/Surprises"
|
||||
import { getLang } from "@/i18n/serverContext"
|
||||
import { ModalTracking } from "@/utils/tracking/profilingConsent"
|
||||
|
||||
import styles from "./layout.module.css"
|
||||
|
||||
@@ -18,6 +26,15 @@ export default async function MyPagesLayout({
|
||||
? getEurobonusMembership(profile.loyalty)
|
||||
: null
|
||||
|
||||
const memberKey =
|
||||
profile?.membership?.membershipNumber || profile?.profileId || ""
|
||||
|
||||
const profilingConsentData = await getProfilingConsent()
|
||||
const profilingConsent = profilingConsentData?.profiling_consent
|
||||
const hasConsent = userHasConsent(profile?.profilingConsent)
|
||||
|
||||
const lang = await getLang()
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.layout}>
|
||||
@@ -27,6 +44,16 @@ export default async function MyPagesLayout({
|
||||
|
||||
{eurobonusMembership && <SASLevelUpgradeCheck />}
|
||||
<Surprises />
|
||||
{memberKey && profilingConsent && !hasConsent ? (
|
||||
<>
|
||||
<ProfilingConsentModal
|
||||
memberKey={memberKey}
|
||||
content={profilingConsent.modal}
|
||||
iconIdentifier={profilingConsent.icon}
|
||||
/>
|
||||
<TrackingSDK pageData={{ domainLanguage: lang, ...ModalTracking }} />
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
.container {
|
||||
padding-top: var(--Space-x3);
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
.container {
|
||||
padding-top: var(--Space-x6);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { TrackingSDK } from "@scandic-hotels/tracking/TrackingSDK"
|
||||
|
||||
import { getProfile } from "@/lib/trpc/memoizedRequests"
|
||||
import { serverClient } from "@/lib/trpc/server"
|
||||
|
||||
import { ProfilingConsent } from "@/components/Forms/ProfilingConsent"
|
||||
|
||||
import styles from "./page.module.css"
|
||||
|
||||
export default async function ProfilingConsentSlot() {
|
||||
const caller = await serverClient()
|
||||
const accountPage = await caller.contentstack.accountPage.get()
|
||||
const user = await getProfile()
|
||||
|
||||
if (!user || "error" in user || !accountPage) {
|
||||
return null
|
||||
}
|
||||
|
||||
const page = accountPage.accountPage
|
||||
const { heading, preamble } = page
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.container}>
|
||||
<ProfilingConsent
|
||||
heading={heading}
|
||||
preamble={preamble}
|
||||
consent={user.profilingConsent}
|
||||
/>
|
||||
{accountPage && <TrackingSDK pageData={accountPage.tracking} />}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import SignupFormWrapper from "@/components/Blocks/DynamicContent/SignupFormWrap
|
||||
import NextStay from "@/components/Blocks/DynamicContent/Stays/NextStay"
|
||||
import PreviousStays from "@/components/Blocks/DynamicContent/Stays/Previous"
|
||||
import UpcomingStays from "@/components/Blocks/DynamicContent/Stays/UpcomingStays"
|
||||
import { ProfilingConsentBanner } from "@/components/MyPages/ProfilingConsent/Banner"
|
||||
import { SJWidget } from "@/components/SJWidget"
|
||||
|
||||
import JobylonFeed from "./JobylonFeed"
|
||||
@@ -85,6 +86,9 @@ function DynamicContentBlocks(props: DynamicContentProps) {
|
||||
preamble={dynamic_content.subtitle}
|
||||
/>
|
||||
)
|
||||
case DynamicContentEnum.Blocks.components.profiling_consent_banner:
|
||||
return <ProfilingConsentBanner />
|
||||
|
||||
case DynamicContentEnum.Blocks.components.sj_widget:
|
||||
return <SJWidget />
|
||||
default:
|
||||
|
||||
@@ -11,6 +11,14 @@
|
||||
grid-area: title;
|
||||
}
|
||||
|
||||
.welcome {
|
||||
color: var(--Text-Accent-Primary);
|
||||
}
|
||||
|
||||
.name {
|
||||
color: var(--Text-Interactive-Default);
|
||||
}
|
||||
|
||||
.form {
|
||||
display: grid;
|
||||
gap: var(--Space-x5);
|
||||
|
||||
@@ -13,9 +13,9 @@ import {
|
||||
formatPhoneNumber,
|
||||
getDefaultCountryFromLang,
|
||||
} from "@scandic-hotels/common/utils/phone"
|
||||
import { OldDSButton as Button } from "@scandic-hotels/design-system/OldDSButton"
|
||||
import Title from "@scandic-hotels/design-system/Title"
|
||||
import { Button } from "@scandic-hotels/design-system/Button"
|
||||
import { toast } from "@scandic-hotels/design-system/Toast"
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
import { trpc } from "@scandic-hotels/trpc/client"
|
||||
import { langToApiLang } from "@scandic-hotels/trpc/constants/apiLang"
|
||||
|
||||
@@ -124,21 +124,19 @@ export default function Form({ user }: EditFormProps) {
|
||||
<>
|
||||
<section className={styles.container}>
|
||||
<div className={styles.title}>
|
||||
<Title as="h4" color="red" level="h1" textTransform="capitalize">
|
||||
{intl.formatMessage({
|
||||
id: "common.welcome",
|
||||
defaultMessage: "Welcome",
|
||||
})}
|
||||
</Title>
|
||||
<Title
|
||||
data-hj-suppress
|
||||
as="h4"
|
||||
color="burgundy"
|
||||
level="h2"
|
||||
textTransform="capitalize"
|
||||
>
|
||||
{user.name}
|
||||
</Title>
|
||||
<Typography variant="Title/sm">
|
||||
<span>
|
||||
<h1 className={styles.welcome}>
|
||||
{intl.formatMessage({
|
||||
id: "common.welcome",
|
||||
defaultMessage: "Welcome",
|
||||
})}
|
||||
</h1>
|
||||
<h2 data-hj-suppress className={styles.name}>
|
||||
{user.name}
|
||||
</h2>
|
||||
</span>
|
||||
</Typography>
|
||||
</div>
|
||||
<div className={styles.btnContainer}>
|
||||
<Dialog
|
||||
@@ -160,7 +158,7 @@ export default function Form({ user }: EditFormProps) {
|
||||
defaultMessage: "Discard unsaved changes?",
|
||||
})}
|
||||
trigger={
|
||||
<Button intent="secondary" size="small" theme="base">
|
||||
<Button variant="Secondary" size="Small" color="Primary">
|
||||
{intl.formatMessage({
|
||||
id: "editProfile.discardChanges",
|
||||
defaultMessage: "Discard changes",
|
||||
@@ -169,11 +167,11 @@ export default function Form({ user }: EditFormProps) {
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
disabled={!isValid || methods.formState.isSubmitting}
|
||||
isDisabled={!isValid || methods.formState.isSubmitting}
|
||||
form={formId}
|
||||
intent="primary"
|
||||
size="small"
|
||||
theme="base"
|
||||
variant="Primary"
|
||||
size="Small"
|
||||
color="Primary"
|
||||
type="submit"
|
||||
>
|
||||
{intl.formatMessage({
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
.container {
|
||||
display: grid;
|
||||
gap: var(--Space-x4);
|
||||
}
|
||||
|
||||
.form {
|
||||
background-color: var(--Surface-Primary-Default);
|
||||
padding: var(--Space-x4) var(--Space-x2) 0;
|
||||
display: grid;
|
||||
gap: var(--Space-x6);
|
||||
}
|
||||
|
||||
.buttons {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Space-x1);
|
||||
justify-content: center;
|
||||
padding: var(--Space-x2) var(--Space-x3) var(--Space-x3) var(--Space-x3);
|
||||
border-top: 1px solid var(--Border-Divider-Subtle);
|
||||
background: var(--Base-Surface-Primary-light-Normal);
|
||||
}
|
||||
|
||||
.title {
|
||||
color: var(--Text-Interactive-Default);
|
||||
}
|
||||
|
||||
.fieldset {
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
.legend {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Space-x1);
|
||||
padding-bottom: var(--Space-x4);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.container {
|
||||
gap: var(--Space-x6);
|
||||
}
|
||||
|
||||
.form {
|
||||
padding: var(--Space-x4) var(--Space-x3) 0;
|
||||
gap: var(--Space-x6);
|
||||
}
|
||||
|
||||
.buttons {
|
||||
flex-direction: row-reverse;
|
||||
gap: var(--Space-x2);
|
||||
padding: var(--Space-x6) var(--Space-x3) var(--Space-x4);
|
||||
}
|
||||
}
|
||||
205
apps/scandic-web/components/Forms/ProfilingConsent/index.tsx
Normal file
205
apps/scandic-web/components/Forms/ProfilingConsent/index.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
"use client"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useCallback, useEffect } from "react"
|
||||
import { Controller, FormProvider, useForm } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { profile } from "@scandic-hotels/common/constants/routes/myPages"
|
||||
import { Button } from "@scandic-hotels/design-system/Button"
|
||||
import { RadioButtonsGroup } from "@scandic-hotels/design-system/Form/RadioButtonsGroup"
|
||||
import { Tooltip } from "@scandic-hotels/design-system/Tooltip"
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import ProfilingConsentAccordion from "@/components/MyPages/ProfilingConsent/Accordion"
|
||||
import useLang from "@/hooks/useLang"
|
||||
import { useUpdateProfilingConsent } from "@/hooks/useUpdateProfilingConsent"
|
||||
import {
|
||||
trackConsentAction,
|
||||
trackConsentChange,
|
||||
} from "@/utils/tracking/profilingConsent"
|
||||
|
||||
import styles from "./form.module.css"
|
||||
|
||||
interface ProfilingConsentProps {
|
||||
heading: string | null
|
||||
preamble: string | null
|
||||
consent?: boolean
|
||||
}
|
||||
|
||||
export function ProfilingConsent({
|
||||
preamble,
|
||||
heading,
|
||||
consent,
|
||||
}: ProfilingConsentProps) {
|
||||
const intl = useIntl()
|
||||
const router = useRouter()
|
||||
const lang = useLang()
|
||||
|
||||
const options = [
|
||||
{
|
||||
consent: true,
|
||||
value: intl.formatMessage({
|
||||
id: "profilingConsent.accept",
|
||||
defaultMessage: "Accept",
|
||||
}),
|
||||
title: intl.formatMessage({
|
||||
id: "profilingConsent.yesIAccept",
|
||||
defaultMessage: "Yes, I would like to accept profiling!",
|
||||
}),
|
||||
text: intl.formatMessage({
|
||||
id: "profilingConsent.iConsentToScandicUsingMyInformation",
|
||||
defaultMessage:
|
||||
"I consent to Scandic using my information to give me even more personalized travel inspiration and offers from Scandic and trusted Scandic Friends partners. This means Scandic may use information about my interactions with Scandic Friends partners, and share details of my interactions with Scandic with those partners, to make the experience even more relevant to me.",
|
||||
}),
|
||||
},
|
||||
{
|
||||
consent: false,
|
||||
value: intl.formatMessage({
|
||||
id: "profilingConsent.decline",
|
||||
defaultMessage: "Decline",
|
||||
}),
|
||||
title: intl.formatMessage({
|
||||
id: "profilingConsent.noDeclineProfiling",
|
||||
defaultMessage: "No, decline profiling",
|
||||
}),
|
||||
text: intl.formatMessage({
|
||||
id: "profilingConsent.receiveGeneralInformationMarketingOnly",
|
||||
defaultMessage: "Receive general information & marketing only.",
|
||||
}),
|
||||
},
|
||||
]
|
||||
|
||||
type AcceptPersonalizationForm = { consent: string }
|
||||
const form = useForm<AcceptPersonalizationForm>({
|
||||
defaultValues: {
|
||||
consent: options.find((option) => option.consent === consent)?.value,
|
||||
},
|
||||
})
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
formState: { isDirty },
|
||||
} = form
|
||||
|
||||
const { initiateUpdateConsent, isLoading, isSuccess } =
|
||||
useUpdateProfilingConsent()
|
||||
const onSubmit = (data: AcceptPersonalizationForm) => {
|
||||
if (isDirty) {
|
||||
const cons = options.find(
|
||||
(option) => option.value === data.consent
|
||||
)?.consent
|
||||
if (cons !== undefined) {
|
||||
initiateUpdateConsent(cons)
|
||||
trackConsentChange({ from: consent, to: cons })
|
||||
}
|
||||
}
|
||||
}
|
||||
const backToProfile = useCallback(() => {
|
||||
router.push(profile[lang])
|
||||
}, [router, lang])
|
||||
|
||||
useEffect(() => {
|
||||
if (isSuccess) backToProfile()
|
||||
}, [isSuccess, backToProfile])
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className={styles.container}>
|
||||
<Typography variant="Title/md" className={styles.title}>
|
||||
<h1>{heading}</h1>
|
||||
</Typography>
|
||||
<Typography variant="Body/Lead text">
|
||||
<p>{preamble}</p>
|
||||
</Typography>
|
||||
<div className={styles.form}>
|
||||
<fieldset className={styles.fieldset}>
|
||||
<legend className={styles.legend}>
|
||||
<Typography variant="Title/Subtitle/lg">
|
||||
<p>
|
||||
{intl.formatMessage({
|
||||
id: "profilingConsent.profilingConsent",
|
||||
defaultMessage: "Profiling consent",
|
||||
})}
|
||||
</p>
|
||||
</Typography>
|
||||
<Typography>
|
||||
<p>
|
||||
{intl.formatMessage({
|
||||
id: "profilingConsent.updateAtAnyTime",
|
||||
defaultMessage:
|
||||
"You can update your preferences at any time. We respect your choices and will only use and store your data in accordance with your consent.",
|
||||
})}
|
||||
</p>
|
||||
</Typography>
|
||||
</legend>
|
||||
<Controller
|
||||
name="consent"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<RadioButtonsGroup
|
||||
options={options}
|
||||
ariaLabel={intl.formatMessage({
|
||||
id: "profilingConsent.acceptOrDecline",
|
||||
defaultMessage: "Accept or decline personalization",
|
||||
})}
|
||||
onChange={(value: string) => field.onChange(value)}
|
||||
defaultOption={options.find(
|
||||
(option) => option.consent === consent
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</fieldset>
|
||||
<ProfilingConsentAccordion component="profile" />
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.buttons}>
|
||||
<Tooltip
|
||||
text={intl.formatMessage({
|
||||
id: "profilingConsent.tooltip.noChangesInPreferences",
|
||||
defaultMessage: "No changes in preferences have been made",
|
||||
})}
|
||||
position="top"
|
||||
arrow="center"
|
||||
isVisible={!isDirty}
|
||||
>
|
||||
<Button
|
||||
type="submit"
|
||||
typography="Body/Paragraph/mdBold"
|
||||
variant="Primary"
|
||||
color="Primary"
|
||||
size="Medium"
|
||||
isDisabled={!isDirty}
|
||||
isPending={isLoading}
|
||||
>
|
||||
{intl.formatMessage({
|
||||
id: "profilingConsent.save&update",
|
||||
defaultMessage: "Save & update preferences",
|
||||
})}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Button
|
||||
type="button"
|
||||
typography="Body/Paragraph/mdBold"
|
||||
variant="Secondary"
|
||||
color="Primary"
|
||||
size="Medium"
|
||||
onClick={() => {
|
||||
backToProfile()
|
||||
trackConsentAction({
|
||||
position: "profile",
|
||||
name: "cancel",
|
||||
})
|
||||
}}
|
||||
>
|
||||
{intl.formatMessage({
|
||||
id: "common.cancel",
|
||||
defaultMessage: "Cancel",
|
||||
})}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</FormProvider>
|
||||
)
|
||||
}
|
||||
@@ -11,7 +11,8 @@
|
||||
}
|
||||
|
||||
.container,
|
||||
.terms {
|
||||
.terms,
|
||||
.personalization {
|
||||
display: grid;
|
||||
gap: var(--Space-x2);
|
||||
}
|
||||
@@ -26,6 +27,16 @@
|
||||
gap: var(--Space-x1);
|
||||
}
|
||||
|
||||
.personalizationMoreInfo {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
align-items: start;
|
||||
gap: var(--Space-x2);
|
||||
}
|
||||
.personalizationButton {
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1367px) {
|
||||
.formWrapper {
|
||||
gap: var(--Space-x5);
|
||||
|
||||
@@ -18,6 +18,7 @@ import Checkbox from "@scandic-hotels/design-system/Form/Checkbox"
|
||||
import CountrySelect from "@scandic-hotels/design-system/Form/Country"
|
||||
import DateSelect from "@scandic-hotels/design-system/Form/Date"
|
||||
import Phone from "@scandic-hotels/design-system/Form/Phone"
|
||||
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||
import Link from "@scandic-hotels/design-system/OldDSLink"
|
||||
import { toast } from "@scandic-hotels/design-system/Toast"
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
@@ -29,11 +30,14 @@ import {
|
||||
signUpSchema,
|
||||
} from "@scandic-hotels/trpc/routers/user/schemas"
|
||||
|
||||
import ProfilingConsentModalReadOnly from "@/components/MyPages/ProfilingConsent/Modal/ReadOnly"
|
||||
import Input from "@/components/TempDesignSystem/Form/Input"
|
||||
import PasswordInput from "@/components/TempDesignSystem/Form/PasswordInput"
|
||||
import useLang from "@/hooks/useLang"
|
||||
import { getFormattedCountryList } from "@/utils/countries"
|
||||
import { getErrorMessage } from "@/utils/getErrorMessage"
|
||||
import { requestOpen } from "@/utils/profilingConsent"
|
||||
import { trackLinkClick } from "@/utils/tracking/profilingConsent"
|
||||
|
||||
// import { type SignUpSchema, signUpSchema } from "./schema"
|
||||
import styles from "./form.module.css"
|
||||
@@ -92,6 +96,7 @@ export default function SignupForm({ title }: SignUpFormProps) {
|
||||
},
|
||||
password: "",
|
||||
termsAccepted: false,
|
||||
profilingConsent: false,
|
||||
},
|
||||
mode: "all",
|
||||
criteriaMode: "all",
|
||||
@@ -114,8 +119,17 @@ export default function SignupForm({ title }: SignUpFormProps) {
|
||||
trackFormSubmit()
|
||||
}
|
||||
|
||||
function openPersonalizationModal() {
|
||||
trackLinkClick({
|
||||
position: "signup",
|
||||
name: "read more about personalization at scandic",
|
||||
})
|
||||
requestOpen()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.formWrapper}>
|
||||
<ProfilingConsentModalReadOnly />
|
||||
{title ? (
|
||||
<Typography variant="Title/md">
|
||||
<h2>{title}</h2>
|
||||
@@ -262,6 +276,67 @@ export default function SignupForm({ title }: SignUpFormProps) {
|
||||
isNewPassword
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className={styles.personalization}>
|
||||
<header>
|
||||
<Typography variant="Title/Subtitle/md">
|
||||
<h3>
|
||||
{intl.formatMessage({
|
||||
id: "signup.UnlockYourPersonalizedExperience",
|
||||
defaultMessage: "Unlock your personalized experience!",
|
||||
})}
|
||||
</h3>
|
||||
</Typography>
|
||||
</header>
|
||||
<Checkbox
|
||||
name="profilingConsent"
|
||||
registerOptions={{
|
||||
required: false,
|
||||
}}
|
||||
>
|
||||
{intl.formatMessage({
|
||||
id: "signup.yesConsent",
|
||||
defaultMessage:
|
||||
"I consent to Scandic using my information to give me even more personalized travel inspiration and offers from Scandic and trusted Scandic Friends partners. This means Scandic may use information about my interactions with Scandic Friends partners, and share details of my interactions with Scandic with those partners, to make the experience even more relevant to me.",
|
||||
})}
|
||||
</Checkbox>
|
||||
<div className={styles.personalizationMoreInfo}>
|
||||
<MaterialIcon icon="person" color="Icon/Interactive/Default" />
|
||||
<p>
|
||||
{intl.formatMessage({
|
||||
id: "signup.GetATailoredProfile",
|
||||
defaultMessage:
|
||||
"Get a tailored profile that adapts to your interests.",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.personalizationMoreInfo}>
|
||||
<MaterialIcon
|
||||
icon="featured_seasonal_and_gifts"
|
||||
color="Icon/Interactive/Default"
|
||||
/>
|
||||
<p>
|
||||
{intl.formatMessage({
|
||||
id: "signup.PersonalizedOffersEarlyAccess",
|
||||
defaultMessage:
|
||||
"Personalized offers, early access to campaigns, and deals you won't find anywhere else!",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="Text"
|
||||
typography="Link/sm"
|
||||
color="Primary"
|
||||
className={styles.personalizationButton}
|
||||
onClick={openPersonalizationModal}
|
||||
>
|
||||
{intl.formatMessage({
|
||||
id: "signup.ReadMoreAboutPersonalization",
|
||||
defaultMessage: "Read more about personalization at Scandic",
|
||||
})}
|
||||
</Button>
|
||||
</section>
|
||||
|
||||
<section className={styles.terms}>
|
||||
<header>
|
||||
<Typography variant="Title/Subtitle/md">
|
||||
|
||||
@@ -162,6 +162,8 @@ const authenticatedUser: SafeUser = {
|
||||
profileId: "",
|
||||
employmentDetails: undefined,
|
||||
promotions: [],
|
||||
profilingConsent: undefined,
|
||||
profilingConsentUpdate: undefined,
|
||||
}
|
||||
|
||||
const badAuthenticatedUser: SafeUser = {
|
||||
@@ -195,6 +197,8 @@ const badAuthenticatedUser: SafeUser = {
|
||||
profileId: "",
|
||||
employmentDetails: undefined,
|
||||
promotions: [],
|
||||
profilingConsent: undefined,
|
||||
profilingConsentUpdate: undefined,
|
||||
}
|
||||
|
||||
const loggedOutGuest: Guest = {
|
||||
|
||||
@@ -1,34 +1,35 @@
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import ManagePreferencesButton from "@/components/Profile/ManagePreferencesButton"
|
||||
import ManageEmailPreferencesButton from "@/components/Profile/ManageEmailPreferencesButton"
|
||||
import { getIntl } from "@/i18n"
|
||||
|
||||
import styles from "./communication.module.css"
|
||||
import styles from "./slots.module.css"
|
||||
|
||||
export default async function CommunicationSlot() {
|
||||
export default async function EmailSlot() {
|
||||
const intl = await getIntl()
|
||||
|
||||
return (
|
||||
<section className={styles.container}>
|
||||
<div className={styles.content}>
|
||||
<Typography variant="Title/Subtitle/md">
|
||||
<h3>
|
||||
{intl.formatMessage({
|
||||
id: "myPages.myCommunicationPreferences",
|
||||
defaultMessage: "My communication preferences",
|
||||
id: "profile.emailPreferences",
|
||||
defaultMessage: "E-mail preferences",
|
||||
})}
|
||||
</h3>
|
||||
</Typography>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<p>
|
||||
{intl.formatMessage({
|
||||
id: "myPages.tellUsWhatInfoYouWouldLikeToReceive",
|
||||
id: "profile.manageEmailsAndUpdates",
|
||||
defaultMessage:
|
||||
"Tell us what information and updates you'd like to receive, and how, by clicking the link below.",
|
||||
"Manage what e-mails and updates you'd like to receive, and how, by clicking the link below.",
|
||||
})}
|
||||
</p>
|
||||
</Typography>
|
||||
</div>
|
||||
<ManagePreferencesButton />
|
||||
<ManageEmailPreferencesButton />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
"use client"
|
||||
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { profileConsent } from "@scandic-hotels/common/constants/routes/myPages"
|
||||
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||
import { TextLink } from "@scandic-hotels/design-system/TextLink"
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import useLang from "@/hooks/useLang"
|
||||
import { trackLinkClick } from "@/utils/tracking/profilingConsent"
|
||||
|
||||
import styles from "./slots.module.css"
|
||||
|
||||
export default function PersonalizationSlot() {
|
||||
const intl = useIntl()
|
||||
const lang = useLang()
|
||||
|
||||
return (
|
||||
<section className={styles.container}>
|
||||
<div className={styles.content}>
|
||||
<Typography variant="Title/Subtitle/md">
|
||||
<h3>
|
||||
{intl.formatMessage({
|
||||
id: "profile.personalization&profiling",
|
||||
defaultMessage: "Personalization & Profiling",
|
||||
})}
|
||||
</h3>
|
||||
</Typography>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<p>
|
||||
{intl.formatMessage({
|
||||
id: "profile.reviewUpdateConsent",
|
||||
defaultMessage:
|
||||
"Review and update your consent settings to control how we personalize your experience.",
|
||||
})}
|
||||
</p>
|
||||
</Typography>
|
||||
</div>
|
||||
<TextLink
|
||||
href={profileConsent[lang]}
|
||||
color="Text/Interactive/Secondary"
|
||||
typography="Link/md"
|
||||
onClick={() =>
|
||||
trackLinkClick({
|
||||
position: "profile",
|
||||
name: "manage profiling consent",
|
||||
})
|
||||
}
|
||||
>
|
||||
<MaterialIcon size={24} icon="arrow_forward" color="CurrentColor" />
|
||||
{intl.formatMessage({
|
||||
id: "profile.mangeProfilingConsent",
|
||||
defaultMessage: "Manage profiling consent",
|
||||
})}
|
||||
</TextLink>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
.content {
|
||||
display: grid;
|
||||
gap: var(--Space-x1);
|
||||
}
|
||||
|
||||
.container {
|
||||
justify-items: left;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
.container {
|
||||
display: grid;
|
||||
gap: var(--Space-x15);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { getIntl } from "@/i18n"
|
||||
|
||||
import { Section } from "../Section"
|
||||
import EmailSlot from "./Slots/Email"
|
||||
import PersonalizationSlot from "./Slots/Personalization"
|
||||
|
||||
export async function CommunicationSettings() {
|
||||
const intl = await getIntl()
|
||||
|
||||
return (
|
||||
<Section
|
||||
title={intl.formatMessage({
|
||||
id: "profile.communicationSettings",
|
||||
defaultMessage: "Communication settings",
|
||||
})}
|
||||
>
|
||||
<EmailSlot />
|
||||
<PersonalizationSlot />
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
.container {
|
||||
display: grid;
|
||||
gap: var(--Space-x2);
|
||||
justify-items: flex-start;
|
||||
max-width: 510px;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: grid;
|
||||
gap: var(--Space-x1);
|
||||
}
|
||||
|
||||
.cardContainer {
|
||||
display: grid;
|
||||
gap: var(--Space-x1);
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
.container {
|
||||
gap: var(--Space-x3);
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import { serverClient } from "@/lib/trpc/server"
|
||||
|
||||
import AddCreditCardButton from "@/components/Profile/AddCreditCardButton"
|
||||
import CreditCardList from "@/components/Profile/CreditCardList"
|
||||
import { getIntl } from "@/i18n"
|
||||
|
||||
import styles from "./creditCards.module.css"
|
||||
|
||||
export default async function CreditCardSlot() {
|
||||
const intl = await getIntl()
|
||||
const caller = await serverClient()
|
||||
const creditCards = await caller.user.creditCards()
|
||||
|
||||
return (
|
||||
<section className={styles.container}>
|
||||
<div className={styles.content}>
|
||||
<Typography variant="Title/Subtitle/md">
|
||||
<h3>
|
||||
{intl.formatMessage({
|
||||
id: "myPages.myPaymentCards",
|
||||
defaultMessage: "My payment cards",
|
||||
})}
|
||||
</h3>
|
||||
</Typography>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<p>
|
||||
{intl.formatMessage({
|
||||
id: "myPages.checkOutCardsSavedToProfile",
|
||||
defaultMessage:
|
||||
"Check out the credit cards saved to your profile. Pay with a saved card when signed in for a smoother web experience.",
|
||||
})}
|
||||
</p>
|
||||
</Typography>
|
||||
</div>
|
||||
<CreditCardList initialData={creditCards} />
|
||||
<AddCreditCardButton />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import { serverClient } from "@/lib/trpc/server"
|
||||
|
||||
import AddCreditCardButton from "@/components/Profile/AddCreditCardButton"
|
||||
import CreditCardList from "@/components/Profile/CreditCardList"
|
||||
import { getIntl } from "@/i18n"
|
||||
|
||||
import { Section } from "../Section"
|
||||
|
||||
import styles from "./payment.module.css"
|
||||
|
||||
export async function Payment() {
|
||||
const intl = await getIntl()
|
||||
const caller = await serverClient()
|
||||
const creditCards = await caller.user.creditCards()
|
||||
|
||||
return (
|
||||
<Section
|
||||
title={intl.formatMessage({
|
||||
id: "profile.payment",
|
||||
defaultMessage: "Payment",
|
||||
})}
|
||||
>
|
||||
<div className={styles.container}>
|
||||
<span className={styles.content}>
|
||||
<Typography variant="Title/Subtitle/md">
|
||||
<h3>
|
||||
{intl.formatMessage({
|
||||
id: "profile.myPaymentCards",
|
||||
defaultMessage: "My payment cards",
|
||||
})}
|
||||
</h3>
|
||||
</Typography>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<p>
|
||||
{intl.formatMessage({
|
||||
id: "profile.checkOutSavedCreditCards",
|
||||
defaultMessage:
|
||||
"Check out the credit cards saved to your profile. Pay with a saved card when signed in for a smoother web experience.",
|
||||
})}
|
||||
</p>
|
||||
</Typography>
|
||||
</span>
|
||||
<CreditCardList initialData={creditCards} />
|
||||
<AddCreditCardButton />
|
||||
</div>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
.container {
|
||||
display: grid;
|
||||
gap: var(--Space-x3);
|
||||
max-width: 510px;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: grid;
|
||||
gap: var(--Space-x1);
|
||||
}
|
||||
|
||||
.container {
|
||||
display: grid;
|
||||
gap: var(--Space-x3);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import styles from "./section.module.css"
|
||||
|
||||
import type { PropsWithChildren } from "react"
|
||||
|
||||
interface SectionProps extends PropsWithChildren {
|
||||
title: string
|
||||
}
|
||||
export function Section({ children, title }: SectionProps) {
|
||||
return (
|
||||
<section className={styles.section}>
|
||||
<Typography variant="Title/smLowCase">
|
||||
<h2>{title}</h2>
|
||||
</Typography>
|
||||
<div className={styles.content}>{children}</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
.content {
|
||||
display: grid;
|
||||
grid-auto-flow: row;
|
||||
gap: var(--Space-x5);
|
||||
}
|
||||
|
||||
.section {
|
||||
display: grid;
|
||||
gap: var(--Space-x3);
|
||||
}
|
||||
|
||||
@media screen and (min-width: 950px) {
|
||||
.content {
|
||||
grid-auto-flow: column;
|
||||
grid-auto-columns: calc(50% - var(--Space-x9) / 2)
|
||||
calc(50% - var(--Space-x9) / 2);
|
||||
gap: var(--Space-x9);
|
||||
}
|
||||
}
|
||||
@@ -3,20 +3,23 @@ import { profileEdit } from "@scandic-hotels/common/constants/routes/myPages"
|
||||
import { isValidLang } from "@scandic-hotels/common/utils/languages"
|
||||
import ButtonLink from "@scandic-hotels/design-system/ButtonLink"
|
||||
import { Divider } from "@scandic-hotels/design-system/Divider"
|
||||
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||
import {
|
||||
MaterialIcon,
|
||||
type MaterialIconProps,
|
||||
} from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
import { countriesMap } from "@scandic-hotels/trpc/constants/countries"
|
||||
|
||||
import { getProfile } from "@/lib/trpc/memoizedRequests"
|
||||
|
||||
import CommunicationSlot from "@/components/MyPages/Profile/Communication"
|
||||
import CreditCardSlot from "@/components/MyPages/Profile/CreditCards"
|
||||
import Header from "@/components/Profile/Header"
|
||||
import { getIntl } from "@/i18n"
|
||||
import { getLang } from "@/i18n/serverContext"
|
||||
import { isValidCountry } from "@/utils/countries"
|
||||
|
||||
import ChangeNameDisclaimer from "./ChangeNameDisclaimer"
|
||||
import { CommunicationSettings } from "./CommunicationSettings"
|
||||
import { Payment } from "./Payment"
|
||||
|
||||
import styles from "./profile.module.css"
|
||||
|
||||
@@ -67,6 +70,61 @@ export default async function Profile() {
|
||||
? localizedLanguage.charAt(0).toUpperCase() + localizedLanguage.slice(1)
|
||||
: languages[userLang]
|
||||
|
||||
const userDataItems: {
|
||||
icon: MaterialIconProps["icon"]
|
||||
label: string
|
||||
value: string
|
||||
}[] = [
|
||||
{
|
||||
icon: "calendar_month",
|
||||
label: intl.formatMessage({
|
||||
id: "profile.dateOfBirth",
|
||||
defaultMessage: "Date of birth",
|
||||
}),
|
||||
value: user.dateOfBirth,
|
||||
},
|
||||
{
|
||||
icon: "phone",
|
||||
label: intl.formatMessage({
|
||||
id: "common.phoneNumber",
|
||||
defaultMessage: "Phone number",
|
||||
}),
|
||||
value: user.phoneNumber ?? "",
|
||||
},
|
||||
{
|
||||
icon: "globe",
|
||||
label: intl.formatMessage({
|
||||
id: "profile.language",
|
||||
defaultMessage: "Language",
|
||||
}),
|
||||
value: normalizedLanguage,
|
||||
},
|
||||
{
|
||||
icon: "mail",
|
||||
label: intl.formatMessage({
|
||||
id: "common.email",
|
||||
defaultMessage: "Email",
|
||||
}),
|
||||
value: user.email,
|
||||
},
|
||||
{
|
||||
icon: "location_on",
|
||||
label: intl.formatMessage({
|
||||
id: "common.address",
|
||||
defaultMessage: "Address",
|
||||
}),
|
||||
value: addressOutput,
|
||||
},
|
||||
{
|
||||
icon: "lock",
|
||||
label: intl.formatMessage({
|
||||
id: "profile.password",
|
||||
defaultMessage: "Password",
|
||||
}),
|
||||
value: "**********",
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<section className={styles.container}>
|
||||
<Header>
|
||||
@@ -95,102 +153,25 @@ export default async function Profile() {
|
||||
</ButtonLink>
|
||||
</Header>
|
||||
<div className={styles.info}>
|
||||
<div className={styles.item}>
|
||||
<MaterialIcon
|
||||
icon="calendar_month"
|
||||
color="Icon/Interactive/Default"
|
||||
/>
|
||||
<Typography variant="Body/Paragraph/mdBold">
|
||||
<p>
|
||||
{intl.formatMessage({
|
||||
id: "myPages.dateOfBirth",
|
||||
defaultMessage: "Date of birth",
|
||||
})}
|
||||
</p>
|
||||
</Typography>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<p>{user.dateOfBirth}</p>
|
||||
</Typography>
|
||||
</div>
|
||||
<div className={styles.item}>
|
||||
<MaterialIcon icon="phone" color="Icon/Interactive/Default" />
|
||||
<Typography variant="Body/Paragraph/mdBold">
|
||||
<p>
|
||||
{intl.formatMessage({
|
||||
id: "common.phoneNumber",
|
||||
defaultMessage: "Phone number",
|
||||
})}
|
||||
</p>
|
||||
</Typography>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<p>{user.phoneNumber}</p>
|
||||
</Typography>
|
||||
</div>
|
||||
<div className={styles.item}>
|
||||
<MaterialIcon icon="globe" color="Icon/Interactive/Default" />
|
||||
<Typography variant="Body/Paragraph/mdBold">
|
||||
<p>
|
||||
{intl.formatMessage({
|
||||
id: "common.language",
|
||||
defaultMessage: "Language",
|
||||
})}
|
||||
</p>
|
||||
</Typography>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<p>{normalizedLanguage}</p>
|
||||
</Typography>
|
||||
</div>
|
||||
<div className={styles.item}>
|
||||
<MaterialIcon icon="mail" color="Icon/Interactive/Default" />
|
||||
<Typography variant="Body/Paragraph/mdBold">
|
||||
<p>
|
||||
{intl.formatMessage({
|
||||
id: "common.email",
|
||||
defaultMessage: "Email",
|
||||
})}
|
||||
</p>
|
||||
</Typography>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<p>{user.email}</p>
|
||||
</Typography>
|
||||
</div>
|
||||
<div className={styles.item}>
|
||||
<MaterialIcon icon="location_on" color="Icon/Interactive/Default" />
|
||||
<Typography variant="Body/Paragraph/mdBold">
|
||||
<p>
|
||||
{intl.formatMessage({
|
||||
id: "common.address",
|
||||
defaultMessage: "Address",
|
||||
})}
|
||||
</p>
|
||||
</Typography>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<p>{addressOutput}</p>
|
||||
</Typography>
|
||||
</div>
|
||||
<div className={styles.item}>
|
||||
<MaterialIcon icon="lock" color="Icon/Interactive/Default" />
|
||||
<Typography variant="Body/Paragraph/mdBold">
|
||||
<p>
|
||||
{intl.formatMessage({
|
||||
id: "common.password",
|
||||
defaultMessage: "Password",
|
||||
})}
|
||||
</p>
|
||||
</Typography>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
|
||||
<p>**********</p>
|
||||
</Typography>
|
||||
</div>
|
||||
{userDataItems.map(({ icon, label, value }) => (
|
||||
<div className={styles.item} key={label}>
|
||||
<MaterialIcon icon={icon} color="Icon/Interactive/Default" />
|
||||
<Typography variant="Body/Paragraph/mdBold">
|
||||
<p>{label}</p>
|
||||
</Typography>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<p>{value}</p>
|
||||
</Typography>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<ChangeNameDisclaimer />
|
||||
|
||||
<Divider />
|
||||
<CreditCardSlot />
|
||||
<CommunicationSettings />
|
||||
<Payment />
|
||||
{/* <MembershipCardSlot /> */}
|
||||
<CommunicationSlot />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
"use client"
|
||||
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { privacy } from "@scandic-hotels/common/constants/routes/customerService"
|
||||
import Accordion from "@scandic-hotels/design-system/Accordion"
|
||||
import AccordionItem from "@scandic-hotels/design-system/Accordion/AccordionItem"
|
||||
import { IconByIconName } from "@scandic-hotels/design-system/Icons/IconByIconName"
|
||||
import { IconName } from "@scandic-hotels/design-system/Icons/iconName"
|
||||
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||
import { TextLink } from "@scandic-hotels/design-system/TextLink"
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import useLang from "@/hooks/useLang"
|
||||
import { trackLinkClick } from "@/utils/tracking/profilingConsent"
|
||||
|
||||
import styles from "./profilingConsentAccordion.module.css"
|
||||
|
||||
interface ProfilingConsentAccordionProps {
|
||||
component: "modal" | "profile"
|
||||
}
|
||||
export default function ProfilingConsentAccordion({
|
||||
component,
|
||||
}: ProfilingConsentAccordionProps) {
|
||||
const intl = useIntl()
|
||||
const lang = useLang()
|
||||
|
||||
const dataWeProcess = [
|
||||
{
|
||||
icon: IconName.Person,
|
||||
title: intl.formatMessage({
|
||||
id: "profilingConsent.NameAndContactInformation",
|
||||
defaultMessage: "Name and contact information",
|
||||
}),
|
||||
subtitle: intl.formatMessage({
|
||||
id: "profilingConsent.NameEmailPhoneAddress",
|
||||
defaultMessage:
|
||||
"Your name, email address, phone number and postal address",
|
||||
}),
|
||||
},
|
||||
{
|
||||
icon: IconName.Luggage,
|
||||
title: intl.formatMessage({
|
||||
id: "profilingConsent.YourBookingsWithScandic",
|
||||
defaultMessage: "Your bookings with Scandic",
|
||||
}),
|
||||
subtitle: intl.formatMessage({
|
||||
id: "profilingConsent.BookingsDatesDestinationsPreferences",
|
||||
defaultMessage: "Hotel bookings, dates, destinations and preferences",
|
||||
}),
|
||||
},
|
||||
{
|
||||
icon: IconName.Diamond,
|
||||
title: intl.formatMessage({
|
||||
id: "profilingConsent.YourMembershipInformation",
|
||||
defaultMessage: "Your membership information",
|
||||
}),
|
||||
subtitle: intl.formatMessage({
|
||||
id: "profilingConsent.ScandicFriendsPointsBenefits",
|
||||
defaultMessage: "Scandic Friends status, points and member benefits",
|
||||
}),
|
||||
},
|
||||
{
|
||||
icon: IconName.Business,
|
||||
title: intl.formatMessage({
|
||||
id: "profilingConsent.CompanyInformation",
|
||||
defaultMessage: "Company information",
|
||||
}),
|
||||
subtitle: intl.formatMessage({
|
||||
id: "profilingConsent.InformationAboutYourCompany",
|
||||
defaultMessage:
|
||||
"Information about the company you work for (if available)",
|
||||
}),
|
||||
},
|
||||
{
|
||||
icon: IconName.Globe,
|
||||
title: intl.formatMessage({
|
||||
id: "profilingConsent.UserGeneratedData",
|
||||
defaultMessage: "User-generated data",
|
||||
}),
|
||||
subtitle: intl.formatMessage({
|
||||
id: "profilingConsent.ClicksBrowsingPurchasingInteractions",
|
||||
defaultMessage:
|
||||
"Clicks, browsing, purchase history and website interactions",
|
||||
}),
|
||||
},
|
||||
]
|
||||
|
||||
const rights = [
|
||||
intl.formatMessage({
|
||||
id: "profilingConsent.GetInformation",
|
||||
defaultMessage: "Get information about how we process your data",
|
||||
}),
|
||||
intl.formatMessage({
|
||||
id: "profilingConsent.RequestDeletion",
|
||||
defaultMessage: "Request deletion of your personal data",
|
||||
}),
|
||||
intl.formatMessage({
|
||||
id: "profilingConsent.WithdrawConsent",
|
||||
defaultMessage: "Withdraw your consent at any time",
|
||||
}),
|
||||
]
|
||||
|
||||
const security = [
|
||||
intl.formatMessage({
|
||||
id: "profilingConsent.EncryptedSecureDataStorage",
|
||||
defaultMessage: "Encrypted & secure data storage",
|
||||
}),
|
||||
intl.formatMessage({
|
||||
id: "profilingConsent.WeWillNeverSellYourData",
|
||||
defaultMessage: "We will never sell your data",
|
||||
}),
|
||||
intl.formatMessage({
|
||||
id: "profilingConsent.RegularSecurityAudits",
|
||||
defaultMessage: "Regular security audits",
|
||||
}),
|
||||
intl.formatMessage({
|
||||
id: "profilingConsent.GDPRCompliantProcessing",
|
||||
defaultMessage: "GDPR-compliant processing",
|
||||
}),
|
||||
]
|
||||
|
||||
return (
|
||||
<Accordion type="sidepeek" className={styles.accordion}>
|
||||
<AccordionItem
|
||||
title={intl.formatMessage({
|
||||
id: "profilingConsent.whatDoWeProcess",
|
||||
defaultMessage: "What data do we process?",
|
||||
})}
|
||||
iconName={IconName.InfoCircle}
|
||||
className={styles.accordionItem}
|
||||
openedOnRender
|
||||
>
|
||||
<ul className={styles.list}>
|
||||
{dataWeProcess.map((item, i) => (
|
||||
<li key={i} className={styles.row}>
|
||||
<span className={styles.rowIcon} aria-hidden>
|
||||
<IconByIconName
|
||||
iconName={item.icon}
|
||||
color="Icon/Interactive/Default"
|
||||
size={24}
|
||||
/>
|
||||
</span>
|
||||
<span className={styles.rowText}>
|
||||
<Typography variant="Body/Paragraph/mdBold">
|
||||
<p>{item.title}</p>
|
||||
</Typography>
|
||||
<Typography variant="Body/Supporting text (caption)/smRegular">
|
||||
<p>{item.subtitle}</p>
|
||||
</Typography>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem
|
||||
title={intl.formatMessage({
|
||||
id: "profilingConsent.transparency&processing",
|
||||
defaultMessage: "Transparency & data processing",
|
||||
})}
|
||||
iconName={IconName.EyeShow}
|
||||
className={styles.accordionItem}
|
||||
openedOnRender
|
||||
>
|
||||
<div className={styles.columns}>
|
||||
<div className={styles.column}>
|
||||
<Typography variant="Title/Subtitle/md">
|
||||
<h4>
|
||||
{intl.formatMessage({
|
||||
id: "profilingConsent.youHaveTheRightTo",
|
||||
defaultMessage: "You have the right to",
|
||||
})}
|
||||
</h4>
|
||||
</Typography>
|
||||
<ul className={styles.bullets}>
|
||||
{rights.map((text, i) => (
|
||||
<li key={i} className={styles.bulletRow}>
|
||||
<MaterialIcon
|
||||
icon="favorite"
|
||||
size={20}
|
||||
color="Icon/Accent"
|
||||
isFilled
|
||||
/>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<p>{text}</p>
|
||||
</Typography>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div className={styles.column}>
|
||||
<Typography variant="Title/Subtitle/md">
|
||||
<h4>
|
||||
{intl.formatMessage({
|
||||
id: "profilingConsent.dataSecurity",
|
||||
defaultMessage: "Data security",
|
||||
})}
|
||||
</h4>
|
||||
</Typography>
|
||||
<ul className={styles.bullets}>
|
||||
{security.map((text, i) => (
|
||||
<li key={i} className={styles.bulletRow}>
|
||||
<MaterialIcon icon="lock" size={20} color="Icon/Accent" />
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<p>{text}</p>
|
||||
</Typography>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TextLink
|
||||
href={privacy[lang]}
|
||||
color="Text/Interactive/Secondary"
|
||||
typography="Link/md"
|
||||
className={styles.learnMoreLink}
|
||||
onClick={() =>
|
||||
trackLinkClick({
|
||||
position: component,
|
||||
name: "learn more about how we process your data",
|
||||
})
|
||||
}
|
||||
>
|
||||
{intl.formatMessage({
|
||||
id: "profilingConsent.learnMoreAboutProcessing",
|
||||
defaultMessage: "Learn more about how we process your data",
|
||||
})}
|
||||
<MaterialIcon
|
||||
icon="open_in_new"
|
||||
size={20}
|
||||
color="Icon/Interactive/Secondary"
|
||||
/>
|
||||
</TextLink>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
.divider {
|
||||
margin: var(--Space-x1) 0;
|
||||
}
|
||||
|
||||
.accordion {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.list {
|
||||
display: grid;
|
||||
gap: var(--Space-x15);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: 40px 1fr;
|
||||
gap: var(--Space-x2);
|
||||
align-items: start;
|
||||
border: 0.696px solid var(--Border-Divider-Subtle);
|
||||
border-radius: var(--Corner-radius-md);
|
||||
background: var(--Surface-Primary-OnSurface-Default);
|
||||
padding: var(--Space-x1);
|
||||
}
|
||||
|
||||
.accordionItem:first-child {
|
||||
border-top: 1px solid var(--Border-Default);
|
||||
}
|
||||
|
||||
.rowIcon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
place-self: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: var(--Surface-Primary-Default);
|
||||
}
|
||||
|
||||
.rowText {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.columns {
|
||||
display: grid;
|
||||
gap: var(--Space-x3);
|
||||
margin-bottom: var(--Space-x3);
|
||||
}
|
||||
|
||||
.column {
|
||||
display: grid;
|
||||
gap: var(--Space-x1);
|
||||
}
|
||||
|
||||
.bullets {
|
||||
display: grid;
|
||||
gap: var(--Space-x1);
|
||||
}
|
||||
|
||||
.bulletRow {
|
||||
display: grid;
|
||||
grid-template-columns: 20px 1fr;
|
||||
gap: var(--Space-x1);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.learnMoreLink {
|
||||
display: flex;
|
||||
gap: var(--Space-x05);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.columns {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client"
|
||||
import { Button } from "@scandic-hotels/design-system/Button"
|
||||
|
||||
import { requestOpen } from "@/utils/profilingConsent"
|
||||
import { trackConsentAction } from "@/utils/tracking/profilingConsent"
|
||||
|
||||
interface BannerButtonProps {
|
||||
cta: string
|
||||
}
|
||||
|
||||
export function BannerButton({ cta }: BannerButtonProps) {
|
||||
function handleOpenModal() {
|
||||
trackConsentAction({ position: "banner", name: cta })
|
||||
requestOpen()
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="Secondary"
|
||||
color="Primary"
|
||||
size="Small"
|
||||
typography="Body/Supporting text (caption)/smBold"
|
||||
onClick={handleOpenModal}
|
||||
>
|
||||
{cta}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import { getProfile, getProfilingConsent } from "@/lib/trpc/memoizedRequests"
|
||||
|
||||
import { GetMainIconByCSIdentifier, userHasConsent } from "../utils"
|
||||
import { BannerButton } from "./Button"
|
||||
|
||||
import styles from "./profilingConsentBanner.module.css"
|
||||
|
||||
export async function ProfilingConsentBanner() {
|
||||
const user = await getProfile()
|
||||
if (!user || userHasConsent(user?.profilingConsent)) return null
|
||||
|
||||
const data = await getProfilingConsent()
|
||||
if (!data) return null
|
||||
|
||||
const { icon, banner } = data.profiling_consent
|
||||
|
||||
return (
|
||||
<div className={styles.banner}>
|
||||
<GetMainIconByCSIdentifier identifier={icon} className={styles.icon} />
|
||||
<span className={styles.text}>
|
||||
<Typography variant="Title/sm" className={styles.header}>
|
||||
<h4>{banner.header}</h4>
|
||||
</Typography>
|
||||
<Typography variant="Body/Lead text">
|
||||
<p>{banner.sub_header}</p>
|
||||
</Typography>
|
||||
</span>
|
||||
<BannerButton cta={banner.button_text} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
.banner {
|
||||
background-color: var(--Background-Secondary);
|
||||
padding: var(--Space-x4) var(--Space-x3);
|
||||
display: grid;
|
||||
align-items: center;
|
||||
border-radius: var(--Corner-radius-Large);
|
||||
gap: var(--Space-x4);
|
||||
|
||||
grid-auto-flow: row;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.text {
|
||||
display: grid;
|
||||
gap: var(--Space-x15);
|
||||
}
|
||||
|
||||
.icon {
|
||||
justify-self: center;
|
||||
}
|
||||
|
||||
.header {
|
||||
color: var(--Text-Brand-OnPrimary-1-Heading);
|
||||
}
|
||||
|
||||
@media screen and (min-width: 950px) {
|
||||
.banner {
|
||||
padding: var(--Space-x2) var(--Space-x4);
|
||||
grid-template-columns: auto 1fr auto;
|
||||
grid-auto-flow: column;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
"use client"
|
||||
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import styles from "./benefitCards.module.css"
|
||||
|
||||
type BenefitCardProps = {
|
||||
Icon: React.ComponentType<{
|
||||
width?: number
|
||||
height?: number
|
||||
className?: string
|
||||
}>
|
||||
title: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export default function BenefitCard({
|
||||
Icon,
|
||||
title,
|
||||
description,
|
||||
}: BenefitCardProps) {
|
||||
return (
|
||||
<div className={styles.card}>
|
||||
<div className={styles.iconPane} aria-hidden="true">
|
||||
<Icon width={140} height={120} />
|
||||
</div>
|
||||
<div className={styles.copy}>
|
||||
<Typography variant="Title/Subtitle/md">
|
||||
<h3>{title}</h3>
|
||||
</Typography>
|
||||
<Typography variant="Body/Supporting text (caption)/smRegular">
|
||||
<p>{description}</p>
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
.benefitsCarousel {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.card {
|
||||
display: grid;
|
||||
gap: var(--Space-x3);
|
||||
}
|
||||
|
||||
.iconPane {
|
||||
background: var(--Surface-Brand-Primary-1-Default);
|
||||
border-radius: var(--Corner-radius-xLarge);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: var(--Space-x2) var(--Space-x15);
|
||||
min-height: 180px;
|
||||
}
|
||||
|
||||
.copy {
|
||||
text-align: center;
|
||||
display: grid;
|
||||
gap: var(--Space-x1);
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.dots {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
"use client"
|
||||
|
||||
import { Carousel } from "@/components/Carousel"
|
||||
|
||||
import { GetBenefitIconByCSIdentifier } from "../../utils"
|
||||
import BenefitCard from "./BenefitCard"
|
||||
|
||||
import styles from "./benefitCards.module.css"
|
||||
|
||||
import type { ProfilingConsentModal as ProfilingConsentModalType } from "@scandic-hotels/trpc/types/profilingConsent"
|
||||
|
||||
type BenefitCardsProps = {
|
||||
cards?: ProfilingConsentModalType["cards"]
|
||||
}
|
||||
|
||||
export default function BenefitCards({ cards }: BenefitCardsProps) {
|
||||
if (!cards?.length) return null
|
||||
|
||||
return (
|
||||
<Carousel className={styles.benefitsCarousel}>
|
||||
<Carousel.Content>
|
||||
{cards.map((card, index) => {
|
||||
const Icon = GetBenefitIconByCSIdentifier(card.image_type)
|
||||
return (
|
||||
<Carousel.Item key={`${card.title}-${index}`}>
|
||||
<BenefitCard
|
||||
Icon={Icon}
|
||||
title={card.title}
|
||||
description={card.preamble}
|
||||
/>
|
||||
</Carousel.Item>
|
||||
)
|
||||
})}
|
||||
</Carousel.Content>
|
||||
<Carousel.Dots className={styles.dots} />
|
||||
</Carousel>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client"
|
||||
|
||||
import { trpc } from "@scandic-hotels/trpc/client"
|
||||
|
||||
import ProfilingConsentModal from "@/components/MyPages/ProfilingConsent/Modal"
|
||||
import useLang from "@/hooks/useLang"
|
||||
|
||||
/**
|
||||
* A read-only version of the Profiling Consent Modal
|
||||
* without any of the profiling consent actions.
|
||||
**/
|
||||
export default function ProfilingConsentModalReadOnly() {
|
||||
const lang = useLang()
|
||||
const { data } = trpc.contentstack.profilingConsent.get.useQuery({ lang })
|
||||
const modal = data?.profiling_consent?.modal
|
||||
const icon = data?.profiling_consent?.icon
|
||||
|
||||
if (!modal || !icon) return null
|
||||
|
||||
return (
|
||||
<ProfilingConsentModal
|
||||
memberKey={undefined}
|
||||
content={modal}
|
||||
iconIdentifier={icon}
|
||||
readOnly
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
"use client"
|
||||
|
||||
import { AnimatePresence, motion } from "motion/react"
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { Dialog, Modal, ModalOverlay } from "react-aria-components"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
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 { useUpdateProfilingConsent } from "@/hooks/useUpdateProfilingConsent"
|
||||
import {
|
||||
profilingConsentOpenEvent,
|
||||
readDismissed,
|
||||
setDismissed as persistDismissed,
|
||||
} from "@/utils/profilingConsent"
|
||||
import { trackConsentAction } from "@/utils/tracking/profilingConsent"
|
||||
|
||||
import ProfilingConsentAccordion from "../Accordion"
|
||||
import { GetMainIconByCSIdentifier } from "../utils"
|
||||
import BenefitCards from "./BenefitCards"
|
||||
|
||||
import styles from "./profilingConsentModal.module.css"
|
||||
|
||||
import type { ProfilingConsentModal as ProfilingConsentModalType } from "@scandic-hotels/trpc/types/profilingConsent"
|
||||
|
||||
type ProfilingConsentModalProps = {
|
||||
memberKey?: string
|
||||
content: ProfilingConsentModalType
|
||||
iconIdentifier: string
|
||||
readOnly?: boolean
|
||||
}
|
||||
|
||||
const MotionModal = motion.create(Modal)
|
||||
|
||||
export default function ProfilingConsentModal({
|
||||
memberKey,
|
||||
content,
|
||||
iconIdentifier,
|
||||
readOnly = false,
|
||||
}: ProfilingConsentModalProps) {
|
||||
const intl = useIntl()
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
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])
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
if (memberKey) {
|
||||
persistDismissed(memberKey)
|
||||
}
|
||||
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
|
||||
|
||||
return (
|
||||
<ModalOverlay
|
||||
className={styles.overlay}
|
||||
isOpen={open}
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) {
|
||||
onClose()
|
||||
}
|
||||
}}
|
||||
isKeyboardDismissDisabled
|
||||
isDismissable={false}
|
||||
>
|
||||
<AnimatePresence mode="wait">
|
||||
{open && (
|
||||
<MotionModal
|
||||
className={styles.modal}
|
||||
initial={{ y: 32, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={{ y: 32, opacity: 0 }}
|
||||
transition={{
|
||||
y: { duration: 0.4, ease: "easeInOut" },
|
||||
opacity: { duration: 0.4, ease: "easeInOut" },
|
||||
}}
|
||||
>
|
||||
<Dialog
|
||||
aria-label={intl.formatMessage({
|
||||
id: "common.profilingConsent",
|
||||
defaultMessage: "Profiling consent",
|
||||
})}
|
||||
className={styles.dialog}
|
||||
>
|
||||
<header className={styles.header}>
|
||||
<div className={styles.logoWrap}>
|
||||
<ScandicLogoIcon height={20} width={94} />
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="Text"
|
||||
size="Large"
|
||||
color="Primary"
|
||||
className={styles.closeBtn}
|
||||
onClick={() => {
|
||||
trackConsentAction({ position: "modal", name: "close" })
|
||||
onClose()
|
||||
}}
|
||||
aria-label={intl.formatMessage({
|
||||
id: "common.close",
|
||||
defaultMessage: "Close",
|
||||
})}
|
||||
>
|
||||
<MaterialIcon color="CurrentColor" icon="close" />
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<main className={styles.content}>
|
||||
<GetMainIconByCSIdentifier identifier={iconIdentifier} />
|
||||
<div className={styles.textContent}>
|
||||
<Typography className={styles.heading} variant="Title/md">
|
||||
<h2>{content.header}</h2>
|
||||
</Typography>
|
||||
<Typography className={styles.text} variant="Body/Lead text">
|
||||
<p>{content.sub_header}</p>
|
||||
</Typography>
|
||||
</div>
|
||||
<BenefitCards cards={content.cards} />
|
||||
|
||||
<section className={styles.container}>
|
||||
<header className={styles.header}>
|
||||
<Typography variant="Title/Subtitle/lg">
|
||||
<h3>
|
||||
{intl.formatMessage({
|
||||
id: "profilingConsent.personalization&privacy",
|
||||
defaultMessage: "Personalization & privacy",
|
||||
})}
|
||||
</h3>
|
||||
</Typography>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<p>
|
||||
{intl.formatMessage({
|
||||
id: "profilingConsent.byAcceptingThisIConsent",
|
||||
defaultMessage:
|
||||
"By accepting this I consent to Scandic using my information to give me even more personalized travel inspiration and offers from Scandic and trusted Scandic Friends partners. This means Scandic may use information about my interactions with Scandic Friends partners, and share details of my interactions with Scandic with those partners, to make the experience even more relevant to me.",
|
||||
})}
|
||||
</p>
|
||||
</Typography>
|
||||
</header>
|
||||
<ProfilingConsentAccordion component="modal" />
|
||||
</section>
|
||||
</main>
|
||||
|
||||
{!readOnly && (
|
||||
<footer className={styles.actions}>
|
||||
<Button
|
||||
variant="Primary"
|
||||
color="Primary"
|
||||
size="Large"
|
||||
typography="Body/Supporting text (caption)/smRegular"
|
||||
type="button"
|
||||
isDisabled={isLoading}
|
||||
isPending={isLoading && activeChoice === true}
|
||||
onClick={() => {
|
||||
trackConsentAction({
|
||||
position: "modal",
|
||||
name: "accept personalization",
|
||||
})
|
||||
handleClick(true)
|
||||
}}
|
||||
>
|
||||
{intl.formatMessage({
|
||||
id: "profilingConsent.acceptPersonalization",
|
||||
defaultMessage: "Accept Personalization",
|
||||
})}
|
||||
</Button>
|
||||
<Button
|
||||
variant="Secondary"
|
||||
size="Large"
|
||||
color="Primary"
|
||||
typography="Body/Supporting text (caption)/smRegular"
|
||||
type="button"
|
||||
isDisabled={isLoading}
|
||||
isPending={isLoading && activeChoice === false}
|
||||
onClick={() => {
|
||||
trackConsentAction({ position: "modal", name: "decline" })
|
||||
handleClick(false)
|
||||
}}
|
||||
>
|
||||
{intl.formatMessage({
|
||||
id: "common.decline",
|
||||
defaultMessage: "Decline",
|
||||
})}
|
||||
</Button>
|
||||
</footer>
|
||||
)}
|
||||
</Dialog>
|
||||
</MotionModal>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</ModalOverlay>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
@keyframes fade {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.overlay {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
height: var(--visual-viewport-height);
|
||||
z-index: var(--default-modal-overlay-z-index);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-end;
|
||||
|
||||
&[data-entering] {
|
||||
animation: fade 400ms ease-in;
|
||||
}
|
||||
&[data-exiting] {
|
||||
animation: fade 400ms reverse ease-in;
|
||||
}
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: var(--Background-Primary);
|
||||
margin: 0 auto;
|
||||
box-shadow: var(--modal-box-shadow);
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
border-radius: var(--Corner-radius-xLarge) var(--Corner-radius-xLarge) 0 0;
|
||||
}
|
||||
|
||||
.dialog {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 95dvh;
|
||||
outline: 0 none;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.header {
|
||||
position: relative;
|
||||
padding: var(--Space-x3);
|
||||
}
|
||||
|
||||
.logoWrap {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.closeBtn {
|
||||
position: absolute;
|
||||
right: var(--Space-x2);
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: var(--Space-x4);
|
||||
text-align: center;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
place-self: center;
|
||||
gap: var(--Space-x5);
|
||||
}
|
||||
|
||||
.textContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Space-x15);
|
||||
}
|
||||
|
||||
.heading {
|
||||
color: var(--Text-Interactive-Default);
|
||||
}
|
||||
|
||||
.actions {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Space-x1);
|
||||
justify-content: center;
|
||||
padding: var(--Space-x2) var(--Space-x3) var(--Space-x3) var(--Space-x3);
|
||||
border-top: 1px solid var(--Border-Divider-Subtle);
|
||||
background: var(--Base-Surface-Primary-light-Normal);
|
||||
border-bottom-right-radius: var(--Corner-radius-xLarge);
|
||||
border-bottom-left-radius: var(--Corner-radius-xLarge);
|
||||
}
|
||||
|
||||
.container {
|
||||
border-radius: var(--Corner-radius-md);
|
||||
background: var(--Base-Surface-Primary-light-Normal);
|
||||
display: grid;
|
||||
gap: var(--Space-x5);
|
||||
padding: var(--Space-x4) var(--Space-x2);
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: grid;
|
||||
gap: var(--Space-x1);
|
||||
text-align: center;
|
||||
color: var(--Text-Default);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.overlay {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.modal {
|
||||
width: 95%;
|
||||
max-width: 95%;
|
||||
border-radius: var(--Corner-radius-xLarge);
|
||||
}
|
||||
|
||||
.actions {
|
||||
flex-direction: row-reverse;
|
||||
gap: var(--Space-x2);
|
||||
box-shadow: 0 0 8px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1367px) {
|
||||
.content {
|
||||
max-width: 984px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
# Profiling Consent
|
||||
|
||||
A full-page modal shown when a user first visits a My Pages route.
|
||||
|
||||
## Usage
|
||||
|
||||
### My Pages Modal
|
||||
|
||||
Rendered in `app/[lang]/(live)/(protected)/my-pages/layout.tsx` so it is available across all My Pages routes. The layout passes `memberKey` (derived from `membershipNumber` or `profileId`) to enable per-member dismissal tracking.
|
||||
|
||||
### Signup Form Integration
|
||||
|
||||
A read-only version (`Modal/ReadOnly.tsx`) is integrated into the signup form (`components/Forms/Signup/index.tsx`) to provide users with information about personalization benefits during registration. This version:
|
||||
|
||||
- Has no action buttons (Accept/Decline)
|
||||
- Can be opened via the "Read more about personalization at Scandic" button in the signup form
|
||||
- Uses the same Contentstack content as the main modal
|
||||
- Does not require a `memberKey` since it's for non-authenticated users
|
||||
|
||||
## Features
|
||||
|
||||
### My Pages Modal
|
||||
|
||||
- Displays upon landing on any My Pages route (if not previously dismissed)
|
||||
- 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)
|
||||
|
||||
### Read-Only Version (Signup)
|
||||
|
||||
- Same visual content as main modal but without action buttons
|
||||
- Accessible during signup process via "Read more" button
|
||||
- Uses Contentstack content fetched via `trpc.contentstack.profilingConsent.get.useQuery`
|
||||
- No localStorage persistence since it's informational only
|
||||
|
||||
## Components
|
||||
|
||||
- `Modal/` — Main modal shell with header, content, and action buttons
|
||||
- `Modal/ReadOnly.tsx` — Read-only version without action buttons, used in signup form
|
||||
- `Modal/BenefitCards/` — Cards showcasing personalization benefits
|
||||
- `Accordion/` — Privacy and personalization information
|
||||
- `Banner/` — A banner shown on the account overview page that can reopen the modal
|
||||
|
||||
## Banner
|
||||
|
||||
- Purpose: Offer a way to reopen the Profiling Consent modal later when the user is ready to decide.
|
||||
- Visibility: Decided server-side in `Banner/index.tsx` (render only when consent status is pending once API is available).
|
||||
- Behavior: The client CTA dispatches a `profiling-consent:open` event to reopen the modal.
|
||||
|
||||
## Local Persistence
|
||||
|
||||
- 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).
|
||||
|
||||
## Utilities
|
||||
|
||||
Located at `apps/scandic-web/utils/profilingConsent.ts`:
|
||||
|
||||
- `storageKey(memberKey)`
|
||||
- `readDismissed(memberKey)`
|
||||
- `setDismissed(memberKey)`
|
||||
- `clearDismissed(memberKey)`
|
||||
- `profilingConsentOpenEvent` — CustomEvent name used to request the modal to open
|
||||
- `requestOpen()` — Dispatches the open event
|
||||
|
||||
## Testing
|
||||
|
||||
To re-show the modal after dismissing:
|
||||
|
||||
```js
|
||||
// In the browser console:
|
||||
localStorage.removeItem("profiling-consent:dismissed:<memberKey>")
|
||||
// Then refresh the page
|
||||
```
|
||||
|
||||
To open the modal without clearing the dismissed flag:
|
||||
|
||||
```js
|
||||
window.dispatchEvent(new CustomEvent("profiling-consent:open"))
|
||||
```
|
||||
|
||||
Replace `<memberKey>` with the actual `membershipNumber` or `profileId`.
|
||||
|
||||
## Future Work
|
||||
|
||||
- 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
|
||||
@@ -0,0 +1,49 @@
|
||||
import HandGiftIcon from "@scandic-hotels/design-system/Icons/HandGiftIcon"
|
||||
import MagicWandIcon from "@scandic-hotels/design-system/Icons/MagicWandIcon"
|
||||
import SpaIcon from "@scandic-hotels/design-system/Icons/Spa"
|
||||
import VoucherIcon from "@scandic-hotels/design-system/Icons/VoucherIcon"
|
||||
|
||||
import type { User } from "@scandic-hotels/trpc/types/user"
|
||||
|
||||
interface GetMainIconByCSIdentifierProps {
|
||||
identifier: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
// Main (modal/banner) icon mapping — default to Spa icon
|
||||
export function GetMainIconByCSIdentifier({
|
||||
identifier,
|
||||
className,
|
||||
}: GetMainIconByCSIdentifierProps) {
|
||||
switch (identifier) {
|
||||
case "SpaIcon":
|
||||
default:
|
||||
return <SpaIcon width={140} height={128} className={className} />
|
||||
}
|
||||
}
|
||||
|
||||
export function GetBenefitIconByCSIdentifier(
|
||||
identifier: string
|
||||
): React.ComponentType<{
|
||||
width?: number
|
||||
height?: number
|
||||
className?: string
|
||||
}> {
|
||||
switch (identifier) {
|
||||
case "VoucherIcon":
|
||||
return VoucherIcon
|
||||
case "MagicWandIcon":
|
||||
return MagicWandIcon
|
||||
case "HandGiftIcon":
|
||||
default:
|
||||
return HandGiftIcon
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* If profilingConsent is undefined, it means that the user has not yet made an active decision to accept or decline.
|
||||
* Returns true if the user has taken an action, otherwise false.
|
||||
*/
|
||||
export function userHasConsent(consent: User["profilingConsent"]) {
|
||||
return typeof consent === "boolean"
|
||||
}
|
||||
@@ -4,8 +4,8 @@ import { usePathname, useRouter, useSearchParams } from "next/navigation"
|
||||
import { useEffect, useRef } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { Button } from "@scandic-hotels/design-system/Button"
|
||||
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||
import { OldDSButton as Button } from "@scandic-hotels/design-system/OldDSButton"
|
||||
import { toast } from "@scandic-hotels/design-system/Toast"
|
||||
import { trpc } from "@scandic-hotels/trpc/client"
|
||||
|
||||
@@ -98,9 +98,10 @@ export default function AddCreditCardButton() {
|
||||
return (
|
||||
<Button
|
||||
className={styles.addCreditCardButton}
|
||||
variant="icon"
|
||||
theme="base"
|
||||
intent="text"
|
||||
variant="Text"
|
||||
color="Primary"
|
||||
size="Medium"
|
||||
typography="Body/Paragraph/mdBold"
|
||||
onClick={() =>
|
||||
initiateAddCard.mutate({
|
||||
language: lang,
|
||||
|
||||
@@ -5,6 +5,11 @@
|
||||
grid-template-columns: auto auto auto 1fr;
|
||||
justify-items: flex-end;
|
||||
padding: var(--Space-x1) var(--Space-x15);
|
||||
border-radius: var(--Corner-radius-sm);
|
||||
background-color: var(--Background-Primary);
|
||||
border-radius: var(--Corner-radius-Medium);
|
||||
background-color: var(--Base-Surface-Secondary-light-Normal);
|
||||
}
|
||||
|
||||
.cardNumber {
|
||||
margin-left: var(--Space-x1);
|
||||
color: var(--Text-Secondary);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import Body from "@scandic-hotels/design-system/Body"
|
||||
import Caption from "@scandic-hotels/design-system/Caption"
|
||||
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import DeleteCreditCardConfirmation from "../DeleteCreditCardConfirmation"
|
||||
|
||||
@@ -9,13 +8,20 @@ import styles from "./creditCardRow.module.css"
|
||||
import type { CreditCardRowProps } from "@/types/components/myPages/myProfile/creditCards"
|
||||
|
||||
export default function CreditCardRow({ card }: CreditCardRowProps) {
|
||||
const maskedCardNumber = `**** ${card.truncatedNumber.slice(-4)}`
|
||||
const maskedCardNumber = `•••• ${card.truncatedNumber.slice(-4)}`
|
||||
|
||||
return (
|
||||
<div className={styles.card}>
|
||||
<MaterialIcon icon="credit_card" color="Icon/Intense" />
|
||||
<Body textTransform="bold">{card.type}</Body>
|
||||
<Caption color="textMediumContrast">{maskedCardNumber}</Caption>
|
||||
<Typography variant="Body/Paragraph/mdBold">
|
||||
<span>{card.type}</span>
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="Body/Paragraph/mdRegular"
|
||||
className={styles.cardNumber}
|
||||
>
|
||||
<span>{maskedCardNumber}</span>
|
||||
</Typography>
|
||||
<DeleteCreditCardConfirmation card={card} />
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { IconButton } from "@scandic-hotels/design-system/IconButton"
|
||||
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||
import { OldDSButton as Button } from "@scandic-hotels/design-system/OldDSButton"
|
||||
import { toast } from "@scandic-hotels/design-system/Toast"
|
||||
import { trpc } from "@scandic-hotels/trpc/client"
|
||||
|
||||
@@ -70,9 +70,15 @@ export default function DeleteCreditCardConfirmation({
|
||||
defaultMessage: "Remove card from member profile",
|
||||
})}
|
||||
trigger={
|
||||
<Button intent="secondary" size="small" theme="base">
|
||||
<IconButton
|
||||
theme="Black"
|
||||
aria-label={intl.formatMessage({
|
||||
id: "profile.creditCard.deleteCard",
|
||||
defaultMessage: "Delete card",
|
||||
})}
|
||||
>
|
||||
<MaterialIcon icon="delete" color="Icon/Interactive/Default" />
|
||||
</Button>
|
||||
</IconButton>
|
||||
}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||
import { OldDSButton as Button } from "@scandic-hotels/design-system/OldDSButton"
|
||||
import { TextLinkButton } from "@scandic-hotels/design-system/TextLinkButton"
|
||||
import { toast } from "@scandic-hotels/design-system/Toast"
|
||||
import { trpc } from "@scandic-hotels/trpc/client"
|
||||
|
||||
import styles from "./managePreferencesButton.module.css"
|
||||
import styles from "./manageEmailPreferencesButton.module.css"
|
||||
|
||||
export default function ManagePreferencesButton() {
|
||||
export default function ManageEmailPreferencesButton() {
|
||||
const intl = useIntl()
|
||||
const generatePreferencesLink = trpc.user.generatePreferencesLink.useMutation(
|
||||
{
|
||||
@@ -19,7 +19,7 @@ export default function ManagePreferencesButton() {
|
||||
} else {
|
||||
toast.error(
|
||||
intl.formatMessage({
|
||||
id: "profile.managePreferences.communicationPreferencesUnavailable",
|
||||
id: "profile.manageEmailPreferences.communicationPreferencesUnavailable",
|
||||
defaultMessage:
|
||||
"It's not possible to manage your communication preferences right now. Please try again later or contact support if the problem persists.",
|
||||
})
|
||||
@@ -29,7 +29,7 @@ export default function ManagePreferencesButton() {
|
||||
onError: () => {
|
||||
toast.error(
|
||||
intl.formatMessage({
|
||||
id: "profile.managePreferences.error",
|
||||
id: "profile.manageEmailPreferences.anErrorOccured",
|
||||
defaultMessage:
|
||||
"An error occurred trying to manage your preferences, please try again later.",
|
||||
})
|
||||
@@ -39,19 +39,18 @@ export default function ManagePreferencesButton() {
|
||||
)
|
||||
|
||||
return (
|
||||
<Button
|
||||
<TextLinkButton
|
||||
theme="Primary"
|
||||
className={styles.managePreferencesButton}
|
||||
variant="icon"
|
||||
theme="base"
|
||||
intent="text"
|
||||
onClick={() => generatePreferencesLink.mutate()}
|
||||
wrapping
|
||||
typography="Link/md"
|
||||
color="Text/Interactive/Secondary"
|
||||
>
|
||||
<MaterialIcon icon="arrow_forward" color="CurrentColor" />
|
||||
{intl.formatMessage({
|
||||
id: "profile.managePreferences.title",
|
||||
defaultMessage: "Manage preferences",
|
||||
id: "profile.manageEmailPreferences",
|
||||
defaultMessage: "Manage e-mail preferences",
|
||||
})}
|
||||
</Button>
|
||||
</TextLinkButton>
|
||||
)
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
overview,
|
||||
points,
|
||||
profile,
|
||||
profileConsent,
|
||||
profileEdit,
|
||||
stays,
|
||||
} from "@scandic-hotels/common/constants/routes/myPages"
|
||||
@@ -19,6 +20,7 @@ export const authRequired = [
|
||||
...Object.values(overview),
|
||||
...Object.values(profile),
|
||||
...Object.values(profileEdit),
|
||||
...Object.values(profileConsent),
|
||||
...Object.values(stays),
|
||||
...Object.values(points),
|
||||
]
|
||||
|
||||
49
apps/scandic-web/hooks/useUpdateProfilingConsent.ts
Normal file
49
apps/scandic-web/hooks/useUpdateProfilingConsent.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
"use client"
|
||||
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { toast } from "@scandic-hotels/design-system/Toast"
|
||||
import { trpc } from "@scandic-hotels/trpc/client"
|
||||
|
||||
export function useUpdateProfilingConsent() {
|
||||
const intl = useIntl()
|
||||
const utils = trpc.useUtils()
|
||||
const router = useRouter()
|
||||
const updateConsent = trpc.user.profilingConsent.update.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.user.get.invalidate()
|
||||
router.refresh()
|
||||
setTimeout(() => {
|
||||
toast.success(
|
||||
intl.formatMessage({
|
||||
id: "profilingConsent.alert.updateConsentSuccessful",
|
||||
defaultMessage: "Preference saved!",
|
||||
})
|
||||
)
|
||||
})
|
||||
},
|
||||
onError: () => {
|
||||
setTimeout(() => {
|
||||
toast.error(
|
||||
intl.formatMessage({
|
||||
id: "profilingConsent.alert.updateConsentFailed",
|
||||
defaultMessage:
|
||||
"An error occurred when updating preferences, please try again later.",
|
||||
})
|
||||
)
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const initiateUpdateConsent = (consent: boolean) => {
|
||||
updateConsent.mutate({ profilingConsent: consent })
|
||||
}
|
||||
|
||||
return {
|
||||
initiateUpdateConsent,
|
||||
isLoading: updateConsent.isPending,
|
||||
isSuccess: updateConsent.isSuccess,
|
||||
isError: updateConsent.isError,
|
||||
}
|
||||
}
|
||||
@@ -244,3 +244,10 @@ export const getPromoCampaignPage = cache(
|
||||
return caller.contentstack.promoCampaignPage.get()
|
||||
}
|
||||
)
|
||||
|
||||
export const getProfilingConsent = cache(
|
||||
async function getMemoizedProfilingConsent() {
|
||||
const caller = await serverClient()
|
||||
return caller.contentstack.profilingConsent.get()
|
||||
}
|
||||
)
|
||||
|
||||
@@ -3,8 +3,6 @@ import { type NextMiddleware, NextResponse } from "next/server"
|
||||
import {
|
||||
myPages,
|
||||
overview,
|
||||
profile,
|
||||
profileEdit,
|
||||
} from "@scandic-hotels/common/constants/routes/myPages"
|
||||
import { logger } from "@scandic-hotels/common/logger"
|
||||
import { findLang } from "@scandic-hotels/common/utils/languages"
|
||||
@@ -37,6 +35,7 @@ export const middleware: NextMiddleware = async (request) => {
|
||||
pathNameWithoutLang,
|
||||
lang
|
||||
)
|
||||
|
||||
if (error) {
|
||||
throw internalServerError(error)
|
||||
}
|
||||
@@ -50,24 +49,6 @@ export const middleware: NextMiddleware = async (request) => {
|
||||
headers.set("x-uid", uid)
|
||||
headers.set("x-contenttype", contentType)
|
||||
|
||||
// Handle profile and profile edit routes, which are not CMS entries
|
||||
if (profile[lang].startsWith(nextUrl.pathname)) {
|
||||
return NextResponse.rewrite(new URL(`/${lang}/my-pages/profile`, nextUrl), {
|
||||
request: {
|
||||
headers,
|
||||
},
|
||||
})
|
||||
} else if (profileEdit[lang].startsWith(nextUrl.pathname)) {
|
||||
return NextResponse.rewrite(
|
||||
new URL(`/${lang}/my-pages/profile/edit`, nextUrl),
|
||||
{
|
||||
request: {
|
||||
headers,
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.next({
|
||||
request: {
|
||||
headers,
|
||||
|
||||
24
apps/scandic-web/utils/profilingConsent.ts
Normal file
24
apps/scandic-web/utils/profilingConsent.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
export const profilingConsentOpenEvent = "profiling-consent:open"
|
||||
|
||||
export const storageKey = (memberKey: string) =>
|
||||
`profiling-consent:dismissed:${memberKey}`
|
||||
|
||||
export function readDismissed(memberKey: string): boolean {
|
||||
if (!memberKey || typeof window === "undefined") return false
|
||||
return localStorage.getItem(storageKey(memberKey)) === "1"
|
||||
}
|
||||
|
||||
export function setDismissed(memberKey: string): void {
|
||||
if (!memberKey || typeof window === "undefined") return
|
||||
localStorage.setItem(storageKey(memberKey), "1")
|
||||
}
|
||||
|
||||
export function clearDismissed(memberKey: string): void {
|
||||
if (!memberKey || typeof window === "undefined") return
|
||||
localStorage.removeItem(storageKey(memberKey))
|
||||
}
|
||||
|
||||
export function requestOpen(): void {
|
||||
if (typeof window === "undefined") return
|
||||
window.dispatchEvent(new CustomEvent(profilingConsentOpenEvent))
|
||||
}
|
||||
67
apps/scandic-web/utils/tracking/profilingConsent.ts
Normal file
67
apps/scandic-web/utils/tracking/profilingConsent.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { trackEvent } from "@scandic-hotels/tracking/base"
|
||||
|
||||
import type { TrackingSDKPageData } from "@scandic-hotels/tracking/types"
|
||||
|
||||
interface trackLinkClickProps {
|
||||
position: "modal" | "banner" | "signup" | "profile"
|
||||
name:
|
||||
| "learn more about how we process your data"
|
||||
| "edit preference in my profile"
|
||||
| "manage profiling consent"
|
||||
| "read more about personalization at scandic"
|
||||
}
|
||||
export function trackLinkClick({ position, name }: trackLinkClickProps) {
|
||||
trackEvent({
|
||||
event: "profileConsent",
|
||||
profile: {
|
||||
position: position,
|
||||
},
|
||||
link: {
|
||||
name: name,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
interface trackConsentChangeProps {
|
||||
to: boolean
|
||||
from?: boolean
|
||||
}
|
||||
export function trackConsentChange({ to, from }: trackConsentChangeProps) {
|
||||
trackEvent({
|
||||
event: "profileConsentUpdate",
|
||||
profile: {
|
||||
fromConsent: from,
|
||||
toConsent: to,
|
||||
},
|
||||
cta: {
|
||||
name: "save & update the preferences",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
interface trackConsentActionProps {
|
||||
position: "modal" | "banner" | "profile"
|
||||
name: string
|
||||
}
|
||||
export function trackConsentAction({
|
||||
position,
|
||||
name,
|
||||
}: trackConsentActionProps) {
|
||||
trackEvent({
|
||||
event: "profileConsent",
|
||||
profile: {
|
||||
position: position,
|
||||
},
|
||||
cta: {
|
||||
name: name,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const ModalTracking: Omit<TrackingSDKPageData, "domainLanguage"> = {
|
||||
pageType: "profileconsentmodalpage",
|
||||
channel: "scandic-friends",
|
||||
siteVersion: "new-web",
|
||||
pageName: "profile consent modal",
|
||||
siteSections: "profile consent modal",
|
||||
}
|
||||
@@ -37,3 +37,12 @@ export const policies = {
|
||||
no: `${customerService[Lang.no]}/betingelser`,
|
||||
sv: `${customerService[Lang.sv]}/villkor`,
|
||||
} as const satisfies LangRoute
|
||||
|
||||
export const privacy = {
|
||||
da: `${policies[Lang.da]}/privatliv`,
|
||||
de: `${policies[Lang.de]}/datenschutz`,
|
||||
en: `${policies[Lang.en]}/privacy`,
|
||||
fi: `${policies[Lang.fi]}/tietosuojaseloste`,
|
||||
no: `${customerService[Lang.no]}/personvernpolicy`,
|
||||
sv: `${policies[Lang.sv]}/integritetspolicy`,
|
||||
} as const satisfies LangRoute
|
||||
|
||||
@@ -50,6 +50,15 @@ export const profileEdit: LangRoute = {
|
||||
sv: `${profile.sv}/redigera`,
|
||||
}
|
||||
|
||||
export const profileConsent: LangRoute = {
|
||||
da: `${profile.da}/consent`,
|
||||
de: `${profile.de}/consent`,
|
||||
en: `${profile.en}/consent`,
|
||||
fi: `${profile.fi}/consent`,
|
||||
no: `${profile.no}/consent`,
|
||||
sv: `${profile.sv}/consent`,
|
||||
}
|
||||
|
||||
export const points: LangRoute = {
|
||||
da: `${myPages.da}/point`,
|
||||
de: `${myPages.de}/punkte`,
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
|
||||
|
||||
import Accordion from './index'
|
||||
import AccordionItem from './AccordionItem/index'
|
||||
import { MaterialIcon } from '../Icons/MaterialIcon'
|
||||
import { IconName } from '../Icons/iconName'
|
||||
import { Typography } from '../Typography'
|
||||
|
||||
const meta: Meta<typeof Accordion> = {
|
||||
title: 'Components/Accordion',
|
||||
component: Accordion,
|
||||
argTypes: {
|
||||
type: {
|
||||
control: 'select',
|
||||
options: ['card', 'sidepeek'],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export default meta
|
||||
|
||||
type Story = StoryObj<typeof Accordion>
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
type: 'card',
|
||||
},
|
||||
render: (args) => (
|
||||
<Accordion {...args}>
|
||||
<AccordionItem title="General Information">
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<p>
|
||||
All rooms feature comfortable beds, modern amenities, and
|
||||
complimentary Wi-Fi. Check-in is available from 3 PM and check-out
|
||||
is at 12 PM.
|
||||
</p>
|
||||
</Typography>
|
||||
</AccordionItem>
|
||||
<AccordionItem title="Breakfast & Dining">
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<p>
|
||||
Our breakfast buffet is served daily from 6:30 AM to 10:00 AM. We
|
||||
offer a wide selection of hot and cold dishes, including vegetarian
|
||||
and gluten-free options.
|
||||
</p>
|
||||
</Typography>
|
||||
</AccordionItem>
|
||||
<AccordionItem title="Parking & Transportation">
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<p>
|
||||
On-site parking is available for guests at a daily rate. The hotel
|
||||
is conveniently located near public transportation, with the nearest
|
||||
metro station just a 5-minute walk away.
|
||||
</p>
|
||||
</Typography>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
),
|
||||
}
|
||||
|
||||
export const WithIcons: Story = {
|
||||
args: {
|
||||
type: 'card',
|
||||
},
|
||||
render: (args) => (
|
||||
<Accordion {...args}>
|
||||
<AccordionItem title="Hotel Facilities" iconName={IconName.House}>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<p>
|
||||
Our hotel features a fitness center, business lounge, and 24-hour
|
||||
reception. Guests also have access to our rooftop terrace with
|
||||
panoramic city views.
|
||||
</p>
|
||||
</Typography>
|
||||
</AccordionItem>
|
||||
<AccordionItem title="Wellness & Spa" iconName={IconName.Spa}>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<p>
|
||||
Relax and unwind in our wellness area featuring a sauna, steam room,
|
||||
and massage treatments. Advanced booking is recommended for spa
|
||||
services.
|
||||
</p>
|
||||
</Typography>
|
||||
</AccordionItem>
|
||||
<AccordionItem
|
||||
title="Conference Rooms"
|
||||
icon={
|
||||
<MaterialIcon
|
||||
icon="meeting_room"
|
||||
color="Icon/Interactive/Default"
|
||||
size={24}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<p>
|
||||
We offer flexible meeting spaces for 10 to 200 people, equipped with
|
||||
modern AV technology and high-speed internet. Catering packages are
|
||||
available upon request.
|
||||
</p>
|
||||
</Typography>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
),
|
||||
}
|
||||
|
||||
export const WithSubtitle: Story = {
|
||||
args: {
|
||||
type: 'card',
|
||||
},
|
||||
render: (args) => (
|
||||
<Accordion {...args}>
|
||||
<AccordionItem title="Standard Room" subtitle="From €120/night">
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<p>
|
||||
Comfortable room with queen-size bed, work desk, and private
|
||||
bathroom. Perfect for solo travelers or couples.
|
||||
</p>
|
||||
</Typography>
|
||||
</AccordionItem>
|
||||
<AccordionItem title="Family Suite" subtitle="From €180/night">
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<p>
|
||||
Spacious suite with separate sleeping areas, ideal for families.
|
||||
Includes one double bed and two single beds, plus a sofa bed.
|
||||
</p>
|
||||
</Typography>
|
||||
</AccordionItem>
|
||||
<AccordionItem title="Premium with Subtitle Style" showAsSubtitle>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<p>
|
||||
This item uses the showAsSubtitle prop to render the title with
|
||||
subtitle typography styling, without an actual subtitle.
|
||||
</p>
|
||||
</Typography>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
),
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { type ReactNode, useRef } from 'react'
|
||||
import { type ReactNode, useEffect, useRef } from 'react'
|
||||
|
||||
import { IconByIconName } from '../../Icons/IconByIconName'
|
||||
import { MaterialIcon } from '../../Icons/MaterialIcon'
|
||||
@@ -23,6 +23,7 @@ export interface AccordionItemProps
|
||||
subtitle?: string
|
||||
showAsSubtitle?: boolean
|
||||
onOpen?: () => void
|
||||
openedOnRender?: boolean
|
||||
}
|
||||
|
||||
export default function AccordionItem({
|
||||
@@ -36,6 +37,7 @@ export default function AccordionItem({
|
||||
subtitle,
|
||||
showAsSubtitle = false,
|
||||
onOpen,
|
||||
openedOnRender = false,
|
||||
}: AccordionItemProps) {
|
||||
const contentRef = useRef<HTMLDivElement>(null)
|
||||
const detailsRef = useRef<HTMLDetailsElement>(null)
|
||||
@@ -72,6 +74,14 @@ export default function AccordionItem({
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (openedOnRender && detailsRef.current) {
|
||||
requestAnimationFrame(() => {
|
||||
detailsRef.current!.open = true
|
||||
})
|
||||
}
|
||||
}, [openedOnRender])
|
||||
|
||||
const TitleLevel = titleLevel
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
'use client'
|
||||
|
||||
import { cx } from 'class-variance-authority'
|
||||
import { Label, Radio, RadioGroup, Text } from 'react-aria-components'
|
||||
|
||||
import { Divider } from '../../Divider'
|
||||
import { Typography } from '../../Typography'
|
||||
|
||||
import styles from './radioButtonsGroup.module.css'
|
||||
interface Option {
|
||||
value: string
|
||||
title: string
|
||||
text: string
|
||||
}
|
||||
interface RadioButtonsGroupProps {
|
||||
options: Option[]
|
||||
onChange: (value: string) => void
|
||||
ariaLabel: string
|
||||
defaultOption?: Option
|
||||
}
|
||||
export function RadioButtonsGroup({
|
||||
options,
|
||||
onChange,
|
||||
ariaLabel,
|
||||
defaultOption,
|
||||
}: RadioButtonsGroupProps) {
|
||||
return (
|
||||
<RadioGroup
|
||||
className={styles.radioButtons}
|
||||
onChange={onChange}
|
||||
aria-label={ariaLabel}
|
||||
defaultValue={defaultOption?.value}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<Radio
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
className={({ isFocusVisible, isSelected, isHovered, isDisabled }) =>
|
||||
cx(styles.option, {
|
||||
[styles.focused]: isFocusVisible,
|
||||
[styles.selected]: isSelected,
|
||||
[styles.hovered]: isHovered,
|
||||
[styles.disabled]: isDisabled,
|
||||
})
|
||||
}
|
||||
>
|
||||
{({ isSelected, isDisabled }) => (
|
||||
<div className={styles.card}>
|
||||
<span className={styles.titleContainer}>
|
||||
<span
|
||||
className={cx(styles.radio, {
|
||||
[styles.selected]: isSelected,
|
||||
[styles.disabled]: isDisabled,
|
||||
})}
|
||||
aria-hidden
|
||||
/>
|
||||
<Typography variant="Title/Subtitle/md">
|
||||
<Label>{option.title}</Label>
|
||||
</Typography>
|
||||
</span>
|
||||
<span className={styles.bottom}>
|
||||
<Divider />
|
||||
<Typography variant="Body/Supporting text (caption)/smRegular">
|
||||
<Text slot="description">{option.text}</Text>
|
||||
</Typography>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</Radio>
|
||||
))}
|
||||
</RadioGroup>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
.radioButtons {
|
||||
display: flex;
|
||||
gap: var(--Space-x2);
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.option {
|
||||
position: relative;
|
||||
background: var(--Surface-Primary-Default);
|
||||
padding: var(--Space-x15) var(--Space-x2);
|
||||
outline: 1px solid var(--Border-Interactive-Default);
|
||||
border-radius: var(--Corner-radius-md);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--Space-x2);
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.option.hovered {
|
||||
background:
|
||||
linear-gradient(
|
||||
0deg,
|
||||
var(--Surface-Primary-Hover, rgba(31, 28, 27, 0.05)) 0%,
|
||||
var(--Surface-Primary-Hover, rgba(31, 28, 27, 0.05)) 100%
|
||||
),
|
||||
var(--Surface-Primary-Default);
|
||||
}
|
||||
|
||||
.option.focused {
|
||||
outline: 2px solid var(--UI-Input-Controls-Border-Focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.option.disabled {
|
||||
outline: 1px solid var(--Border-Interactive-Disabled);
|
||||
background:
|
||||
linear-gradient(
|
||||
0deg,
|
||||
var(--Surface-UI-Fill-Disabled, rgba(31, 28, 27, 0.1)) 0%,
|
||||
var(--Surface-UI-Fill-Disabled, rgba(31, 28, 27, 0.1)) 100%
|
||||
),
|
||||
var(--Surface-Primary-Default, #fff);
|
||||
color: var(--Text-Interactive-Disabled);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.option.selected {
|
||||
color: var(--Text-Default);
|
||||
}
|
||||
|
||||
.option.selected:not(.disabled) {
|
||||
outline: 2px solid var(--Border-Interactive-Active);
|
||||
background: var(--Surface-Primary-Default);
|
||||
}
|
||||
|
||||
.card {
|
||||
display: grid;
|
||||
gap: var(--Space-x2);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.radio {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 1px solid var(--Border-Interactive-Default);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
background-color: var(--Surface-UI-Fill-Default);
|
||||
background: var(--Surface-UI-Fill-Default);
|
||||
}
|
||||
|
||||
.radio.selected {
|
||||
border: 8px solid var(--Surface-UI-Fill-Active);
|
||||
}
|
||||
|
||||
.radio.disabled {
|
||||
background-color: var(--Surface-UI-Fill-Disabled);
|
||||
}
|
||||
|
||||
.titleContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--Space-x15);
|
||||
}
|
||||
|
||||
.bottom {
|
||||
display: grid;
|
||||
gap: var(--Space-x1);
|
||||
color: var(--Text-Secondary);
|
||||
}
|
||||
|
||||
.selected .bottom {
|
||||
color: var(--Text-Default);
|
||||
}
|
||||
|
||||
.disabled:not(.selected) .bottom {
|
||||
color: var(--Text-Interactive-Disabled);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.radioButtons {
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import KidsMocktailIcon from './Illustrations/KidsMocktail'
|
||||
import MagicWandIcon from './Illustrations/MagicWand'
|
||||
import MoneyHandIcon from './Illustrations/MoneyHand'
|
||||
import MoneyHandEllipsisIcon from './Illustrations/MoneyHandEllipsis'
|
||||
import SpaIcon from './Illustrations/Spa'
|
||||
import TrophyIcon from './Illustrations/Trophy'
|
||||
import VoucherIcon from './Illustrations/Voucher'
|
||||
|
||||
@@ -48,6 +49,8 @@ export function IllustrationByIconName(iconName: IconName | null) {
|
||||
return TrophyIcon
|
||||
case IconName.Voucher:
|
||||
return VoucherIcon
|
||||
case IconName.Spa:
|
||||
return SpaIcon
|
||||
default:
|
||||
return null
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
5593
packages/design-system/lib/components/Icons/Illustrations/Spa.tsx
Normal file
5593
packages/design-system/lib/components/Icons/Illustrations/Spa.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -6,29 +6,61 @@ export default function VoucherIcon(props: IllustrationProps) {
|
||||
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 358 202"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="23.95 39.7 147.95 116.88"
|
||||
{...props}
|
||||
{...ariaProps}
|
||||
>
|
||||
<path fill="#fff" d="M0 .375h358V201.75H0z" />
|
||||
<g fill="#4d001b">
|
||||
<path d="M117.481 107.462c-.185-.315-.011-.663.365-.697l3.353-.325a.48.48 0 0 1 .455.236l5.059 8.794.152-.017 3.257-9.609a.51.51 0 0 1 .399-.32l3.352-.325c.377-.04.612.269.495.612l-6.256 17.734a.5.5 0 0 1-.399.32l-.253.022a.47.47 0 0 1-.455-.235zM143.959 103.953c5.043-.494 9.468 3.167 9.962 8.21s-3.145 9.44-8.187 9.934a9.035 9.035 0 0 1-9.934-8.16c-.495-5.042 3.116-9.49 8.159-9.984m1.382 14.112c2.774-.27 4.818-2.763 4.548-5.509s-2.763-4.846-5.537-4.571c-2.746.269-4.79 2.785-4.52 5.559.269 2.746 2.757 4.796 5.509 4.527zM155.898 103.519a.5.5 0 0 1 .432-.527l3.1-.304c.275-.028.5.18.528.433l.999 10.209c.174 1.763 1.623 3.049 3.409 2.869 1.814-.179 3.01-1.718 2.842-3.481l-1-10.209a.486.486 0 0 1 .432-.528l3.1-.304c.253-.022.5.18.528.433l1.016 10.411c.377 3.83-2.538 7.323-6.519 7.71-3.959.388-7.469-2.476-7.845-6.306l-1.017-10.411zM182.061 100.224c2.521-.247 4.465.354 6.329 1.747a.47.47 0 0 1 .095.702l-1.763 2.235a.446.446 0 0 1-.629.061 5.19 5.19 0 0 0-3.527-.954c-2.847.28-4.705 2.852-4.43 5.677.275 2.797 2.617 4.908 5.464 4.633 1.185-.118 2.426-.668 3.257-1.539.163-.168.488-.202.657-.039l2.173 1.898c.191.157.202.489.039.685-1.55 1.831-3.644 2.825-5.885 3.044-5.043.494-9.49-3.117-9.984-8.16s3.167-9.496 8.21-9.99zM191.276 100.055a.504.504 0 0 1 .433-.528l2.998-.292c.275-.028.5.18.528.433l.623 6.351 7.211-.707-.623-6.352a.486.486 0 0 1 .432-.528l2.999-.292c.252-.022.5.18.528.433l1.634 16.684c.022.252-.18.5-.433.528l-2.998.292a.485.485 0 0 1-.528-.433l-.652-6.626-7.21.707.651 6.627a.486.486 0 0 1-.432.528l-2.999.292a.504.504 0 0 1-.528-.433zM210.734 98.152a.487.487 0 0 1 .433-.528l10.434-1.022a.484.484 0 0 1 .527.433l.27 2.746a.486.486 0 0 1-.432.527l-6.981.686.304 3.127 5.744-.561c.253-.023.5.18.528.432l.27 2.746c.028.275-.18.5-.433.528l-5.744.562.331 3.403 6.98-.685c.275-.028.5.179.528.432l.27 2.746a.487.487 0 0 1-.433.528l-10.434 1.022a.486.486 0 0 1-.528-.432l-1.634-16.684zM225.503 96.708a.486.486 0 0 1 .432-.528l7.536-.735c3.027-.298 5.734 1.91 6.026 4.908.225 2.319-1.129 4.335-3.235 5.43l4.077 6.065c.208.309.073.73-.359.775l-3.353.326a.46.46 0 0 1-.426-.186l-4.004-6.351-1.741.169.629 6.424c.022.253-.18.5-.433.528l-2.998.292a.486.486 0 0 1-.528-.433l-1.634-16.684zm8.373 6.301c1.033-.101 1.819-1.145 1.718-2.201s-1.067-1.83-2.1-1.73l-3.729.365.388 3.931 3.728-.365z" />
|
||||
</g>
|
||||
<path
|
||||
fill="#cd0921"
|
||||
d="M185.11 140.69c-31.891 1.528-33.177 2.769-34.767 33.453-1.589-30.684-2.88-31.925-34.766-33.453 31.891-1.527 33.177-2.768 34.766-33.452 1.59 30.684 2.881 31.925 34.767 33.452M235.6 57.713c-43.465 2.365-45.223 4.285-47.391 51.731-2.167-47.446-3.925-49.366-47.39-51.73 43.465-2.365 45.223-4.285 47.39-51.732 2.168 47.447 3.926 49.367 47.391 51.731"
|
||||
d="M60.3191 101.871C60.2056 101.678 60.3123 101.465 60.5427 101.444L62.5961 101.245C62.72 101.231 62.8369 101.313 62.8747 101.389L65.9738 106.775L66.0667 106.765L68.0616 100.88C68.0857 100.801 68.1854 100.698 68.3058 100.684L70.3592 100.484C70.5897 100.46 70.7341 100.65 70.6619 100.859L66.8302 111.722C66.7924 111.818 66.7064 111.904 66.586 111.918L66.4312 111.931C66.3074 111.945 66.2077 111.876 66.1526 111.787L60.3191 101.871Z"
|
||||
fill="#4D001B"
|
||||
/>
|
||||
<path
|
||||
stroke="#4d001b"
|
||||
strokeMiterlimit="10"
|
||||
strokeWidth="1.685"
|
||||
d="M262.19 59.623c-.062-.629-.933-1.056-1.561-.994q-3.303.325-6.599.64c-3.302.326-3.302.287-6.604.612s-3.262.702-6.559 1.022c-3.296.32-3.313.174-6.615.5s-3.336-.017-6.632.309-3.313.196-6.61.517c-3.296.32-3.24.954-6.536 1.28-3.297.326-3.341-.135-6.643.19s-3.297.349-6.593.67c-3.297.32-3.291.403-6.593.73s-3.313.173-6.609.499c-3.297.326-3.308.264-6.604.584s-3.291.432-6.588.758c-3.296.326-3.285.444-6.587.77s-3.285.449-6.587.769-3.347-.169-6.649.151-3.296.348-6.598.669-3.246.887-6.542 1.207c-3.297.32-3.353-.208-6.655.118s-3.24.943-6.542 1.269-3.307.213-6.609.534-3.353-.197-6.649.129c-3.297.325-3.285.471-6.587.792s-3.319.134-6.621.454-3.235 1-6.537 1.326-3.307.292-6.61.612c-.628.061-1.364.331-1.302.966.224 2.302.747 4.582.971 6.884s.056 4.65.287 6.952c.23 2.303.449 4.61.674 6.919.224 2.308.977 4.56 1.201 6.862s.37 4.616.596 6.924c.224 2.308.005 4.656.235 6.958.225 2.302.905 4.565 1.13 6.874s.218 4.632.443 6.94c.224 2.303.904 4.566 1.129 6.874s.286 4.627.51 6.93.09 4.644.315 6.952.921 4.565 1.151 6.868c.062.629.382 1.471 1.011 1.409 3.302-.326 3.291-.387 6.593-.707 3.302-.321 3.302-.281 6.604-.607s3.296-.354 6.598-.674 3.24-.954 6.537-1.28 3.33-.039 6.626-.365 3.28-.551 6.576-.871 3.336.04 6.632-.28 3.319-.124 6.615-.444 3.252-.786 6.554-1.112 3.307-.236 6.609-.561 3.341.089 6.638-.236c3.296-.326 3.274-.596 6.57-.916s3.268-.657 6.57-.982c3.302-.326 3.325-.079 6.621-.399s3.274-.612 6.57-.938 3.364.32 6.666 0 3.235-1 6.537-1.325c3.302-.326 3.341.095 6.643-.225s3.274-.589 6.576-.915 3.352.219 6.654-.107 3.257-.741 6.559-1.061 3.342.09 6.644-.236 3.262-.741 6.564-1.067 3.319-.129 6.621-.455 3.302-.314 6.604-.64 3.291-.472 6.593-.798c.629-.061 1.044-.578.983-1.207-.315-3.19-.377-3.184-.691-6.374-.315-3.189 0-3.217-.315-6.413-.314-3.195-.741-3.15-1.055-6.34-.315-3.189.179-3.24-.13-6.435-.207-2.128-.651-4.234-.859-6.363-.208-2.128-.64-4.234-.848-6.362s-.224-4.274-.432-6.402-.36-4.262-.567-6.39c-.208-2.129-.382-4.257-.59-6.385s-.825-4.218-1.033-6.346.241-4.318.033-6.447c-.207-2.128-.758-4.223-.965-6.351-.208-2.128-.107-4.285-.315-6.413z"
|
||||
d="M76.5371 99.7207C79.6258 99.418 82.3362 101.661 82.6389 104.749C82.9416 107.838 80.7127 110.531 77.624 110.834C74.5353 111.137 71.8421 108.922 71.5394 105.836C71.2367 102.748 73.4484 100.023 76.5371 99.7207ZM77.3832 108.364C79.0824 108.199 80.3344 106.672 80.1693 104.99C80.0042 103.308 78.477 102.022 76.7779 102.19C75.0959 102.355 73.8439 103.896 74.009 105.595C74.1741 107.277 75.6978 108.533 77.3832 108.368V108.364Z"
|
||||
fill="#4D001B"
|
||||
/>
|
||||
<path
|
||||
fill="#cd0921"
|
||||
d="M143.122 35.325c-18.891.943-19.655 1.707-20.598 20.598-.944-18.891-1.707-19.655-20.598-20.598 18.891-.944 19.654-1.708 20.598-20.598.943 18.89 1.707 19.654 20.598 20.598M248.813 176.168c-18.891.944-19.654 1.708-20.598 20.598-.943-18.89-1.707-19.654-20.598-20.598 18.891-.943 19.655-1.707 20.598-20.598.944 18.891 1.707 19.655 20.598 20.598"
|
||||
d="M83.8497 99.4554C83.8359 99.3006 83.9597 99.1493 84.1145 99.1321L86.0132 98.9463C86.1817 98.9291 86.3193 99.0564 86.3365 99.2112L86.9487 105.464C87.0553 106.544 87.9428 107.332 89.0365 107.222C90.1475 107.112 90.8801 106.169 90.777 105.089L90.1647 98.8363C90.151 98.6815 90.261 98.5302 90.4296 98.513L92.3282 98.3272C92.483 98.3135 92.6343 98.4373 92.6515 98.5921L93.2741 104.969C93.5045 107.315 91.7194 109.454 89.2807 109.692C86.8559 109.929 84.7061 108.175 84.4757 105.829L83.8531 99.452L83.8497 99.4554Z"
|
||||
fill="#4D001B"
|
||||
/>
|
||||
<path
|
||||
d="M99.8738 97.4355C101.418 97.2842 102.608 97.6522 103.75 98.5052C103.902 98.6153 103.919 98.801 103.809 98.9352L102.729 100.304C102.632 100.421 102.46 100.438 102.343 100.342C101.721 99.8879 100.94 99.685 100.183 99.7572C98.4395 99.9292 97.3011 101.505 97.4696 103.235C97.6381 104.948 99.0724 106.241 100.816 106.072C101.542 106 102.302 105.663 102.811 105.13C102.911 105.027 103.11 105.006 103.214 105.106L104.545 106.268C104.662 106.365 104.669 106.568 104.569 106.688C103.62 107.809 102.337 108.418 100.964 108.552C97.8755 108.855 95.1513 106.643 94.8487 103.555C94.546 100.466 96.7886 97.7382 99.8773 97.4355H99.8738Z"
|
||||
fill="#4D001B"
|
||||
/>
|
||||
<path
|
||||
d="M105.519 97.3341C105.505 97.1793 105.629 97.028 105.783 97.0108L107.62 96.8319C107.789 96.8147 107.926 96.942 107.944 97.0968L108.325 100.987L112.742 100.554L112.36 96.6634C112.346 96.5086 112.456 96.3573 112.625 96.3401L114.461 96.1612C114.616 96.1474 114.768 96.2713 114.785 96.426L115.786 106.645C115.799 106.8 115.676 106.951 115.521 106.968L113.684 107.147C113.516 107.164 113.378 107.037 113.361 106.882L112.962 102.824L108.545 103.257L108.944 107.316C108.958 107.47 108.848 107.622 108.68 107.639L106.843 107.818C106.688 107.832 106.537 107.708 106.52 107.553L105.519 97.3341Z"
|
||||
fill="#4D001B"
|
||||
/>
|
||||
<path
|
||||
d="M117.437 96.1657C117.423 96.0109 117.533 95.8596 117.702 95.8424L124.092 95.2164C124.261 95.1992 124.398 95.3265 124.416 95.4812L124.581 97.1632C124.594 97.318 124.484 97.4693 124.316 97.4865L120.04 97.9061L120.226 99.822L123.745 99.478C123.9 99.4643 124.051 99.5881 124.068 99.7429L124.233 101.425C124.25 101.593 124.123 101.731 123.968 101.748L120.45 102.092L120.653 104.176L124.928 103.757C125.097 103.74 125.234 103.867 125.251 104.022L125.416 105.704C125.43 105.858 125.32 106.01 125.152 106.027L118.761 106.653C118.592 106.67 118.455 106.543 118.438 106.388L117.437 96.1692V96.1657Z"
|
||||
fill="#4D001B"
|
||||
/>
|
||||
<path
|
||||
d="M126.482 95.2826C126.469 95.1278 126.579 94.9765 126.747 94.9593L131.363 94.5087C133.217 94.3264 134.875 95.6781 135.054 97.5148C135.191 98.9354 134.363 100.17 133.073 100.841L135.57 104.556C135.697 104.745 135.615 105.003 135.35 105.03L133.296 105.23C133.172 105.244 133.073 105.175 133.035 105.116L130.582 101.226L129.516 101.329L129.901 105.264C129.915 105.419 129.791 105.57 129.637 105.587L127.8 105.766C127.631 105.784 127.494 105.656 127.476 105.502L126.476 95.2826H126.482ZM131.611 99.1418C132.244 99.0798 132.725 98.4401 132.663 97.7934C132.601 97.1468 132.01 96.6721 131.377 96.7341L129.093 96.9576L129.33 99.3653L131.614 99.1418H131.611Z"
|
||||
fill="#4D001B"
|
||||
/>
|
||||
<path
|
||||
d="M101.742 122.224C82.2087 123.159 81.421 123.92 80.4476 142.713C79.4743 123.92 78.6832 123.159 59.1533 122.224C78.6866 121.288 79.4743 120.528 80.4476 101.734C81.421 120.528 82.2121 121.288 101.742 122.224Z"
|
||||
fill="#CD0921"
|
||||
/>
|
||||
<path
|
||||
d="M132.667 71.4001C106.045 72.8481 104.968 74.0245 103.641 103.085C102.313 74.0245 101.236 72.8481 74.6143 71.4001C101.236 69.952 102.313 68.7757 103.641 39.7148C104.968 68.7757 106.045 69.952 132.667 71.4001Z"
|
||||
fill="#CD0921"
|
||||
/>
|
||||
<path
|
||||
d="M148.954 72.5695C148.916 72.1842 148.383 71.9228 147.998 71.9607C146.65 72.0937 145.302 72.2244 143.956 72.3528C141.934 72.5523 141.934 72.5282 139.912 72.7277C137.889 72.9272 137.913 73.1576 135.894 73.3537C133.875 73.5497 133.865 73.4603 131.842 73.6598C129.82 73.8593 129.799 73.6495 127.78 73.849C125.761 74.0485 125.751 73.9694 123.732 74.1654C121.713 74.3615 121.747 74.7502 119.728 74.9496C117.709 75.1491 117.682 74.8671 115.659 75.0666C113.637 75.2661 113.64 75.2798 111.621 75.4759C109.602 75.672 109.606 75.7235 107.583 75.923C105.561 76.1225 105.554 76.0297 103.535 76.2292C101.516 76.4287 101.509 76.3908 99.4898 76.5869C97.4708 76.7829 97.4742 76.8517 95.4552 77.0512C93.4362 77.2507 93.4431 77.3229 91.4206 77.5224C89.3982 77.7219 89.4085 77.7976 87.386 77.9937C85.3635 78.1897 85.336 77.8905 83.3136 78.0865C81.2911 78.2826 81.2945 78.2998 79.2721 78.4958C77.2496 78.6919 77.284 79.0393 75.265 79.2353C73.246 79.4314 73.2116 79.1081 71.1891 79.3076C69.1666 79.5071 69.2045 79.8854 67.182 80.0849C65.1596 80.2844 65.1561 80.2156 63.1337 80.4117C61.1112 80.6077 61.0802 80.2913 59.0612 80.4908C57.0422 80.6903 57.0491 80.7797 55.0266 80.9758C53.0041 81.1718 52.9938 81.0583 50.9714 81.2544C48.9489 81.4504 48.9902 81.8666 46.9677 82.0661C44.9453 82.2656 44.9418 82.245 42.9194 82.441C42.5341 82.4788 42.0835 82.6439 42.1214 83.0326C42.259 84.4428 42.5788 85.8393 42.7164 87.2495C42.854 88.6597 42.7508 90.0975 42.8918 91.5077C43.0329 92.9179 43.167 94.3316 43.3046 95.7452C43.4422 97.1589 43.9031 98.5382 44.0406 99.9484C44.1782 101.359 44.2677 102.776 44.4052 104.189C44.5428 105.603 44.4087 107.041 44.5497 108.451C44.6873 109.861 45.1035 111.247 45.2411 112.661C45.3786 114.075 45.3752 115.499 45.5128 116.912C45.6504 118.323 46.0666 119.709 46.2041 121.122C46.3417 122.536 46.3796 123.957 46.5171 125.367C46.6547 126.777 46.5722 128.211 46.7097 129.625C46.8473 131.039 47.2738 132.421 47.4149 133.832C47.4527 134.217 47.6487 134.733 48.034 134.695C50.0564 134.495 50.0496 134.458 52.072 134.261C54.0945 134.065 54.0945 134.089 56.1169 133.89C58.1394 133.69 58.136 133.673 60.1584 133.477C62.1809 133.281 62.1431 132.893 64.1621 132.693C66.1811 132.494 66.2017 132.669 68.2208 132.469C70.2398 132.27 70.2295 132.132 72.2485 131.936C74.2675 131.74 74.2916 131.96 76.3106 131.764C78.3296 131.568 78.3434 131.689 80.3624 131.493C82.3814 131.297 82.3539 131.011 84.3764 130.812C86.3988 130.612 86.4023 130.667 88.4247 130.468C90.4472 130.268 90.4713 130.523 92.4903 130.323C94.5093 130.124 94.4956 129.959 96.5146 129.763C98.5336 129.566 98.5164 129.36 100.539 129.161C102.561 128.961 102.575 129.112 104.594 128.916C106.613 128.72 106.599 128.541 108.618 128.342C110.637 128.142 110.679 128.538 112.701 128.342C114.724 128.146 114.682 127.73 116.705 127.53C118.727 127.331 118.751 127.589 120.774 127.393C122.796 127.197 122.779 127.031 124.802 126.832C126.824 126.632 126.855 126.966 128.877 126.767C130.9 126.567 130.872 126.313 132.895 126.117C134.917 125.921 134.941 126.172 136.964 125.972C138.986 125.773 138.962 125.518 140.985 125.319C143.007 125.119 143.017 125.239 145.04 125.04C147.062 124.84 147.062 124.847 149.085 124.648C151.107 124.448 151.1 124.359 153.123 124.159C153.508 124.122 153.763 123.805 153.725 123.42C153.532 121.466 153.494 121.47 153.302 119.516C153.109 117.562 153.302 117.545 153.109 115.588C152.917 113.631 152.655 113.658 152.463 111.705C152.27 109.751 152.573 109.72 152.383 107.763C152.256 106.459 151.984 105.17 151.857 103.866C151.73 102.562 151.465 101.273 151.338 99.969C151.211 98.6654 151.2 97.3515 151.073 96.0479C150.946 94.7443 150.853 93.4373 150.726 92.1337C150.598 90.8301 150.492 89.5265 150.364 88.2229C150.237 86.9193 149.859 85.6398 149.731 84.3362C149.604 83.0326 149.879 81.6912 149.752 80.3876C149.625 79.084 149.288 77.801 149.161 76.4974C149.033 75.1939 149.095 73.8731 148.968 72.5695H148.954Z"
|
||||
stroke="#4D001B"
|
||||
stroke-width="3.30337"
|
||||
stroke-miterlimit="10"
|
||||
/>
|
||||
<path
|
||||
d="M76.0236 57.6847C64.453 58.2625 63.9852 58.7303 63.4073 70.301C62.8295 58.7303 62.3617 58.2625 50.791 57.6847C62.3617 57.1068 62.8295 56.639 63.4073 45.0684C63.9852 56.639 64.453 57.1068 76.0236 57.6847Z"
|
||||
fill="#CD0921"
|
||||
/>
|
||||
<path
|
||||
d="M140.76 143.954C129.189 144.532 128.721 145 128.144 156.571C127.566 145 127.098 144.532 115.527 143.954C127.098 143.376 127.566 142.909 128.144 131.338C128.721 142.909 129.189 143.376 140.76 143.954Z"
|
||||
fill="#CD0921"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { cx } from 'class-variance-authority'
|
||||
|
||||
import { TextLinkProps } from '../TextLink/types'
|
||||
import { getTextLinkClasses } from './textLinkStyles'
|
||||
|
||||
import styles from './textLinkButton.module.css'
|
||||
|
||||
export type TextLinkButtonProps = {
|
||||
theme?: TextLinkProps['theme']
|
||||
typography?: TextLinkProps['typography']
|
||||
isDisabled?: TextLinkProps['isDisabled']
|
||||
isInline?: TextLinkProps['isInline']
|
||||
} & React.ButtonHTMLAttributes<HTMLButtonElement>
|
||||
|
||||
/* A Button with the same styling as a TextLink to handle an edge case. */
|
||||
export function TextLinkButton({
|
||||
theme,
|
||||
isDisabled,
|
||||
isInline,
|
||||
typography,
|
||||
className,
|
||||
...props
|
||||
}: TextLinkButtonProps) {
|
||||
const classNames = getTextLinkClasses({
|
||||
theme,
|
||||
isDisabled,
|
||||
isInline,
|
||||
typography,
|
||||
className,
|
||||
})
|
||||
|
||||
return (
|
||||
<button
|
||||
{...props}
|
||||
disabled={isDisabled}
|
||||
className={cx(classNames, styles.button)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { TextLinkButton } from './TextLinkButton'
|
||||
@@ -0,0 +1,6 @@
|
||||
.button {
|
||||
justify-self: flex-start;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { cx } from 'class-variance-authority'
|
||||
import { variants } from '../TextLink/variants'
|
||||
import styles from '../TextLink/textLink.module.css'
|
||||
import type { TextLinkButtonProps } from './TextLinkButton'
|
||||
|
||||
export function getTextLinkClasses({
|
||||
theme,
|
||||
isDisabled,
|
||||
isInline,
|
||||
typography,
|
||||
className,
|
||||
}: TextLinkButtonProps) {
|
||||
const variantClasses = variants({ theme, typography, className })
|
||||
|
||||
return cx(variantClasses, styles, {
|
||||
[styles.disabled]: isDisabled,
|
||||
[styles.inline]: isInline,
|
||||
})
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { type PropsWithChildren, useState } from 'react'
|
||||
import { tooltipVariants } from './variants'
|
||||
|
||||
import styles from './tooltip.module.css'
|
||||
import Caption from '../Caption'
|
||||
import { Typography } from '../Typography'
|
||||
|
||||
type TooltipPosition = 'left' | 'right' | 'top' | 'bottom'
|
||||
type VerticalArrow = 'top' | 'bottom' | 'center'
|
||||
@@ -26,6 +26,7 @@ interface TooltipProps<P extends TooltipPosition = TooltipPosition> {
|
||||
position: P
|
||||
arrow: ValidArrow<P>
|
||||
isTouchable?: boolean
|
||||
isVisible?: boolean
|
||||
}
|
||||
|
||||
export function Tooltip<P extends TooltipPosition>({
|
||||
@@ -35,6 +36,7 @@ export function Tooltip<P extends TooltipPosition>({
|
||||
arrow,
|
||||
children,
|
||||
isTouchable = false,
|
||||
isVisible = true,
|
||||
}: PropsWithChildren<TooltipProps<P>>) {
|
||||
const className = tooltipVariants({ position, arrow })
|
||||
const [isActive, setIsActive] = useState(false)
|
||||
@@ -50,6 +52,10 @@ export function Tooltip<P extends TooltipPosition>({
|
||||
}
|
||||
}
|
||||
|
||||
if (!isVisible) {
|
||||
return <> {children} </>
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.tooltipContainer}
|
||||
@@ -62,12 +68,16 @@ export function Tooltip<P extends TooltipPosition>({
|
||||
data-active={isActive}
|
||||
>
|
||||
<div className={className}>
|
||||
{heading && (
|
||||
<Caption type="bold" color="white">
|
||||
{heading}
|
||||
</Caption>
|
||||
)}
|
||||
{text && <Caption color="white">{text}</Caption>}
|
||||
{heading ? (
|
||||
<Typography variant="Body/Supporting text (caption)/smBold">
|
||||
<p>{heading}</p>
|
||||
</Typography>
|
||||
) : null}
|
||||
{text ? (
|
||||
<Typography variant="Body/Supporting text (caption)/smRegular">
|
||||
<p>{text} </p>
|
||||
</Typography>
|
||||
) : null}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
background-color: var(--Surface-UI-Fill-Intense);
|
||||
border: 0.5px solid var(--Border-Interactive-Focus);
|
||||
border-radius: var(--Corner-radius-md);
|
||||
color: var(--Base-Text-Inverted);
|
||||
color: var(--Text-Inverted);
|
||||
position: absolute;
|
||||
visibility: hidden;
|
||||
z-index: 1000;
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
"./Form/Country": "./lib/components/Form/Country/index.tsx",
|
||||
"./Form/Date": "./lib/components/Form/Date/index.tsx",
|
||||
"./Form/ErrorMessage": "./lib/components/Form/ErrorMessage/index.tsx",
|
||||
"./Form/RadioButtonsGroup": "./lib/components/Form/RadioButtonsGroup/index.tsx",
|
||||
"./Form/PaymentOption": "./lib/components/Form/PaymentOption/PaymentOption.tsx",
|
||||
"./Form/PaymentOptionsGroup": "./lib/components/Form/PaymentOption/PaymentOptionsGroup.tsx",
|
||||
"./Form/Phone": "./lib/components/Form/Phone/index.tsx",
|
||||
@@ -86,6 +87,7 @@
|
||||
"./Icons/HairdresserIcon": "./lib/components/Icons/Nucleo/Amenities_Facilities/hairdresser-1.tsx",
|
||||
"./Icons/HairdryerIcon": "./lib/components/Icons/Customised/Amenities_Facilities/Hairdryer.tsx",
|
||||
"./Icons/HandKeyIcon": "./lib/components/Icons/Illustrations/HandKey.tsx",
|
||||
"./Icons/HandGiftIcon": "./lib/components/Icons/Illustrations/HandGift.tsx",
|
||||
"./Icons/HandSoapIcon": "./lib/components/Icons/Customised/Amenities_Facilities/HandSoap.tsx",
|
||||
"./Icons/HaymarketIcon": "./lib/components/Icons/Logos/Haymarket.tsx",
|
||||
"./Icons/HotelLogoIcon": "./lib/components/Icons/Logos/index.tsx",
|
||||
@@ -119,6 +121,7 @@
|
||||
"./Icons/ScandicGoIcon": "./lib/components/Icons/Logos/ScandicGoLogo.tsx",
|
||||
"./Icons/ScandicLogoIcon": "./lib/components/Icons/Logos/ScandicLogo.tsx",
|
||||
"./Icons/SlippersIcon": "./lib/components/Icons/Customised/Amenities_Facilities/Slippers.tsx",
|
||||
"./Icons/Spa": "./lib/components/Icons/Illustrations/Spa.tsx",
|
||||
"./Icons/SurpriseIcon": "./lib/components/Icons/Illustrations/Surprise.tsx",
|
||||
"./Icons/ToiletIcon": "./lib/components/Icons/Nucleo/Amenities_Facilities/toilet-2.tsx",
|
||||
"./Icons/TowelIcon": "./lib/components/Icons/Customised/Amenities_Facilities/Towel.tsx",
|
||||
@@ -172,6 +175,7 @@
|
||||
"./Switch": "./lib/components/Switch/index.tsx",
|
||||
"./Table": "./lib/components/Table/index.tsx",
|
||||
"./TextLink": "./lib/components/TextLink/index.tsx",
|
||||
"./TextLinkButton": "./lib/components/TextLinkButton/index.tsx",
|
||||
"./Title": "./lib/components/Title/index.tsx",
|
||||
"./Toast": "./lib/components/Toasts/index.tsx",
|
||||
"./ToastHandler": "./lib/components/Toasts/ToastHandler.tsx",
|
||||
|
||||
@@ -34,6 +34,7 @@ export type TrackingSDKPageData = {
|
||||
|
||||
type LoggedInScandicUserData = TrackingSDKUserDataBase & {
|
||||
memberType: "scandic-friends"
|
||||
profileConsent?: boolean
|
||||
loginType?: LoginType
|
||||
memberId?: string
|
||||
membershipNumber?: string
|
||||
|
||||
27
packages/trpc/lib/graphql/Query/ProfilingConsent.graphql.ts
Normal file
27
packages/trpc/lib/graphql/Query/ProfilingConsent.graphql.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { gql } from "graphql-tag"
|
||||
|
||||
export const GetProfilingConsent = gql`
|
||||
query GetProfilingConsent($locale: String!) {
|
||||
all_profiling_consent(limit: 1, locale: $locale) {
|
||||
items {
|
||||
main_icon
|
||||
profiling_consent_banner {
|
||||
button_text
|
||||
header
|
||||
sub_header
|
||||
}
|
||||
profiling_consent_modal {
|
||||
header
|
||||
sub_header
|
||||
cards {
|
||||
card {
|
||||
image_type
|
||||
preamble
|
||||
title
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
@@ -16,6 +16,7 @@ import { loyaltyPageRouter } from "./loyaltyPage"
|
||||
import { metadataRouter } from "./metadata"
|
||||
import { pageSettingsRouter } from "./pageSettings"
|
||||
import { partnerRouter } from "./partner"
|
||||
import { profilingConsentRouter } from "./profilingConsent"
|
||||
import { promoCampaignPageRouter } from "./promoCampaignPage"
|
||||
import { rewardRouter } from "./reward"
|
||||
import { startPageRouter } from "./startPage"
|
||||
@@ -41,4 +42,5 @@ export const contentstackRouter = router({
|
||||
startPage: startPageRouter,
|
||||
partner: partnerRouter,
|
||||
promoCampaignPage: promoCampaignPageRouter,
|
||||
profilingConsent: profilingConsentRouter,
|
||||
})
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import { mergeRouters } from "../../.."
|
||||
import { profilingConsentQueryRouter } from "./query"
|
||||
|
||||
export const profilingConsentRouter = mergeRouters(profilingConsentQueryRouter)
|
||||
@@ -0,0 +1,48 @@
|
||||
import { z } from "zod"
|
||||
|
||||
export const bannerSchema = z.object({
|
||||
button_text: z.string(),
|
||||
header: z.string(),
|
||||
sub_header: z.string(),
|
||||
})
|
||||
|
||||
export const modalSchema = z
|
||||
.object({
|
||||
header: z.string(),
|
||||
sub_header: z.string(),
|
||||
cards: z.object({
|
||||
card: z.array(
|
||||
z.object({
|
||||
image_type: z.string(),
|
||||
preamble: z.string(),
|
||||
title: z.string(),
|
||||
})
|
||||
),
|
||||
}),
|
||||
})
|
||||
.transform(({ header, sub_header, cards }) => ({
|
||||
header,
|
||||
sub_header,
|
||||
cards: cards.card,
|
||||
}))
|
||||
|
||||
export const profilingConsentSchema = z
|
||||
.object({
|
||||
all_profiling_consent: z.object({
|
||||
items: z.array(
|
||||
z.object({
|
||||
main_icon: z.string(),
|
||||
profiling_consent_banner: bannerSchema,
|
||||
profiling_consent_modal: modalSchema,
|
||||
})
|
||||
),
|
||||
}),
|
||||
})
|
||||
.transform((data) => {
|
||||
const profiling_consent = data.all_profiling_consent.items[0]
|
||||
return {
|
||||
icon: profiling_consent.main_icon,
|
||||
banner: profiling_consent.profiling_consent_banner,
|
||||
modal: profiling_consent.profiling_consent_modal,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,61 @@
|
||||
import { createCounter } from "@scandic-hotels/common/telemetry"
|
||||
|
||||
import { router } from "../../.."
|
||||
import { notFound } from "../../../errors"
|
||||
import { GetProfilingConsent } from "../../../graphql/Query/ProfilingConsent.graphql"
|
||||
import { request } from "../../../graphql/request"
|
||||
import { contentstackBaseProcedure } from "../../../procedures"
|
||||
import { langInput } from "../../../utils"
|
||||
import { profilingConsentSchema } from "./output"
|
||||
|
||||
import type { GetProfilingConsentData } from "../../../types/profilingConsent"
|
||||
|
||||
export const profilingConsentQueryRouter = router({
|
||||
get: contentstackBaseProcedure
|
||||
.input(langInput.optional())
|
||||
.query(async ({ input, ctx }) => {
|
||||
const lang = input?.lang ?? ctx.lang
|
||||
|
||||
const tag = `${lang}:profiling_consent`
|
||||
|
||||
const getProfilingConsentCounter = createCounter(
|
||||
"trpc.contentstack",
|
||||
"profilingConsent.get"
|
||||
)
|
||||
const metricsGetProfilingConsent = getProfilingConsentCounter.init({
|
||||
lang,
|
||||
})
|
||||
|
||||
metricsGetProfilingConsent.start()
|
||||
|
||||
const response = await request<GetProfilingConsentData>(
|
||||
GetProfilingConsent,
|
||||
{
|
||||
locale: lang,
|
||||
},
|
||||
{
|
||||
key: tag,
|
||||
ttl: "max",
|
||||
}
|
||||
)
|
||||
if (!response.data) {
|
||||
const notFoundError = notFound(response)
|
||||
metricsGetProfilingConsent.noDataError()
|
||||
throw notFoundError
|
||||
}
|
||||
const validatedResponse = profilingConsentSchema.safeParse(response.data)
|
||||
|
||||
if (!validatedResponse.success) {
|
||||
metricsGetProfilingConsent.validationError(validatedResponse.error)
|
||||
return null
|
||||
}
|
||||
|
||||
const profiling_consent = validatedResponse.data
|
||||
|
||||
metricsGetProfilingConsent.success()
|
||||
|
||||
return {
|
||||
profiling_consent,
|
||||
}
|
||||
}),
|
||||
})
|
||||
@@ -44,6 +44,7 @@ type UserDataScandicLoggedIn = {
|
||||
membershipNumber?: string
|
||||
memberLevel?: MembershipLevel
|
||||
loginAction?: "login success"
|
||||
profileConsent?: boolean
|
||||
memberType: "scandic-friends"
|
||||
}
|
||||
|
||||
@@ -58,6 +59,7 @@ type UserDataEurobonusLoggedIn = {
|
||||
memberId?: string
|
||||
membershipNumber?: string
|
||||
memberLevel?: MembershipLevel
|
||||
profileConsent?: boolean
|
||||
}
|
||||
|
||||
export type TrackingUserData =
|
||||
|
||||
@@ -44,7 +44,7 @@ export const signupInput = signUpSchema
|
||||
language: z.nativeEnum(Lang),
|
||||
})
|
||||
.omit({ termsAccepted: true })
|
||||
.transform((data) => ({
|
||||
.transform(({ profilingConsent, ...data }) => ({
|
||||
...data,
|
||||
phoneNumber: data.phoneNumber.replace(/\s+/g, ""),
|
||||
address: {
|
||||
@@ -53,8 +53,13 @@ export const signupInput = signUpSchema
|
||||
country: "",
|
||||
streetAddress: "",
|
||||
},
|
||||
...(profilingConsent ? { profilingConsent } : {}),
|
||||
}))
|
||||
|
||||
export const profilingConsentInput = z.object({
|
||||
profilingConsent: z.boolean(),
|
||||
})
|
||||
|
||||
export const getSavedPaymentCardsInput = z.object({
|
||||
supportedCards: z.array(z.string()),
|
||||
})
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
addCreditCardInput,
|
||||
addPromoCampaignInput,
|
||||
deleteCreditCardInput,
|
||||
profilingConsentInput,
|
||||
saveCreditCardInput,
|
||||
signupInput,
|
||||
} from "./input"
|
||||
@@ -197,7 +198,7 @@ export const userMutationRouter = router({
|
||||
const signupCounter = createCounter("trpc.user", "signup")
|
||||
const metricsSignup = signupCounter.init()
|
||||
|
||||
const apiResponse = await api.post(api.endpoints.v1.Profile.profile, {
|
||||
const apiResponse = await api.post(api.endpoints.v2.Profile.profile, {
|
||||
body: input,
|
||||
headers: {
|
||||
Authorization: `Bearer ${ctx.serviceToken}`,
|
||||
@@ -217,6 +218,34 @@ export const userMutationRouter = router({
|
||||
redirectUrl: signupVerify[input.language],
|
||||
}
|
||||
}),
|
||||
profilingConsent: router({
|
||||
update: protectedProcedure
|
||||
.input(profilingConsentInput)
|
||||
.mutation(async function ({ ctx, input }) {
|
||||
const profilingConsentCounter = createCounter(
|
||||
"trpc.user",
|
||||
"profilingConsent"
|
||||
)
|
||||
const metricsProfilingConsent = profilingConsentCounter.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 metricsProfilingConsent.httpError(apiResponse)
|
||||
const text = await apiResponse.text()
|
||||
throw serverErrorByStatus(apiResponse.status, text)
|
||||
}
|
||||
|
||||
metricsProfilingConsent.success()
|
||||
|
||||
return true
|
||||
}),
|
||||
}),
|
||||
promoCampaign: router({
|
||||
add: protectedProcedure
|
||||
.input(addPromoCampaignInput)
|
||||
|
||||
@@ -113,6 +113,8 @@ export const getUserSchema = z
|
||||
.nullable(),
|
||||
loyalty: userLoyaltySchema.optional(),
|
||||
employmentDetails: employmentDetailsSchema,
|
||||
profilingConsent: z.boolean().optional(),
|
||||
profilingConsentUpdate: z.string().optional(),
|
||||
promotions: z.array(z.string()).nullish(),
|
||||
}),
|
||||
type: z.string(),
|
||||
|
||||
@@ -51,6 +51,7 @@ export const signUpSchema = z.object({
|
||||
.regex(/^[A-Za-z0-9-\s]{1,9}$/g, signupErrors.ZIP_CODE_INVALID),
|
||||
}),
|
||||
password: passwordValidator(signupErrors.PASSWORD_REQUIRED),
|
||||
profilingConsent: z.boolean(),
|
||||
termsAccepted: z
|
||||
.boolean()
|
||||
.refine((value) => value === true, signupErrors.TERMS_REQUIRED),
|
||||
|
||||
@@ -28,6 +28,8 @@ export function parsedUser(data: User, maskValues: boolean) {
|
||||
name: `${data.firstName} ${data.lastName}`,
|
||||
phoneNumber: data.phoneNumber,
|
||||
profileId: data.profileId,
|
||||
profilingConsent: data.profilingConsent,
|
||||
profilingConsentUpdate: data.profilingConsentUpdate,
|
||||
promotions: data.promotions || null,
|
||||
} satisfies User
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ export namespace DynamicContentEnum {
|
||||
overview_table: "overview_table",
|
||||
points_overview: "points_overview",
|
||||
previous_stays: "previous_stays",
|
||||
profiling_consent_banner: "profiling_consent_banner",
|
||||
sign_up_form: "sign_up_form",
|
||||
sign_up_verification: "sign_up_verification",
|
||||
soonest_stays: "soonest_stays",
|
||||
|
||||
13
packages/trpc/lib/types/profilingConsent.ts
Normal file
13
packages/trpc/lib/types/profilingConsent.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { z } from "zod"
|
||||
|
||||
import type { profilingConsentSchema } from "../routers/contentstack/profilingConsent/output"
|
||||
|
||||
export interface GetProfilingConsentData
|
||||
extends z.input<typeof profilingConsentSchema> {}
|
||||
|
||||
export interface ProfilingConsent
|
||||
extends z.output<typeof profilingConsentSchema> {}
|
||||
|
||||
export type ProfilingConsentBanner = NonNullable<ProfilingConsent["banner"]>
|
||||
|
||||
export type ProfilingConsentModal = NonNullable<ProfilingConsent["modal"]>
|
||||
Reference in New Issue
Block a user