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