Merged in feat/LOY-336-Points-to-Spend-Card (pull request #2830)

feat(LOY-336): Add PointsToSpendCard

* feat(LOY-366): Add PointsToSpendCard

* feat(LOY-336): Add Expiring Points Table Sidepeek

* fix(LOY-336): Hide old section

* fix(LOY-336): description mobile styling

* chore(LOY-336): css cleanup


Approved-by: Matilda Landström
This commit is contained in:
Chuma Mcphoy (We Ahead)
2025-09-22 10:16:40 +00:00
parent 8ebc48b138
commit 6a9d598b97
10 changed files with 391 additions and 10 deletions

View File

@@ -12,6 +12,7 @@ import Friend from "../../Overview/Friend"
import Hero from "../../Overview/Friend/Hero"
import MembershipNumber from "../../Overview/Friend/MembershipNumber"
import Stats from "../../Overview/Stats"
import PointsToSpendCard from "../PointsToSpendCard"
import styles from "./overview.module.css"
@@ -36,23 +37,23 @@ export default async function PointsOverview({
headingAs={"h3"}
headingLevel={"h1"}
/>
{env.ENABLE_NEW_OVERVIEW_SECTION && (
{env.ENABLE_NEW_OVERVIEW_SECTION ? (
<div className={styles.membershipCardsContainer}>
{/*TODO: Add PointsToSpendCard */}
<PointsToSpendCard user={user} />
<LevelProgressCard
color="Surface/Brand/Primary 1/Default"
user={user}
/>
</div>
) : (
<Hero color="burgundy">
<Friend membership={user.membership} name={user.name}>
<MembershipNumber color="red" membership={user.membership} />
</Friend>
<Divider className={styles.divider} color="peach" />
<Stats user={user} />
</Hero>
)}
{/*TODO: Once the PointsToSpendCard is added hide the Hero Section under the flag above. */}
<Hero color="burgundy">
<Friend membership={user.membership} name={user.name}>
<MembershipNumber color="red" membership={user.membership} />
</Friend>
<Divider className={styles.divider} color="peach" />
<Stats user={user} />
</Hero>
<SectionLink link={link} variant="mobile" />
</SectionContainer>
)

View File

@@ -0,0 +1,53 @@
"use client"
import { DialogTrigger } 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 SidePeekSelfControlled from "@scandic-hotels/design-system/SidePeekSelfControlled"
import { Typography } from "@scandic-hotels/design-system/Typography"
import ExpiringPointsTable from "../ExpiringPoints/ExpiringPointsTable"
import styles from "./PointsToSpendCard.module.css"
interface ExpiringPointsSeeAllButtonProps {
expiringPoints: number
expiryDate: string
}
export default function ExpiringPointsSeeAllButton({
expiringPoints,
expiryDate,
}: ExpiringPointsSeeAllButtonProps) {
const intl = useIntl()
return (
<DialogTrigger>
<Button variant="Text" size="Medium" typography="Body/Paragraph/mdBold">
{intl.formatMessage({ defaultMessage: "See all" })}
<MaterialIcon icon="chevron_right" color="CurrentColor" />
</Button>
<SidePeekSelfControlled
title={intl.formatMessage({ defaultMessage: "Expiring Points" })}
>
<div className={styles.sidePeekContent}>
<Typography variant="Body/Paragraph/mdRegular">
<p>
{intl.formatMessage({
defaultMessage:
"Points expire three years after they are earned, on the last day of that month. Expiring points do not affect your level.",
})}
</p>
</Typography>
{/* TODO: The table will be rebuilt as part of the My Pages Optimisations 3 Epic. */}
<ExpiringPointsTable
points={expiringPoints}
expirationDate={expiryDate}
/>
</div>
</SidePeekSelfControlled>
</DialogTrigger>
)
}

View File

@@ -0,0 +1,113 @@
.card {
background: var(--Surface-Brand-Primary-1-Default);
border-radius: var(--Corner-radius-lg);
padding: var(--Space-x3) var(--Space-x2);
position: relative;
min-height: 200px;
display: flex;
flex-direction: column;
gap: var(--Space-x3);
}
.content {
display: grid;
place-items: center;
gap: var(--Space-x3);
}
.textContent {
display: grid;
gap: var(--Space-x1);
align-self: center;
justify-items: center;
}
.title {
color: var(--Text-Brand-OnPrimary-1-Heading);
}
.pointsContainer {
display: flex;
align-items: center;
gap: var(--Space-x1);
}
.pointsValue {
color: var(--Text-Accent-Primary);
}
.pointsLabel {
color: var(--Text-Brand-OnPrimary-1-Heading);
}
.description {
margin-top: auto;
padding: var(--Space-x2);
border-radius: var(--Corner-radius-md);
background: var(--Surface-Primary-Default);
}
.descriptionText {
color: var(--Text-Brand-OnPrimary-1-Body);
line-height: 1.5;
margin: 0;
}
.expiringPointsCard {
display: flex;
padding: var(--Space-x2);
flex-direction: column;
justify-content: center;
align-items: flex-start;
gap: var(--Space-x1);
border-radius: var(--Corner-radius-md);
background: var(--Surface-Primary-Default);
}
.expiryDate {
color: var(--Text-Heading);
}
.pointsRow {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
}
.expiringPoints {
margin: 0;
}
.sidePeekContent {
display: flex;
flex-direction: column;
gap: var(--Space-x2);
}
@media screen and (min-width: 768px) {
.content {
grid-template-columns: auto 1fr;
place-items: start;
}
.textContent {
justify-items: start;
}
.expiringPointsCard {
padding: var(--Space-x2) var(--Space-x4);
}
.description {
padding: 0;
border-radius: 0;
background: transparent;
}
}
@media screen and (min-width: 1367px) {
.card {
padding: var(--Space-x3) var(--Space-x4);
}
}

View File

@@ -0,0 +1,135 @@
import ButtonLink from "@scandic-hotels/design-system/ButtonLink"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import MoneyHandEllipsisIcon from "@scandic-hotels/design-system/Icons/MoneyHandEllipsisIcon"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { spendPoints } from "@/constants/webHrefs"
import { getIntl } from "@/i18n"
import { getLang } from "@/i18n/serverContext"
import ExpiringPointsSeeAllButton from "./ExpiringPointsSeeAllButton"
import { getExpiryLabel } from "./utils"
import styles from "./PointsToSpendCard.module.css"
import type { User } from "@scandic-hotels/trpc/types/user"
interface PointsToSpendCardProps {
user: User
}
export default async function PointsToSpendCard({
user,
}: PointsToSpendCardProps) {
const intl = await getIntl()
const lang = await getLang()
if (!user.membership) {
return null
}
const spendablePoints = user.membership.currentPoints
const hasPointsToSpend = spendablePoints > 0
const expiringPoints = user.membership.pointsToExpire
const expiryDate = user.membership.pointsExpiryDate
const expiryDateText = getExpiryLabel(expiryDate, intl, lang)
return (
<section
className={styles.card}
aria-labelledby="points-to-spend-card-title"
>
<div className={styles.content}>
<MoneyHandEllipsisIcon
width="140"
height="128"
className={styles.icon}
aria-hidden="true"
/>
<div className={styles.textContent}>
<Typography variant="Title/xs">
<h2 id="points-to-spend-card-title" className={styles.title}>
{intl.formatMessage({
defaultMessage: "Points to spend",
})}
</h2>
</Typography>
<div className={styles.pointsContainer}>
<Typography variant="Title/lg">
<span className={styles.pointsValue}>
{intl.formatNumber(spendablePoints)}
</span>
</Typography>
{!hasPointsToSpend && (
<Typography variant="Title/Overline/sm">
<span className={styles.pointsLabel}>
{intl.formatMessage({
defaultMessage: "POINTS",
})}
</span>
</Typography>
)}
</div>
{hasPointsToSpend && (
<ButtonLink href={spendPoints[lang]} target="_blank" variant="Text">
{intl.formatMessage({
defaultMessage: "How to spend points",
})}
<MaterialIcon
icon="chevron_right"
color="CurrentColor"
size={24}
/>
</ButtonLink>
)}
</div>
</div>
{!hasPointsToSpend && (
<div className={styles.description}>
<Typography variant="Body/Paragraph/mdRegular">
<p className={styles.descriptionText}>
{intl.formatMessage({
defaultMessage:
"Earn points by staying at Scandic. Turn your points into free nights and memorable experiences.",
})}
</p>
</Typography>
</div>
)}
{expiringPoints && expiryDate && (
<div className={styles.expiringPointsCard}>
<Typography variant="Title/Overline/sm">
<p className={styles.expiryDate}>{expiryDateText}</p>
</Typography>
<div className={styles.pointsRow}>
<Typography variant="Body/Lead text">
<p className={styles.expiringPoints}>
{intl.formatMessage(
{
defaultMessage: "{expiringPoints} points expiring",
},
{
expiringPoints: (
<Typography
key={`expiring-points-${expiringPoints}`}
variant="Title/Subtitle/md"
>
<strong>{intl.formatNumber(expiringPoints)}</strong>
</Typography>
),
}
)}
</p>
</Typography>
<ExpiringPointsSeeAllButton
expiringPoints={expiringPoints}
expiryDate={expiryDate}
/>
</div>
</div>
)}
</section>
)
}

View File

@@ -0,0 +1,43 @@
import { dt } from "@scandic-hotels/common/dt"
import type { IntlShape } from "react-intl"
export const DAYS_UNTIL_EXPIRY_WARNING = 30
/**
* Get expiry label for points based on the expiry date.
* @returns The formatted expiry date text or empty string if no expiry date
*/
export function getExpiryLabel(
pointsExpiryDate: string | undefined,
intl: IntlShape,
lang: string
): string {
if (!pointsExpiryDate) {
return ""
}
const now = dt()
const expiryDate = dt(pointsExpiryDate).locale(lang)
const daysUntilExpiry = expiryDate.diff(now, "days")
if (daysUntilExpiry <= DAYS_UNTIL_EXPIRY_WARNING) {
return intl.formatMessage(
{
defaultMessage: "In {days} days",
},
{
days: daysUntilExpiry,
}
)
} else {
return intl.formatMessage(
{
defaultMessage: "on {expiryDate}",
},
{
expiryDate: expiryDate.format("DD MMM YYYY"),
}
)
}
}

View File

@@ -28,3 +28,12 @@ export const partners: LangRoute = {
no: `/${Lang.no}/scandic-friends/partnere`,
sv: `/${Lang.sv}/scandic-friends/partners`,
}
export const spendPoints: LangRoute = {
da: `/${Lang.da}/scandic-friends/brug-point`,
de: `/${Lang.de}/scandic-friends/ausgeben`,
en: `/${Lang.en}/scandic-friends/spend-points`,
fi: `/${Lang.fi}/scandic-friends/hyodynna`,
no: `/${Lang.no}/scandic-friends/bruk-poeng`,
sv: `/${Lang.sv}/scandic-friends/anvand-poang`,
}

View File

@@ -10,6 +10,7 @@ import KidsIcon from './Illustrations/Kids'
import KidsMocktailIcon from './Illustrations/KidsMocktail'
import MagicWandIcon from './Illustrations/MagicWand'
import MoneyHandIcon from './Illustrations/MoneyHand'
import MoneyHandEllipsisIcon from './Illustrations/MoneyHandEllipsis'
import TrophyIcon from './Illustrations/Trophy'
import VoucherIcon from './Illustrations/Voucher'
@@ -25,6 +26,8 @@ export function IllustrationByIconName(iconName: IconName | null) {
return MagicWandIcon
case IconName.MoneyHand:
return MoneyHandIcon
case IconName.MoneyHandEllipsis:
return MoneyHandEllipsisIcon
case IconName.HandKey:
return HandKeyIcon
case IconName.HotelNight:

File diff suppressed because one or more lines are too long

View File

@@ -125,6 +125,7 @@ export enum IconName {
Minibar = 'Minibar',
Minus = 'Minus',
MoneyHand = 'MoneyHand',
MoneyHandEllipsis = 'MoneyHandEllipsis',
Museum = 'Museum',
Nature = 'Nature',
Nightlife = 'Nightlife',

View File

@@ -106,6 +106,7 @@
"./Icons/MinimizeIcon": "./lib/components/Icons/Customised/UI/Minimize.tsx",
"./Icons/MirrorIcon": "./lib/components/Icons/Customised/Amenities_Facilities/Mirror.tsx",
"./Icons/MoneyHandIcon": "./lib/components/Icons/Illustrations/MoneyHand.tsx",
"./Icons/MoneyHandEllipsisIcon": "./lib/components/Icons/Illustrations/MoneyHandEllipsis.tsx",
"./Icons/MovingBedsIcon": "./lib/components/Icons/Customised/Amenities_Facilities/MovingBeds.tsx",
"./Icons/NoBreakfastBuffetIcon": "./lib/components/Icons/Illustrations/NoBreakfastBuffet.tsx",
"./Icons/PalmTreeIcon": "./lib/components/Icons/Nucleo/Experiences/palm-tree-2.tsx",