Merged in feat/sw-1291-show-sas-membership-data (pull request #1503)

Show SAS membership data in Linked Accounts

* Rip out old styling

* Desktop version of new linked accounts design

* Use new design system tokens

* Refactor SASLinkedAccount to handle all states

* Improve small screen styling

* Add intl etc

* Skeletons

* Tiny fixes

* Add i18n keys to all languages


Approved-by: Linus Flood
This commit is contained in:
Anton Gunnarsson
2025-03-10 10:13:18 +00:00
parent 9280bb3f1c
commit 393546d35d
12 changed files with 432 additions and 349 deletions

View File

@@ -1,207 +0,0 @@
import { cva, type VariantProps } from "class-variance-authority"
import Image from "next/image"
import RocketLaunch from "@/components/Icons/RocketLaunch"
import SkeletonShimmer from "@/components/SkeletonShimmer"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import { getIntl } from "@/i18n"
import styles from "./tierLevelCard.module.css"
import type { ComponentProps, ReactNode } from "react"
type BoostState = "boostedInOtherSystem" | "boostedInThisSystem" | "notBoosted"
type BaseProps = {
points: number
tier: string
boostState: BoostState
}
type BoostedInOther = BaseProps & {
boostState: "boostedInOtherSystem"
boostedTier: string
boostExpiration: Date
}
type BoostedInThis = BaseProps & {
boostState: "boostedInThisSystem"
}
type NotBoosted = BaseProps & {
boostState: "notBoosted"
}
const variants = cva(styles.tierlevelcard, {
variants: {
bonusSystem: {
scandic: styles.scandic,
sas: styles.sas,
},
},
})
type Props = VariantProps<typeof variants> &
(BoostedInOther | BoostedInThis | NotBoosted)
export async function TierLevelCard({
points,
tier,
bonusSystem,
...boosted
}: Props) {
const intl = await getIntl()
const className = variants({ bonusSystem })
return (
<article className={className}>
{boosted.boostState === "boostedInOtherSystem" && (
<section className={styles.boostedInfo}>
<div className={styles.boostedTier}>
<Caption
uppercase
type="bold"
color={bonusSystemSpecifics[bonusSystem!].color}
>
{boosted.boostedTier}
</Caption>
<Caption
type="bold"
color={bonusSystemSpecifics[bonusSystem!].color}
>
{intl.formatMessage({ id: "Level upgrade" })}
</Caption>
</div>
<Caption color={bonusSystemSpecifics[bonusSystem!].color}>
{intl.formatMessage(
{
id: "Upgrade expires {upgradeExpires, date, short}",
},
{ upgradeExpires: boosted.boostExpiration }
)}
</Caption>
</section>
)}
<section className={styles.baseInfo}>
<div className={styles.header}>
<div className={styles.tierInfo}>
<span>
<Caption
uppercase
type="bold"
color={bonusSystemSpecifics[bonusSystem!].color}
>
{tier}
</Caption>
</span>
<div className={styles.logoContainer}>
{bonusSystemSpecifics[bonusSystem!].logo}
</div>
</div>
{boosted.boostState === "boostedInThisSystem" && (
<Footnote className={styles.footnote}>
<RocketLaunch
color={bonusSystemSpecifics[bonusSystem!].rocketLaunchColor}
/>
<span>
{intl.formatMessage({
id: bonusSystemSpecifics[bonusSystem!].boostTextId,
})}
</span>
</Footnote>
)}
</div>
<Subtitle>
{intl.formatMessage(
{ id: "{points, number} Bonus points" },
{ points }
)}
</Subtitle>
</section>
</article>
)
}
export function TierLevelCardSkeleton({
bonusSystem,
}: {
bonusSystem?: NonNullable<Props["bonusSystem"]>
}) {
const className = variants({ bonusSystem })
return (
<article className={className}>
<section className={styles.baseInfo}>
<div className={styles.header}>
<div className={styles.tierInfo}>
<span>
<SkeletonShimmer width={"50px"} height={"16px"} />
</span>
{bonusSystem && (
<div className={styles.eurobonusLogo}>
{bonusSystemSpecifics[bonusSystem!].logo}
</div>
)}
{!bonusSystem && <SkeletonShimmer width={"74px"} height={"16px"} />}
</div>
</div>
<Subtitle>
<SkeletonShimmer width={"240px"} height={"26px"} />
</Subtitle>
</section>
</article>
)
}
type BonusSystemSpecifics = {
boostTextId: string
logo: ReactNode
rocketLaunchColor: ComponentProps<typeof RocketLaunch>["color"]
color: ComponentProps<typeof Caption>["color"]
}
type BonusSystemMapping = {
[bonusSystem in NonNullable<
VariantProps<typeof variants>["bonusSystem"]
>]: BonusSystemSpecifics
}
const bonusSystemSpecifics: BonusSystemMapping = {
scandic: {
boostTextId: "Your Friends level has upgraded your Eurobonus level",
rocketLaunchColor: "red",
color: "burgundy",
logo: (
<Image
alt="Scandic logo"
height={16}
src="/_static/img/scandic-logotype.svg"
width={74}
/>
),
},
sas: {
boostTextId: "Your Eurobonus level has upgraded your friends level",
rocketLaunchColor: "blue",
color: "uiTextActive",
logo: (
<>
<Image
alt="SAS logo"
height={16}
src="/_static/img/sas/sas-logotype.svg"
width={44}
/>
<Caption uppercase type="bold">
Eurobonus
</Caption>
</>
),
},
}

View File

@@ -1,93 +0,0 @@
.tierlevelcard {
display: flex;
flex-direction: column;
justify-content: space-between;
border-radius: var(--Corner-radius-Medium);
background: white;
box-shadow: 0px 0px 8px 3px #0000001a;
overflow: hidden;
width: 100%;
min-height: 176px;
@media screen and (min-width: 768px) {
max-width: 335px;
}
&.scandic {
background: white;
color: var(--Main-Brand-Burgundy);
.boostedInfo {
background: linear-gradient(86.64deg, #faf6f2 0%, #f4d5c8 100.91%);
border-radius: var(--Corner-radius-Medium);
}
}
&.sas {
background: #f0f4ff;
color: var(--Main-Brand-DarkBlue);
.boostedInfo {
background: linear-gradient(90deg, #f0f4ff 0%, #bdcdff 100%);
border-radius: var(--Corner-radius-Medium);
}
}
}
.boostedInfo {
display: flex;
flex-direction: column;
gap: var(--Spacing-x-half);
box-shadow: 0px 0px 4px 2px #0000001a;
flex: 0;
padding: var(--Spacing-x2) var(--Spacing-x-one-and-half);
}
.boostedTier {
display: flex;
justify-content: space-between;
gap: var(--Spacing-x-one-and-half);
}
.baseInfo {
flex: 1;
display: flex;
flex-direction: column;
gap: var(--Spacing-x-one-and-half);
padding: var(--Spacing-x2) var(--Spacing-x-one-and-half);
justify-content: space-between;
}
.header {
display: flex;
flex-direction: column;
gap: var(--Spacing-x2);
}
.tierInfo {
display: flex;
justify-content: space-between;
text-transform: uppercase;
gap: var(--Spacing-x1);
}
.logoContainer {
display: flex;
justify-content: center;
align-items: center;
gap: var(--Spacing-x1);
}
.footnote {
display: flex;
align-items: center;
justify-content: flex-start;
gap: var(--Spacing-x1);
}

View File

@@ -4,6 +4,7 @@ import { useParams } from "next/navigation"
import { useIntl } from "react-intl"
import Dialog from "@/components/Dialog"
import { ChevronRightSmallIcon } from "@/components/Icons"
import Button from "@/components/TempDesignSystem/Button"
import type { LangParams } from "@/types/params"
@@ -27,6 +28,7 @@ export function UnlinkSAS() {
trigger={
<Button intent="text" theme="base">
{intl.formatMessage({ id: "Unlink accounts" })}
<ChevronRightSmallIcon color="burgundy" />
</Button>
}
/>

View File

@@ -1,20 +1,30 @@
import { Suspense } from "react"
import { cx } from "class-variance-authority"
import { type ReactNode, Suspense } from "react"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { TIER_TO_FRIEND_MAP } from "@/constants/membershipLevels"
import { env } from "@/env/server"
import { getProfile } from "@/lib/trpc/memoizedRequests"
import { DiamondIcon, InfoCircleIcon, LinkIcon } from "@/components/Icons"
import SectionContainer from "@/components/Section/Container"
import SectionHeader from "@/components/Section/Header"
import SectionLink from "@/components/Section/Link"
import { timeout } from "@/utils/timeout"
import SkeletonShimmer from "@/components/SkeletonShimmer"
import { getIntl } from "@/i18n"
import {
getEurobonusMembership,
getFriendsMembership,
scandicMemberships,
} from "@/utils/user"
import { TierLevelCard, TierLevelCardSkeleton } from "./Card/TierLevelCard"
import { LevelUpgradeButton } from "./LevelUpgradeButton"
import { UnlinkSAS } from "./UnlinkSAS"
import styles from "./linkedAccounts.module.css"
import type { Membership } from "@/types/user"
type Props = {
title?: string
link?: { href: string; text: string }
@@ -30,65 +40,271 @@ export default async function SASLinkedAccount({
return null
}
const intl = await getIntl()
return (
<>
<div className={styles.container}>
<SectionContainer>
<SectionHeader link={link} preamble={subtitle} title={title} />
<SectionLink link={link} variant="mobile" />
<section className={styles.cardsContainer}>
<Suspense fallback={<TierLevelCardsSkeleton />}>
<TierLevelCards />
<Suspense fallback={<MatchedAccountInfoSkeleton />}>
<MatchedAccountInfo />
</Suspense>
</section>
</SectionContainer>
<div className={styles.mutationSection}>
<Typography variant="Body/Supporting text (caption)/smRegular">
<p className={styles.caption}>
<InfoCircleIcon height={20} width={20} />
{intl.formatMessage({
id: "Changes in tier match can take up to 24 hours to be displayed.",
})}
</p>
</Typography>
<UnlinkSAS />
<LevelUpgradeButton />
</div>
</>
</div>
)
}
function TierLevelCardsSkeleton() {
return (
<>
<TierLevelCardSkeleton bonusSystem={"scandic"} />
<TierLevelCardSkeleton bonusSystem={"sas"} />
</>
)
}
async function TierLevelCards() {
console.log("[SAS] Fetching tier level cards")
await timeout(2_000)
console.log("[SAS] AFTER Fetching tier level cards")
async function MatchedAccountInfo() {
const user = await getProfile()
if (!user || "error" in user) {
return null
}
const sasPoints = 250_000
const sfPoints = user.membership?.currentPoints || 0
const sfLevelName =
TIER_TO_FRIEND_MAP[user.membership?.membershipLevel ?? "L1"]
const intl = await getIntl()
const eurobonusMembership = getEurobonusMembership(user.memberships)
const friendsMembership = user.membership
if (!eurobonusMembership || !friendsMembership) {
return null
}
const sasLevelName = eurobonusMembership.membershipLevel || "-"
const sasMembershipNumber = eurobonusMembership.membershipNumber
const sasTierExpirationDate = eurobonusMembership.tierExpirationDate
const scandicLevelName = TIER_TO_FRIEND_MAP[friendsMembership.membershipLevel]
const scandicExpirationDate = friendsMembership.tierExpirationDate
const matchState = calculateMatchState(user.memberships)
return (
<>
<TierLevelCard
points={sfPoints}
tier={sfLevelName}
boostState="boostedInThisSystem"
bonusSystem={"scandic"}
/>
<TierLevelCard
points={sasPoints}
tier="Silver"
boostState="boostedInOtherSystem"
bonusSystem={"sas"}
boostExpiration={new Date("2022-12-31")}
boostedTier="Gold"
/>
</>
<section className={styles.matchedAccountSection}>
<div className={styles.accountDetails}>
<div className={styles.stack}>
<Label>{intl.formatMessage({ id: "Linked account" })}</Label>
<Typography variant="Body/Paragraph/mdBold">
<p>SAS EuroBonus</p>
</Typography>
</div>
<div className={styles.stack}>
<Label>{intl.formatMessage({ id: "Tier status" })}</Label>
<Typography variant="Body/Paragraph/mdBold">
<p>{sasLevelName}</p>
</Typography>
</div>
<div className={cx(styles.stack, styles.accountMemberNumber)}>
<Label>{intl.formatMessage({ id: "Member number" })}</Label>
<Typography variant="Body/Paragraph/mdBold">
<p className={styles.textRight}>EB{sasMembershipNumber}</p>
</Typography>
</div>
</div>
<div className={styles.tierMatchStatus}>
<TierMatchMessage
matchState={matchState}
scandicLevelName={scandicLevelName}
sasLevelName={sasLevelName}
/>
<TierMatchExpiration
matchState={matchState}
sasExpirationDate={sasTierExpirationDate}
scandicExpirationDate={scandicExpirationDate}
/>
</div>
</section>
)
}
async function MatchedAccountInfoSkeleton() {
const intl = await getIntl()
return (
<section className={styles.matchedAccountSection}>
<div className={styles.accountDetails}>
<div className={styles.stack}>
<Label>{intl.formatMessage({ id: "Linked account" })}</Label>
<Typography variant="Body/Paragraph/mdBold">
<p>SAS EuroBonus</p>
</Typography>
</div>
<div className={styles.stack}>
<Label>{intl.formatMessage({ id: "Tier status" })}</Label>
<SkeletonShimmer width="6ch" height="24px" />
</div>
<div className={cx(styles.stack, styles.accountMemberNumber)}>
<Label>{intl.formatMessage({ id: "Member number" })}</Label>
<SkeletonShimmer width="10ch" height="24px" />
</div>
</div>
<div className={styles.tierMatchStatus}>
<TierMatchMessageSkeleton />
</div>
</section>
)
}
type TierMatchMessageProps = {
matchState: MatchState
scandicLevelName: string
sasLevelName: string
}
async function TierMatchMessage({
matchState,
sasLevelName,
scandicLevelName,
}: TierMatchMessageProps) {
const intl = await getIntl()
const messageValues = {
sasLevelName: sasLevelName,
scandicLevelName: scandicLevelName,
sasMark: (text: ReactNode) => (
<Typography variant="Body/Paragraph/mdBold">
<span className={styles.sasMark}>{text}</span>
</Typography>
),
scandicMark: (text: ReactNode) => (
<Typography variant="Body/Paragraph/mdBold">
<span className={styles.scandicMark}>{text}</span>
</Typography>
),
}
const messageMap: Record<MatchState, ReactNode> = {
boostedBySAS: intl.formatMessage(
{
id: "<sasMark>SAS {sasLevelName}</sasMark> has upgraded your Scandic Friends level to <scandicMark>{scandicLevelName}</scandicMark>.",
},
messageValues
),
boostedByScandic: intl.formatMessage(
{
id: "<scandicMark>Scandic {scandicLevelName}</scandicMark> has upgraded you to <sasMark>{sasLevelName}</sasMark>.",
},
messageValues
),
noBoost: intl.formatMessage(
{
id: "<sasMark>SAS {sasLevelName}</sasMark> and <scandicMark>{scandicLevelName}</scandicMark> are equally matched tiers. Level up one of your memberships for a chance of an upgrade!",
},
messageValues
),
}
const iconMap: Record<MatchState, ReactNode> = {
boostedBySAS: (
<DiamondIcon height={20} width={20} color="uiTextMediumContrast" />
),
boostedByScandic: (
<DiamondIcon height={20} width={20} color="uiTextMediumContrast" />
),
noBoost: <LinkIcon height={20} width={20} color="uiTextMediumContrast" />,
}
return (
<div className={styles.stack}>
<Label>{intl.formatMessage({ id: "Tier match status" })}</Label>
<div className={styles.tierMatchText}>
<div className={styles.iconWrapper}>{iconMap[matchState]}</div>
<Typography variant="Body/Paragraph/mdRegular">
<p>{messageMap[matchState]}</p>
</Typography>
</div>
</div>
)
}
async function TierMatchMessageSkeleton() {
const intl = await getIntl()
return (
<div className={styles.stack}>
<Label>{intl.formatMessage({ id: "Tier match status" })}</Label>
<div className={styles.tierMatchText}>
<SkeletonShimmer width="250px" height="24px" />
</div>
</div>
)
}
type TierMatchExpirationProps = {
matchState: MatchState
sasExpirationDate: string | undefined
scandicExpirationDate: string | undefined
}
async function TierMatchExpiration({
matchState,
sasExpirationDate,
scandicExpirationDate,
}: TierMatchExpirationProps) {
if (matchState === "noBoost") {
return null
}
const intl = await getIntl()
return (
<div className={styles.stack}>
<Label>{intl.formatMessage({ id: "Upgrade valid until" })}</Label>
<Typography variant="Body/Paragraph/mdBold">
<p>
{matchState === "boostedBySAS"
? scandicExpirationDate
: sasExpirationDate}
</p>
</Typography>
</div>
)
}
function Label({ children }: { children: ReactNode }) {
return (
<Typography variant="Tag/sm">
<p className={styles.label}>{children}</p>
</Typography>
)
}
type MatchState = "boostedBySAS" | "boostedByScandic" | "noBoost"
function calculateMatchState(memberships: Membership[]): MatchState {
const eurobonusMembership = getEurobonusMembership(memberships)
const friendsMembership = getFriendsMembership(memberships)
const nativeMembership = memberships.find(
(x) => x.membershipType === scandicMemberships.scandic_native_tiers
)
if (!eurobonusMembership || !friendsMembership || !nativeMembership) {
return "noBoost"
}
const nativeLevel = nativeMembership.membershipLevel
const friendsLevel = friendsMembership.membershipLevel
if (nativeLevel !== friendsLevel) {
return "boostedBySAS"
}
// TODO check if SAS have been boosted by Scandic when API is available
const isBoostedByScandic = false
if (isBoostedByScandic) {
return "boostedByScandic"
}
return "noBoost"
}

View File

@@ -1,9 +1,13 @@
.divider {
margin-top: var(--Spacing-x2);
.container {
display: flex;
flex-direction: column;
gap: var(--Spacing-x3);
}
@media screen and (min-width: 768px) {
.divider {
.divider {
margin-top: var(--Spacing-x2);
@media screen and (min-width: 768px) {
display: none;
}
}
@@ -21,11 +25,117 @@
.mutationSection {
display: flex;
flex-direction: column-reverse;
flex-direction: column;
justify-content: space-between;
gap: var(--Spacing-x2);
align-items: center;
align-items: flex-end;
@media screen and (min-width: 768px) {
align-items: center;
flex-direction: row;
}
}
.matchedAccountSection {
display: flex;
flex-direction: column;
gap: var(--Spacing-x3);
padding: var(--Spacing-x4) var(--Spacing-x-one-and-half);
width: 100%;
background-color: var(--Surface-Primary-On-Surface-Default);
border: 1px solid var(--Border-Default);
border-radius: var(--Corner-radius-Medium);
box-shadow:
0px 0px 4px 2px rgba(0, 0, 0, 0.1),
0px 4px 4px 0px rgba(255, 255, 255, 0.29) inset;
@media screen and (min-width: 768px) {
padding: var(--Spacing-x4) var(--Spacing-x3);
}
}
.accountDetails {
display: flex;
width: 100%;
justify-content: space-between;
gap: var(--Spacing-x1);
@media screen and (min-width: 768px) {
gap: var(--Spacing-x7);
}
}
.accountMemberNumber {
@media screen and (min-width: 768px) {
margin-left: auto;
}
}
.tierMatchStatus {
display: flex;
flex-direction: column;
gap: var(--Spacing-x2);
width: 100%;
justify-content: space-between;
border: 1px solid var(--Border-Divider-Accent);
border-radius: var(--Corner-radius-Small);
padding: var(--Spacing-x2);
background:
linear-gradient(
0deg,
var(--Surface-Brand-Primary-1-Default, --Scandic-Peach-10),
var(--Surface-Brand-Primary-1-Default, --Scandic-Peach-10)
),
linear-gradient(
180deg,
rgba(242, 236, 230, 0.05) 0%,
rgba(143, 67, 80, 0.05) 100%
);
@media screen and (min-width: 768px) {
flex-direction: row;
gap: var(--Spacing-x1);
}
}
.textRight {
text-align: right;
}
.stack {
display: flex;
flex-direction: column;
gap: var(--Spacing-x-half);
}
.caption {
display: flex;
align-items: center;
gap: var(--Spacing-x-half);
color: var(--Text-Tertiary);
align-self: flex-start;
}
.sasMark {
color: var(--Scandic-Blue-70);
}
.scandicMark {
color: var(--Scandic-Red-Default);
}
.tierMatchText {
display: flex;
align-items: center;
gap: var(--Spacing-x1);
}
.label {
color: var(--Text-Tertiary);
text-transform: uppercase;
}
.iconWrapper {
display: flex;
align-items: center;
}