Merged in monorepo-step-1 (pull request #1080)

Migrate to a monorepo setup - step 1

* Move web to subfolder /apps/scandic-web

* Yarn + transitive deps

- Move to yarn
- design-system package removed for now since yarn doesn't
support the parameter for token (ie project currently broken)
- Add missing transitive dependencies as Yarn otherwise
prevents these imports
- VS Code doesn't pick up TS path aliases unless you open
/apps/scandic-web instead of root (will be fixed with monorepo)

* Pin framer-motion to temporarily fix typing issue

https://github.com/adobe/react-spectrum/issues/7494

* Pin zod to avoid typ error

There seems to have been a breaking change in the types
returned by zod where error is now returned as undefined
instead of missing in the type. We should just handle this
but to avoid merge conflicts just pin the dependency for
now.

* Pin react-intl version

Pin version of react-intl to avoid tiny type issue where formatMessage
does not accept a generic any more. This will be fixed in a future
commit, but to avoid merge conflicts just pin for now.

* Pin typescript version

Temporarily pin version as newer versions as stricter and results in
a type error. Will be fixed in future commit after merge.

* Setup workspaces

* Add design-system as a monorepo package

* Remove unused env var DESIGN_SYSTEM_ACCESS_TOKEN

* Fix husky for monorepo setup

* Update netlify.toml

* Add lint script to root package.json

* Add stub readme

* Fix react-intl formatMessage types

* Test netlify.toml in root

* Remove root toml

* Update netlify.toml publish path

* Remove package-lock.json

* Update build for branch/preview builds


Approved-by: Linus Flood
This commit is contained in:
Anton Gunnarsson
2025-02-26 10:36:17 +00:00
committed by Linus Flood
parent 667cab6fb6
commit 80100e7631
2731 changed files with 30986 additions and 23708 deletions
@@ -0,0 +1,9 @@
.container {
align-items: center;
background-color: var(--UI-Grey-10);
border-radius: var(--Corner-radius-xLarge);
display: flex;
height: 370px;
justify-content: center;
width: 100%;
}
@@ -0,0 +1,22 @@
import Title from "@/components/TempDesignSystem/Text/Title"
import { getIntl } from "@/i18n"
import SectionWrapper from "../SectionWrapper"
import styles from "./howItWorks.module.css"
import type { HowItWorksProps } from "@/types/components/blocks/dynamicContent"
export default async function HowItWorks({
dynamic_content,
firstItem,
}: HowItWorksProps) {
const intl = await getIntl()
return (
<SectionWrapper dynamic_content={dynamic_content} firstItem={firstItem}>
<section className={styles.container}>
<Title level="h3">{intl.formatMessage({ id: "How it works" })}</Title>
</section>
</SectionWrapper>
)
}
@@ -0,0 +1,98 @@
import { serverClient } from "@/lib/trpc/server"
import { CheckIcon } from "@/components/Icons"
import MembershipLevelIcon from "@/components/Levels/Icon"
import BiroScript from "@/components/TempDesignSystem/Text/BiroScript"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Title from "@/components/TempDesignSystem/Text/Title"
import { getIntl } from "@/i18n"
import SectionWrapper from "../SectionWrapper"
import styles from "./loyaltyLevels.module.css"
import type { FormatXMLElementFn } from "intl-messageformat"
import type { LoyaltyLevelsProps } from "@/types/components/blocks/dynamicContent"
import type { LevelCardProps } from "@/types/components/overviewTable"
export default async function LoyaltyLevels({
dynamic_content,
firstItem,
}: LoyaltyLevelsProps) {
const uniqueLevels = await serverClient().contentstack.rewards.all({
unique: true,
})
return (
<SectionWrapper dynamic_content={dynamic_content} firstItem={firstItem}>
<section className={styles.cardContainer}>
{uniqueLevels.map((level) => (
<LevelCard key={level.level_id} level={level} />
))}
</section>
</SectionWrapper>
)
}
async function LevelCard({ level }: LevelCardProps) {
const intl = await getIntl()
let pointsMsg: React.ReactNode = intl.formatMessage(
{ id: "{pointsAmount, number} points" },
{ pointsAmount: level.required_points }
)
if (level.required_nights) {
pointsMsg = intl.formatMessage<
React.ReactNode,
FormatXMLElementFn<React.ReactNode>
>(
{
id: "{pointsAmount, number} points <highlight>or {nightsAmount, number} nights</highlight>",
},
{
pointsAmount: level.required_points,
nightsAmount: level.required_nights,
highlight: (str) => <span className={styles.redText}>{str}</span>,
}
)
}
return (
<article className={styles.card}>
<header>
<BiroScript
type="two"
color="primaryLightOnSurfaceAccent"
tilted="large"
>
{intl.formatMessage(
{ id: "Level {level}" },
{ level: level.user_facing_tag }
)}
</BiroScript>
<MembershipLevelIcon level={level.level_id} color="red" />
</header>
<Title textAlign="center" level="h5">
{pointsMsg}
</Title>
<div className={styles.textContainer}>
{level.rewards.map((reward) => (
<Caption
className={styles.levelText}
key={reward.reward_id}
textAlign="center"
color="textMediumContrast"
>
<CheckIcon
className={styles.checkIcon}
color="primaryLightOnSurfaceAccent"
/>
{reward.label}
</Caption>
))}
</div>
</article>
)
}
@@ -0,0 +1,55 @@
.cardContainer {
display: grid;
gap: var(--Spacing-x2);
}
.link {
justify-self: center;
}
.card {
background-color: var(--Scandic-Brand-Pale-Peach);
border-radius: var(--Corner-radius-xLarge);
display: grid;
gap: var(--Spacing-x2);
min-height: 280px;
justify-items: center;
padding: var(--Spacing-x5) var(--Spacing-x1);
grid-template-rows: auto auto 1fr;
}
.textContainer {
align-content: flex-start;
display: flex;
gap: var(--Spacing-x-one-and-half);
width: 100%;
flex-wrap: wrap;
justify-content: center;
}
.redText {
color: var(--Base-Text-Accent);
}
.levelText {
margin: 0;
}
.checkIcon {
vertical-align: middle;
}
@media screen and (min-width: 1367px) {
.cardContainer {
display: grid;
grid-template-columns: repeat(12, 1fr);
}
.card:nth-of-type(-n + 3) {
grid-column: span 4;
}
.card:nth-of-type(n + 4) {
grid-column: span 3;
}
}
@@ -0,0 +1,35 @@
"use client"
import { useIntl } from "react-intl"
import CopyIcon from "@/components/Icons/Copy"
import Button from "@/components/TempDesignSystem/Button"
import { toast } from "@/components/TempDesignSystem/Toasts"
import styles from "./copybutton.module.css"
import type { CopyButtonProps } from "@/types/components/myPages/membership"
export default function CopyButton({ membershipNumber }: CopyButtonProps) {
const intl = useIntl()
function handleCopy() {
navigator.clipboard.writeText(membershipNumber)
toast.success(
intl.formatMessage({ id: "Membership ID copied to clipboard" })
)
}
return (
<Button
onClick={handleCopy}
className={styles.button}
type="button"
variant="icon"
size="small"
intent="tertiary"
>
<CopyIcon color="pale" />
</Button>
)
}
@@ -0,0 +1,5 @@
.button {
display: flex;
justify-content: center;
align-items: center;
}
@@ -0,0 +1,21 @@
.hero {
border-radius: var(--Corner-radius-xLarge);
display: grid;
gap: var(--Spacing-x2);
grid-template-columns: 1fr;
padding: var(--Spacing-x7) var(--Spacing-x6);
}
.burgundy {
background-color: var(--Scandic-Brand-Burgundy);
}
.red {
background-color: var(--Scandic-Brand-Scandic-Red);
}
@media screen and (min-width: 768px) {
.hero {
grid-template-columns: 1fr 1fr;
}
}
@@ -0,0 +1,7 @@
import type { VariantProps } from "class-variance-authority"
import type { heroVariants } from "./heroVariants"
export interface HeroProps
extends Omit<React.HTMLAttributes<HTMLDivElement>, "color">,
VariantProps<typeof heroVariants> {}
@@ -0,0 +1,15 @@
import { cva } from "class-variance-authority"
import styles from "./hero.module.css"
export const heroVariants = cva(styles.hero, {
variants: {
color: {
burgundy: styles.burgundy,
red: styles.red,
},
},
defaultVariants: {
color: "red",
},
})
@@ -0,0 +1,8 @@
import { heroVariants } from "./heroVariants"
import type { HeroProps } from "./hero"
export default function Hero({ className, color, children }: HeroProps) {
const classNames = heroVariants({ className, color })
return <section className={classNames}>{children}</section>
}
@@ -0,0 +1,36 @@
import Caption from "@/components/TempDesignSystem/Text/Caption"
import { getIntl } from "@/i18n"
import CopyButton from "../../Buttons/CopyButton"
import { membershipNumberVariants } from "./membershipNumberVariants"
import styles from "./membershipNumber.module.css"
import type { MembershipNumberProps } from "@/types/components/myPages/membershipNumber"
export default async function MembershipNumber({
className,
color,
membership,
}: MembershipNumberProps) {
const intl = await getIntl()
const classNames = membershipNumberVariants({ className, color })
return (
<div className={classNames}>
<Caption color="pale">
{intl.formatMessage({ id: "Membership ID: " })}
</Caption>
<span className={styles.icon}>
<Caption className={styles.icon} color="pale" asChild>
<code data-hj-suppress>
{membership?.membershipNumber ?? intl.formatMessage({ id: "N/A" })}
</code>
</Caption>
{membership?.membershipNumber && (
<CopyButton membershipNumber={membership.membershipNumber} />
)}
</span>
</div>
)
}
@@ -0,0 +1,37 @@
.membershipContainer {
align-items: center;
background: var(--Scandic-Brand-Burgundy);
border-radius: var(--Corner-radius-Small);
display: grid;
grid-template-columns: 1fr;
justify-items: center;
padding: var(--Spacing-x1) var(--Spacing-x2) 0;
}
.icon {
display: flex;
align-items: center;
flex-direction: row;
justify-content: center;
padding-left: var(--Spacing-x2);
}
.burgundy {
background-color: var(--Main-Brand-Burgundy);
}
.red {
background-color: var(--Scandic-Brand-Scandic-Red);
}
@media screen and (min-width: 768px) {
.membershipContainer {
grid-template-columns: auto auto;
padding: 0 0 0 var(--Spacing-x2);
gap: var(--Spacing-x-half);
}
.icon {
padding-left: 0;
}
}
@@ -0,0 +1,15 @@
import { cva } from "class-variance-authority"
import styles from "./membershipNumber.module.css"
export const membershipNumberVariants = cva(styles.membershipContainer, {
variants: {
color: {
burgundy: styles.burgundy,
red: styles.red,
},
},
defaultVariants: {
color: "burgundy",
},
})
@@ -0,0 +1,41 @@
.friend {
align-items: center;
display: flex;
flex-direction: column;
gap: var(--Spacing-x4);
justify-content: center;
}
.header {
display: flex;
flex-direction: column;
gap: var(--Spacing-x2);
}
.levelLabel {
position: relative;
transform: rotate(-13deg) translate(0px, -15px);
margin-left: -35px;
}
.friend .name {
text-align: center;
}
.membership {
align-items: center;
display: flex;
flex-direction: column;
gap: var(--Spacing-x2);
justify-content: center;
}
.membershipContainer {
align-items: center;
background: var(--Scandic-Brand-Burgundy);
border-radius: var(--Corner-radius-Small);
display: grid;
grid-template-columns: 1fr;
justify-items: center;
padding: var(--Spacing-x1) var(--Spacing-x7) 0 var(--Spacing-x7);
}
@@ -0,0 +1,54 @@
import {
MembershipLevelEnum,
membershipLevels,
} from "@/constants/membershipLevels"
import MembershipLevelIcon from "@/components/Levels/Icon"
import Body from "@/components/TempDesignSystem/Text/Body"
import Title from "@/components/TempDesignSystem/Text/Title"
import { getIntl } from "@/i18n"
import { isHighestMembership } from "@/utils/user"
import styles from "./friend.module.css"
import type { FriendProps } from "@/types/components/myPages/friend"
export default async function Friend({
children,
membership,
name,
}: FriendProps) {
const intl = await getIntl()
if (!membership?.membershipLevel) {
return null
}
const isHighestLevel = isHighestMembership(membership.membershipLevel)
const lvlMessageHighest = intl.formatMessage({ id: "Highest level" })
const lvlMessageLevel = intl.formatMessage(
{ id: "Level {level}" },
{ level: membershipLevels[membership.membershipLevel] }
)
return (
<section className={styles.friend}>
<header className={styles.header}>
<Body color="white" textTransform="bold" textAlign="center">
{isHighestLevel ? lvlMessageHighest : lvlMessageLevel}
</Body>
<MembershipLevelIcon
level={MembershipLevelEnum[membership.membershipLevel]}
height="110"
width="220"
/>
</header>
<div className={styles.membership}>
<Title data-hj-suppress className={styles.name} color="pale" level="h3">
{name}
</Title>
{children}
</div>
</section>
)
}
@@ -0,0 +1,36 @@
import { Lang } from "@/constants/languages"
import { dt } from "@/lib/dt"
import Body from "@/components/TempDesignSystem/Text/Body"
import { getIntl } from "@/i18n"
import { getLang } from "@/i18n/serverContext"
import { getMembership } from "@/utils/user"
import type { UserProps } from "@/types/components/myPages/user"
export default async function ExpiringPoints({ user }: UserProps) {
const intl = await getIntl()
const membership = getMembership(user.memberships)
if (!membership || !membership.pointsToExpire) {
// TODO: handle this case?
return null
}
const d = dt(membership.pointsExpiryDate)
const dateFormat = getLang() == Lang.fi ? "DD.MM.YYYY" : "YYYY-MM-DD"
return (
<section>
<Body color="white" textTransform="bold" textAlign="center">
{intl.formatMessage(
{ id: "{points} spendable points expiring by {date}" },
{
points: intl.formatNumber(membership.pointsToExpire),
date: d.format(dateFormat),
}
)}
</Body>
</section>
)
}
@@ -0,0 +1,13 @@
.points {
display: grid;
gap: var(--Spacing-x5);
text-wrap: balance;
}
@media screen and (min-width: 768px) {
.points {
grid-auto-flow: column;
row-gap: 0;
column-gap: var(--Spacing-x2);
}
}
@@ -0,0 +1,5 @@
import styles from "./container.module.css"
export default function PointsContainer({ children }: React.PropsWithChildren) {
return <section className={styles.points}>{children}</section>
}
@@ -0,0 +1,41 @@
import Body from "@/components/TempDesignSystem/Text/Body"
import Title from "@/components/TempDesignSystem/Text/Title"
import { getIntl } from "@/i18n"
import styles from "./pointsColumn.module.css"
import type { PointsColumnProps } from "@/types/components/myPages/points"
export async function PointsColumn({
title,
subtitle,
value,
}: PointsColumnProps) {
const intl = await getIntl()
let number = "N/A"
if (typeof value === "number") {
number = intl.formatNumber(value)
}
return (
<article className={styles.article}>
<Body
color="white"
textTransform="bold"
textAlign="center"
className={styles.firstRow}
>
{title}
</Body>
<Title color="white" level="h2" textAlign="center">
{number}
</Title>
{subtitle ? (
<Body color="white" textAlign="center">
{subtitle}
</Body>
) : null}
</article>
)
}
@@ -0,0 +1,9 @@
@media screen and (min-width: 768px) {
.firstRow {
align-content: flex-end;
}
.article {
display: grid;
}
}
@@ -0,0 +1,53 @@
import { MembershipLevelEnum } from "@/constants/membershipLevels"
import { serverClient } from "@/lib/trpc/server"
import { getIntl } from "@/i18n"
import { getMembership } from "@/utils/user"
import PointsContainer from "./Container"
import { PointsColumn } from "./PointsColumn"
import type { UserProps } from "@/types/components/myPages/user"
export default async function Points({ user }: UserProps) {
const intl = await getIntl()
const membership = getMembership(user.memberships)
const nextLevel =
membership?.nextLevel && MembershipLevelEnum[membership.nextLevel]
? await serverClient().contentstack.loyaltyLevels.byLevel({
level: MembershipLevelEnum[membership.nextLevel],
})
: null
return (
<PointsContainer>
<PointsColumn
value={membership?.currentPoints}
title={intl.formatMessage({ id: "Your points to spend" })}
subtitle={intl.formatMessage({ id: "as of today" })}
/>
{nextLevel && (
<PointsColumn
value={membership?.pointsRequiredToNextlevel}
title={intl.formatMessage({ id: "Points needed to level up" })}
subtitle={intl.formatMessage(
{ id: "next level: {nextLevel}" },
{ nextLevel: nextLevel.name }
)}
/>
)}
{/* TODO: Show NextLevelNightsColumn when nightsToTopTier data is correct from Antavo */}
{/* {membership?.nightsToTopTier && (
<NextLevelNightsColumn
nights={membership.nightsToTopTier}
subtitle={
membership.tierExpirationDate &&
`by ${membership.tierExpirationDate}`
}
/>
)} */}
</PointsContainer>
)
}
@@ -0,0 +1,18 @@
import Divider from "@/components/TempDesignSystem/Divider"
import ExpiringPoints from "./ExpiringPoints"
import Points from "./Points"
import styles from "./stats.module.css"
import type { UserProps } from "@/types/components/myPages/user"
export default function Stats({ user }: UserProps) {
return (
<section className={styles.stats}>
<Points user={user} />
<Divider color="pale" />
<ExpiringPoints user={user} />
</section>
)
}
@@ -0,0 +1,11 @@
.stats {
align-content: center;
display: grid;
gap: var(--Spacing-x2);
}
@media screen and (min-width: 1367px) {
.stats {
gap: var(--Spacing-x4);
}
}
@@ -0,0 +1,46 @@
import { getProfile } from "@/lib/trpc/memoizedRequests"
import SectionContainer from "@/components/Section/Container"
import SectionHeader from "@/components/Section/Header"
import SectionLink from "@/components/Section/Link"
import Divider from "@/components/TempDesignSystem/Divider"
import Hero from "./Friend/Hero"
import MembershipNumber from "./Friend/MembershipNumber"
import Friend from "./Friend"
import Stats from "./Stats"
import styles from "./overview.module.css"
import type { AccountPageComponentProps } from "@/types/components/myPages/myPage/accountPage"
export default async function Overview({
link,
subtitle,
title,
}: AccountPageComponentProps) {
const user = await getProfile()
if (!user || "error" in user) {
return null
}
return (
<SectionContainer>
<SectionHeader
link={link}
preamble={subtitle}
title={title}
headingAs={"h3"}
headingLevel={"h1"}
/>
<Hero color="red">
<Friend membership={user.membership} name={user.name}>
<MembershipNumber color="burgundy" membership={user.membership} />
</Friend>
<Divider className={styles.divider} color="peach" />
<Stats user={user} />
</Hero>
<SectionLink link={link} variant="mobile" />
</SectionContainer>
)
}
@@ -0,0 +1,22 @@
.divider {
margin-top: var(--Spacing-x2);
}
@media screen and (max-width: 767px) {
.container {
/* Full-width override styling */
left: 50%;
margin-left: -50vw;
margin-right: -50vw;
padding: 0 var(--Spacing-x2);
position: relative;
right: 50%;
width: 100dvw;
}
}
@media screen and (min-width: 768px) {
.divider {
display: none;
}
}
@@ -0,0 +1,176 @@
"use client"
import { useReducer } from "react"
import { useIntl } from "react-intl"
import {
type MembershipLevel,
membershipLevels,
} from "@/constants/membershipLevels"
import MembershipLevelIcon from "@/components/Levels/Icon"
import Select from "@/components/TempDesignSystem/Select"
import LargeTable from "./LargeTable"
import LevelSummary from "./LevelSummary"
import { getInitialState, getLevel, reducer } from "./reducer"
import RewardList from "./RewardList"
import YourLevel from "./YourLevelScript"
import styles from "./overviewTable.module.css"
import type { Key } from "react-aria-components"
import {
type ComparisonLevel,
type DesktopSelectColumns,
type MobileColumnHeaderProps,
OverviewTableActionsEnum,
type OverviewTableClientProps,
} from "@/types/components/overviewTable"
function getLevelNamesForSelect(level: MembershipLevel, levelName: string) {
const levelToNumber = membershipLevels[level]
return [levelToNumber, levelName].join(" ")
}
export default function OverviewTableClient({
activeMembership,
levels,
}: OverviewTableClientProps) {
const intl = useIntl()
const [selectionState, dispatch] = useReducer(
reducer,
{ activeMembership, levels },
getInitialState
)
function handleSelectChange(actionType: OverviewTableActionsEnum) {
return (key: Key) => {
dispatch({
payload: getLevel(key as MembershipLevel, levels),
type: actionType,
})
}
}
const levelOptions = levels.map((level) => ({
label: getLevelNamesForSelect(level.level_id, level.name),
value: level.level_id,
}))
const activeMembershipLevel = activeMembership ?? null
function MobileColumnHeader({ column }: MobileColumnHeaderProps) {
let selectedLevelMobile: ComparisonLevel
let actionEnumMobile: OverviewTableActionsEnum
switch (column) {
case "A":
selectedLevelMobile = selectionState.selectedLevelAMobile
actionEnumMobile = OverviewTableActionsEnum.SET_SELECTED_LEVEL_A_MOBILE
break
case "B":
selectedLevelMobile = selectionState.selectedLevelBMobile
actionEnumMobile = OverviewTableActionsEnum.SET_SELECTED_LEVEL_B_MOBILE
break
default:
return null
}
return (
<div className={styles.columnHeader}>
<div className={styles.icon}>
{activeMembershipLevel === selectedLevelMobile.level_id ? (
<YourLevel />
) : null}
<MembershipLevelIcon
level={selectedLevelMobile.level_id}
color="red"
height="50"
width="100"
/>
</div>
<LevelSummary
level={
levels.find(
(level) => level.level_id === selectedLevelMobile.level_id
)!
}
showDescription={false}
/>
<Select
aria-label={intl.formatMessage({ id: "Level" })}
name={`reward` + column}
label={intl.formatMessage({ id: "Level" })}
items={levelOptions}
value={selectedLevelMobile.level_id}
onSelect={handleSelectChange(actionEnumMobile)}
/>
</div>
)
}
function SelectDesktop({ column }: DesktopSelectColumns) {
let selectedLevelDesktop: ComparisonLevel
let actionEnumDesktop: OverviewTableActionsEnum
switch (column) {
case "A":
selectedLevelDesktop = selectionState.selectedLevelADesktop
actionEnumDesktop =
OverviewTableActionsEnum.SET_SELECTED_LEVEL_A_DESKTOP
break
case "B":
selectedLevelDesktop = selectionState.selectedLevelBDesktop
actionEnumDesktop =
OverviewTableActionsEnum.SET_SELECTED_LEVEL_B_DESKTOP
break
case "C":
selectedLevelDesktop = selectionState.selectedLevelCDesktop
actionEnumDesktop =
OverviewTableActionsEnum.SET_SELECTED_LEVEL_C_DESKTOP
break
default:
return null
}
return (
<Select
aria-label={intl.formatMessage({ id: "Level" })}
name={`reward` + column}
label={intl.formatMessage({ id: "Level" })}
items={levelOptions}
value={selectedLevelDesktop.level_id}
onSelect={handleSelectChange(actionEnumDesktop)}
/>
)
}
return (
<div>
<div className={styles.mobileColumns}>
<div className={styles.columnHeaderContainer}>
<MobileColumnHeader column={"A"} />
<MobileColumnHeader column={"B"} />
</div>
<RewardList
levels={[
selectionState.selectedLevelAMobile,
selectionState.selectedLevelBMobile,
]}
/>
</div>
<div className={styles.columns}>
<LargeTable
levels={[
selectionState.selectedLevelADesktop,
selectionState.selectedLevelBDesktop,
selectionState.selectedLevelCDesktop,
]}
activeLevel={activeMembershipLevel}
Select={SelectDesktop}
/>
</div>
<div className={styles.largeTableContainer}>
<LargeTable levels={levels} activeLevel={activeMembershipLevel} />
</div>
</div>
)
}
@@ -0,0 +1,33 @@
.header {
background-color: inherit;
}
.iconRow {
border-bottom: none;
position: sticky;
top: 0;
z-index: 1;
background-color: inherit;
}
.verticalTableHeader {
min-width: 242px;
}
.iconTh {
padding: var(--Spacing-x5) var(--Spacing-x2) var(--Spacing-x2);
font-weight: var(--typography-Caption-Regular-fontWeight);
vertical-align: bottom;
}
.summaryTh {
font-size: var(--typography-Caption-Regular-fontSize);
font-weight: var(--typography-Caption-Regular-fontWeight);
padding: 0 var(--Spacing-x2) var(--Spacing-x2);
vertical-align: top;
}
.select {
font-weight: var(--typography-Caption-Regular-fontWeight);
padding: 0 var(--Spacing-x2) var(--Spacing-x2);
}
@@ -0,0 +1,63 @@
import MembershipLevelIcon from "@/components/Levels/Icon"
import LevelSummary from "../../LevelSummary"
import YourLevel from "../../YourLevelScript"
import styles from "./desktopHeader.module.css"
import type {
DesktopSelectColumns,
LargeTableProps,
} from "@/types/components/overviewTable"
export default function DesktopHeader({
levels,
activeLevel,
Select,
}: LargeTableProps) {
return (
<thead className={styles.header}>
<tr className={styles.iconRow}>
<th className={styles.verticalTableHeader} />
{levels.map((level, idx) => {
return (
<th key={"image" + level.level_id + idx} className={styles.iconTh}>
{activeLevel === level.level_id ? <YourLevel /> : null}
<MembershipLevelIcon
color="red"
level={level.level_id}
height="50"
width="100"
/>
</th>
)
})}
</tr>
<tr>
<th />
{levels.map((level, idx) => {
return (
<th
key={"summary" + level.level_id + idx}
className={styles.summaryTh}
>
<LevelSummary level={level} />
</th>
)
})}
</tr>
{Select && (
<tr>
<th />
{["A", "B", "C"].map((column, idx) => {
return (
<th key={column + idx} className={styles.select}>
<Select column={column as DesktopSelectColumns["column"]} />
</th>
)
})}
</tr>
)}
</thead>
)
}
@@ -0,0 +1,87 @@
import { ChevronDown } from "react-feather"
import Title from "@/components/TempDesignSystem/Text/Title"
import {
findAvailableRewards,
getGroupedLabelAndDescription,
getGroupedRewards,
} from "@/utils/loyaltyTable"
import RewardValue from "../RewardValue"
import DesktopHeader from "./DesktopHeader"
import styles from "./largeTable.module.css"
import type {
LargeTableProps,
RewardTableHeaderProps,
} from "@/types/components/overviewTable"
export default function LargeTable({
levels,
activeLevel,
Select,
}: LargeTableProps) {
const keyedGroupedRewards = getGroupedRewards(levels)
return (
<table className={styles.table}>
<DesktopHeader
levels={levels}
activeLevel={activeLevel}
Select={Select}
/>
<tbody className={styles.tbody}>
{Object.entries(keyedGroupedRewards).map(
([key, groupedRewards], idx) => {
const { label, description } =
getGroupedLabelAndDescription(groupedRewards)
return (
<tr key={key + idx} className={styles.tr}>
<th scope={"row"} className={styles.rewardTh}>
<RewardTableHeader name={label} description={description} />
</th>
{levels.map((level, idx) => {
const rewardIdsInGroup = groupedRewards.map(
(b) => b.reward_id
)
const reward = findAvailableRewards(rewardIdsInGroup, level)
return (
<td
key={`${reward?.reward_id}-${idx}`}
className={styles.td}
>
<RewardValue reward={reward} />
</td>
)
})}
</tr>
)
}
)}
</tbody>
</table>
)
}
function RewardTableHeader({ name, description }: RewardTableHeaderProps) {
return (
<details className={styles.details}>
<summary className={styles.summary}>
<hgroup className={styles.rewardHeader}>
<Title as="h4" level="h2" textTransform={"regular"}>
{name}
</Title>
<span className={styles.chevron}>
<ChevronDown />
</span>
</hgroup>
</summary>
<p
className={styles.rewardDescription}
dangerouslySetInnerHTML={{ __html: description }}
/>
</details>
)
}
@@ -0,0 +1,58 @@
.table {
border: none;
border-collapse: collapse;
background-color: var(--UI-Opacity-White-100);
border-radius: var(--Corner-radius-Medium);
color: var(--UI-Grey-100);
}
.tr {
border-bottom: 1px solid var(--Base-Border-Subtle);
}
.tr:last-child {
border: none;
}
.td {
font-size: var(--typography-Footnote-Regular-fontSize);
text-align: center;
}
.rewardTh {
padding: var(--Spacing-x3) var(--Spacing-x2);
font-size: var(--typography-Caption-Regular-fontSize);
font-weight: var(--typography-Caption-Regular-fontWeight);
}
.details[open] .chevron {
transform: rotate(180deg);
}
.rewardHeader {
display: grid;
gap: var(--Spacing-x1);
grid-template-columns: 1fr auto;
text-align: start;
}
.rewardDescription {
margin: 0;
padding-top: var(--Spacing-x1);
text-align: start;
padding-right: calc(var(--Spacing-x3) + var(--Spacing-x1));
}
.chevron {
display: flex;
align-self: start;
color: var(--UI-Grey-80);
}
.summary::-webkit-details-marker {
display: none;
}
.summary {
list-style: none;
}
@@ -0,0 +1,37 @@
import { useIntl } from "react-intl"
import styles from "./levelSummary.module.css"
import type { LevelSummaryProps } from "@/types/components/overviewTable"
export default function LevelSummary({
level,
showDescription = true,
}: LevelSummaryProps) {
const intl = useIntl()
const pointsMsg: React.ReactNode = level.required_nights
? intl.formatMessage(
{
id: "{pointsAmount, number} points or {nightsAmount, number} nights",
},
{
pointsAmount: level.required_points,
nightsAmount: level.required_nights,
highlight: (str) => <span className={styles.redText}>{str}</span>,
}
)
: intl.formatMessage(
{ id: "{pointsAmount, number} points" },
{ pointsAmount: level.required_points }
)
return (
<div className={styles.levelSummary}>
<span className={styles.levelRequirements}>{pointsMsg}</span>
{showDescription && (
<p className={styles.levelSummaryText}>{level.description}</p>
)}
</div>
)
}
@@ -0,0 +1,37 @@
.levelSummary {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--Spacing-x3);
padding-bottom: var(--Spacing-x1);
}
.levelRequirements {
border-radius: var(--Corner-radius-Medium);
background-color: var(--Scandic-Brand-Pale-Peach);
color: var(--Scandic-Peach-80);
padding: var(--Spacing-x-half) var(--Spacing-x1);
text-align: center;
width: 100%;
}
.levelSummaryText {
font-size: var(--typography-Caption-Regular-fontSize);
line-height: var(--typography-Body-Regular-lineHeight);
margin: 0;
}
@media screen and (min-width: 950px) {
.levelRequirements {
padding: var(--Spacing-x-half) var(--Spacing-x1);
}
}
@media screen and (min-width: 1367px) {
.levelRequirements {
font-size: var(--typography-Footnote-Regular-fontSize);
}
.levelSummaryText {
font-size: var(--typography-Caption-Regular-fontSize);
}
}
@@ -0,0 +1,48 @@
import { ChevronDown } from "react-feather"
import Title from "@/components/TempDesignSystem/Text/Title"
import RewardValue from "../../RewardValue"
import styles from "./rewardCard.module.css"
import type { RewardCardProps } from "@/types/components/overviewTable"
export default function RewardCard({
comparedValues,
title,
description,
}: RewardCardProps) {
return (
<div className={styles.rewardCard}>
<div className={styles.rewardInfo}>
<details className={styles.details}>
<summary className={styles.summary}>
<hgroup className={styles.rewardCardHeader}>
<Title as="h4" level="h2" textTransform={"regular"}>
{title}
</Title>
<span className={styles.chevron}>
<ChevronDown />
</span>
</hgroup>
</summary>
<p
className={styles.rewardCardDescription}
dangerouslySetInnerHTML={{ __html: description }}
/>
</details>
</div>
<div className={styles.rewardComparison}>
{comparedValues.map((reward, idx) => (
<div
key={`${reward?.reward_id}-${idx}`}
className={styles.comparisonItem}
>
<RewardValue reward={reward} />
</div>
))}
</div>
</div>
)
}
@@ -0,0 +1,55 @@
.rewardCard {
padding-bottom: var(--Spacing-x-one-and-half);
grid-column: 1/3;
}
.rewardCardHeader {
display: grid;
grid-template-columns: 1fr auto;
}
.rewardCardDescription {
font-size: var(--typography-Caption-Regular-fontSize);
line-height: 150%;
padding-right: var(--Spacing-x4);
}
.rewardInfo {
padding-bottom: var(--Spacing-x-one-and-half);
}
.rewardComparison {
display: grid;
grid-template-columns: 1fr 1fr;
}
.comparisonItem {
display: flex;
justify-content: center;
align-items: center;
padding-top: var(--Spacing-x-one-and-half);
}
.details[open] .chevron {
transform: rotate(180deg);
}
.chevron {
display: flex;
align-items: center;
color: var(--UI-Grey-80);
}
.summary::-webkit-details-marker {
display: none;
}
.summary {
list-style: none;
}
@media screen and (min-width: 950px) {
.rewardComparison {
grid-template-columns: 1fr 1fr 1fr;
}
}
@@ -0,0 +1,38 @@
import {
findAvailableRewards,
getGroupedLabelAndDescription,
getGroupedRewards,
} from "@/utils/loyaltyTable"
import RewardCard from "./Card"
import styles from "./rewardList.module.css"
import type { RewardListProps } from "@/types/components/overviewTable"
export default function RewardList({ levels }: RewardListProps) {
const keyedGroupedRewards = getGroupedRewards(levels)
return Object.values(keyedGroupedRewards).map((groupedRewards) => {
const rewardIdsInGroup = groupedRewards.map((b) => b.reward_id)
const { label, description } = getGroupedLabelAndDescription(groupedRewards)
const levelRewards = levels.map((level) => {
return findAvailableRewards(rewardIdsInGroup, level)
})
return (
<div
key={levelRewards[0]?.reward_id ?? ""}
className={styles.rewardCardWrapper}
>
<RewardCard
title={label}
description={description}
comparedValues={levelRewards}
/>
</div>
)
})
}
@@ -0,0 +1,19 @@
.rewardCardWrapper {
border-bottom: 1px solid var(--Base-Border-Subtle);
position: relative;
display: grid;
grid-template-columns: 1fr 1fr;
grid-column: 1/3;
padding-top: 0;
margin: var(--Spacing-x1) var(--Spacing-x2);
}
.rewardCardWrapper:last-child {
border: none;
}
@media screen and (min-width: 950px) {
.rewardCardWrapper {
grid-column: 1/4;
}
}
@@ -0,0 +1,21 @@
import { Minus } from "react-feather"
import CheckCircle from "@/components/Icons/CheckCircle"
import styles from "./rewardValue.module.css"
import type { RewardValueProps } from "@/types/components/overviewTable"
export default function RewardValue({ reward }: RewardValueProps) {
if (!reward) {
return <Minus color="var(--UI-Grey-40)" />
}
if (!reward.value) {
return <CheckCircle height={32} width={32} color="green" />
}
return (
<div className={styles.rewardValueContainer}>
<span className={styles.rewardValue}>{reward.value}</span>
</div>
)
}
@@ -0,0 +1,19 @@
.rewardValueContainer {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--Spacing-x-half);
padding: 0 var(--Spacing-x4) 0 var(--Spacing-x4);
text-wrap: balance;
}
.rewardValue {
font-size: var(--typography-Body-Bold-fontSize);
font-weight: var(--typography-Body-Bold-fontWeight);
}
.rewardValueDetails {
font-size: var(--typography-Footnote-Regular-fontSize);
text-align: center;
color: var(--UI-Grey-80);
}
@@ -0,0 +1,19 @@
import { useIntl } from "react-intl"
import BiroScript from "@/components/TempDesignSystem/Text/BiroScript"
import styles from "./yourLevel.module.css"
export default function YourLevel() {
const intl = useIntl()
return (
<BiroScript
className={styles.script}
color="peach80"
type="two"
textAlign={"center"}
>
{intl.formatMessage({ id: "Your level" })}
</BiroScript>
)
}
@@ -0,0 +1,10 @@
.script {
transform: rotate(-4deg);
padding-bottom: var(--Spacing-x-half);
}
@media screen and (min-width: 950px) {
.script {
padding-bottom: var(--Spacing-x1);
}
}
@@ -0,0 +1,26 @@
import { getMembershipLevelSafely } from "@/lib/trpc/memoizedRequests"
import { serverClient } from "@/lib/trpc/server"
import SectionWrapper from "../SectionWrapper"
import OverviewTableClient from "./Client"
import type { OverviewTableProps } from "@/types/components/blocks/dynamicContent"
export default async function OverviewTable({
dynamic_content,
firstItem,
}: OverviewTableProps) {
const [levels, membershipLevel] = await Promise.all([
serverClient().contentstack.rewards.all(),
getMembershipLevelSafely(),
])
return (
<SectionWrapper dynamic_content={dynamic_content} firstItem={firstItem}>
<OverviewTableClient
levels={levels}
activeMembership={membershipLevel?.membershipLevel ?? null}
/>
</SectionWrapper>
)
}
@@ -0,0 +1,100 @@
.intro {
display: grid;
gap: var(--Spacing-x3);
}
.largeTableContainer {
display: none;
}
.columns {
display: none;
position: relative;
background-color: var(--UI-Opacity-White-100);
border-radius: var(--Corner-radius-Medium);
}
.mobileColumns {
background-color: var(--UI-Opacity-White-100);
display: grid;
grid-template-columns: 1fr 1fr;
margin: 0 calc(0px - var(--Spacing-x2)) calc(0px - var(--Spacing-x9))
calc(0px - var(--Spacing-x2));
padding-bottom: var(--Spacing-x9);
position: relative;
}
.columnHeaderContainer {
display: contents;
grid-template-columns: 1fr 1fr;
gap: var(--Spacing-x2);
}
.columnHeader {
display: flex;
flex-direction: column;
gap: var(--Spacing-x2);
padding: var(--Spacing-x4) var(--Spacing-x2);
justify-content: flex-end;
}
.icon {
align-self: center;
}
.columnHeader:nth-child(1) {
padding-right: var(--Spacing-x1);
}
.columnHeader:nth-child(2) {
padding-left: var(--Spacing-x1);
border-top-left-radius: var(--Corner-radius-Medium);
}
.columnHeader:nth-child(2):has(+ .columnHeader) {
padding-left: var(--Spacing-x1);
padding-right: var(--Spacing-x1);
}
.columnHeader:nth-child(3) {
padding-left: var(--Spacing-x1);
}
@media screen and (min-width: 768px) {
.mobileColumns {
padding-bottom: 0;
margin-bottom: 0;
}
}
@media screen and (min-width: 950px) {
.mobileColumns {
display: none;
}
.columnHeaderContainer {
grid-template-columns: 1fr 1fr 1fr;
}
.columnHeader:nth-child(2) {
border-top-right-radius: var(--Corner-radius-Medium);
}
.columns {
display: block;
}
}
@media screen and (min-width: 1367px) {
.columns {
display: none;
}
.intro {
margin: auto;
}
.largeTableContainer {
display: block;
margin: auto;
}
}
@@ -0,0 +1,95 @@
import {
type MembershipLevel,
MembershipLevelEnum,
} from "@/constants/membershipLevels"
import { getSteppedUpLevel } from "@/utils/user"
import {
type LevelWithRewards,
OverviewTableActionsEnum,
type OverviewTableClientProps,
type OverviewTableReducerAction,
} from "@/types/components/overviewTable"
export function getLevel(
membershipLevel: MembershipLevel,
levels: LevelWithRewards[]
) {
return levels.find((level) => level.level_id === membershipLevel)!
}
export function getInitialState({
activeMembership,
levels,
}: OverviewTableClientProps) {
if (!activeMembership) {
return {
selectedLevelAMobile: getLevel(MembershipLevelEnum.L1, levels),
selectedLevelBMobile: getLevel(MembershipLevelEnum.L2, levels),
selectedLevelADesktop: getLevel(MembershipLevelEnum.L1, levels),
selectedLevelBDesktop: getLevel(MembershipLevelEnum.L2, levels),
selectedLevelCDesktop: getLevel(MembershipLevelEnum.L3, levels),
}
}
const level = MembershipLevelEnum[activeMembership]
switch (level) {
case MembershipLevelEnum.L6:
return {
selectedLevelAMobile: getLevel(MembershipLevelEnum.L6, levels),
selectedLevelBMobile: getLevel(MembershipLevelEnum.L7, levels),
selectedLevelADesktop: getLevel(MembershipLevelEnum.L5, levels),
selectedLevelBDesktop: getLevel(MembershipLevelEnum.L6, levels),
selectedLevelCDesktop: getLevel(MembershipLevelEnum.L7, levels),
}
case MembershipLevelEnum.L7:
return {
selectedLevelAMobile: getLevel(MembershipLevelEnum.L6, levels),
selectedLevelBMobile: getLevel(MembershipLevelEnum.L7, levels),
selectedLevelADesktop: getLevel(MembershipLevelEnum.L6, levels),
selectedLevelBDesktop: getLevel(MembershipLevelEnum.L7, levels),
selectedLevelCDesktop: getLevel(MembershipLevelEnum.L1, levels),
}
default:
return {
selectedLevelAMobile: getLevel(level, levels),
selectedLevelBMobile: getLevel(getSteppedUpLevel(level, 1), levels),
selectedLevelADesktop: getLevel(level, levels),
selectedLevelBDesktop: getLevel(getSteppedUpLevel(level, 1), levels),
selectedLevelCDesktop: getLevel(getSteppedUpLevel(level, 2), levels),
}
}
}
export function reducer(state: any, action: OverviewTableReducerAction) {
switch (action.type) {
case OverviewTableActionsEnum.SET_SELECTED_LEVEL_A_MOBILE:
return {
...state,
selectedLevelAMobile: action.payload,
}
case OverviewTableActionsEnum.SET_SELECTED_LEVEL_B_MOBILE:
return {
...state,
selectedLevelBMobile: action.payload,
}
case OverviewTableActionsEnum.SET_SELECTED_LEVEL_A_DESKTOP:
return {
...state,
selectedLevelADesktop: action.payload,
}
case OverviewTableActionsEnum.SET_SELECTED_LEVEL_B_DESKTOP:
return {
...state,
selectedLevelBDesktop: action.payload,
}
case OverviewTableActionsEnum.SET_SELECTED_LEVEL_C_DESKTOP:
return {
...state,
selectedLevelCDesktop: action.payload,
}
default:
return state
}
}
@@ -0,0 +1,23 @@
.awardPoints {
color: var(--Base-Text-High-contrast);
}
.addition {
color: var(--Secondary-Light-On-Surface-Accent);
}
.addition::before {
color: var(--Secondary-Light-On-Surface-Accent);
content: "+";
margin-right: var(--Spacing-x-half);
}
.negation {
color: var(--Base-Text-Accent);
}
.negation::before {
color: var(--Base-Text-Accent);
content: "-";
margin-right: var(--Spacing-x-half);
}
@@ -0,0 +1,12 @@
import { cva } from "class-variance-authority"
import styles from "./awardPoints.module.css"
export const awardPointsVariants = cva(styles.awardPoints, {
variants: {
variant: {
addition: styles.addition,
negation: styles.negation,
},
},
})
@@ -0,0 +1,40 @@
import { useIntl } from "react-intl"
import Body from "@/components/TempDesignSystem/Text/Body"
import { awardPointsVariants } from "./awardPointsVariants"
import type { AwardPointsVariantProps } from "@/types/components/myPages/myPage/earnAndBurn"
export default function AwardPoints({
awardPoints,
isCalculated,
isExpiringPoints = false,
}: {
awardPoints: number
isCalculated: boolean
isExpiringPoints?: boolean
}) {
let variant: AwardPointsVariantProps["variant"] = null
const intl = useIntl()
if (isCalculated && !isExpiringPoints) {
if (awardPoints > 0) {
variant = "addition"
} else if (awardPoints < 0) {
variant = "negation"
awardPoints = Math.abs(awardPoints)
}
}
const classNames = awardPointsVariants({
variant,
})
return (
<Body textTransform="bold" className={classNames}>
{isCalculated
? intl.formatNumber(awardPoints)
: intl.formatMessage({ id: "Points being calculated" })}
</Body>
)
}
@@ -0,0 +1,42 @@
"use client"
import { keepPreviousData } from "@tanstack/react-query"
import { useState } from "react"
import { trpc } from "@/lib/trpc/client"
import LoadingSpinner from "@/components/LoadingSpinner"
import Pagination from "@/components/MyPages/Pagination"
import ClientTable from "./ClientTable"
export default function TransactionTable() {
const limit = 5
const [page, setPage] = useState(1)
const { data, isFetching, isLoading } =
trpc.user.transaction.friendTransactions.useQuery(
{
limit,
page,
},
{
placeholderData: keepPreviousData,
}
)
return isLoading ? (
<LoadingSpinner />
) : (
<>
<ClientTable transactions={data?.data.transactions || []} />
{data && data.meta.totalPages > 1 ? (
<Pagination
handlePageChange={setPage}
pageCount={data.meta.totalPages}
isFetching={isFetching}
currentPage={page}
/>
) : null}
</>
)
}
@@ -0,0 +1,108 @@
"use client"
import { usePathname } from "next/navigation"
import { useIntl } from "react-intl"
import { webviews } from "@/constants/routes/webviews"
import { dt } from "@/lib/dt"
import Link from "@/components/TempDesignSystem/Link"
import Table from "@/components/TempDesignSystem/Table"
import Body from "@/components/TempDesignSystem/Text/Body"
import useLang from "@/hooks/useLang"
import AwardPoints from "../../../AwardPoints"
import type { RowProps } from "@/types/components/myPages/myPage/earnAndBurn"
import { Transactions } from "@/types/enums/transactions"
export default function Row({ transaction }: RowProps) {
const intl = useIntl()
const lang = useLang()
const pathName = usePathname()
const isWebview = webviews.includes(pathName)
const nightsMsg = intl.formatMessage(
{
id: "{totalNights, plural, one {# night} other {# nights}}",
},
{
totalNights: transaction.nights,
}
)
let description =
transaction.hotelName && transaction.city
? `${transaction.hotelName}, ${transaction.city} ${nightsMsg}`
: `${nightsMsg}`
switch (transaction.type) {
case Transactions.rewardType.stay:
case Transactions.rewardType.stayAdj:
if (transaction.hotelId === "ORS") {
description = intl.formatMessage({ id: "Former Scandic Hotel" })
}
if (transaction.confirmationNumber === "BALFWD") {
description = intl.formatMessage({
id: "Points earned prior to May 1, 2021",
})
}
break
case Transactions.rewardType.ancillary:
description = intl.formatMessage({ id: "Extras to your booking" })
break
case Transactions.rewardType.enrollment:
description = intl.formatMessage({ id: "Sign up bonus" })
break
case Transactions.rewardType.mastercard_points:
description = intl.formatMessage({ id: "Scandic Friends Mastercard" })
break
case Transactions.rewardType.tui_points:
description = intl.formatMessage({ id: "TUI Points" })
case Transactions.rewardType.pointShop:
description = intl.formatMessage({ id: "Scandic Friends Point Shop" })
break
}
const arrival = dt(transaction.checkinDate).locale(lang).format("DD MMM YYYY")
function renderConfirmationNumber() {
if (transaction.confirmationNumber === "BALFWD") return null
if (
!isWebview &&
transaction.bookingUrl &&
(transaction.type === Transactions.rewardType.stay ||
transaction.type === Transactions.rewardType.rewardNight)
) {
return (
<Link variant="underscored" href={transaction.bookingUrl}>
{transaction.confirmationNumber}
</Link>
)
}
return transaction.confirmationNumber
}
return (
<Table.TR>
<Table.TD>
<AwardPoints
awardPoints={transaction.awardPoints}
isCalculated={transaction.pointsCalculated}
/>
</Table.TD>
<Table.TD>
<Body textTransform="bold">{description}</Body>
</Table.TD>
<Table.TD>{renderConfirmationNumber()}</Table.TD>
<Table.TD>
{transaction.checkinDate && transaction.confirmationNumber !== "BALFWD"
? arrival
: null}
</Table.TD>
</Table.TR>
)
}
@@ -0,0 +1,18 @@
.container {
overflow-x: auto;
border-radius: var(--Corner-radius-Small);
}
.placeholder {
width: 100%;
padding: 24px;
text-align: center;
border: 1px solid var(--Scandic-Brand-Pale-Peach);
background-color: #fff;
}
@media screen and (min-width: 768px) {
.container {
border-radius: var(--Corner-radius-Large);
}
}
@@ -0,0 +1,55 @@
"use client"
import { useIntl } from "react-intl"
import Table from "@/components/TempDesignSystem/Table"
import Body from "@/components/TempDesignSystem/Text/Body"
import Row from "./Row"
import styles from "./clientTable.module.css"
import type { ClientTableProps } from "@/types/components/myPages/myPage/earnAndBurn"
export default function ClientTable({ transactions }: ClientTableProps) {
const intl = useIntl()
const tableHeadings = [
intl.formatMessage({ id: "Points" }),
intl.formatMessage({ id: "Description" }),
intl.formatMessage({ id: "Booking number" }),
intl.formatMessage({ id: "Arrival date" }),
]
return (
<div className={styles.container}>
<Table>
<Table.THead>
<Table.TR>
{tableHeadings.map((heading) => (
<Table.TH key={heading}>
<Body textTransform="bold">{heading}</Body>
</Table.TH>
))}
</Table.TR>
</Table.THead>
<Table.TBody>
{transactions.length ? (
transactions.map((transaction, index) => (
<Row
key={`${transaction.confirmationNumber}-${index}`}
transaction={transaction}
/>
))
) : (
<Table.TR className={styles.placeholder}>
<Table.TD colSpan={tableHeadings.length}>
{intl.formatMessage({ id: "No transactions available" })}
</Table.TD>
</Table.TR>
)}
</Table.TBody>
</Table>
</div>
)
}
@@ -0,0 +1,5 @@
import ClientJourney from "./Client"
export default async function JourneyTable() {
return <ClientJourney />
}
@@ -0,0 +1,21 @@
import SectionContainer from "@/components/Section/Container"
import SectionHeader from "@/components/Section/Header"
import SectionLink from "@/components/Section/Link"
import JourneyTable from "./JourneyTable"
import type { AccountPageComponentProps } from "@/types/components/myPages/myPage/accountPage"
export default function EarnAndBurn({
link,
subtitle,
title,
}: AccountPageComponentProps) {
return (
<SectionContainer>
<SectionHeader title={title} link={link} preamble={subtitle} />
<JourneyTable />
<SectionLink link={link} variant="mobile" />
</SectionContainer>
)
}
@@ -0,0 +1,12 @@
.container {
display: flex;
flex-direction: column;
overflow-x: auto;
border-radius: var(--Corner-radius-Small);
}
@media screen and (min-width: 768px) {
.container {
border-radius: var(--Corner-radius-Large);
}
}
@@ -0,0 +1,50 @@
"use client"
import { useIntl } from "react-intl"
import { dt } from "@/lib/dt"
import Table from "@/components/TempDesignSystem/Table"
import Body from "@/components/TempDesignSystem/Text/Body"
import useLang from "@/hooks/useLang"
import AwardPoints from "../../EarnAndBurn/AwardPoints"
export default function ExpiringPointsTable({
points,
expirationDate,
}: {
points: number
expirationDate: string
}) {
const intl = useIntl()
const lang = useLang()
const expiration = dt(expirationDate).locale(lang).format("DD MMM YYYY")
const tableHeadings = [
intl.formatMessage({ id: "Points" }),
intl.formatMessage({ id: "Expiration Date" }),
]
return (
<Table>
<Table.THead>
<Table.TR>
{tableHeadings.map((heading) => (
<Table.TH key={heading}>
<Body textTransform="bold">{heading}</Body>
</Table.TH>
))}
</Table.TR>
</Table.THead>
<Table.TBody>
<Table.TR>
<Table.TD>
<AwardPoints awardPoints={points} isCalculated isExpiringPoints />
</Table.TD>
<Table.TD>{expiration}</Table.TD>
</Table.TR>
</Table.TBody>
</Table>
)
}
@@ -0,0 +1,25 @@
.table {
border-spacing: 0;
border-collapse: collapse;
width: 100%;
}
.thead {
background-color: var(--Main-Grey-10);
border-left: 1px solid var(--Main-Grey-10);
border-right: 1px solid var(--Main-Grey-10);
}
.tr {
border: 1px solid var(--Main-Grey-10);
}
.th {
text-align: left;
padding: var(--Spacing-x2) var(--Spacing-x4);
}
.td {
text-align: left;
padding: var(--Spacing-x2) var(--Spacing-x4);
}
@@ -0,0 +1,30 @@
import { getMembershipLevel } from "@/lib/trpc/memoizedRequests"
import SectionContainer from "@/components/Section/Container"
import SectionHeader from "@/components/Section/Header"
import ExpiringPointsTable from "./ExpiringPointsTable"
import type { AccountPageComponentProps } from "@/types/components/myPages/myPage/accountPage"
export default async function ExpiringPoints({
link,
subtitle,
title,
}: AccountPageComponentProps) {
const membershipLevel = await getMembershipLevel()
if (!membershipLevel?.pointsToExpire || !membershipLevel?.pointsExpiryDate) {
return null
}
return (
<SectionContainer>
<SectionHeader title={title} link={link} preamble={subtitle} />
<ExpiringPointsTable
points={membershipLevel.pointsToExpire}
expirationDate={membershipLevel.pointsExpiryDate}
/>
</SectionContainer>
)
}
@@ -0,0 +1,46 @@
import { getProfile } from "@/lib/trpc/memoizedRequests"
import SectionContainer from "@/components/Section/Container"
import SectionHeader from "@/components/Section/Header"
import SectionLink from "@/components/Section/Link"
import Divider from "@/components/TempDesignSystem/Divider"
import Friend from "../../Overview/Friend"
import Hero from "../../Overview/Friend/Hero"
import MembershipNumber from "../../Overview/Friend/MembershipNumber"
import Stats from "../../Overview/Stats"
import styles from "./overview.module.css"
import type { AccountPageComponentProps } from "@/types/components/myPages/myPage/accountPage"
export default async function PointsOverview({
link,
subtitle,
title,
}: AccountPageComponentProps) {
const user = await getProfile()
if (!user || "error" in user) {
return null
}
return (
<SectionContainer>
<SectionHeader
link={link}
preamble={subtitle}
title={title}
headingAs={"h3"}
headingLevel={"h1"}
/>
<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>
)
}
@@ -0,0 +1,9 @@
.divider {
margin-top: var(--Spacing-x2);
}
@media screen and (min-width: 768px) {
.divider {
display: none;
}
}
@@ -0,0 +1,106 @@
"use client"
import { useRef, useState } from "react"
import { trpc } from "@/lib/trpc/client"
import { RewardIcon } from "@/components/Blocks/DynamicContent/Rewards/RewardIcon"
import ScriptedRewardText from "@/components/Blocks/DynamicContent/Rewards/ScriptedRewardText"
import Pagination from "@/components/MyPages/Pagination"
import Grids from "@/components/TempDesignSystem/Grids"
import Title from "@/components/TempDesignSystem/Text/Title"
import useLang from "@/hooks/useLang"
import Redeem from "../Redeem"
import styles from "./current.module.css"
import type { CurrentRewardsClientProps } from "@/types/components/myPages/myPage/accountPage"
import type {
Reward,
RewardWithRedeem,
} from "@/server/routers/contentstack/reward/output"
export default function ClientCurrentRewards({
rewards: initialData,
pageSize,
showRedeem,
membershipNumber,
}: CurrentRewardsClientProps) {
const lang = useLang()
const containerRef = useRef<HTMLDivElement>(null)
const [currentPage, setCurrentPage] = useState(1)
const { data } = trpc.contentstack.rewards.current.useQuery<{
rewards: (Reward | RewardWithRedeem)[]
}>(
{
lang,
},
{
initialData: { rewards: initialData },
}
)
if (!data) {
return null
}
const rewards = data.rewards
const totalPages = Math.ceil(rewards.length / pageSize)
const startIndex = (currentPage - 1) * pageSize
const endIndex = startIndex + pageSize
const currentRewards = rewards.slice(startIndex, endIndex)
function handlePageChange(page: number) {
requestAnimationFrame(() => {
setCurrentPage(page)
containerRef.current?.scrollIntoView({
behavior: "smooth",
block: "start",
inline: "nearest",
})
})
}
return (
<div ref={containerRef} className={styles.container}>
<Grids.Stackable>
{currentRewards.map((reward, idx) => (
<article className={styles.card} key={`${reward.reward_id}-${idx}`}>
<div className={styles.content}>
<RewardIcon rewardId={reward.reward_id} />
{showRedeem && (
<ScriptedRewardText
rewardType={reward.rewardType}
rewardTierLevel={reward.rewardTierLevel}
/>
)}
<Title
as="h4"
level="h3"
textAlign="center"
textTransform="regular"
>
{reward.label}
</Title>
</div>
{showRedeem && "redeem_description" in reward && (
<div className={styles.btnContainer}>
<Redeem reward={reward} membershipNumber={membershipNumber} />
</div>
)}
</article>
))}
</Grids.Stackable>
{totalPages > 1 && (
<Pagination
pageCount={totalPages}
currentPage={currentPage}
handlePageChange={handlePageChange}
/>
)}
</div>
)
}
@@ -0,0 +1,29 @@
.container {
display: flex;
flex-direction: column;
gap: var(--Spacing-x4);
position: relative;
scroll-margin-top: calc(var(--current-mobile-site-header-height) * 2);
}
.card {
background-color: var(--UI-Opacity-White-100);
border: 1px solid var(--Base-Border-Subtle);
border-radius: var(--Corner-radius-Medium);
display: flex;
flex-direction: column;
justify-content: space-between;
}
.content {
display: flex;
flex-direction: column;
gap: var(--Spacing-x2);
align-items: center;
justify-content: center;
padding: var(--Spacing-x3);
}
.btnContainer {
padding: 0 var(--Spacing-x3) var(--Spacing-x3);
}
@@ -0,0 +1,41 @@
import { env } from "@/env/server"
import {
getCurrentRewards,
getMembershipLevel,
} from "@/lib/trpc/memoizedRequests"
import SectionContainer from "@/components/Section/Container"
import SectionHeader from "@/components/Section/Header"
import SectionLink from "@/components/Section/Link"
import ClientCurrentRewards from "./Client"
import type { AccountPageComponentProps } from "@/types/components/myPages/myPage/accountPage"
export default async function CurrentRewardsBlock({
title,
subtitle,
link,
}: AccountPageComponentProps) {
const [rewardsResponse, membershipLevel] = await Promise.all([
getCurrentRewards(),
getMembershipLevel(),
])
if (!rewardsResponse?.rewards.length) {
return null
}
return (
<SectionContainer>
<SectionHeader title={title} link={link} preamble={subtitle} />
<ClientCurrentRewards
rewards={rewardsResponse.rewards}
pageSize={6}
showRedeem={env.USE_NEW_REWARDS_ENDPOINT && env.USE_NEW_REWARD_MODEL}
membershipNumber={membershipLevel?.membershipNumber}
/>
<SectionLink link={link} variant="mobile" />
</SectionContainer>
)
}
@@ -0,0 +1,69 @@
import { Lock } from "react-feather"
import { MembershipLevelEnum } from "@/constants/membershipLevels"
import { getMembershipLevel } from "@/lib/trpc/memoizedRequests"
import { serverClient } from "@/lib/trpc/server"
import SectionContainer from "@/components/Section/Container"
import SectionHeader from "@/components/Section/Header"
import SectionLink from "@/components/Section/Link"
import Chip from "@/components/TempDesignSystem/Chip"
import Grids from "@/components/TempDesignSystem/Grids"
import Body from "@/components/TempDesignSystem/Text/Body"
import Title from "@/components/TempDesignSystem/Text/Title"
import { getIntl } from "@/i18n"
import styles from "./next.module.css"
import type { AccountPageComponentProps } from "@/types/components/myPages/myPage/accountPage"
export default async function NextLevelRewardsBlock({
title,
subtitle,
link,
}: AccountPageComponentProps) {
const intl = await getIntl()
const membershipLevel = await getMembershipLevel()
if (!membershipLevel || !membershipLevel?.nextLevel) {
return null
}
const nextLevelRewards = await serverClient().contentstack.rewards.byLevel({
level_id: MembershipLevelEnum[membershipLevel?.nextLevel],
unique: true,
})
// TODO: handle this case, when missing or when user is top level?
if (!nextLevelRewards) {
return null
}
return (
<SectionContainer>
<SectionHeader title={title} preamble={subtitle} link={link} />
<Grids.Stackable columns={2}>
{nextLevelRewards.rewards.map((reward) => (
<article key={reward.reward_id} className={styles.card}>
<Chip>
<Lock height={16} />
{intl.formatMessage({ id: "Level up to unlock" })}
</Chip>
<div className={styles.textContainer}>
<Body color="peach50" textAlign="center">
{intl.formatMessage(
{ id: "As our {level}" },
{ level: nextLevelRewards.level?.name }
)}
</Body>
<Title level="h4" as="h4" color="pale" textAlign="center">
{reward.label}
</Title>
</div>
</article>
))}
</Grids.Stackable>
<SectionLink link={link} variant="mobile" />
</SectionContainer>
)
}
@@ -0,0 +1,15 @@
.card {
align-items: center;
background-color: var(--Scandic-Brand-Burgundy);
border-radius: var(--Corner-radius-Small);
display: flex;
flex-direction: column;
gap: var(--Spacing-x2);
justify-content: center;
padding: var(--Spacing-x3) var(--Spacing-x3) var(--Spacing-x7);
}
.textContainer {
display: grid;
gap: var(--Spacing-x1);
}
@@ -0,0 +1,31 @@
"use client"
import { motion } from "framer-motion"
import { useIntl } from "react-intl"
import { CheckCircleIcon } from "@/components/Icons"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import styles from "./redeem.module.css"
export default function ActiveRedeemedBadge() {
const intl = useIntl()
return (
<div className={styles.redeemed}>
<motion.div
animate={{
opacity: [1, 0.4, 1],
}}
transition={{
duration: 2,
repeat: Infinity,
ease: "easeInOut",
}}
>
<CheckCircleIcon color="uiSemanticSuccess" />
</motion.div>
<Caption>{intl.formatMessage({ id: "Active" })}</Caption>
</div>
)
}
@@ -0,0 +1,48 @@
import { useIntl } from "react-intl"
import Button from "@/components/TempDesignSystem/Button"
import Body from "@/components/TempDesignSystem/Text/Body"
import Title from "@/components/TempDesignSystem/Text/Title"
import useRedeemFlow from "./useRedeemFlow"
import styles from "./redeem.module.css"
export function ConfirmClose({ close }: { close: VoidFunction }) {
const intl = useIntl()
const { setRedeemStep } = useRedeemFlow()
return (
<>
<div className={styles.modalContent}>
<Title level="h3" textAlign="center" textTransform="regular">
{intl.formatMessage({
id: "If you close this your benefit will be removed",
})}
</Title>
<Body>
{intl.formatMessage({
id: "Have you showed this benefit to the hotel staff?",
})}
</Body>
<Body>
{intl.formatMessage({
id: "If not, please go back and do so before you close this. Once you close this your benefit will be void and removed from My Benefits.",
})}
</Body>
</div>
<footer className={styles.modalFooter}>
<Button
onClick={() => setRedeemStep("redeemed")}
intent="primary"
theme="base"
>
{intl.formatMessage({ id: "No, go back" })}
</Button>
<Button onClick={close} intent="secondary" theme="base">
{intl.formatMessage({ id: "Yes, close and remove benefit" })}
</Button>
</footer>
</>
)
}
@@ -0,0 +1,60 @@
"use client"
import { useIntl } from "react-intl"
import CopyIcon from "@/components/Icons/Copy"
import Button from "@/components/TempDesignSystem/Button"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Title from "@/components/TempDesignSystem/Text/Title"
import { toast } from "@/components/TempDesignSystem/Toasts"
import { RewardIcon } from "../../RewardIcon"
import useRedeemFlow from "../useRedeemFlow"
import styles from "../redeem.module.css"
export default function Campaign() {
const { reward } = useRedeemFlow()
const intl = useIntl()
if (!reward) {
return null
}
return (
<>
<div className={styles.modalContent}>
<RewardIcon rewardId={reward.reward_id} />
<Title level="h3" textAlign="center" textTransform="regular">
{reward.label}
</Title>
<Body textAlign="center">{reward.description}</Body>
<div className={styles.rewardBadge}>
<Caption textAlign="center" color="uiTextHighContrast" type="bold">
{intl.formatMessage({ id: "Promo code" })}
</Caption>
<Caption textAlign="center" color="uiTextHighContrast">
{reward.operaRewardId}
</Caption>
</div>
</div>
<footer className={styles.modalFooter}>
<Button
onClick={() => {
navigator.clipboard.writeText(reward.operaRewardId)
toast.success(intl.formatMessage({ id: "Copied to clipboard" }))
}}
type="button"
variant="icon"
size="small"
theme="base"
intent="primary"
>
<CopyIcon color="pale" />
{intl.formatMessage({ id: "Copy promotion code" })}
</Button>
</footer>
</>
)
}
@@ -0,0 +1,116 @@
"use client"
import { useIntl } from "react-intl"
import JsonToHtml from "@/components/JsonToHtml"
import Button from "@/components/TempDesignSystem/Button"
import Body from "@/components/TempDesignSystem/Text/Body"
import Title from "@/components/TempDesignSystem/Text/Title"
import { isRestaurantOnSiteTierReward } from "@/utils/rewards"
import { RewardIcon } from "../../RewardIcon"
import ActiveRedeemedBadge from "../ActiveRedeemedBadge"
import MembershipNumberBadge from "../MembershipNumberBadge"
import TimedRedeemedBadge from "../TimedRedeemedBadge"
import useRedeemFlow from "../useRedeemFlow"
import styles from "../redeem.module.css"
export default function Tier({
membershipNumber,
}: {
membershipNumber: string
}) {
const { reward, onRedeem, redeemStep, setRedeemStep, isRedeeming } =
useRedeemFlow()
const intl = useIntl()
if (!reward) {
return null
}
return (
<>
<div className={styles.modalContent}>
{redeemStep === "redeemed" && (
<div className={styles.badge}>
{isRestaurantOnSiteTierReward(reward) ? (
<ActiveRedeemedBadge />
) : (
<TimedRedeemedBadge />
)}
</div>
)}
<RewardIcon rewardId={reward.reward_id} />
<Title level="h3" textAlign="center" textTransform="regular">
{reward.label}
</Title>
{reward.redeemLocation !== "Non-redeemable" ? (
<>
{redeemStep === "initial" && (
<Body textAlign="center">{reward.description}</Body>
)}
{redeemStep === "confirmation" && (
<JsonToHtml
embeds={
reward.redeem_description.embedded_itemsConnection.edges
}
nodes={reward.redeem_description.json.children}
/>
)}
{redeemStep === "redeemed" &&
isRestaurantOnSiteTierReward(reward) &&
membershipNumber && (
<MembershipNumberBadge membershipNumber={membershipNumber} />
)}
</>
) : (
<JsonToHtml
embeds={reward.redeem_description.embedded_itemsConnection.edges}
nodes={reward.redeem_description.json.children}
/>
)}
</div>
{reward.redeemLocation !== "Non-redeemable" ? (
<>
{redeemStep === "initial" && (
<footer className={styles.modalFooter}>
<Button
onClick={() => setRedeemStep("confirmation")}
intent="primary"
theme="base"
>
{intl.formatMessage({ id: "Redeem benefit" })}
</Button>
</footer>
)}
{redeemStep === "confirmation" && (
<footer className={styles.modalFooter}>
<Button
onClick={onRedeem}
disabled={isRedeeming}
intent="primary"
theme="base"
>
{intl.formatMessage({ id: "Yes, redeem" })}
</Button>
<Button
onClick={() => setRedeemStep("initial")}
intent="secondary"
theme="base"
>
{intl.formatMessage({ id: "Go back" })}
</Button>
</footer>
)}
</>
) : null}
</>
)
}
@@ -0,0 +1,24 @@
import { useIntl } from "react-intl"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import styles from "./redeem.module.css"
export default function MembershipNumberBadge({
membershipNumber,
}: {
membershipNumber: string
}) {
const intl = useIntl()
return (
<div className={styles.rewardBadge}>
<Caption textAlign="center" color="uiTextHighContrast">
{intl.formatMessage(
{ id: "Membership ID: {id}" },
{ id: membershipNumber }
)}
</Caption>
</div>
)
}
@@ -0,0 +1,37 @@
"use client"
import { useIntl } from "react-intl"
import { dt } from "@/lib/dt"
import Countdown from "@/components/Countdown"
import { CheckCircleIcon } from "@/components/Icons"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import useRedeemFlow from "./useRedeemFlow"
import styles from "./redeem.module.css"
export default function TimedRedeemedBadge() {
const intl = useIntl()
const { timeRemaining, setTimeRemaining } = useRedeemFlow()
const duration = dt.duration(timeRemaining)
return (
<>
<div className={styles.redeemed}>
<CheckCircleIcon color="uiSemanticSuccess" />
<Caption>
{intl.formatMessage({
id: "Redeemed & valid through:",
})}
</Caption>
</div>
<Countdown
minutes={duration.minutes()}
seconds={duration.seconds()}
onChange={(newTime) => setTimeRemaining(newTime)}
/>
</>
)
}
@@ -0,0 +1,174 @@
"use client"
import { motion } from "framer-motion"
import { useState } from "react"
import {
Dialog,
DialogTrigger,
Modal,
ModalOverlay,
} from "react-aria-components"
import { useIntl } from "react-intl"
import { trpc } from "@/lib/trpc/client"
import { CloseLargeIcon } from "@/components/Icons"
import Button from "@/components/TempDesignSystem/Button"
import useLang from "@/hooks/useLang"
import Campaign from "./Flows/Campaign"
import Tier from "./Flows/Tier"
import { ConfirmClose } from "./ConfirmClose"
import { RedeemContext } from "./useRedeemFlow"
import styles from "./redeem.module.css"
import type {
RedeemModalState,
RedeemProps,
RedeemStep,
} from "@/types/components/myPages/myPage/accountPage"
import type { RewardWithRedeem } from "@/server/routers/contentstack/reward/output"
const MotionOverlay = motion(ModalOverlay)
const MotionModal = motion(Modal)
const thirtyMinutesInMs = 1000 * 60 * 30
export default function Redeem({ reward, membershipNumber }: RedeemProps) {
const [animation, setAnimation] = useState<RedeemModalState>("unmounted")
const intl = useIntl()
const lang = useLang()
const utils = trpc.useUtils()
const [redeemStep, setRedeemStep] = useState<RedeemStep>("initial")
const [timeRemaining, setTimeRemaining] = useState(thirtyMinutesInMs)
function modalStateHandler(newAnimationState: RedeemModalState) {
setAnimation((currentAnimationState) =>
newAnimationState === "hidden" && currentAnimationState === "hidden"
? "unmounted"
: currentAnimationState
)
if (newAnimationState === "unmounted") {
setRedeemStep("initial")
}
}
return (
<RedeemContext.Provider
value={{
reward,
redeemStep,
setRedeemStep,
defaultTimeRemaining: thirtyMinutesInMs,
timeRemaining,
setTimeRemaining,
}}
>
<DialogTrigger
onOpenChange={(isOpen) => setAnimation(isOpen ? "visible" : "hidden")}
>
<Button intent="primary" fullWidth>
{reward.redeemLocation === "Non-redeemable"
? intl.formatMessage({ id: "How to use" })
: intl.formatMessage({ id: "Open" })}
</Button>
<MotionOverlay
className={styles.overlay}
isExiting={animation === "hidden"}
onAnimationComplete={modalStateHandler}
variants={variants.fade}
initial="hidden"
animate={animation}
>
<MotionModal
className={styles.modal}
variants={variants.slideInOut}
initial="hidden"
animate={animation}
>
<Dialog className={styles.dialog} aria-label={reward.label}>
{({ close }) => {
function closeModal() {
if (
redeemStep === "redeemed" ||
redeemStep === "confirm-close"
) {
utils.contentstack.rewards.current.invalidate({
lang,
})
}
close()
}
return (
<>
<header className={styles.modalHeader}>
<button
onClick={() => {
if (redeemStep === "redeemed") {
setRedeemStep("confirm-close")
} else {
closeModal()
}
}}
type="button"
className={styles.modalClose}
>
<CloseLargeIcon />
</button>
</header>
{redeemStep === "confirm-close" ? (
<ConfirmClose close={closeModal} />
) : (
getRedeemFlow(reward, membershipNumber || "")
)}
</>
)
}}
</Dialog>
</MotionModal>
</MotionOverlay>
</DialogTrigger>
</RedeemContext.Provider>
)
}
const variants = {
fade: {
hidden: {
opacity: 0,
transition: { duration: 0.4, ease: "easeInOut" },
},
visible: {
opacity: 1,
transition: { duration: 0.4, ease: "easeInOut" },
},
},
slideInOut: {
hidden: {
opacity: 0,
y: 32,
transition: { duration: 0.4, ease: "easeInOut" },
},
visible: {
opacity: 1,
y: 0,
transition: { duration: 0.4, ease: "easeInOut" },
},
},
}
function getRedeemFlow(reward: RewardWithRedeem, membershipNumber: string) {
switch (reward.rewardType) {
case "Campaign":
return <Campaign />
case "Surprise":
case "Tier":
return <Tier membershipNumber={membershipNumber} />
default:
console.warn("Unsupported reward type for redeem:", reward.rewardType)
return null
}
}
@@ -0,0 +1,117 @@
.badge {
border-radius: var(--Small, 4px);
border: 1px solid var(--Base-Border-Subtle);
display: flex;
padding: var(--Spacing-x1) var(--Spacing-x2);
flex-direction: column;
justify-content: center;
align-items: center;
}
.redeemed {
display: flex;
justify-content: center;
align-items: center;
gap: var(--Spacing-x-half);
align-self: stretch;
}
.overlay {
background: rgba(0, 0, 0, 0.5);
height: var(--visual-viewport-height);
position: fixed;
top: 0;
left: 0;
width: 100vw;
z-index: 100;
}
@media screen and (min-width: 768px) {
.overlay {
display: flex;
justify-content: center;
align-items: center;
}
}
.modal {
background-color: var(--Base-Surface-Primary-light-Normal);
border-radius: var(--Corner-radius-Medium);
box-shadow: var(--modal-box-shadow);
width: 100%;
position: absolute;
left: 0;
bottom: 0;
z-index: 101;
}
@media screen and (min-width: 768px) {
.modal {
left: auto;
bottom: auto;
width: 400px;
}
}
.dialog {
display: flex;
flex-direction: column;
padding-bottom: var(--Spacing-x3);
}
.modalHeader {
--button-height: 32px;
box-sizing: content-box;
display: flex;
align-items: center;
height: var(--button-height);
position: relative;
justify-content: center;
padding: var(--Spacing-x3) var(--Spacing-x2) 0;
}
.modalContent {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--Spacing-x2);
padding: 0 var(--Spacing-x3) var(--Spacing-x3);
}
.modalFooter {
display: flex;
flex-direction: column;
padding: 0 var(--Spacing-x3) var(--Spacing-x1);
gap: var(--Spacing-x-one-and-half);
}
.modalFooter > button {
flex: 1 0 100%;
}
.modalClose {
background: none;
border: none;
cursor: pointer;
position: absolute;
right: var(--Spacing-x2);
width: 32px;
height: var(--button-height);
display: flex;
align-items: center;
}
.active {
display: flex;
align-items: center;
gap: var(--Spacing-x-half);
color: var(--UI-Semantic-Success);
}
.rewardBadge {
border-radius: var(--Corner-radius-Small);
padding: var(--Spacing-x1) var(--Spacing-x-one-and-half);
background: var(--Base-Surface-Secondary-light-Normal);
display: grid;
gap: var(--Spacing-x-half);
}
@@ -0,0 +1,64 @@
"use client"
import { createContext, useCallback, useContext, useEffect } from "react"
import { trpc } from "@/lib/trpc/client"
import type { RedeemFlowContext } from "@/types/components/myPages/myPage/accountPage"
import type { RewardWithRedeem } from "@/server/routers/contentstack/reward/output"
export const RedeemContext = createContext<RedeemFlowContext>({
reward: null,
redeemStep: "initial",
setRedeemStep: () => undefined,
defaultTimeRemaining: 0,
timeRemaining: 0,
setTimeRemaining: () => undefined,
})
export default function useRedeemFlow() {
const {
reward,
redeemStep,
setRedeemStep,
defaultTimeRemaining,
timeRemaining,
setTimeRemaining,
} = useContext(RedeemContext)
const update = trpc.contentstack.rewards.redeem.useMutation<{
rewards: RewardWithRedeem[]
}>()
const onRedeem = useCallback(() => {
if (reward?.id) {
update.mutate(
{ rewardId: reward.id, couponCode: reward.couponCode },
{
onSuccess() {
setRedeemStep("redeemed")
},
onError(error) {
console.error("Failed to redeem", error)
},
}
)
}
}, [reward, update, setRedeemStep])
useEffect(() => {
if (redeemStep === "initial") {
setTimeRemaining(defaultTimeRemaining)
}
}, [redeemStep, setTimeRemaining, defaultTimeRemaining])
return {
reward,
onRedeem,
redeemStep,
setRedeemStep,
isRedeeming: update.isPending,
timeRemaining,
setTimeRemaining,
}
}
@@ -0,0 +1,69 @@
import { REWARD_IDS } from "@/constants/rewards"
import { getIconByIconName } from "@/components/Icons/get-icon-by-icon-name"
import { isValidRewardId } from "@/utils/rewards"
import type { FC } from "react"
import { IconName, type IconProps } from "@/types/components/icon"
import type { RewardId } from "@/types/components/myPages/rewards"
function getIconForRewardId(rewardId: RewardId): IconName {
switch (rewardId) {
// Food & beverage
case REWARD_IDS.TenPercentFood:
case REWARD_IDS.FifteenPercentFood:
return IconName.CroissantCoffeeEgg
case REWARD_IDS.TwoForOneBreakfast:
return IconName.CutleryTwo
case REWARD_IDS.FreeBreakfast:
return IconName.CutleryOne
case REWARD_IDS.FreeKidsDrink:
return IconName.KidsMocktail
// Monetary vouchers
case REWARD_IDS.Bonus50SEK:
case REWARD_IDS.Bonus75SEK:
case REWARD_IDS.Bonus100SEK:
case REWARD_IDS.Bonus150SEK:
case REWARD_IDS.Bonus200SEK:
return IconName.Voucher
// Hotel perks
case REWARD_IDS.EarlyCheckin:
return IconName.HandKey
case REWARD_IDS.LateCheckout:
return IconName.HotelNight
case REWARD_IDS.FreeUpgrade:
return IconName.MagicWand
case REWARD_IDS.RoomGuarantee48H:
return IconName.Bed
// Earnings
case REWARD_IDS.EarnRate25Percent:
case REWARD_IDS.EarnRate50Percent:
return IconName.MoneyHand
case REWARD_IDS.StayBoostForKids:
return IconName.Kids
case REWARD_IDS.MemberRate:
return IconName.Coin
// Special
case REWARD_IDS.YearlyExclusiveGift:
return IconName.GiftOpen
default: {
return IconName.GiftOpen
}
}
}
export function mapRewardToIcon(rewardId: string): FC<IconProps> | null {
if (!isValidRewardId(rewardId)) {
// TODO: Update once UX has decided on fallback icon.
return getIconByIconName(IconName.GiftOpen)
}
const iconName = getIconForRewardId(rewardId)
return getIconByIconName(iconName)
}
@@ -0,0 +1,27 @@
import { mapRewardToIcon } from "./data"
import type { RewardIconProps } from "@/types/components/myPages/rewards"
// Original SVG aspect ratio is 358:202 (≈1.77:1)
const sizeMap = {
small: { width: 120, height: 68 }, // 40% of card width
medium: { width: 180, height: 102 }, // 60% of card width
large: { width: 240, height: 135 }, // 80% of card width
} as const
export function RewardIcon({
rewardId,
size = "medium",
...props
}: RewardIconProps) {
const IconComponent = mapRewardToIcon(rewardId)
if (!IconComponent) return null
return (
<IconComponent
{...props}
width={sizeMap[size].width}
height={sizeMap[size].height}
/>
)
}
@@ -0,0 +1,45 @@
import { useIntl } from "react-intl"
import { TIER_TO_FRIEND_MAP } from "@/constants/membershipLevels"
import BiroScript from "@/components/TempDesignSystem/Text/BiroScript"
import { isMembershipLevel } from "@/utils/membershipLevels"
import { getRewardType } from "@/utils/rewards"
import type { ScriptedRewardTextProps } from "@/types/components/myPages/myPage/accountPage"
export default function ScriptedRewardText({
rewardType,
rewardTierLevel,
}: ScriptedRewardTextProps) {
const intl = useIntl()
function getLabel(rewardType?: string, rewardTierLevel?: string) {
const type = getRewardType(rewardType)
switch (type) {
case "Tier":
return rewardTierLevel && isMembershipLevel(rewardTierLevel)
? TIER_TO_FRIEND_MAP[rewardTierLevel]
: null
case "Campaign":
return intl.formatMessage({ id: "Campaign" })
case "Surprise":
return intl.formatMessage({ id: "Surprise!" })
case "Member-voucher":
return intl.formatMessage({ id: "Voucher" })
default:
return null
}
}
const label = getLabel(rewardType, rewardTierLevel)
if (!label) return null
return (
<BiroScript type="two" color="red" tilted="small">
{label}
</BiroScript>
)
}
@@ -0,0 +1,207 @@
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>
</>
),
},
}
@@ -0,0 +1,93 @@
.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);
}
@@ -0,0 +1,50 @@
"use client"
import { useIntl } from "react-intl"
import { trpc } from "@/lib/trpc/client"
import Refresh from "@/components/Icons/Refresh"
import { Loading } from "@/components/Loading"
import Button from "@/components/TempDesignSystem/Button"
import { toast } from "@/components/TempDesignSystem/Toasts"
import styles from "./levelupgradebutton.module.css"
export function LevelUpgradeButton() {
const intl = useIntl()
const { mutate, isPending } =
trpc.partner.sas.performLevelUpgrade.useMutation({
onSuccess() {
toast.success(intl.formatMessage({ id: "Level upgraded" }))
},
onError() {
toast.error(intl.formatMessage({ id: "Failed to upgrade level" }))
},
})
const handleClick = () => {
mutate()
}
return (
<>
<Button
intent="primary"
theme="primaryLight"
onClick={handleClick}
className={styles.button}
>
<div
className={styles.textContainer}
style={{ visibility: isPending ? "hidden" : "visible" }}
>
<Refresh color="currentColor" />
{intl.formatMessage({ id: "Check for level upgrade" })}
</div>
{isPending && <Loading color="white" className={styles.loading} />}
</Button>
</>
)
}
@@ -0,0 +1,34 @@
"use client"
import { useParams } from "next/navigation"
import { useIntl } from "react-intl"
import Dialog from "@/components/Dialog"
import Button from "@/components/TempDesignSystem/Button"
import type { LangParams } from "@/types/params"
export function UnlinkSAS() {
const intl = useIntl()
const params = useParams<LangParams>()
return (
<Dialog
titleText={intl.formatMessage({
id: "Are you sure you want to unlink your account?",
})}
// TODO update copy
bodyText={intl.formatMessage({
id: "We could not connect your accounts to give you access. Please contact us and well help you resolve this issue.",
})}
cancelButtonText={intl.formatMessage({ id: "Go back" })}
proceedText={intl.formatMessage({ id: "Yes, unlink my accounts" })}
proceedHref={`/${params.lang}/sas-x-scandic/login?intent=unlink`}
trigger={
<Button intent="text" theme="base">
{intl.formatMessage({ id: "Unlink accounts" })}
</Button>
}
/>
)
}
@@ -0,0 +1,94 @@
import { Suspense } from "react"
import { TIER_TO_FRIEND_MAP } from "@/constants/membershipLevels"
import { env } from "@/env/server"
import { getProfile } from "@/lib/trpc/memoizedRequests"
import SectionContainer from "@/components/Section/Container"
import SectionHeader from "@/components/Section/Header"
import SectionLink from "@/components/Section/Link"
import { timeout } from "@/utils/timeout"
import { TierLevelCard, TierLevelCardSkeleton } from "./Card/TierLevelCard"
import { LevelUpgradeButton } from "./LevelUpgradeButton"
import { UnlinkSAS } from "./UnlinkSAS"
import styles from "./linkedAccounts.module.css"
type Props = {
title?: string
link?: { href: string; text: string }
subtitle?: string
}
export default async function SASLinkedAccount({
title,
subtitle,
link,
}: Props) {
if (!env.SAS_ENABLED) {
return null
}
return (
<>
<SectionContainer>
<SectionHeader link={link} preamble={subtitle} title={title} />
<SectionLink link={link} variant="mobile" />
<section className={styles.cardsContainer}>
<Suspense fallback={<TierLevelCardsSkeleton />}>
<TierLevelCards />
</Suspense>
</section>
</SectionContainer>
<div className={styles.mutationSection}>
<UnlinkSAS />
<LevelUpgradeButton />
</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")
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"]
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"
/>
</>
)
}
@@ -0,0 +1,25 @@
.button {
position: relative;
display: flex;
justify-content: center;
align-items: center;
width: 100%;
& .textContainer {
display: flex;
justify-content: center;
align-items: center;
gap: var(--Spacing-x1);
}
@media screen and (min-width: 768px) {
width: fit-content;
}
}
.loading {
position: absolute;
&.hidden {
opacity: 0;
}
}
@@ -0,0 +1,31 @@
.divider {
margin-top: var(--Spacing-x2);
}
@media screen and (min-width: 768px) {
.divider {
display: none;
}
}
.cardsContainer {
display: flex;
flex-direction: column;
gap: var(--Spacing-x3);
justify-content: flex-start;
@media screen and (min-width: 768px) {
flex-direction: row;
}
}
.mutationSection {
display: flex;
flex-direction: column-reverse;
gap: var(--Spacing-x2);
align-items: center;
@media screen and (min-width: 768px) {
flex-direction: row;
}
}
@@ -0,0 +1,25 @@
import { serverClient } from "@/lib/trpc/server"
import { SasTierComparison } from "@/components/SasTierComparison"
type SASTierComparisonBlockProps = {
title: string
preamble: string
}
export default async function SASTierComparisonBlock({
title,
preamble,
}: SASTierComparisonBlockProps) {
const tierComparison =
await serverClient().contentstack.partner.getSasTierComparison()
if (!tierComparison) return null
return (
<SasTierComparison
title={title}
preamble={preamble}
tierComparison={tierComparison}
/>
)
}
@@ -0,0 +1,34 @@
import SectionContainer from "@/components/Section/Container"
import SectionHeader from "@/components/Section/Header"
import SectionLink from "@/components/Section/Link"
import type { DynamicContentProps } from "@/types/components/blocks/dynamicContent"
export default function SectionWrapper({
children,
dynamic_content,
firstItem,
}: React.PropsWithChildren<DynamicContentProps>) {
const displayHeader = !!(
dynamic_content.link ||
dynamic_content.subtitle ||
dynamic_content.title
)
return (
<SectionContainer>
{displayHeader ? (
<SectionHeader
link={dynamic_content.link}
preamble={dynamic_content.subtitle}
title={dynamic_content.title}
headingAs={firstItem ? "h3" : "h4"}
headingLevel={firstItem ? "h1" : "h2"}
/>
) : null}
{children}
{dynamic_content.link ? (
<SectionLink link={dynamic_content.link} variant="mobile" />
) : null}
</SectionContainer>
)
}
@@ -0,0 +1,9 @@
import SignupForm from "@/components/Forms/Signup"
import type { SignupFormWrapperProps } from "@/types/components/blocks/dynamicContent"
export default async function SignupFormWrapper({
dynamic_content,
}: SignupFormWrapperProps) {
return <SignupForm {...dynamic_content} />
}
@@ -0,0 +1,4 @@
.container {
display: grid;
gap: var(--Spacing-x2);
}
@@ -0,0 +1,5 @@
import styles from "./container.module.css"
export default function ListContainer({ children }: React.PropsWithChildren) {
return <section className={styles.container}>{children}</section>
}
@@ -0,0 +1,63 @@
"use client"
import { trpc } from "@/lib/trpc/client"
import LoadingSpinner from "@/components/LoadingSpinner"
import Grids from "@/components/TempDesignSystem/Grids"
import ListContainer from "../ListContainer"
import ShowMoreButton from "../ShowMoreButton"
import StayCard from "../StayCard"
import type {
PreviousStaysClientProps,
PreviousStaysNonNullResponseObject,
} from "@/types/components/myPages/stays/previous"
export default function ClientPreviousStays({
initialPreviousStays,
}: PreviousStaysClientProps) {
const { data, isFetching, fetchNextPage, hasNextPage, isLoading } =
trpc.user.stays.previous.useInfiniteQuery(
{
limit: 6,
},
{
getNextPageParam: (lastPage) => {
return lastPage?.nextCursor
},
initialData: {
pageParams: [undefined, 1],
pages: [initialPreviousStays],
},
}
)
function loadMoreData() {
if (hasNextPage) {
fetchNextPage()
}
}
// TS having a hard time with the filtered type.
// This is only temporary as we will not return null
// later on when we handle errors appropriately.
const filteredStays = (data?.pages.filter((page) => page?.data) ??
[]) as unknown as PreviousStaysNonNullResponseObject[]
const stays = filteredStays.flatMap((page) => page.data)
return isLoading ? (
<LoadingSpinner />
) : (
<ListContainer>
<Grids.Stackable>
{stays.map((stay) => (
<StayCard key={stay.attributes.confirmationNumber} stay={stay} />
))}
</Grids.Stackable>
{hasNextPage ? (
<ShowMoreButton disabled={isFetching} loadMoreData={loadMoreData} />
) : null}
</ListContainer>
)
}
@@ -0,0 +1,10 @@
.container {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
background-color: var(--Scandic-Brand-Pale-Peach);
border-radius: var(--Corner-radius-Medium);
margin-bottom: var(--Spacing-x-half);
height: 200px;
}
@@ -0,0 +1,17 @@
import Title from "@/components/TempDesignSystem/Text/Title"
import { getIntl } from "@/i18n"
import styles from "./emptyPreviousStays.module.css"
export default async function EmptyPreviousStaysBlock() {
const intl = await getIntl()
return (
<section className={styles.container}>
<Title as="h4" level="h3" color="red" textAlign="center">
{intl.formatMessage({
id: "You have no previous stays.",
})}
</Title>
</section>
)
}
@@ -0,0 +1,31 @@
import { serverClient } from "@/lib/trpc/server"
import SectionContainer from "@/components/Section/Container"
import SectionHeader from "@/components/Section/Header"
import SectionLink from "@/components/Section/Link"
import ClientPreviousStays from "./Client"
import type { AccountPageComponentProps } from "@/types/components/myPages/myPage/accountPage"
export default async function PreviousStays({
title,
subtitle,
link,
}: AccountPageComponentProps) {
const initialPreviousStays = await serverClient().user.stays.previous({
limit: 6,
})
if (!initialPreviousStays?.data.length) {
return null
}
return (
<SectionContainer>
<SectionHeader title={title} preamble={subtitle} link={link} />
<ClientPreviousStays initialPreviousStays={initialPreviousStays} />
<SectionLink link={link} variant="mobile" />
</SectionContainer>
)
}
@@ -0,0 +1,4 @@
.container {
display: flex;
justify-content: center;
}
@@ -0,0 +1,32 @@
"use client"
import { useIntl } from "react-intl"
import { ChevronDownIcon } from "@/components/Icons"
import Button from "@/components/TempDesignSystem/Button"
import styles from "./button.module.css"
import type { ShowMoreButtonParams } from "@/types/components/myPages/stays/button"
export default function ShowMoreButton({
disabled,
loadMoreData,
}: ShowMoreButtonParams) {
const intl = useIntl()
return (
<div className={styles.container}>
<Button
disabled={disabled}
onClick={loadMoreData}
variant="icon"
type="button"
theme="base"
intent="text"
>
<ChevronDownIcon width={20} height={20} />
{intl.formatMessage({ id: "Show more" })}
</Button>
</div>
)
}
@@ -0,0 +1,32 @@
.container {
display: grid;
grid-template-rows: 1fr min(50px);
background-color: var(--Base-Surface-Primary-light-Normal);
border-radius: var(--Corner-radius-Medium);
min-height: 250px;
margin-bottom: var(--Spacing-x-half);
overflow: hidden;
}
.titleContainer {
display: flex;
justify-content: center;
align-items: center;
background-color: var(--Scandic-Brand-Pale-Peach);
}
.title {
display: flex;
flex-direction: column;
align-items: center;
}
.burgundyTitle {
color: var(--Scandic-Brand-Burgundy);
}
.link {
display: flex;
justify-content: center;
align-items: center;
}
@@ -0,0 +1,40 @@
import { homeHrefs } from "@/constants/homeHrefs"
import { env } from "@/env/server"
import { ArrowRightIcon } from "@/components/Icons"
import Link from "@/components/TempDesignSystem/Link"
import Title from "@/components/TempDesignSystem/Text/Title"
import { getIntl } from "@/i18n"
import { getLang } from "@/i18n/serverContext"
import styles from "./emptyUpcomingStays.module.css"
export default async function EmptyUpcomingStaysBlock() {
const intl = await getIntl()
return (
<section className={styles.container}>
<div className={styles.titleContainer}>
<Title
as="h4"
level="h3"
color="red"
className={styles.title}
textAlign="center"
>
{intl.formatMessage({ id: "You have no upcoming stays." })}
<span className={styles.burgundyTitle}>
{intl.formatMessage({ id: "Where should you go next?" })}
</span>
</Title>
</div>
<Link
href={homeHrefs[env.NODE_ENV][getLang()]}
className={styles.link}
color="peach80"
>
{intl.formatMessage({ id: "Get inspired" })}
<ArrowRightIcon color="peach80" />
</Link>
</section>
)
}
@@ -0,0 +1,38 @@
import { serverClient } from "@/lib/trpc/server"
import SectionContainer from "@/components/Section/Container"
import SectionHeader from "@/components/Section/Header"
import SectionLink from "@/components/Section/Link"
import Grids from "@/components/TempDesignSystem/Grids"
import StayCard from "../StayCard"
import EmptyUpcomingStaysBlock from "./EmptyUpcomingStays"
import type { AccountPageComponentProps } from "@/types/components/myPages/myPage/accountPage"
export default async function SoonestStays({
title,
subtitle,
link,
}: AccountPageComponentProps) {
const upcomingStays = await serverClient().user.stays.upcoming({ limit: 3 })
if (!upcomingStays?.data) {
return null
}
return (
<SectionContainer>
<SectionHeader title={title} preamble={subtitle} link={link} />
{upcomingStays.data.length ? (
<Grids.Stackable>
{upcomingStays.data.map((stay) => (
<StayCard key={stay.attributes.confirmationNumber} stay={stay} />
))}
</Grids.Stackable>
) : (
<EmptyUpcomingStaysBlock />
)}
<SectionLink link={link} variant="mobile" />
</SectionContainer>
)
}
@@ -0,0 +1,72 @@
"use client"
import { useState } from "react"
import { dt } from "@/lib/dt"
import { CalendarIcon } from "@/components/Icons"
import Image from "@/components/Image"
import LoadingSpinner from "@/components/LoadingSpinner"
import Link from "@/components/TempDesignSystem/Link"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Title from "@/components/TempDesignSystem/Text/Title"
import useLang from "@/hooks/useLang"
import styles from "./stay.module.css"
import type { StayCardProps } from "@/types/components/myPages/stays/stayCard"
export default function StayCard({ stay }: StayCardProps) {
const lang = useLang()
// TODO: Temporary loading. Remove when current web is deleted.
const [loading, setLoading] = useState(false)
const { checkinDate, checkoutDate, hotelInformation, bookingUrl } =
stay.attributes
const arrival = dt(checkinDate).locale(lang)
const arrivalDate = arrival.format("DD MMM")
const arrivalDateTime = arrival.format("YYYY-MM-DD")
const depart = dt(checkoutDate).locale(lang)
const departDate = depart.format("DD MMM YYYY")
const departDateTime = depart.format("YYYY-MM-DD")
return (
<Link
href={bookingUrl}
className={styles.link}
onClick={() => setLoading(true)}
>
<article className={styles.stay}>
<Image
className={styles.image}
alt={hotelInformation.hotelContent.images.metaData.altText}
src={hotelInformation.hotelContent.images.imageSizes.small}
width={420}
height={240}
/>
<footer className={styles.footer}>
<Title as="h4" className={styles.hotel} level="h3">
{hotelInformation.hotelName}
</Title>
<div className={styles.date}>
<CalendarIcon color="burgundy" height={24} width={24} />
<Caption asChild>
<time dateTime={arrivalDateTime}>{arrivalDate}</time>
</Caption>
{" - "}
<Caption asChild>
<time dateTime={departDateTime}>{departDate}</time>
</Caption>
</div>
</footer>
</article>
{loading && (
<div className={styles.loadingcontainer}>
<LoadingSpinner />
</div>
)}
</Link>
)
}
@@ -0,0 +1,61 @@
.stay {
background-color: var(--Main-Grey-White);
border: 1px solid var(--Base-Border-Subtle);
border-radius: var(--Corner-radius-Medium);
display: grid;
overflow: hidden;
}
.link {
text-decoration: none;
position: relative;
}
.stay:hover {
border: 1.5px solid var(--Base-Border-Hover);
}
.image {
height: auto;
min-height: 220px;
object-fit: cover;
overflow: hidden;
width: 100%;
}
.footer {
color: var(--Scandic-Brand-Burgundy);
display: grid;
gap: var(--Spacing-x2);
margin-top: auto;
overflow: hidden;
padding: var(--Spacing-x2);
width: 100%;
}
.hotel {
margin: 0;
overflow: hidden;
padding: 0;
text-overflow: ellipsis;
text-wrap: nowrap;
}
.date {
align-items: center;
display: flex;
gap: var(--Spacing-x-half);
}
.loadingcontainer {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
background: rgb(255 255 255 / 80%);
display: flex;
align-items: center;
justify-content: center;
padding-bottom: 70px;
}
@@ -0,0 +1,63 @@
"use client"
import { trpc } from "@/lib/trpc/client"
import LoadingSpinner from "@/components/LoadingSpinner"
import Grids from "@/components/TempDesignSystem/Grids"
import ListContainer from "../ListContainer"
import ShowMoreButton from "../ShowMoreButton"
import StayCard from "../StayCard"
import type {
UpcomingStaysClientProps,
UpcomingStaysNonNullResponseObject,
} from "@/types/components/myPages/stays/upcoming"
export default function ClientUpcomingStays({
initialUpcomingStays,
}: UpcomingStaysClientProps) {
const { data, isFetching, fetchNextPage, hasNextPage, isLoading } =
trpc.user.stays.upcoming.useInfiniteQuery(
{
limit: 6,
},
{
getNextPageParam: (lastPage) => {
return lastPage?.nextCursor
},
initialData: {
pageParams: [undefined, 1],
pages: [initialUpcomingStays],
},
}
)
function loadMoreData() {
if (hasNextPage) {
fetchNextPage()
}
}
// TS having a hard time with the filtered type.
// This is only temporary as we will not return null
// later on when we handle errors appropriately.
const filteredStays = (data?.pages.filter((page) => page && page.data) ??
[]) as unknown as UpcomingStaysNonNullResponseObject[]
const stays = filteredStays.flatMap((page) => page.data)
return isLoading ? (
<LoadingSpinner />
) : stays.length ? (
<ListContainer>
<Grids.Stackable>
{stays.map((stay) => (
<StayCard key={stay.attributes.confirmationNumber} stay={stay} />
))}
</Grids.Stackable>
{hasNextPage ? (
<ShowMoreButton disabled={isFetching} loadMoreData={loadMoreData} />
) : null}
</ListContainer>
) : null
}
@@ -0,0 +1,32 @@
.container {
display: grid;
grid-template-rows: 1fr min(50px);
background-color: var(--Base-Surface-Primary-light-Normal);
border-radius: var(--Corner-radius-Medium);
min-height: 250px;
margin-bottom: var(--Spacing-x-half);
overflow: hidden;
}
.titleContainer {
display: flex;
justify-content: center;
align-items: center;
background-color: var(--Scandic-Brand-Pale-Peach);
}
.title {
display: flex;
flex-direction: column;
align-items: center;
}
.burgundyTitle {
color: var(--Scandic-Brand-Burgundy);
}
.link {
display: flex;
justify-content: center;
align-items: center;
}

Some files were not shown because too many files have changed in this diff Show More