diff --git a/apps/scandic-web/app/[lang]/(live)/(protected)/my-pages/[...path]/page.tsx b/apps/scandic-web/app/[lang]/(live)/(protected)/my-pages/[...path]/page.tsx index 33edbbc4f..b58b19ba7 100644 --- a/apps/scandic-web/app/[lang]/(live)/(protected)/my-pages/[...path]/page.tsx +++ b/apps/scandic-web/app/[lang]/(live)/(protected)/my-pages/[...path]/page.tsx @@ -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() diff --git a/apps/scandic-web/app/[lang]/(live)/(protected)/my-pages/layout.tsx b/apps/scandic-web/app/[lang]/(live)/(protected)/my-pages/layout.tsx index 7bac64ba7..b3e3ca541 100644 --- a/apps/scandic-web/app/[lang]/(live)/(protected)/my-pages/layout.tsx +++ b/apps/scandic-web/app/[lang]/(live)/(protected)/my-pages/layout.tsx @@ -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 (
@@ -27,6 +44,16 @@ export default async function MyPagesLayout({ {eurobonusMembership && } + {memberKey && profilingConsent && !hasConsent ? ( + <> + + + + ) : null}
) } diff --git a/apps/scandic-web/app/[lang]/(live)/(protected)/my-pages/profile/consent/page.module.css b/apps/scandic-web/app/[lang]/(live)/(protected)/my-pages/profile/consent/page.module.css new file mode 100644 index 000000000..eba60df2a --- /dev/null +++ b/apps/scandic-web/app/[lang]/(live)/(protected)/my-pages/profile/consent/page.module.css @@ -0,0 +1,9 @@ +.container { + padding-top: var(--Space-x3); +} + +@media screen and (min-width: 768px) { + .container { + padding-top: var(--Space-x6); + } +} diff --git a/apps/scandic-web/app/[lang]/(live)/(protected)/my-pages/profile/consent/page.tsx b/apps/scandic-web/app/[lang]/(live)/(protected)/my-pages/profile/consent/page.tsx new file mode 100644 index 000000000..2468ef75e --- /dev/null +++ b/apps/scandic-web/app/[lang]/(live)/(protected)/my-pages/profile/consent/page.tsx @@ -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 ( + <> +
+ + {accountPage && } +
+ + ) +} diff --git a/apps/scandic-web/components/Blocks/DynamicContent/index.tsx b/apps/scandic-web/components/Blocks/DynamicContent/index.tsx index 3f8ae1448..049ffb3a8 100644 --- a/apps/scandic-web/components/Blocks/DynamicContent/index.tsx +++ b/apps/scandic-web/components/Blocks/DynamicContent/index.tsx @@ -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 + case DynamicContentEnum.Blocks.components.sj_widget: return default: diff --git a/apps/scandic-web/components/Forms/Edit/Profile/form.module.css b/apps/scandic-web/components/Forms/Edit/Profile/form.module.css index d4365edc9..ccab00a3e 100644 --- a/apps/scandic-web/components/Forms/Edit/Profile/form.module.css +++ b/apps/scandic-web/components/Forms/Edit/Profile/form.module.css @@ -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); diff --git a/apps/scandic-web/components/Forms/Edit/Profile/index.tsx b/apps/scandic-web/components/Forms/Edit/Profile/index.tsx index 56c7ed8a2..8fb722617 100644 --- a/apps/scandic-web/components/Forms/Edit/Profile/index.tsx +++ b/apps/scandic-web/components/Forms/Edit/Profile/index.tsx @@ -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) { <>
- - {intl.formatMessage({ - id: "common.welcome", - defaultMessage: "Welcome", - })} - - - {user.name} - + + +

+ {intl.formatMessage({ + id: "common.welcome", + defaultMessage: "Welcome", + })} +

+

+ {user.name} +

+
+
+ + + +
+ + + ) +} diff --git a/apps/scandic-web/components/Forms/Signup/form.module.css b/apps/scandic-web/components/Forms/Signup/form.module.css index b2bd6b372..3322021bd 100644 --- a/apps/scandic-web/components/Forms/Signup/form.module.css +++ b/apps/scandic-web/components/Forms/Signup/form.module.css @@ -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); diff --git a/apps/scandic-web/components/Forms/Signup/index.tsx b/apps/scandic-web/components/Forms/Signup/index.tsx index b79bab265..352f03a60 100644 --- a/apps/scandic-web/components/Forms/Signup/index.tsx +++ b/apps/scandic-web/components/Forms/Signup/index.tsx @@ -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 (
+ {title ? (

{title}

@@ -262,6 +276,67 @@ export default function SignupForm({ title }: SignUpFormProps) { isNewPassword />
+ +
+
+ +

+ {intl.formatMessage({ + id: "signup.UnlockYourPersonalizedExperience", + defaultMessage: "Unlock your personalized experience!", + })} +

+
+
+ + {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.", + })} + +
+ +

+ {intl.formatMessage({ + id: "signup.GetATailoredProfile", + defaultMessage: + "Get a tailored profile that adapts to your interests.", + })} +

+
+
+ +

+ {intl.formatMessage({ + id: "signup.PersonalizedOffersEarlyAccess", + defaultMessage: + "Personalized offers, early access to campaigns, and deals you won't find anywhere else!", + })} +

+
+ +
+
diff --git a/apps/scandic-web/components/HotelReservation/MyStay/accessBooking.test.ts b/apps/scandic-web/components/HotelReservation/MyStay/accessBooking.test.ts index b9084fb64..503575996 100644 --- a/apps/scandic-web/components/HotelReservation/MyStay/accessBooking.test.ts +++ b/apps/scandic-web/components/HotelReservation/MyStay/accessBooking.test.ts @@ -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 = { diff --git a/apps/scandic-web/components/MyPages/Profile/Communication/index.tsx b/apps/scandic-web/components/MyPages/Profile/CommunicationSettings/Slots/Email.tsx similarity index 53% rename from apps/scandic-web/components/MyPages/Profile/Communication/index.tsx rename to apps/scandic-web/components/MyPages/Profile/CommunicationSettings/Slots/Email.tsx index 59acf1790..09280b70a 100644 --- a/apps/scandic-web/components/MyPages/Profile/Communication/index.tsx +++ b/apps/scandic-web/components/MyPages/Profile/CommunicationSettings/Slots/Email.tsx @@ -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 (

{intl.formatMessage({ - id: "myPages.myCommunicationPreferences", - defaultMessage: "My communication preferences", + id: "profile.emailPreferences", + defaultMessage: "E-mail preferences", })}

{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.", })}

- +
) } diff --git a/apps/scandic-web/components/MyPages/Profile/CommunicationSettings/Slots/Personalization.tsx b/apps/scandic-web/components/MyPages/Profile/CommunicationSettings/Slots/Personalization.tsx new file mode 100644 index 000000000..7d3da9e72 --- /dev/null +++ b/apps/scandic-web/components/MyPages/Profile/CommunicationSettings/Slots/Personalization.tsx @@ -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 ( +
+
+ +

+ {intl.formatMessage({ + id: "profile.personalization&profiling", + defaultMessage: "Personalization & Profiling", + })} +

+
+ +

+ {intl.formatMessage({ + id: "profile.reviewUpdateConsent", + defaultMessage: + "Review and update your consent settings to control how we personalize your experience.", + })} +

+
+
+ + trackLinkClick({ + position: "profile", + name: "manage profiling consent", + }) + } + > + + {intl.formatMessage({ + id: "profile.mangeProfilingConsent", + defaultMessage: "Manage profiling consent", + })} + +
+ ) +} diff --git a/apps/scandic-web/components/MyPages/Profile/CommunicationSettings/Slots/slots.module.css b/apps/scandic-web/components/MyPages/Profile/CommunicationSettings/Slots/slots.module.css new file mode 100644 index 000000000..8e71d7973 --- /dev/null +++ b/apps/scandic-web/components/MyPages/Profile/CommunicationSettings/Slots/slots.module.css @@ -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); + } +} diff --git a/apps/scandic-web/components/MyPages/Profile/CommunicationSettings/index.tsx b/apps/scandic-web/components/MyPages/Profile/CommunicationSettings/index.tsx new file mode 100644 index 000000000..674241ac1 --- /dev/null +++ b/apps/scandic-web/components/MyPages/Profile/CommunicationSettings/index.tsx @@ -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 ( +
+ + +
+ ) +} diff --git a/apps/scandic-web/components/MyPages/Profile/CreditCards/creditCards.module.css b/apps/scandic-web/components/MyPages/Profile/CreditCards/creditCards.module.css deleted file mode 100644 index a664dd8c4..000000000 --- a/apps/scandic-web/components/MyPages/Profile/CreditCards/creditCards.module.css +++ /dev/null @@ -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); - } -} diff --git a/apps/scandic-web/components/MyPages/Profile/CreditCards/index.tsx b/apps/scandic-web/components/MyPages/Profile/CreditCards/index.tsx deleted file mode 100644 index 73934d7fa..000000000 --- a/apps/scandic-web/components/MyPages/Profile/CreditCards/index.tsx +++ /dev/null @@ -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 ( -
-
- -

- {intl.formatMessage({ - id: "myPages.myPaymentCards", - defaultMessage: "My payment cards", - })} -

-
- -

- {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.", - })} -

-
-
- - -
- ) -} diff --git a/apps/scandic-web/components/MyPages/Profile/Payment/index.tsx b/apps/scandic-web/components/MyPages/Profile/Payment/index.tsx new file mode 100644 index 000000000..58902f14e --- /dev/null +++ b/apps/scandic-web/components/MyPages/Profile/Payment/index.tsx @@ -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 ( +
+
+ + +

+ {intl.formatMessage({ + id: "profile.myPaymentCards", + defaultMessage: "My payment cards", + })} +

+
+ +

+ {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.", + })} +

+
+
+ + +
+
+ ) +} diff --git a/apps/scandic-web/components/MyPages/Profile/Communication/communication.module.css b/apps/scandic-web/components/MyPages/Profile/Payment/payment.module.css similarity index 84% rename from apps/scandic-web/components/MyPages/Profile/Communication/communication.module.css rename to apps/scandic-web/components/MyPages/Profile/Payment/payment.module.css index 26edf863d..40b387a19 100644 --- a/apps/scandic-web/components/MyPages/Profile/Communication/communication.module.css +++ b/apps/scandic-web/components/MyPages/Profile/Payment/payment.module.css @@ -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); +} diff --git a/apps/scandic-web/components/MyPages/Profile/Section/index.tsx b/apps/scandic-web/components/MyPages/Profile/Section/index.tsx new file mode 100644 index 000000000..34c26a715 --- /dev/null +++ b/apps/scandic-web/components/MyPages/Profile/Section/index.tsx @@ -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 ( +
+ +

{title}

+
+
{children}
+
+ ) +} diff --git a/apps/scandic-web/components/MyPages/Profile/Section/section.module.css b/apps/scandic-web/components/MyPages/Profile/Section/section.module.css new file mode 100644 index 000000000..00799e037 --- /dev/null +++ b/apps/scandic-web/components/MyPages/Profile/Section/section.module.css @@ -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); + } +} diff --git a/apps/scandic-web/components/MyPages/Profile/index.tsx b/apps/scandic-web/components/MyPages/Profile/index.tsx index 8a7eff23c..a1a805cc8 100644 --- a/apps/scandic-web/components/MyPages/Profile/index.tsx +++ b/apps/scandic-web/components/MyPages/Profile/index.tsx @@ -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 (
@@ -95,102 +153,25 @@ export default async function Profile() {
-
- - -

- {intl.formatMessage({ - id: "myPages.dateOfBirth", - defaultMessage: "Date of birth", - })} -

-
- -

{user.dateOfBirth}

-
-
-
- - -

- {intl.formatMessage({ - id: "common.phoneNumber", - defaultMessage: "Phone number", - })} -

-
- -

{user.phoneNumber}

-
-
-
- - -

- {intl.formatMessage({ - id: "common.language", - defaultMessage: "Language", - })} -

-
- -

{normalizedLanguage}

-
-
-
- - -

- {intl.formatMessage({ - id: "common.email", - defaultMessage: "Email", - })} -

-
- -

{user.email}

-
-
-
- - -

- {intl.formatMessage({ - id: "common.address", - defaultMessage: "Address", - })} -

-
- -

{addressOutput}

-
-
-
- - -

- {intl.formatMessage({ - id: "common.password", - defaultMessage: "Password", - })} -

-
- - {/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */} -

**********

-
-
+ {userDataItems.map(({ icon, label, value }) => ( +
+ + +

{label}

+
+ +

{value}

+
+
+ ))}
- + + {/* */} -
) } diff --git a/apps/scandic-web/components/MyPages/ProfilingConsent/Accordion/index.tsx b/apps/scandic-web/components/MyPages/ProfilingConsent/Accordion/index.tsx new file mode 100644 index 000000000..608c71e2d --- /dev/null +++ b/apps/scandic-web/components/MyPages/ProfilingConsent/Accordion/index.tsx @@ -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 ( + + +
    + {dataWeProcess.map((item, i) => ( +
  • + + + + + +

    {item.title}

    +
    + +

    {item.subtitle}

    +
    +
    +
  • + ))} +
+
+ + +
+
+ +

+ {intl.formatMessage({ + id: "profilingConsent.youHaveTheRightTo", + defaultMessage: "You have the right to", + })} +

+
+
    + {rights.map((text, i) => ( +
  • + + +

    {text}

    +
    +
  • + ))} +
+
+
+ +

+ {intl.formatMessage({ + id: "profilingConsent.dataSecurity", + defaultMessage: "Data security", + })} +

+
+
    + {security.map((text, i) => ( +
  • + + +

    {text}

    +
    +
  • + ))} +
+
+
+ + + 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", + })} + + +
+
+ ) +} diff --git a/apps/scandic-web/components/MyPages/ProfilingConsent/Accordion/profilingConsentAccordion.module.css b/apps/scandic-web/components/MyPages/ProfilingConsent/Accordion/profilingConsentAccordion.module.css new file mode 100644 index 000000000..1b4467c89 --- /dev/null +++ b/apps/scandic-web/components/MyPages/ProfilingConsent/Accordion/profilingConsentAccordion.module.css @@ -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; + } +} diff --git a/apps/scandic-web/components/MyPages/ProfilingConsent/Banner/Button.tsx b/apps/scandic-web/components/MyPages/ProfilingConsent/Banner/Button.tsx new file mode 100644 index 000000000..36e54adb5 --- /dev/null +++ b/apps/scandic-web/components/MyPages/ProfilingConsent/Banner/Button.tsx @@ -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 ( + + ) +} diff --git a/apps/scandic-web/components/MyPages/ProfilingConsent/Banner/index.tsx b/apps/scandic-web/components/MyPages/ProfilingConsent/Banner/index.tsx new file mode 100644 index 000000000..dc2c729e4 --- /dev/null +++ b/apps/scandic-web/components/MyPages/ProfilingConsent/Banner/index.tsx @@ -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 ( +
+ + + +

{banner.header}

+
+ +

{banner.sub_header}

+
+
+ +
+ ) +} diff --git a/apps/scandic-web/components/MyPages/ProfilingConsent/Banner/profilingConsentBanner.module.css b/apps/scandic-web/components/MyPages/ProfilingConsent/Banner/profilingConsentBanner.module.css new file mode 100644 index 000000000..b6567d5f3 --- /dev/null +++ b/apps/scandic-web/components/MyPages/ProfilingConsent/Banner/profilingConsentBanner.module.css @@ -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; + } +} diff --git a/apps/scandic-web/components/MyPages/ProfilingConsent/Modal/BenefitCards/BenefitCard.tsx b/apps/scandic-web/components/MyPages/ProfilingConsent/Modal/BenefitCards/BenefitCard.tsx new file mode 100644 index 000000000..e4e901a16 --- /dev/null +++ b/apps/scandic-web/components/MyPages/ProfilingConsent/Modal/BenefitCards/BenefitCard.tsx @@ -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 ( +
+ +
+ +

{title}

+
+ +

{description}

+
+
+
+ ) +} diff --git a/apps/scandic-web/components/MyPages/ProfilingConsent/Modal/BenefitCards/benefitCards.module.css b/apps/scandic-web/components/MyPages/ProfilingConsent/Modal/BenefitCards/benefitCards.module.css new file mode 100644 index 000000000..e4fbb5234 --- /dev/null +++ b/apps/scandic-web/components/MyPages/ProfilingConsent/Modal/BenefitCards/benefitCards.module.css @@ -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; + } +} diff --git a/apps/scandic-web/components/MyPages/ProfilingConsent/Modal/BenefitCards/index.tsx b/apps/scandic-web/components/MyPages/ProfilingConsent/Modal/BenefitCards/index.tsx new file mode 100644 index 000000000..544196c70 --- /dev/null +++ b/apps/scandic-web/components/MyPages/ProfilingConsent/Modal/BenefitCards/index.tsx @@ -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 ( + + + {cards.map((card, index) => { + const Icon = GetBenefitIconByCSIdentifier(card.image_type) + return ( + + + + ) + })} + + + + ) +} diff --git a/apps/scandic-web/components/MyPages/ProfilingConsent/Modal/ReadOnly.tsx b/apps/scandic-web/components/MyPages/ProfilingConsent/Modal/ReadOnly.tsx new file mode 100644 index 000000000..29f24c42b --- /dev/null +++ b/apps/scandic-web/components/MyPages/ProfilingConsent/Modal/ReadOnly.tsx @@ -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 ( + + ) +} diff --git a/apps/scandic-web/components/MyPages/ProfilingConsent/Modal/index.tsx b/apps/scandic-web/components/MyPages/ProfilingConsent/Modal/index.tsx new file mode 100644 index 000000000..0267dd65e --- /dev/null +++ b/apps/scandic-web/components/MyPages/ProfilingConsent/Modal/index.tsx @@ -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(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 ( + { + if (!isOpen) { + onClose() + } + }} + isKeyboardDismissDisabled + isDismissable={false} + > + + {open && ( + + +
+
+ +
+ +
+ +
+ +
+ +

{content.header}

+
+ +

{content.sub_header}

+
+
+ + +
+
+ +

+ {intl.formatMessage({ + id: "profilingConsent.personalization&privacy", + defaultMessage: "Personalization & privacy", + })} +

+
+ +

+ {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.", + })} +

+
+
+ +
+
+ + {!readOnly && ( +
+ + +
+ )} +
+
+ )} +
+
+ ) +} diff --git a/apps/scandic-web/components/MyPages/ProfilingConsent/Modal/profilingConsentModal.module.css b/apps/scandic-web/components/MyPages/ProfilingConsent/Modal/profilingConsentModal.module.css new file mode 100644 index 000000000..f9dd652f3 --- /dev/null +++ b/apps/scandic-web/components/MyPages/ProfilingConsent/Modal/profilingConsentModal.module.css @@ -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; + } +} diff --git a/apps/scandic-web/components/MyPages/ProfilingConsent/README.md b/apps/scandic-web/components/MyPages/ProfilingConsent/README.md new file mode 100644 index 000000000..bd8020180 --- /dev/null +++ b/apps/scandic-web/components/MyPages/ProfilingConsent/README.md @@ -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:` +- 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:") +// Then refresh the page +``` + +To open the modal without clearing the dismissed flag: + +```js +window.dispatchEvent(new CustomEvent("profiling-consent:open")) +``` + +Replace `` 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 diff --git a/apps/scandic-web/components/MyPages/ProfilingConsent/utils.tsx b/apps/scandic-web/components/MyPages/ProfilingConsent/utils.tsx new file mode 100644 index 000000000..648c2d0a2 --- /dev/null +++ b/apps/scandic-web/components/MyPages/ProfilingConsent/utils.tsx @@ -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 + } +} + +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" +} diff --git a/apps/scandic-web/components/Profile/AddCreditCardButton/index.tsx b/apps/scandic-web/components/Profile/AddCreditCardButton/index.tsx index 1a2410980..ecd23f32b 100644 --- a/apps/scandic-web/components/Profile/AddCreditCardButton/index.tsx +++ b/apps/scandic-web/components/Profile/AddCreditCardButton/index.tsx @@ -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 ( + } /> ) diff --git a/apps/scandic-web/components/Profile/ManagePreferencesButton/index.tsx b/apps/scandic-web/components/Profile/ManageEmailPreferencesButton/index.tsx similarity index 68% rename from apps/scandic-web/components/Profile/ManagePreferencesButton/index.tsx rename to apps/scandic-web/components/Profile/ManageEmailPreferencesButton/index.tsx index fd24e4de4..27279a595 100644 --- a/apps/scandic-web/components/Profile/ManagePreferencesButton/index.tsx +++ b/apps/scandic-web/components/Profile/ManageEmailPreferencesButton/index.tsx @@ -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 ( - + ) } diff --git a/apps/scandic-web/components/Profile/ManagePreferencesButton/managePreferencesButton.module.css b/apps/scandic-web/components/Profile/ManageEmailPreferencesButton/manageEmailPreferencesButton.module.css similarity index 100% rename from apps/scandic-web/components/Profile/ManagePreferencesButton/managePreferencesButton.module.css rename to apps/scandic-web/components/Profile/ManageEmailPreferencesButton/manageEmailPreferencesButton.module.css diff --git a/apps/scandic-web/constants/routes/authRequired.ts b/apps/scandic-web/constants/routes/authRequired.ts index 657d46593..960ce498a 100644 --- a/apps/scandic-web/constants/routes/authRequired.ts +++ b/apps/scandic-web/constants/routes/authRequired.ts @@ -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), ] diff --git a/apps/scandic-web/hooks/useUpdateProfilingConsent.ts b/apps/scandic-web/hooks/useUpdateProfilingConsent.ts new file mode 100644 index 000000000..9b52e3e7e --- /dev/null +++ b/apps/scandic-web/hooks/useUpdateProfilingConsent.ts @@ -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, + } +} diff --git a/apps/scandic-web/lib/trpc/memoizedRequests/index.ts b/apps/scandic-web/lib/trpc/memoizedRequests/index.ts index f480811e6..8321027ad 100644 --- a/apps/scandic-web/lib/trpc/memoizedRequests/index.ts +++ b/apps/scandic-web/lib/trpc/memoizedRequests/index.ts @@ -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() + } +) diff --git a/apps/scandic-web/middlewares/myPages.ts b/apps/scandic-web/middlewares/myPages.ts index 9a3b63c3d..a67511968 100644 --- a/apps/scandic-web/middlewares/myPages.ts +++ b/apps/scandic-web/middlewares/myPages.ts @@ -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, diff --git a/apps/scandic-web/utils/profilingConsent.ts b/apps/scandic-web/utils/profilingConsent.ts new file mode 100644 index 000000000..5b03037db --- /dev/null +++ b/apps/scandic-web/utils/profilingConsent.ts @@ -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)) +} diff --git a/apps/scandic-web/utils/tracking/profilingConsent.ts b/apps/scandic-web/utils/tracking/profilingConsent.ts new file mode 100644 index 000000000..ccb94f2df --- /dev/null +++ b/apps/scandic-web/utils/tracking/profilingConsent.ts @@ -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 = { + pageType: "profileconsentmodalpage", + channel: "scandic-friends", + siteVersion: "new-web", + pageName: "profile consent modal", + siteSections: "profile consent modal", +} diff --git a/packages/common/constants/routes/customerService.ts b/packages/common/constants/routes/customerService.ts index cf63c4535..ccccdbe5f 100644 --- a/packages/common/constants/routes/customerService.ts +++ b/packages/common/constants/routes/customerService.ts @@ -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 diff --git a/packages/common/constants/routes/myPages.ts b/packages/common/constants/routes/myPages.ts index 94f8ab3ae..30c416863 100644 --- a/packages/common/constants/routes/myPages.ts +++ b/packages/common/constants/routes/myPages.ts @@ -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`, diff --git a/packages/design-system/lib/components/Accordion/Accordion.stories.tsx b/packages/design-system/lib/components/Accordion/Accordion.stories.tsx new file mode 100644 index 000000000..e71d132b0 --- /dev/null +++ b/packages/design-system/lib/components/Accordion/Accordion.stories.tsx @@ -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 = { + title: 'Components/Accordion', + component: Accordion, + argTypes: { + type: { + control: 'select', + options: ['card', 'sidepeek'], + }, + }, +} + +export default meta + +type Story = StoryObj + +export const Default: Story = { + args: { + type: 'card', + }, + render: (args) => ( + + + +

+ 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. +

+
+
+ + +

+ 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. +

+
+
+ + +

+ 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. +

+
+
+
+ ), +} + +export const WithIcons: Story = { + args: { + type: 'card', + }, + render: (args) => ( + + + +

+ Our hotel features a fitness center, business lounge, and 24-hour + reception. Guests also have access to our rooftop terrace with + panoramic city views. +

+
+
+ + +

+ Relax and unwind in our wellness area featuring a sauna, steam room, + and massage treatments. Advanced booking is recommended for spa + services. +

+
+
+ + } + > + +

+ 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. +

+
+
+
+ ), +} + +export const WithSubtitle: Story = { + args: { + type: 'card', + }, + render: (args) => ( + + + +

+ Comfortable room with queen-size bed, work desk, and private + bathroom. Perfect for solo travelers or couples. +

+
+
+ + +

+ Spacious suite with separate sleeping areas, ideal for families. + Includes one double bed and two single beds, plus a sofa bed. +

+
+
+ + +

+ This item uses the showAsSubtitle prop to render the title with + subtitle typography styling, without an actual subtitle. +

+
+
+
+ ), +} diff --git a/packages/design-system/lib/components/Accordion/AccordionItem/index.tsx b/packages/design-system/lib/components/Accordion/AccordionItem/index.tsx index b9f8dd4ba..04a5e095d 100644 --- a/packages/design-system/lib/components/Accordion/AccordionItem/index.tsx +++ b/packages/design-system/lib/components/Accordion/AccordionItem/index.tsx @@ -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(null) const detailsRef = useRef(null) @@ -72,6 +74,14 @@ export default function AccordionItem({ } } + useEffect(() => { + if (openedOnRender && detailsRef.current) { + requestAnimationFrame(() => { + detailsRef.current!.open = true + }) + } + }, [openedOnRender]) + const TitleLevel = titleLevel return ( diff --git a/packages/design-system/lib/components/Form/RadioButtonsGroup/index.tsx b/packages/design-system/lib/components/Form/RadioButtonsGroup/index.tsx new file mode 100644 index 000000000..cd28d077c --- /dev/null +++ b/packages/design-system/lib/components/Form/RadioButtonsGroup/index.tsx @@ -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 ( + + {options.map((option) => ( + + cx(styles.option, { + [styles.focused]: isFocusVisible, + [styles.selected]: isSelected, + [styles.hovered]: isHovered, + [styles.disabled]: isDisabled, + }) + } + > + {({ isSelected, isDisabled }) => ( +
+ + + + + + + + + + {option.text} + + +
+ )} +
+ ))} +
+ ) +} diff --git a/packages/design-system/lib/components/Form/RadioButtonsGroup/radioButtonsGroup.module.css b/packages/design-system/lib/components/Form/RadioButtonsGroup/radioButtonsGroup.module.css new file mode 100644 index 000000000..f97acad9d --- /dev/null +++ b/packages/design-system/lib/components/Form/RadioButtonsGroup/radioButtonsGroup.module.css @@ -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; + } +} diff --git a/packages/design-system/lib/components/Icons/IllustrationByIconName.ts b/packages/design-system/lib/components/Icons/IllustrationByIconName.ts index fbe7ccd0c..121ddc9f9 100644 --- a/packages/design-system/lib/components/Icons/IllustrationByIconName.ts +++ b/packages/design-system/lib/components/Icons/IllustrationByIconName.ts @@ -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 } diff --git a/packages/design-system/lib/components/Icons/Illustrations/HandGift.tsx b/packages/design-system/lib/components/Icons/Illustrations/HandGift.tsx new file mode 100644 index 000000000..b2f3ab5e4 --- /dev/null +++ b/packages/design-system/lib/components/Icons/Illustrations/HandGift.tsx @@ -0,0 +1,1094 @@ +import type { IllustrationProps } from '../icon' + +export default function HandGiftIcon(props: IllustrationProps) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/packages/design-system/lib/components/Icons/Illustrations/Spa.tsx b/packages/design-system/lib/components/Icons/Illustrations/Spa.tsx new file mode 100644 index 000000000..05a6404f5 --- /dev/null +++ b/packages/design-system/lib/components/Icons/Illustrations/Spa.tsx @@ -0,0 +1,5593 @@ +import type { IllustrationProps } from '../icon' + +export default function SpaIcon(props: IllustrationProps) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/packages/design-system/lib/components/Icons/Illustrations/Voucher.tsx b/packages/design-system/lib/components/Icons/Illustrations/Voucher.tsx index f5a75a94a..4b4464894 100644 --- a/packages/design-system/lib/components/Icons/Illustrations/Voucher.tsx +++ b/packages/design-system/lib/components/Icons/Illustrations/Voucher.tsx @@ -6,29 +6,61 @@ export default function VoucherIcon(props: IllustrationProps) { return ( - - - - + + + + + + + + + ) diff --git a/packages/design-system/lib/components/TextLinkButton/TextLinkButton.tsx b/packages/design-system/lib/components/TextLinkButton/TextLinkButton.tsx new file mode 100644 index 000000000..a285eaef0 --- /dev/null +++ b/packages/design-system/lib/components/TextLinkButton/TextLinkButton.tsx @@ -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 + +/* 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 ( +