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

View File

@@ -0,0 +1,7 @@
.accordion:not(.allVisible) :nth-child(n + 6) {
display: none;
}
.accordion:not(.allVisible) :nth-child(5) {
border: none;
}

View File

@@ -0,0 +1,58 @@
"use client"
import { useState } from "react"
import JsonToHtml from "@/components/JsonToHtml"
import SectionContainer from "@/components/Section/Container"
import SectionHeader from "@/components/Section/Header"
import Accordion from "@/components/TempDesignSystem/Accordion"
import AccordionItem from "@/components/TempDesignSystem/Accordion/AccordionItem"
import ShowMoreButton from "@/components/TempDesignSystem/ShowMoreButton"
import styles from "./accordion.module.css"
import type { AccordionProps } from "@/types/components/blocks/Accordion"
import { HotelHashValues } from "@/types/components/hotelPage/tabNavigation"
import type { RTENode } from "@/types/transitionTypes/rte/node"
export default function AccordionSection({ accordion, title }: AccordionProps) {
const showToggleButton = accordion.length > 5
const [allAccordionsVisible, setAllAccordionsVisible] =
useState(!showToggleButton)
function toggleAccordions() {
setAllAccordionsVisible((state) => !state)
}
return (
<SectionContainer id={HotelHashValues.faq}>
<SectionHeader textTransform="uppercase" title={title} />
<Accordion
className={`${styles.accordion} ${allAccordionsVisible ? styles.allVisible : ""}`}
theme="light"
variant="card"
>
{accordion.map((acc) => (
<AccordionItem key={acc.question} title={acc.question}>
{acc.answer.json.children.map(
(child: { children: RTENode[] }, idx: number) => (
<JsonToHtml
key={acc.question + idx}
embeds={acc.answer.embedded_itemsConnection.edges}
nodes={child.children}
/>
)
)}
</AccordionItem>
))}
</Accordion>
{showToggleButton ? (
<ShowMoreButton
loadMoreData={toggleAccordions}
showLess={allAccordionsVisible}
/>
) : null}
</SectionContainer>
)
}

View File

@@ -0,0 +1,25 @@
.cardsList {
list-style: none;
display: none;
gap: var(--Spacing-x4) var(--Spacing-x1);
}
.navigationButton {
top: 30%;
}
@media screen and (min-width: 768px) {
.carousel {
display: none;
}
.cardsList {
display: grid;
grid-template-columns: repeat(2, 1fr);
}
}
@media screen and (min-width: 1024px) {
.cardsList {
grid-template-columns: repeat(3, 1fr);
}
}

View File

@@ -0,0 +1,67 @@
"use client"
import { useState } from "react"
import { Carousel } from "@/components/Carousel"
import ContentCard from "@/components/ContentCard"
import SectionContainer from "@/components/Section/Container"
import SectionHeader from "@/components/Section/Header"
import SectionLink from "@/components/Section/Link"
import TabFilters from "@/components/TabFilters"
import styles from "./cardGallery.module.css"
import type { CardGalleryProps } from "@/types/components/blocks/cardGallery"
export default function CardGallery({ card_gallery }: CardGalleryProps) {
const { heading, defaultFilter, filterCategories, cards, link } = card_gallery
const [activeFilter, setActiveFilter] = useState(defaultFilter)
const filteredCards = cards.filter((card) => card.filterId === activeFilter)
return (
<SectionContainer>
<SectionHeader title={heading} link={link} />
{filterCategories.length > 0 && activeFilter && (
<TabFilters
categories={filterCategories}
selectedFilter={activeFilter}
onFilterSelect={setActiveFilter}
/>
)}
<ul className={styles.cardsList}>
{filteredCards.map((card, index) => (
<li key={`${card.heading}-${index}`}>
<ContentCard
heading={card.heading}
image={card.image}
bodyText={card.bodyText}
promoText={card.promoText}
link={card.link}
/>
</li>
))}
</ul>
<Carousel className={styles.carousel}>
<Carousel.Content>
{filteredCards.map((card, index) => (
<Carousel.Item key={`${card.heading}-${index}`}>
<ContentCard
heading={card.heading}
image={card.image}
bodyText={card.bodyText}
promoText={card.promoText}
link={card.link}
/>
</Carousel.Item>
))}
</Carousel.Content>
<Carousel.Previous className={styles.navigationButton} />
<Carousel.Next className={styles.navigationButton} />
<Carousel.Dots />
</Carousel>
<SectionLink link={link} variant="mobile" />
</SectionContainer>
)
}

View File

@@ -0,0 +1,102 @@
import InfoCard from "@/components/ContentType/StartPage/InfoCard"
import SectionContainer from "@/components/Section/Container"
import SectionHeader from "@/components/Section/Header"
import Card from "@/components/TempDesignSystem/Card"
import Grids from "@/components/TempDesignSystem/Grids"
import LoyaltyCard from "@/components/TempDesignSystem/LoyaltyCard"
import TeaserCard from "@/components/TempDesignSystem/TeaserCard"
import type { CardsGridProps } from "@/types/components/blocks/cardsGrid"
import { CardsGridEnum, CardsGridLayoutEnum } from "@/types/enums/cardsGrid"
import type { StackableGridProps } from "../TempDesignSystem/Grids/Stackable/stackable"
export default function CardsGrid({
cards_grid,
firstItem = false,
}: CardsGridProps) {
let columns: StackableGridProps["columns"]
switch (cards_grid.layout) {
case CardsGridLayoutEnum.ONE_COLUMN:
columns = 1
break
case CardsGridLayoutEnum.TWO_COLUMNS:
columns = 2
break
case CardsGridLayoutEnum.THREE_COLUMNS:
columns = 3
break
default:
columns = 3
}
return (
<SectionContainer>
<SectionHeader
title={cards_grid.title}
preamble={cards_grid.preamble}
headingAs={firstItem ? "h3" : "h4"}
headingLevel={firstItem ? "h1" : "h2"}
/>
<Grids.Stackable columns={columns}>
{cards_grid.cards.map((card, index) => {
switch (card.__typename) {
case CardsGridEnum.cards.Card:
return (
<Card
theme={
card.backgroundImage ? "image" : cards_grid.theme ?? "one"
}
key={card.system.uid}
scriptedTopTitle={card.scripted_top_title}
heading={card.heading}
bodyText={card.body_text}
secondaryButton={card.secondaryButton}
primaryButton={card.primaryButton}
backgroundImage={card.backgroundImage}
imageGradient
/>
)
case CardsGridEnum.cards.InfoCard:
return (
<InfoCard
key={card.system.uid}
scriptedTopTitle={card.scriptedTopTitle}
heading={card.heading}
bodyText={card.bodyText}
image={card.image}
theme={card.theme ?? "one"}
primaryButton={card.primaryButton}
secondaryButton={card.secondaryButton}
imagePosition={index % 2 === 0 ? "right" : "left"}
/>
)
case CardsGridEnum.cards.TeaserCard:
return (
<TeaserCard
key={card.system.uid}
title={card.heading}
description={card.body_text}
primaryButton={card.primaryButton}
secondaryButton={card.secondaryButton}
sidePeekButton={card.sidePeekButton}
sidePeekContent={card.sidePeekContent}
image={card.image}
/>
)
case CardsGridEnum.cards.LoyaltyCard:
return (
<LoyaltyCard
key={card.system.uid}
image={card.image}
heading={card.heading}
bodyText={card.body_text}
link={card.link}
/>
)
}
})}
</Grids.Stackable>
</SectionContainer>
)
}

View File

@@ -0,0 +1,3 @@
.navigationButton {
top: 25%;
}

View File

@@ -0,0 +1,72 @@
"use client"
import { useState } from "react"
import { Carousel } from "@/components/Carousel"
import ContentCard from "@/components/ContentCard"
import SectionContainer from "@/components/Section/Container"
import SectionHeader from "@/components/Section/Header"
import SectionLink from "@/components/Section/Link"
import TabFilters from "@/components/TabFilters"
import styles from "./carouselCards.module.css"
import type { CarouselCardsProps } from "@/types/components/blocks/carouselCards"
export default function CarouselCards({ carousel_cards }: CarouselCardsProps) {
const {
heading,
enableFilters,
defaultFilter,
filterCategories,
cards,
link,
} = carousel_cards
const [activeFilter, setActiveFilter] = useState(
enableFilters ? defaultFilter : null
)
const filteredCards = !activeFilter
? cards
: cards.filter(
(card) => "filterId" in card && card.filterId === activeFilter
)
return (
<SectionContainer>
<SectionHeader
title={heading}
headingLevel="h2"
headingAs="h3"
link={link}
/>
{filterCategories.length > 0 && activeFilter && (
<TabFilters
categories={filterCategories}
selectedFilter={activeFilter}
onFilterSelect={setActiveFilter}
/>
)}
<Carousel key={activeFilter}>
<Carousel.Content>
{filteredCards.map((card, index) => (
<Carousel.Item key={`${card.heading}-${index}`}>
<ContentCard
heading={card.heading}
image={card.image}
bodyText={card.bodyText}
promoText={card.promoText}
link={card.link}
/>
</Carousel.Item>
))}
</Carousel.Content>
<Carousel.Previous className={styles.navigationButton} />
<Carousel.Next className={styles.navigationButton} />
<Carousel.Dots />
</Carousel>
<SectionLink link={link} variant="mobile" />
</SectionContainer>
)
}

View File

@@ -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%;
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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;
}
}

View File

@@ -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>
)
}

View File

@@ -0,0 +1,5 @@
.button {
display: flex;
justify-content: center;
align-items: center;
}

View File

@@ -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;
}
}

View File

@@ -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> {}

View File

@@ -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",
},
})

View File

@@ -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>
}

View File

@@ -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>
)
}

View File

@@ -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;
}
}

View File

@@ -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",
},
})

View File

@@ -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);
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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);
}
}

View File

@@ -0,0 +1,5 @@
import styles from "./container.module.css"
export default function PointsContainer({ children }: React.PropsWithChildren) {
return <section className={styles.points}>{children}</section>
}

View File

@@ -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>
)
}

View File

@@ -0,0 +1,9 @@
@media screen and (min-width: 768px) {
.firstRow {
align-content: flex-end;
}
.article {
display: grid;
}
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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);
}
}

View File

@@ -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>
)
}

View File

@@ -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;
}
}

View File

@@ -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>
)
}

View File

@@ -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);
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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;
}

View File

@@ -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>
)
}

View File

@@ -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);
}
}

View File

@@ -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>
)
}

View File

@@ -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;
}
}

View File

@@ -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>
)
})
}

View File

@@ -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;
}
}

View File

@@ -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>
)
}

View File

@@ -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);
}

View File

@@ -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>
)
}

View File

@@ -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);
}
}

View File

@@ -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>
)
}

View File

@@ -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;
}
}

View File

@@ -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
}
}

View File

@@ -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);
}

View File

@@ -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,
},
},
})

View File

@@ -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>
)
}

View File

@@ -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}
</>
)
}

View File

@@ -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>
)
}

View File

@@ -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);
}
}

View File

@@ -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>
)
}

View File

@@ -0,0 +1,5 @@
import ClientJourney from "./Client"
export default async function JourneyTable() {
return <ClientJourney />
}

View File

@@ -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>
)
}

View File

@@ -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);
}
}

View File

@@ -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>
)
}

View File

@@ -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);
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -0,0 +1,9 @@
.divider {
margin-top: var(--Spacing-x2);
}
@media screen and (min-width: 768px) {
.divider {
display: none;
}
}

View File

@@ -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>
)
}

View File

@@ -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);
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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);
}

View File

@@ -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>
)
}

View File

@@ -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>
</>
)
}

View File

@@ -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>
</>
)
}

View File

@@ -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}
</>
)
}

View File

@@ -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>
)
}

View File

@@ -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)}
/>
</>
)
}

View File

@@ -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
}
}

View File

@@ -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);
}

View File

@@ -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,
}
}

View File

@@ -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)
}

View File

@@ -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}
/>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
</>
),
},
}

View File

@@ -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);
}

View File

@@ -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>
</>
)
}

View File

@@ -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>
}
/>
)
}

View File

@@ -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"
/>
</>
)
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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}
/>
)
}

View File

@@ -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>
)
}

View File

@@ -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} />
}

View File

@@ -0,0 +1,4 @@
.container {
display: grid;
gap: var(--Spacing-x2);
}

View File

@@ -0,0 +1,5 @@
import styles from "./container.module.css"
export default function ListContainer({ children }: React.PropsWithChildren) {
return <section className={styles.container}>{children}</section>
}

View File

@@ -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>
)
}

View File

@@ -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;
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -0,0 +1,4 @@
.container {
display: flex;
justify-content: center;
}

View File

@@ -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>
)
}

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