Merged in feat/LOY-423-Nights-Stayed-Progress-for-L6-Members (pull request #3360)

feat(LOY-423): Add progress bar for L6 members showing nights stayed

* feat(LOY-423): Add progress bar for L6 members showing nights stayed

* chore(LOY-423): shorten css selector


Approved-by: Matilda Landström
This commit is contained in:
Chuma Mcphoy (We Ahead)
2025-12-17 10:45:30 +00:00
parent 3dce2d310f
commit ac53f128af
6 changed files with 267 additions and 8 deletions

View File

@@ -0,0 +1,72 @@
"use client"
import { useIntl } from "react-intl"
import { Divider } from "@scandic-hotels/design-system/Divider"
import { Progress } from "@scandic-hotels/design-system/Progress"
import { Typography } from "@scandic-hotels/design-system/Typography"
import styles from "./l6Progress.module.css"
interface ProgressSectionProps {
nightsStayed: number
progressValue: number
maxNights: number
bestFriendLabel: string
}
export function ProgressSection({
nightsStayed,
progressValue,
maxNights,
bestFriendLabel,
}: ProgressSectionProps) {
const intl = useIntl()
return (
<div className={styles.progressSection}>
<Typography variant="Body/Supporting text (caption)/smRegular">
<span className={styles.startLabelText}>
{intl.formatMessage({
id: "previousStays.nightsStayed",
defaultMessage: "Nights stayed",
})}
</span>
</Typography>
<Divider variant="vertical" className={styles.startDivider} />
<Typography variant="Title/Subtitle/md">
<span className={styles.startValue}>{nightsStayed}</span>
</Typography>
<Progress
className={styles.progressBar}
value={progressValue}
maxValue={maxNights}
aria-label={intl.formatMessage(
{
id: "previousStays.progressAriaLabel",
defaultMessage:
"{nightsStayed} of {maxNights} nights stayed towards {levelName}",
},
{
nightsStayed,
maxNights,
levelName: bestFriendLabel,
}
)}
/>
<Typography variant="Title/Subtitle/md">
<span className={styles.endValue}>{maxNights}</span>
</Typography>
<Divider variant="vertical" className={styles.endDivider} />
<Typography variant="Body/Supporting text (caption)/smRegular">
<span className={styles.endLabelText}>{bestFriendLabel}</span>
</Typography>
</div>
)
}

View File

@@ -0,0 +1,82 @@
import { MembershipLevelEnum } from "@scandic-hotels/common/constants/membershipLevels"
import { dt } from "@scandic-hotels/common/dt"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { TIER_TO_FRIEND_MAP } from "@/constants/membershipLevels"
import { getProfile } from "@/lib/trpc/memoizedRequests"
import { getIntl } from "@/i18n"
import { getLang } from "@/i18n/serverContext"
import { getTierStartDate } from "@/utils/getTierStartDate"
import { ProgressSection } from "./ProgressSection"
import styles from "./l6Progress.module.css"
const MAX_NIGHTS = 100
export async function L6Progress() {
const user = await getProfile()
const intl = await getIntl()
const lang = await getLang()
if (
!user ||
"error" in user ||
user?.membership?.membershipLevel !== MembershipLevelEnum.L6
) {
return null
}
const nightsToTopTier = user?.membership?.nightsToTopTier
if (nightsToTopTier == null) {
return null
}
const nightsStayed = MAX_NIGHTS - nightsToTopTier
// Cap progress at 100 to prevent overflow.
const progressValue = Math.min(nightsStayed, MAX_NIGHTS)
const tierStartDate = getTierStartDate(user.membership.tierExpirationDate)
const formattedStartDate = tierStartDate
? dt(tierStartDate).locale(lang).format("D MMMM YYYY")
: null
const bestFriendLabel = TIER_TO_FRIEND_MAP[MembershipLevelEnum.L7]
return (
<div className={styles.l6Progress}>
<header className={styles.header}>
<Typography variant="Title/Subtitle/md">
<h3 className={styles.title}>
{intl.formatMessage({
id: "previousStays.totalStays",
defaultMessage: "Total stays",
})}
</h3>
</Typography>
{formattedStartDate && (
<Typography variant="Body/Paragraph/mdRegular">
<p className={styles.since}>
{intl.formatMessage(
{
id: "previousStays.sinceDate",
defaultMessage: "Since {date}",
},
{ date: formattedStartDate }
)}
</p>
</Typography>
)}
</header>
<ProgressSection
nightsStayed={nightsStayed}
progressValue={progressValue}
maxNights={MAX_NIGHTS}
bestFriendLabel={bestFriendLabel}
/>
</div>
)
}

View File

@@ -0,0 +1,95 @@
.l6Progress {
border-radius: var(--Corner-radius-md);
border: 1px solid var(--Border-Divider-Default);
background: var(--Surface-Primary-Default);
padding: var(--Space-x3);
display: flex;
flex-direction: column;
gap: var(--Space-x2);
}
.header {
display: flex;
flex-direction: column;
gap: var(--Space-x05);
}
.title,
.since {
color: var(--Text-Secondary);
}
.progressSection {
display: grid;
grid-template-columns: min-content 1fr auto;
gap: var(--Space-x05);
align-items: center;
grid-template-areas:
"startLabel . endLabel"
"startValue bar endValue";
}
.startLabelText {
grid-area: startLabel;
color: var(--Text-Secondary);
}
.endLabelText {
grid-area: endLabel;
text-align: right;
color: var(--Text-Secondary);
}
.startValue {
grid-area: startValue;
color: var(--Surface-Brand-Primary-1-OnSurface-Accent);
padding-right: var(--Space-x2);
}
.progressBar {
grid-area: bar;
}
.endValue {
grid-area: endValue;
text-align: right;
color: var(--Surface-Brand-Primary-1-OnSurface-Accent);
padding-left: var(--Space-x2);
}
.startDivider,
.endDivider {
display: none;
}
@media screen and (min-width: 768px) {
.l6Progress {
padding: var(--Space-x3) var(--Space-x4);
}
.header {
flex-direction: row;
align-items: baseline;
gap: var(--Space-x1);
}
.progressSection {
grid-template-columns: auto auto auto 1fr auto auto auto;
gap: var(--Space-x2);
grid-template-areas: "startLabel startDiv startValue bar endValue endDiv endLabel";
}
.startDivider {
grid-area: startDiv;
display: block;
}
.endDivider {
grid-area: endDiv;
display: block;
}
.endValue .endLabelText {
text-align: left;
}
}

View File

@@ -8,6 +8,7 @@ import SectionLink from "@/components/Section/Link"
import { Cards } from "./Cards" import { Cards } from "./Cards"
import { INITIAL_STAYS_FETCH_LIMIT } from "./data" import { INITIAL_STAYS_FETCH_LIMIT } from "./data"
import { L6Progress } from "./L6Progress"
import { ClientPreviousStays } from "./OldClient" import { ClientPreviousStays } from "./OldClient"
import styles from "./previous.module.css" import styles from "./previous.module.css"
@@ -35,6 +36,7 @@ export default async function PreviousStays({
<SectionHeader heading={title ?? undefined} link={link} /> <SectionHeader heading={title ?? undefined} link={link} />
<ClaimPoints /> <ClaimPoints />
</div> </div>
<L6Progress />
<StaysComponent initialPreviousStays={initialPreviousStays} /> <StaysComponent initialPreviousStays={initialPreviousStays} />
<SectionLink link={link} variant="mobile" /> <SectionLink link={link} variant="mobile" />
</Section> </Section>

View File

@@ -13,6 +13,7 @@ import { Typography } from "@scandic-hotels/design-system/Typography"
import { compareAllLevels, faq } from "@/constants/webHrefs" import { compareAllLevels, faq } from "@/constants/webHrefs"
import useLang from "@/hooks/useLang" import useLang from "@/hooks/useLang"
import { getTierStartDate } from "@/utils/getTierStartDate"
import styles from "./LevelProgressModal.module.css" import styles from "./LevelProgressModal.module.css"
@@ -28,14 +29,7 @@ export default function LevelProgressModal({
const intl = useIntl() const intl = useIntl()
const lang = useLang() const lang = useLang()
// calculate tierStarts by tierExpires const tierStarts = getTierStartDate(tierExpires)
let tierStarts: string | null = null
if (tierExpires) {
const date = new Date(tierExpires)
date.setFullYear(date.getFullYear() - 1)
tierStarts = date.toISOString().split("T")[0]
}
return ( return (
<Modal <Modal

View File

@@ -0,0 +1,14 @@
/**
* Calculate the tier start date from the tier expiration date.
* The tier period is 1 year, so start date is expiration minus 1 year.
*/
export function getTierStartDate(
tierExpirationDate: string | null | undefined
): string | null {
if (!tierExpirationDate) return null
const date = new Date(tierExpirationDate)
date.setFullYear(date.getFullYear() - 1)
return date.toISOString().split("T")[0]
}