Merged in feat/LOY-316-Level-Progress-Card (pull request #2739)
Feat/LOY-316 Level Progress Card
* feat(LOY-315): Add MembershipOverviewCard
* refactor(LOY-315): abstract sasbooststatus
* feat(LOY-316): build out LevelProgressCard skeleton & variant styling
* feat(LOY-316): Add HighesMembershipCard
* feat(LOY-316): ProgressBarCard base
* refactor(LOY-315): highest level card misc fixes
* feat(LOY-316): Add progress component to design system
* fix(LOY-316): type check
* refactor(LOY-316): calculate currentEarnings correctly
* fix(LOY-316): sas icon showing when not boosted
* fix(LOY-316): css module
* refactor(LOY-316): Restructure components
* feat(LOY-316): Add marker pin 📍
* fix(LOY-316): strict equality checks
* fix(LOY-316): code review fixes
* chore(LOY-316): conditionally hide old section under flag
* feat(LOY-316): Add level progress card to my points page
* chore(LOY-316): marker label container height
Approved-by: Matilda Landström
This commit is contained in:
@@ -40,7 +40,7 @@ export default async function MembershipOverviewCard({
|
|||||||
return (
|
return (
|
||||||
<section className={styles.card} aria-labelledby="membership-level">
|
<section className={styles.card} aria-labelledby="membership-level">
|
||||||
<header className={styles.membershipHeader}>
|
<header className={styles.membershipHeader}>
|
||||||
{sasMembership && (
|
{sasMembership?.boostedTierExpires && (
|
||||||
<MaterialIcon icon="travel" size={20} color="Icon/Accent" />
|
<MaterialIcon icon="travel" size={20} color="Icon/Accent" />
|
||||||
)}
|
)}
|
||||||
<Typography variant="Title/Overline/sm">
|
<Typography variant="Title/Overline/sm">
|
||||||
|
|||||||
@@ -34,7 +34,7 @@
|
|||||||
color: var(--Text-Brand-OnPrimary-3-Accent);
|
color: var(--Text-Brand-OnPrimary-3-Accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 1367px) {
|
@media screen and (min-width: 1367px) {
|
||||||
.card {
|
.card {
|
||||||
padding: var(--Space-x3) var(--Space-x4);
|
padding: var(--Space-x3) var(--Space-x4);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { getProfileWithExtendedPartnerData } from "@/lib/trpc/memoizedRequests"
|
|||||||
import { TeamMemberCardTrigger } from "@/components/DigitalTeamMemberCard/Trigger"
|
import { TeamMemberCardTrigger } from "@/components/DigitalTeamMemberCard/Trigger"
|
||||||
import DigitalTeamMemberCard from "@/components/MyPages/DigitalTeamMemberCard"
|
import DigitalTeamMemberCard from "@/components/MyPages/DigitalTeamMemberCard"
|
||||||
import DigitalTeamMemberCardAlert from "@/components/MyPages/DigitalTeamMemberCard/Alert"
|
import DigitalTeamMemberCardAlert from "@/components/MyPages/DigitalTeamMemberCard/Alert"
|
||||||
|
import LevelProgressCard from "@/components/MyPages/LevelProgressCard"
|
||||||
import SectionContainer from "@/components/Section/Container"
|
import SectionContainer from "@/components/Section/Container"
|
||||||
import SectionHeader from "@/components/Section/Header"
|
import SectionHeader from "@/components/Section/Header"
|
||||||
import SectionLink from "@/components/Section/Link"
|
import SectionLink from "@/components/Section/Link"
|
||||||
@@ -64,12 +65,10 @@ export default async function Overview({
|
|||||||
<UserBaseInfo user={user} />
|
<UserBaseInfo user={user} />
|
||||||
<div className={styles.membershipCardsContainer}>
|
<div className={styles.membershipCardsContainer}>
|
||||||
<MembershipOverviewCard user={user} />
|
<MembershipOverviewCard user={user} />
|
||||||
{/* LevelProgressCard will be added here in the next iteration */}
|
<LevelProgressCard user={user} />
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : (
|
||||||
|
|
||||||
{/*TODO: Replace hero section with new section above. */}
|
|
||||||
<Hero color="red">
|
<Hero color="red">
|
||||||
<Friend membership={user.membership} name={user.name}>
|
<Friend membership={user.membership} name={user.name}>
|
||||||
<MembershipNumber color="burgundy" membership={user.membership} />
|
<MembershipNumber color="burgundy" membership={user.membership} />
|
||||||
@@ -77,6 +76,8 @@ export default async function Overview({
|
|||||||
<Divider className={styles.divider} color="peach" />
|
<Divider className={styles.divider} color="peach" />
|
||||||
<Stats user={user} />
|
<Stats user={user} />
|
||||||
</Hero>
|
</Hero>
|
||||||
|
)}
|
||||||
|
|
||||||
<SectionLink link={link} variant="mobile" />
|
<SectionLink link={link} variant="mobile" />
|
||||||
</SectionContainer>
|
</SectionContainer>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
.membershipCardsContainer {
|
.membershipCardsContainer {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--Space-x2);
|
gap: var(--Space-x2);
|
||||||
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (min-width: 768px) {
|
@media screen and (min-width: 768px) {
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { Divider } from "@scandic-hotels/design-system/Divider"
|
import { Divider } from "@scandic-hotels/design-system/Divider"
|
||||||
|
|
||||||
|
import { env } from "@/env/server"
|
||||||
import { getProfile } from "@/lib/trpc/memoizedRequests"
|
import { getProfile } from "@/lib/trpc/memoizedRequests"
|
||||||
|
|
||||||
|
import LevelProgressCard from "@/components/MyPages/LevelProgressCard"
|
||||||
import SectionContainer from "@/components/Section/Container"
|
import SectionContainer from "@/components/Section/Container"
|
||||||
import SectionHeader from "@/components/Section/Header"
|
import SectionHeader from "@/components/Section/Header"
|
||||||
import SectionLink from "@/components/Section/Link"
|
import SectionLink from "@/components/Section/Link"
|
||||||
@@ -34,6 +36,16 @@ export default async function PointsOverview({
|
|||||||
headingAs={"h3"}
|
headingAs={"h3"}
|
||||||
headingLevel={"h1"}
|
headingLevel={"h1"}
|
||||||
/>
|
/>
|
||||||
|
{env.ENABLE_NEW_OVERVIEW_SECTION && (
|
||||||
|
<div className={styles.membershipCardsContainer}>
|
||||||
|
{/*TODO: Add PointsToSpendCard */}
|
||||||
|
<LevelProgressCard
|
||||||
|
color="Surface/Brand/Primary 1/Default"
|
||||||
|
user={user}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/*TODO: Once the PointsToSpendCard is added hide the Hero Section under the flag above. */}
|
||||||
<Hero color="burgundy">
|
<Hero color="burgundy">
|
||||||
<Friend membership={user.membership} name={user.name}>
|
<Friend membership={user.membership} name={user.name}>
|
||||||
<MembershipNumber color="red" membership={user.membership} />
|
<MembershipNumber color="red" membership={user.membership} />
|
||||||
|
|||||||
@@ -2,8 +2,20 @@
|
|||||||
margin-top: var(--Spacing-x2);
|
margin-top: var(--Spacing-x2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.membershipCardsContainer {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--Space-x2);
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
@media screen and (min-width: 768px) {
|
@media screen and (min-width: 768px) {
|
||||||
.divider {
|
.divider {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media screen and (min-width: 1367px) {
|
||||||
|
.membershipCardsContainer {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
.card {
|
||||||
|
background: var(--Surface-Primary-Default);
|
||||||
|
border-radius: var(--Corner-radius-lg);
|
||||||
|
padding: var(--Space-x2) var(--Space-x4) var(--Space-x4);
|
||||||
|
display: grid;
|
||||||
|
justify-items: center;
|
||||||
|
text-align: center;
|
||||||
|
grid-template-areas:
|
||||||
|
"icon"
|
||||||
|
"content";
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
color: var(--Text-Heading);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
grid-area: content;
|
||||||
|
display: grid;
|
||||||
|
gap: var(--Space-x1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
grid-area: icon;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (min-width: 768px) {
|
||||||
|
.card {
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
grid-template-areas: "icon content";
|
||||||
|
padding: var(--Space-x1) var(--Space-x4);
|
||||||
|
justify-items: start;
|
||||||
|
text-align: left;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--Space-x4);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { MembershipLevelEnum } from "@scandic-hotels/common/constants/membershipLevels"
|
||||||
|
import TrophyIcon from "@scandic-hotels/design-system/Icons/TrophyIcon"
|
||||||
|
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||||
|
|
||||||
|
import { serverClient } from "@/lib/trpc/server"
|
||||||
|
|
||||||
|
import styles from "./highestLevelCard.module.css"
|
||||||
|
|
||||||
|
import type { HighestLevelCardProps } from "../types"
|
||||||
|
|
||||||
|
export default async function HighestLevelCard({
|
||||||
|
membershipLevel,
|
||||||
|
intl,
|
||||||
|
}: HighestLevelCardProps) {
|
||||||
|
const caller = await serverClient()
|
||||||
|
|
||||||
|
const highestLevel = await caller.contentstack.loyaltyLevels.byLevel({
|
||||||
|
level: MembershipLevelEnum[membershipLevel],
|
||||||
|
})
|
||||||
|
|
||||||
|
const pointsEarned = highestLevel?.required_points
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.card}>
|
||||||
|
<TrophyIcon className={styles.icon} width={79} height={118} />
|
||||||
|
<div className={styles.content}>
|
||||||
|
<Typography variant="Title/Subtitle/md">
|
||||||
|
<h3 className={styles.title}>
|
||||||
|
{intl.formatMessage({
|
||||||
|
defaultMessage: "Hello Best Friend!",
|
||||||
|
})}
|
||||||
|
</h3>
|
||||||
|
</Typography>
|
||||||
|
{pointsEarned && (
|
||||||
|
<Typography variant="Body/Paragraph/mdRegular">
|
||||||
|
<p>
|
||||||
|
{intl.formatMessage(
|
||||||
|
{
|
||||||
|
defaultMessage:
|
||||||
|
"you've made it to the top by earning {pointAmount} <strong>points!</strong> Continue earning points for more points to spend.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pointAmount: (
|
||||||
|
<strong>{intl.formatNumber(pointsEarned)}</strong>
|
||||||
|
),
|
||||||
|
strong: (str) => <strong>{str}</strong>,
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
|
import { Progress } from "@scandic-hotels/design-system/Progress"
|
||||||
|
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||||
|
|
||||||
|
import styles from "./progressLevelCard.module.css"
|
||||||
|
|
||||||
|
import type { ProgressLevelCardProps } from "../types"
|
||||||
|
|
||||||
|
export default function ProgressLevelCard({
|
||||||
|
pointsEarned,
|
||||||
|
pointsToNextLevel,
|
||||||
|
pointsNeededToKeepLevel,
|
||||||
|
}: ProgressLevelCardProps) {
|
||||||
|
const intl = useIntl()
|
||||||
|
|
||||||
|
// TODO: Awaiting proper UX/UI specs on missing point data scenarios.
|
||||||
|
if (
|
||||||
|
typeof pointsEarned !== "number" ||
|
||||||
|
typeof pointsToNextLevel !== "number"
|
||||||
|
) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalPointsForCurrentLevel = pointsEarned + pointsToNextLevel
|
||||||
|
const progressPercentage = (pointsEarned / totalPointsForCurrentLevel) * 100
|
||||||
|
|
||||||
|
// Calculate marker position (minimum threshold to keep current level)
|
||||||
|
const markerPosition = pointsNeededToKeepLevel
|
||||||
|
? ((pointsEarned + pointsNeededToKeepLevel) / totalPointsForCurrentLevel) *
|
||||||
|
100
|
||||||
|
: null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.card}>
|
||||||
|
<div className={styles.statsContainer}>
|
||||||
|
<div className={styles.statItem}>
|
||||||
|
<Typography variant="Title/Overline/sm">
|
||||||
|
<span className={styles.label}>
|
||||||
|
{intl.formatMessage({
|
||||||
|
defaultMessage: "Points Earned",
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Typography variant="Title/md">
|
||||||
|
<span
|
||||||
|
className={styles.value}
|
||||||
|
aria-describedby="points-earned-desc"
|
||||||
|
>
|
||||||
|
{intl.formatNumber(pointsEarned)}
|
||||||
|
</span>
|
||||||
|
</Typography>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.statItem}>
|
||||||
|
<Typography variant="Title/Overline/sm">
|
||||||
|
<span className={styles.label}>
|
||||||
|
{intl.formatMessage({
|
||||||
|
defaultMessage: "Left to level up",
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Typography variant="Title/md">
|
||||||
|
<span
|
||||||
|
className={styles.value}
|
||||||
|
aria-describedby="points-remaining-desc"
|
||||||
|
>
|
||||||
|
{intl.formatNumber(pointsToNextLevel)}
|
||||||
|
</span>
|
||||||
|
</Typography>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`${styles.progressSection} ${markerPosition !== null ? styles.hasMarker : ""}`}
|
||||||
|
>
|
||||||
|
<div className={styles.progressContainer}>
|
||||||
|
<Progress
|
||||||
|
value={progressPercentage}
|
||||||
|
aria-label={intl.formatMessage(
|
||||||
|
{
|
||||||
|
defaultMessage:
|
||||||
|
"Level progress: {earned} of {total} points earned",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
earned: intl.formatNumber(pointsEarned),
|
||||||
|
total: intl.formatNumber(totalPointsForCurrentLevel),
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{markerPosition !== null && pointsNeededToKeepLevel && (
|
||||||
|
<div
|
||||||
|
className={styles.levelMarker}
|
||||||
|
style={{ left: `${markerPosition}%` }}
|
||||||
|
>
|
||||||
|
<div className={styles.markerPin} />
|
||||||
|
<div className={styles.markerLine} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{markerPosition !== null && pointsNeededToKeepLevel && (
|
||||||
|
<div className={styles.markerLabelContainer}>
|
||||||
|
<span
|
||||||
|
className={styles.markerLabel}
|
||||||
|
style={
|
||||||
|
{ "--marker-pos": `${markerPosition}%` } as React.CSSProperties
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{intl.formatMessage(
|
||||||
|
{
|
||||||
|
defaultMessage:
|
||||||
|
"{pointsAmount} <points>POINTS</points> <support>left to keep level</support>",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pointsAmount: (
|
||||||
|
<Typography variant="Title/Overline/sm">
|
||||||
|
<span>{intl.formatNumber(pointsNeededToKeepLevel)}</span>
|
||||||
|
</Typography>
|
||||||
|
),
|
||||||
|
points: (str) => (
|
||||||
|
<Typography variant="Title/Overline/sm">
|
||||||
|
<span>{str}</span>
|
||||||
|
</Typography>
|
||||||
|
),
|
||||||
|
support: (str) => (
|
||||||
|
<Typography variant="Body/Supporting text (caption)/smRegular">
|
||||||
|
<span>{str}</span>
|
||||||
|
</Typography>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
.card {
|
||||||
|
background: var(--Surface-Primary-Default);
|
||||||
|
border-radius: var(--Corner-radius-lg);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--Space-x2);
|
||||||
|
padding: var(--Space-x3) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statsContainer {
|
||||||
|
display: flex;
|
||||||
|
padding: 0 var(--Space-x3);
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
align-self: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statItem {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--Space-x05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.statItem:last-child {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
color: var(--Text-Secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.value {
|
||||||
|
color: var(--Text-Accent-Primary);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progressSection {
|
||||||
|
padding: 0 var(--Space-x3);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--Space-x1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progressSection.hasMarker {
|
||||||
|
padding-bottom: var(--Space-x1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progressContainer {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.levelMarker {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 2;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markerPin {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
background: var(--Surface-Brand-Primary-1-OnSurface-Default);
|
||||||
|
border-radius: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markerLine {
|
||||||
|
width: 1px;
|
||||||
|
height: 21px;
|
||||||
|
background: var(--Surface-Brand-Primary-1-OnSurface-Default);
|
||||||
|
margin-top: -3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markerLabelContainer {
|
||||||
|
position: relative;
|
||||||
|
height: var(--Space-x3);
|
||||||
|
margin-top: var(--Space-x15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markerLabel {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
|
||||||
|
/* Define both edges - let browser calculate width automatically */
|
||||||
|
inset-inline-start: var(--Space-x3); /* respects card padding */
|
||||||
|
inset-inline-end: calc(100% - var(--marker-pos)); /* right edge under pin */
|
||||||
|
|
||||||
|
white-space: normal;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
color: var(--Text-Secondary);
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (min-width: 521px) {
|
||||||
|
.markerLabelContainer {
|
||||||
|
height: var(--Space-x2);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { MembershipLevelEnum } from "@scandic-hotels/common/constants/membershipLevels"
|
||||||
|
|
||||||
|
import { serverClient } from "@/lib/trpc/server"
|
||||||
|
|
||||||
|
import ProgressLevelCard from "./ProgressLevelCard"
|
||||||
|
|
||||||
|
import type { ProgressLevelWrapperProps } from "./types"
|
||||||
|
|
||||||
|
export default async function ProgressLevelWrapper({
|
||||||
|
user,
|
||||||
|
}: ProgressLevelWrapperProps) {
|
||||||
|
const caller = await serverClient()
|
||||||
|
|
||||||
|
if (!user.membership || !user.membership.membershipLevel) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTE: We currently rely on the CMS to get "required_points" for a given level,
|
||||||
|
// but API is working on including them in the Profile endpoint.
|
||||||
|
const [currentLevel, nextLevel] = await Promise.all([
|
||||||
|
caller.contentstack.loyaltyLevels.byLevel({
|
||||||
|
level: MembershipLevelEnum[user.membership.membershipLevel],
|
||||||
|
}),
|
||||||
|
|
||||||
|
user.membership.nextLevel && MembershipLevelEnum[user.membership.nextLevel]
|
||||||
|
? caller.contentstack.loyaltyLevels.byLevel({
|
||||||
|
level: MembershipLevelEnum[user.membership.nextLevel],
|
||||||
|
})
|
||||||
|
: null,
|
||||||
|
])
|
||||||
|
|
||||||
|
const pointsToNextLevel = user.membership.pointsRequiredToNextlevel
|
||||||
|
|
||||||
|
// Relying on user.loyalty.points.earned isn't suffice here as it isn't limited to loyalty-level specific earnings,
|
||||||
|
// nor limited to earnings in the current member year.
|
||||||
|
// TODO: API is working on adding a tierEarnings field which we'll be able to use for this.
|
||||||
|
// Once that's out we won't need the nextLevel call above.
|
||||||
|
const currentEarnings =
|
||||||
|
nextLevel?.required_points && pointsToNextLevel
|
||||||
|
? nextLevel?.required_points - pointsToNextLevel
|
||||||
|
: null
|
||||||
|
|
||||||
|
let pointsNeededToKeepLevel = null
|
||||||
|
|
||||||
|
if (currentEarnings !== null && currentLevel?.required_points) {
|
||||||
|
if (currentEarnings < currentLevel.required_points) {
|
||||||
|
pointsNeededToKeepLevel = currentLevel.required_points - currentEarnings
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProgressLevelCard
|
||||||
|
pointsToNextLevel={pointsToNextLevel}
|
||||||
|
pointsEarned={currentEarnings}
|
||||||
|
pointsNeededToKeepLevel={pointsNeededToKeepLevel}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { dt } from "@scandic-hotels/common/dt"
|
||||||
|
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||||
|
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||||
|
|
||||||
|
import { getIntl } from "@/i18n"
|
||||||
|
import { getLang } from "@/i18n/serverContext"
|
||||||
|
import { isHighestMembership } from "@/utils/user"
|
||||||
|
|
||||||
|
import HighestLevelCard from "./HighestLevelCard"
|
||||||
|
import ProgressLevelWrapper from "./ProgressLevelWrapper"
|
||||||
|
import { levelProgressCardVariants } from "./variants"
|
||||||
|
|
||||||
|
import styles from "./levelProgressCard.module.css"
|
||||||
|
|
||||||
|
import type { levelProgressCardProps } from "./types"
|
||||||
|
|
||||||
|
export default async function LevelProgressCard({
|
||||||
|
user,
|
||||||
|
className,
|
||||||
|
color = "Surface/Brand/Primary 1/OnSurface/Default",
|
||||||
|
}: levelProgressCardProps) {
|
||||||
|
const intl = await getIntl()
|
||||||
|
const lang = await getLang()
|
||||||
|
|
||||||
|
if (!user.membership?.membershipLevel) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const classNames = levelProgressCardVariants({ className, color })
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section aria-labelledby="level-progress-card-title" className={classNames}>
|
||||||
|
<header className={styles.progressHeader}>
|
||||||
|
<Typography variant="Title/xs">
|
||||||
|
<h2 id="level-progress-card-title" className={styles.title}>
|
||||||
|
{intl.formatMessage({
|
||||||
|
defaultMessage: "Your Level Progress",
|
||||||
|
})}
|
||||||
|
</h2>
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="Body/Paragraph/mdRegular">
|
||||||
|
<p className={styles.date}>
|
||||||
|
{intl.formatMessage(
|
||||||
|
{ defaultMessage: "Valid until {date}" },
|
||||||
|
{
|
||||||
|
date: dt(user.membership.tierExpirationDate)
|
||||||
|
.locale(lang)
|
||||||
|
.format("D MMM YYYY"),
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</Typography>
|
||||||
|
<MaterialIcon
|
||||||
|
className={styles.infoButton}
|
||||||
|
icon="info"
|
||||||
|
color={
|
||||||
|
color === "Surface/Brand/Primary 1/OnSurface/Default"
|
||||||
|
? "Icon/Inverted"
|
||||||
|
: "Icon/Interactive/Default"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</header>
|
||||||
|
{isHighestMembership(user.membership.membershipLevel) ? (
|
||||||
|
<HighestLevelCard
|
||||||
|
membershipLevel={user.membership.membershipLevel}
|
||||||
|
intl={intl}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ProgressLevelWrapper user={user} />
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
.Surface-Brand-Primary-1-OnSurface-Default {
|
||||||
|
background: var(--Surface-Brand-Primary-1-OnSurface-Default);
|
||||||
|
}
|
||||||
|
|
||||||
|
.Surface-Brand-Primary-1-Default {
|
||||||
|
background: var(--Surface-Brand-Primary-1-Default);
|
||||||
|
}
|
||||||
|
|
||||||
|
.Surface-Brand-Primary-1-OnSurface-Default .title {
|
||||||
|
color: var(--Text-Brand-OnPrimary-3-Heading);
|
||||||
|
}
|
||||||
|
|
||||||
|
.Surface-Brand-Primary-1-OnSurface-Default .date {
|
||||||
|
color: var(--Text-Brand-OnPrimary-2-Accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.Surface-Brand-Primary-1-Default .title,
|
||||||
|
.Surface-Brand-Primary-1-Default .date {
|
||||||
|
color: var(--Text-Brand-OnPrimary-1-Heading);
|
||||||
|
}
|
||||||
|
|
||||||
|
.levelProgressCard {
|
||||||
|
border-radius: var(--Corner-radius-lg);
|
||||||
|
padding: var(--Space-x3) var(--Space-x2);
|
||||||
|
position: relative;
|
||||||
|
min-height: 200px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--Space-x3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progressHeader {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
grid-template-rows: auto auto;
|
||||||
|
gap: var(--Space-x05);
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
grid-column: 1;
|
||||||
|
grid-row: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date {
|
||||||
|
grid-column: 1;
|
||||||
|
grid-row: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infoButton {
|
||||||
|
grid-column: 2;
|
||||||
|
grid-row: 1 / 3;
|
||||||
|
align-self: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (min-width: 1367px) {
|
||||||
|
.levelProgressCard {
|
||||||
|
padding: var(--Space-x3) var(--Space-x4);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import type { MembershipLevel } from "@scandic-hotels/common/constants/membershipLevels"
|
||||||
|
import type { User } from "@scandic-hotels/trpc/types/user"
|
||||||
|
import type { VariantProps } from "class-variance-authority"
|
||||||
|
import type { IntlShape } from "react-intl"
|
||||||
|
|
||||||
|
import type { levelProgressCardVariants } from "./variants"
|
||||||
|
|
||||||
|
export interface levelProgressCardProps
|
||||||
|
extends Omit<React.HTMLAttributes<HTMLDivElement>, "color">,
|
||||||
|
VariantProps<typeof levelProgressCardVariants> {
|
||||||
|
user: User
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProgressLevelWrapperProps {
|
||||||
|
user: User
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProgressLevelCardProps {
|
||||||
|
pointsEarned?: number | null
|
||||||
|
pointsToNextLevel?: number | null
|
||||||
|
pointsNeededToKeepLevel: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HighestLevelCardProps {
|
||||||
|
membershipLevel: MembershipLevel
|
||||||
|
intl: IntlShape
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { cva } from "class-variance-authority"
|
||||||
|
|
||||||
|
import styles from "./levelProgressCard.module.css"
|
||||||
|
|
||||||
|
export const levelProgressCardVariants = cva(styles.levelProgressCard, {
|
||||||
|
variants: {
|
||||||
|
color: {
|
||||||
|
"Surface/Brand/Primary 1/OnSurface/Default":
|
||||||
|
styles["Surface-Brand-Primary-1-OnSurface-Default"],
|
||||||
|
"Surface/Brand/Primary 1/Default":
|
||||||
|
styles["Surface-Brand-Primary-1-Default"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
color: "Surface/Brand/Primary 1/OnSurface/Default",
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -10,6 +10,7 @@ import KidsIcon from './Illustrations/Kids'
|
|||||||
import KidsMocktailIcon from './Illustrations/KidsMocktail'
|
import KidsMocktailIcon from './Illustrations/KidsMocktail'
|
||||||
import MagicWandIcon from './Illustrations/MagicWand'
|
import MagicWandIcon from './Illustrations/MagicWand'
|
||||||
import MoneyHandIcon from './Illustrations/MoneyHand'
|
import MoneyHandIcon from './Illustrations/MoneyHand'
|
||||||
|
import TrophyIcon from './Illustrations/Trophy'
|
||||||
import VoucherIcon from './Illustrations/Voucher'
|
import VoucherIcon from './Illustrations/Voucher'
|
||||||
|
|
||||||
import { IconName } from './iconName'
|
import { IconName } from './iconName'
|
||||||
@@ -40,6 +41,8 @@ export function IllustrationByIconName(iconName: IconName | null) {
|
|||||||
return CoinIcon
|
return CoinIcon
|
||||||
case IconName.Bed:
|
case IconName.Bed:
|
||||||
return BedIcon
|
return BedIcon
|
||||||
|
case IconName.Trophy:
|
||||||
|
return TrophyIcon
|
||||||
case IconName.Voucher:
|
case IconName.Voucher:
|
||||||
return VoucherIcon
|
return VoucherIcon
|
||||||
default:
|
default:
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -169,6 +169,7 @@ export enum IconName {
|
|||||||
Ticket = 'Ticket',
|
Ticket = 'Ticket',
|
||||||
Train = 'Train',
|
Train = 'Train',
|
||||||
Tripadvisor = 'Tripadvisor',
|
Tripadvisor = 'Tripadvisor',
|
||||||
|
Trophy = 'Trophy',
|
||||||
Tshirt = 'Tshirt',
|
Tshirt = 'Tshirt',
|
||||||
TshirtWash = 'TshirtWash',
|
TshirtWash = 'TshirtWash',
|
||||||
TvCasting = 'TvCasting',
|
TvCasting = 'TvCasting',
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
|
||||||
|
|
||||||
|
import { Progress } from './index'
|
||||||
|
|
||||||
|
const meta: Meta<typeof Progress> = {
|
||||||
|
title: 'Components/Progress',
|
||||||
|
component: Progress,
|
||||||
|
parameters: {
|
||||||
|
backgrounds: { disable: true },
|
||||||
|
},
|
||||||
|
argTypes: {
|
||||||
|
value: {
|
||||||
|
control: { type: 'range', min: 0, max: 100, step: 1 },
|
||||||
|
description: 'The current progress value (0-100)',
|
||||||
|
},
|
||||||
|
'aria-label': {
|
||||||
|
control: 'text',
|
||||||
|
description: 'Accessible label for the progress bar',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
args: {
|
||||||
|
'aria-label': 'Loading progress',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default meta
|
||||||
|
|
||||||
|
type Story = StoryObj<typeof Progress>
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
args: {
|
||||||
|
value: 65,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LowProgress: Story = {
|
||||||
|
args: {
|
||||||
|
value: 15,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HighProgress: Story = {
|
||||||
|
args: {
|
||||||
|
value: 90,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Complete: Story = {
|
||||||
|
args: {
|
||||||
|
value: 100,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Empty: Story = {
|
||||||
|
args: {
|
||||||
|
value: 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
30
packages/design-system/lib/components/Progress/index.tsx
Normal file
30
packages/design-system/lib/components/Progress/index.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { ProgressBar } from 'react-aria-components'
|
||||||
|
import { cx } from 'class-variance-authority'
|
||||||
|
import styles from './progress.module.css'
|
||||||
|
import { ProgressProps } from './types'
|
||||||
|
|
||||||
|
export function Progress({
|
||||||
|
value,
|
||||||
|
minValue = 0,
|
||||||
|
maxValue = 100,
|
||||||
|
'aria-label': ariaLabel,
|
||||||
|
className,
|
||||||
|
}: ProgressProps) {
|
||||||
|
return (
|
||||||
|
<ProgressBar
|
||||||
|
value={value}
|
||||||
|
minValue={minValue}
|
||||||
|
maxValue={maxValue}
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
className={cx(styles.progress, className)}
|
||||||
|
>
|
||||||
|
{({ percentage }) => (
|
||||||
|
<>
|
||||||
|
<div className={styles.track}>
|
||||||
|
<div className={styles.fill} style={{ width: `${percentage}%` }} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ProgressBar>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
.progress {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track {
|
||||||
|
height: var(--Space-x2);
|
||||||
|
border-radius: var(--Corner-radius-md);
|
||||||
|
padding: var(--Space-x05);
|
||||||
|
background: var(--Surface-Secondary-Default);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fill {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--Surface-Brand-Primary-1-OnSurface-Accent);
|
||||||
|
border-radius: var(--Corner-radius-md);
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
7
packages/design-system/lib/components/Progress/types.ts
Normal file
7
packages/design-system/lib/components/Progress/types.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export interface ProgressProps {
|
||||||
|
value: number
|
||||||
|
minValue?: number
|
||||||
|
maxValue?: number
|
||||||
|
'aria-label'?: string
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
10
packages/design-system/lib/components/Progress/variants.ts
Normal file
10
packages/design-system/lib/components/Progress/variants.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { cva } from 'class-variance-authority'
|
||||||
|
|
||||||
|
import styles from './progress.module.css'
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
variants: {},
|
||||||
|
defaultVariants: {},
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export const variants = cva(styles.progress, config)
|
||||||
@@ -121,6 +121,7 @@
|
|||||||
"./Icons/ToiletIcon": "./lib/components/Icons/Nucleo/Amenities_Facilities/toilet-2.tsx",
|
"./Icons/ToiletIcon": "./lib/components/Icons/Nucleo/Amenities_Facilities/toilet-2.tsx",
|
||||||
"./Icons/TowelIcon": "./lib/components/Icons/Customised/Amenities_Facilities/Towel.tsx",
|
"./Icons/TowelIcon": "./lib/components/Icons/Customised/Amenities_Facilities/Towel.tsx",
|
||||||
"./Icons/TripadvisorIcon": "./lib/components/Icons/Customised/Socials/Tripadvisor.tsx",
|
"./Icons/TripadvisorIcon": "./lib/components/Icons/Customised/Socials/Tripadvisor.tsx",
|
||||||
|
"./Icons/TrophyIcon": "./lib/components/Icons/Illustrations/Trophy.tsx",
|
||||||
"./Icons/UserPoliceIcon": "./lib/components/Icons/Nucleo/Amenities_Facilities/user-police-2.tsx",
|
"./Icons/UserPoliceIcon": "./lib/components/Icons/Nucleo/Amenities_Facilities/user-police-2.tsx",
|
||||||
"./Icons/ViewIcon": "./lib/components/Icons/Customised/Amenities_Facilities/View.tsx",
|
"./Icons/ViewIcon": "./lib/components/Icons/Customised/Amenities_Facilities/View.tsx",
|
||||||
"./Icons/VoucherIcon": "./lib/components/Icons/Illustrations/Voucher.tsx",
|
"./Icons/VoucherIcon": "./lib/components/Icons/Illustrations/Voucher.tsx",
|
||||||
@@ -152,6 +153,7 @@
|
|||||||
"./Payment/PaymentMethodIcon": "./lib/components/Payment/PaymentMethodIcon.tsx",
|
"./Payment/PaymentMethodIcon": "./lib/components/Payment/PaymentMethodIcon.tsx",
|
||||||
"./PointsRateCard": "./lib/components/RateCard/Points/index.tsx",
|
"./PointsRateCard": "./lib/components/RateCard/Points/index.tsx",
|
||||||
"./Preamble": "./lib/components/Preamble/index.tsx",
|
"./Preamble": "./lib/components/Preamble/index.tsx",
|
||||||
|
"./Progress": "./lib/components/Progress/index.tsx",
|
||||||
"./RegularRateCard": "./lib/components/RateCard/Regular/index.tsx",
|
"./RegularRateCard": "./lib/components/RateCard/Regular/index.tsx",
|
||||||
"./Select": "./lib/components/Select/index.tsx",
|
"./Select": "./lib/components/Select/index.tsx",
|
||||||
"./SidePeek": "./lib/components/SidePeek/index.tsx",
|
"./SidePeek": "./lib/components/SidePeek/index.tsx",
|
||||||
|
|||||||
Reference in New Issue
Block a user