Merged in feat/LOY-361-Promo-Campaign-Hero (pull request #2857)

Feat(LOY-363): Promo Campaign Hero

* feat(LOY-361): Add Promo Campaign Hero

* feat(LOY-361): auth cta's wip

* fix(LOY-361): improve hero card css

* fix(LOY-361): correct size for button

* fix(LOY-361): Make Promo Hero Required

* fix(LOY-361): semantic css classes


Approved-by: Matilda Landström
This commit is contained in:
Chuma Mcphoy (We Ahead)
2025-09-24 14:36:31 +00:00
parent 5bbd7eb9ab
commit 7ee76992be
7 changed files with 323 additions and 1 deletions

View File

@@ -0,0 +1,24 @@
"use client"
import { useIntl } from "react-intl"
import { Button } from "@scandic-hotels/design-system/Button"
import styles from "../hero.module.css"
// TODO: Trigger acivation.
export default function ActivateOfferButton() {
const intl = useIntl()
return (
<Button
variant="Tertiary"
size="Medium"
typography="Body/Paragraph/mdRegular"
className={styles.activateButton}
>
{intl.formatMessage({
defaultMessage: "Activate offer",
})}
</Button>
)
}

View File

@@ -0,0 +1,135 @@
.hero {
--card-float-offset: -170px;
--card-width: 90%;
--card-radius: var(--Corner-radius-Large);
position: relative;
display: block;
min-height: 478px;
border-radius: var(--card-radius);
overflow: hidden;
margin-bottom: var(--card-float-offset);
}
.imageContainer {
overflow: hidden;
width: 100%;
height: 478px;
position: relative;
border-radius: var(--card-radius);
}
.image {
object-fit: cover;
}
.cardSection {
display: grid;
}
.heroLoggedIn .cardSection {
grid-template-rows: 1fr;
}
.heroLoggedOut .cardSection {
grid-template-rows: 1fr 1fr;
}
.card {
width: var(--card-width);
margin-inline: auto;
padding: var(--Space-x3);
transform: translateY(var(--card-float-offset));
display: grid;
gap: var(--Space-x2);
align-content: center;
}
.benefitsCard {
background-color: var(--Surface-Brand-Accent-Default);
}
@media (max-width: 767px) {
.cardSection > .benefitsCard:only-child {
border-radius: var(--card-radius);
}
.cardSection > .benefitsCard:not(:only-child) {
border-radius: var(--card-radius) var(--card-radius) 0 0;
}
}
.authCard {
background-color: var(--Surface-Brand-Primary-3-Default);
border-radius: 0 0 var(--card-radius) var(--card-radius);
}
.heading {
color: var(--Text-Brand-OnAccent-Heading);
}
.benefitList {
display: grid;
list-style-type: none;
gap: var(--Space-x05);
}
.benefitList > li {
display: flex;
gap: var(--Space-x1);
color: var(--Surface-Brand-Primary-1-OnSurface-Accent-Secondary);
}
.text {
color: var(--Text-Brand-OnPrimary-1-Default);
}
.activateButton {
width: 100%;
}
.authHeading {
color: var(--Text-Inverted);
}
.orSection {
display: flex;
align-items: center;
gap: var(--Space-x2);
}
.orText {
color: var(--Text-Brand-OnPrimary-3-Default);
margin: 0;
white-space: nowrap;
}
@media (min-width: 768px) {
.hero {
--card-float-offset: 0;
--card-width: 388px;
grid-template-columns: 1fr var(--card-width);
display: grid;
}
.imageContainer {
height: 100%;
border-radius: 0;
}
.card {
width: 100%;
margin: 0;
transform: none;
}
.benefitsCard {
border-radius: 0;
padding: var(--Space-x7) var(--Space-x3);
}
.authCard {
border-radius: 0;
}
}

View File

@@ -0,0 +1,143 @@
import { cx } from "class-variance-authority"
import { login } from "@scandic-hotels/common/constants/routes/handleAuth"
import { signup } from "@scandic-hotels/common/constants/routes/signup"
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 Image from "@scandic-hotels/design-system/Image"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { getIntl } from "@/i18n"
import { getLang } from "@/i18n/serverContext"
import { isLoggedInUser } from "@/utils/isLoggedInUser"
import ActivateOfferButton from "./ActivateOfferButton"
import styles from "./hero.module.css"
import type { PromoHero } from "@scandic-hotels/trpc/types/promoCampaignPage"
interface PromoCampaignHeroProps extends React.HTMLAttributes<HTMLDivElement> {
promoHero: PromoHero
}
export default async function PromoCampaignHero({
promoHero,
className,
...props
}: PromoCampaignHeroProps) {
const { image, heading, benefits } = promoHero
const isLoggedIn = await isLoggedInUser()
const intl = await getIntl()
const lang = await getLang()
return (
<header
className={cx(
styles.hero,
isLoggedIn ? styles.heroLoggedIn : styles.heroLoggedOut,
className
)}
{...props}
>
{image ? (
<div className={styles.imageContainer}>
<Image
src={image.url}
alt={image.meta.alt || image.meta.caption || ""}
className={styles.image}
fill
sizes="(min-width: 768px) 800px, 100vw"
focalPoint={image.focalPoint}
dimensions={image.dimensions}
/>
</div>
) : null}
<div className={styles.cardSection}>
<div className={cx(styles.card, styles.benefitsCard)}>
<Typography variant="Title/xs" className={styles.heading}>
<h2>{heading}</h2>
</Typography>
{benefits?.length ? (
<ul className={styles.benefitList}>
{benefits.map((benefit) => (
<li key={benefit}>
<MaterialIcon
icon="favorite"
isFilled
color="CurrentColor"
size={20}
/>
<Typography
variant="Body/Paragraph/mdRegular"
className={styles.text}
>
<span>{benefit}</span>
</Typography>
</li>
))}
</ul>
) : null}
{isLoggedIn && (
<div>
{/* TODO: Account for more activation states. */}
<ActivateOfferButton />
</div>
)}
</div>
{!isLoggedIn && (
<div className={cx(styles.card, styles.authCard)}>
<Typography
variant="Title/Overline/sm"
className={styles.authHeading}
>
<p>
{intl.formatMessage({
defaultMessage: "TO ACTIVATE OFFER",
})}
</p>
</Typography>
<ButtonLink
href={login[lang]}
variant="Primary"
color="Inverted"
size="Medium"
>
{intl.formatMessage({
defaultMessage: "Login",
})}
</ButtonLink>
<div className={styles.orSection}>
<Divider color="white" />
<Typography
variant="Body/Paragraph/mdRegular"
className={styles.orText}
>
<span>
{intl.formatMessage({
defaultMessage: "or",
})}
</span>
</Typography>
<Divider color="white" />
</div>
<ButtonLink
href={signup[lang]}
variant="Secondary"
color="Inverted"
size="Medium"
>
{intl.formatMessage({
defaultMessage: "Sign up",
})}
</ButtonLink>
</div>
)}
</div>
</header>
)
}

View File

@@ -6,6 +6,7 @@ import { TrackingSDK } from "@scandic-hotels/tracking/TrackingSDK"
import { getPromoCampaignPage } from "@/lib/trpc/memoizedRequests"
import PromoCampaignHero from "./Hero"
import PromoCampaignPageSkeleton from "./PromoCampaignPageSkeleton"
import styles from "./promoCampaignPage.module.css"
@@ -17,12 +18,13 @@ export default async function PromoCampaignPage() {
}
//const isUserLoggedIn = await isLoggedInUser()
const { promo_campaign_page, tracking } = pageData
const { heading, subheading } = promo_campaign_page
const { heading, subheading, promo_hero } = promo_campaign_page
return (
<>
<Suspense fallback={<PromoCampaignPageSkeleton />}>
<div className={styles.pageContainer}>
<PromoCampaignHero promoHero={promo_hero} />
<div className={styles.intro}>
<div className={styles.headingWrapper}>
<Typography variant="Title/lg">

View File

@@ -13,6 +13,11 @@ query GetPromoCampaignPage($locale: String!, $uid: String!) {
title
heading
subheading
promo_hero {
image
heading
benefits
}
page_settings {
booking_code
}

View File

@@ -1,5 +1,6 @@
import { z } from "zod"
import { transformedImageVaultAssetSchema } from "@scandic-hotels/common/utils/imageVault"
import { nullableStringValidator } from "@scandic-hotels/common/utils/zod/stringValidator"
import { systemSchema } from "../schemas/system"
@@ -9,12 +10,22 @@ export const CAMPAIGN_TYPES = {
POINT: "POINT",
} as const
export const promoHeroSchema = z.object({
image: transformedImageVaultAssetSchema,
heading: z.string(),
benefits: z
.array(z.string())
.nullish()
.transform((data) => data || []),
})
export const promoCampaignPageSchema = z
.object({
promo_campaign_page: z.object({
title: z.string(),
heading: z.string(),
subheading: z.string().nullish(),
promo_hero: promoHeroSchema,
page_settings: z
.object({
booking_code: z.string().nullish(),

View File

@@ -21,6 +21,8 @@ export interface PromoCampaignPage
export type PromoCampaignPageData = PromoCampaignPage["promo_campaign_page"]
export type PromoHero = NonNullable<PromoCampaignPageData["promo_hero"]>
/* REFS */
export interface GetPromoCampaignPageRefsData
extends z.input<typeof promoCampaignPageRefsSchema> {}