feat(SW-66, SW-348): search functionality and ui

This commit is contained in:
Simon Emanuelsson
2024-08-28 10:47:57 +02:00
parent b9dbcf7d90
commit af850c90e7
437 changed files with 7663 additions and 9881 deletions
-100
View File
@@ -1,100 +0,0 @@
import JsonToHtml from "@/components/JsonToHtml"
import CurrentBenefitsBlock from "@/components/MyPages/Blocks/Benefits/CurrentLevel"
import NextLevelBenefitsBlock from "@/components/MyPages/Blocks/Benefits/NextLevel"
import Overview from "@/components/MyPages/Blocks/Overview"
import EarnAndBurn from "@/components/MyPages/Blocks/Points/EarnAndBurn"
import ExpiringPoints from "@/components/MyPages/Blocks/Points/ExpiringPoints"
import PointsOverview from "@/components/MyPages/Blocks/Points/Overview"
import Shortcuts from "@/components/MyPages/Blocks/Shortcuts"
import PreviousStays from "@/components/MyPages/Blocks/Stays/Previous"
import SoonestStays from "@/components/MyPages/Blocks/Stays/Soonest"
import UpcomingStays from "@/components/MyPages/Blocks/Stays/Upcoming"
import { getLang } from "@/i18n/serverContext"
import { removeMultipleSlashes } from "@/utils/url"
import {
AccountPageContentProps,
ContentProps,
} from "@/types/components/myPages/myPage/accountPage"
import {
ContentEntries,
DynamicContentComponents,
} from "@/types/components/myPages/myPage/enums"
function DynamicComponent({ component, props }: AccountPageContentProps) {
switch (component) {
case DynamicContentComponents.membership_overview:
return <Overview {...props} />
case DynamicContentComponents.points_overview:
return <PointsOverview {...props} />
case DynamicContentComponents.previous_stays:
return <PreviousStays {...props} />
case DynamicContentComponents.soonest_stays:
return <SoonestStays {...props} />
case DynamicContentComponents.upcoming_stays:
return <UpcomingStays {...props} />
case DynamicContentComponents.current_benefits:
return <CurrentBenefitsBlock {...props} />
case DynamicContentComponents.next_benefits:
return <NextLevelBenefitsBlock {...props} />
case DynamicContentComponents.expiring_points:
return <ExpiringPoints {...props} />
case DynamicContentComponents.earn_and_burn:
return <EarnAndBurn {...props} />
default:
return null
}
}
export default function Content({ content }: ContentProps) {
return content.map((item, idx) => {
switch (item.__typename) {
case ContentEntries.AccountPageContentDynamicContent:
const link = item.dynamic_content.link.linkConnection.edges.length
? {
href:
item.dynamic_content.link.linkConnection.edges[0].node
.original_url ||
removeMultipleSlashes(
`/${getLang()}/${item.dynamic_content.link.linkConnection.edges[0].node.url}`
),
text: item.dynamic_content.link.link_text,
}
: null
const componentProps = {
title: item.dynamic_content.title,
// TODO: rename preamble to subtitle in Contentstack?
subtitle: item.dynamic_content.preamble,
...(link && { link }),
}
return (
<DynamicComponent
key={`${item.dynamic_content.title}-${idx}`}
component={item.dynamic_content.component}
props={componentProps}
/>
)
case ContentEntries.AccountPageContentShortcuts:
return (
<Shortcuts
key={`${item.shortcuts.title}-${idx}`}
shortcuts={item.shortcuts.shortcuts}
subtitle={item.shortcuts.preamble}
title={item.shortcuts.title}
/>
)
case ContentEntries.AccountPageContentTextContent:
return (
<section key={`${item.__typename}-${idx}`}>
<JsonToHtml
embeds={item.text_content.content.embedded_itemsConnection.edges}
nodes={item.text_content.content.json.children}
/>
</section>
)
default:
return null
}
})
}
@@ -1,110 +0,0 @@
import JsonToHtml from "@/components/JsonToHtml"
import Overview from "@/components/MyPages/Blocks/Overview"
import Shortcuts from "@/components/MyPages/Blocks/Shortcuts"
import { getLang } from "@/i18n/serverContext"
import { removeMultipleSlashes } from "@/utils/url"
import { modWebviewLink } from "@/utils/webviews"
import CurrentBenefitsBlock from "../../Blocks/Benefits/CurrentLevel"
import NextLevelBenefitsBlock from "../../Blocks/Benefits/NextLevel"
import EarnAndBurn from "../../Blocks/Points/EarnAndBurn"
import PointsOverview from "../../Blocks/Points/Overview"
import {
AccountPageContentProps,
ContentProps,
} from "@/types/components/myPages/myPage/accountPage"
import {
ContentEntries,
DynamicContentComponents,
} from "@/types/components/myPages/myPage/enums"
function DynamicComponent({ component, props }: AccountPageContentProps) {
switch (component) {
case DynamicContentComponents.membership_overview:
return <Overview {...props} />
case DynamicContentComponents.points_overview:
return <PointsOverview {...props} />
case DynamicContentComponents.current_benefits:
return <CurrentBenefitsBlock {...props} />
case DynamicContentComponents.next_benefits:
return <NextLevelBenefitsBlock {...props} />
case DynamicContentComponents.expiring_points:
// TODO: Add once available
// return <ExpiringPoints />
return null
case DynamicContentComponents.earn_and_burn:
return <EarnAndBurn {...props} />
default:
return null
}
}
export default function Content({ content }: ContentProps) {
return (
<>
{content.map((item, idx) => {
switch (item.__typename) {
case ContentEntries.AccountPageContentDynamicContent:
const link = item.dynamic_content.link.linkConnection.edges.length
? {
href:
item.dynamic_content.link.linkConnection.edges[0].node
.original_url ||
modWebviewLink(
removeMultipleSlashes(
`/${item.dynamic_content.link.linkConnection.edges[0].node.system.locale}/${item.dynamic_content.link.linkConnection.edges[0].node.url}`
),
item.dynamic_content.link.linkConnection.edges[0].node
.system.locale
),
text: item.dynamic_content.link.link_text,
}
: null
const componentProps = {
title: item.dynamic_content.title,
// TODO: rename preamble to subtitle in Contentstack?
subtitle: item.dynamic_content.preamble,
...(link && { link }),
}
return (
<DynamicComponent
key={`${item.dynamic_content.title}-${idx}`}
component={item.dynamic_content.component}
props={componentProps}
/>
)
case ContentEntries.AccountPageContentShortcuts:
const shortcuts = item.shortcuts.shortcuts.map((shortcut) => {
return {
...shortcut,
url: modWebviewLink(shortcut.url, getLang()),
}
})
return (
<Shortcuts
key={`${item.shortcuts.title}-${idx}`}
shortcuts={shortcuts}
subtitle={item.shortcuts.preamble}
title={item.shortcuts.title}
/>
)
case ContentEntries.AccountPageContentTextContent:
return (
<section key={`${item.__typename}-${idx}`}>
<JsonToHtml
embeds={
item.text_content.content.embedded_itemsConnection.edges
}
nodes={item.text_content.content.json.children}
/>
</section>
)
default:
return null
}
})}
</>
)
}
@@ -1,12 +0,0 @@
.card {
align-items: center;
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;
gap: var(--Spacing-x1);
justify-content: center;
min-height: 280px;
padding: var(--Spacing-x7) var(--Spacing-x3);
}
@@ -1,64 +0,0 @@
import { MembershipLevelEnum } from "@/constants/membershipLevels"
import { serverClient } from "@/lib/trpc/server"
import SectionContainer from "@/components/Section/Container"
import SectionHeader from "@/components/Section/Header"
import SectionLink from "@/components/Section/Link"
import Grids from "@/components/TempDesignSystem/Grids"
import Title from "@/components/TempDesignSystem/Text/Title"
import { getLang } from "@/i18n/serverContext"
import { getMembershipLevelObject } from "@/utils/membershipLevel"
import { getMembership } from "@/utils/user"
import styles from "./current.module.css"
import type { AccountPageComponentProps } from "@/types/components/myPages/myPage/accountPage"
export default async function CurrentBenefitsBlock({
title,
subtitle,
link,
}: AccountPageComponentProps) {
const user = await serverClient().user.get()
// TAKE NOTE: we need clarification on how benefits stack from different levels
// in order to determine if a benefit is specific to a level or if it is a cumulative benefit
// we might have to add a new boolean property "exclusive" or similar
if (!user || "error" in user) {
return null
}
const membership = getMembership(user.memberships)
if (!membership) {
// TODO: handle this case?
return null
}
const currentLevel = getMembershipLevelObject(
user.memberships[0].membershipLevel as MembershipLevelEnum,
getLang()
)
if (!currentLevel) {
// TODO: handle this case?
return null
}
return (
<SectionContainer>
<SectionHeader title={title} link={link} preamble={subtitle} />
<Grids.Stackable>
{currentLevel.benefits.map((benefit, idx) => (
<article className={styles.card} key={`${currentLevel}-${idx}`}>
<Title
as="h4"
level="h3"
textAlign="center"
textTransform="regular"
>
{benefit.title}
</Title>
</article>
))}
</Grids.Stackable>
<SectionLink link={link} variant="mobile" />
</SectionContainer>
)
}
@@ -1,67 +0,0 @@
import { Lock } from "react-feather"
import { MembershipLevelEnum } from "@/constants/membershipLevels"
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 { getLang } from "@/i18n/serverContext"
import { getMembershipLevelObject } from "@/utils/membershipLevel"
import styles from "./next.module.css"
import type { AccountPageComponentProps } from "@/types/components/myPages/myPage/accountPage"
export default async function NextLevelBenefitsBlock({
title,
subtitle,
link,
}: AccountPageComponentProps) {
const intl = await getIntl()
const user = await serverClient().user.get()
if (!user || "error" in user) {
return null
}
const nextLevel = getMembershipLevelObject(
user.memberships[0].nextLevel as MembershipLevelEnum,
getLang()
)
if (!nextLevel) {
// TODO: handle this case, when missing or when user is top level?
return null
}
// TODO: how to handle different count of unlockable benefits?
return (
<SectionContainer>
<SectionHeader title={title} preamble={subtitle} link={link} />
<Grids.Stackable columns={2}>
{nextLevel.benefits.map((benefit) => (
<article key={benefit.title} 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: nextLevel.name }
)}
</Body>{" "}
<Title level="h4" as="h5" color="pale" textAlign="center">
{benefit.title}
</Title>
</div>
</article>
))}
</Grids.Stackable>
<SectionLink link={link} variant="mobile" />
</SectionContainer>
)
}
@@ -1,15 +0,0 @@
.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);
}
@@ -1,35 +0,0 @@
"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>
)
}
@@ -1,5 +0,0 @@
.button {
display: flex;
justify-content: center;
align-items: center;
}
@@ -1,21 +0,0 @@
.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;
}
}
@@ -1,7 +0,0 @@
import { VariantProps } from "class-variance-authority"
import { heroVariants } from "./heroVariants"
export interface HeroProps
extends Omit<React.HTMLAttributes<HTMLDivElement>, "color">,
VariantProps<typeof heroVariants> {}
@@ -1,15 +0,0 @@
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",
},
})
@@ -1,7 +0,0 @@
import { HeroProps } from "./hero"
import { heroVariants } from "./heroVariants"
export default function Hero({ className, color, children }: HeroProps) {
const classNames = heroVariants({ className, color })
return <section className={classNames}>{children}</section>
}
@@ -1,36 +0,0 @@
import { membershipLevels } from "@/constants/membershipLevels"
import {
BestFriend,
CloseFriend,
DearFriend,
GoodFriend,
LoyalFriend,
NewFriend,
TrueFriend,
} from "@/components/Levels"
import styles from "./membershipLevel.module.css"
import type { MembershipLevelProps } from "@/types/components/myPages/membership"
export default function MembershipLevel({ level }: MembershipLevelProps) {
switch (level) {
case membershipLevels.L1:
return <NewFriend className={styles.level} color="pale" />
case membershipLevels.L2:
return <GoodFriend className={styles.level} color="pale" />
case membershipLevels.L3:
return <CloseFriend className={styles.level} color="pale" />
case membershipLevels.L4:
return <DearFriend className={styles.level} color="pale" />
case membershipLevels.L5:
return <LoyalFriend className={styles.level} color="pale" />
case membershipLevels.L6:
return <TrueFriend className={styles.level} color="pale" />
case membershipLevels.L7:
return <BestFriend className={styles.level} color="pale" />
default:
return null
}
}
@@ -1,4 +0,0 @@
.level {
height: 105px;
width: 219px;
}
@@ -1,34 +0,0 @@
import Caption from "@/components/TempDesignSystem/Text/Caption"
import { getIntl } from "@/i18n"
import CopyButton from "../../Buttons/CopyButton"
import { MembershipNumberProps } from "./membershipNumber"
import { membershipNumberVariants } from "./membershipNumberVariants"
import styles from "./membershipNumber.module.css"
export default async function MembershipNumber({
className,
color,
membership,
}: MembershipNumberProps) {
const { formatMessage } = await getIntl()
const classNames = membershipNumberVariants({ className, color })
return (
<div className={classNames}>
<Caption color="pale">
{formatMessage({ id: "Membership ID" })}
{": "}
</Caption>
<span className={styles.icon}>
<Caption className={styles.icon} color="pale" asChild>
<code>{membership.membershipNumber ?? "N/A"}</code>
</Caption>
{membership && (
<CopyButton membershipNumber={membership.membershipNumber} />
)}
</span>
</div>
)
}
@@ -1,37 +0,0 @@
.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;
}
}
@@ -1,11 +0,0 @@
import { VariantProps } from "class-variance-authority"
import { membershipNumberVariants } from "./membershipNumberVariants"
import { User } from "@/types/user"
export interface MembershipNumberProps
extends Omit<React.HTMLAttributes<HTMLDivElement>, "color">,
VariantProps<typeof membershipNumberVariants> {
membership: User["memberships"][number]
}
@@ -1,15 +0,0 @@
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",
},
})
@@ -1,41 +0,0 @@
.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);
}
@@ -1,54 +0,0 @@
import { membershipLevels } from "@/constants/membershipLevels"
import Body from "@/components/TempDesignSystem/Text/Body"
import Title from "@/components/TempDesignSystem/Text/Title"
import { getIntl } from "@/i18n"
import { getMembership, isHighestMembership } from "@/utils/user"
import { MembershipNumberProps } from "./MemershipNumber/membershipNumber"
import MembershipLevel from "./MembershipLevel"
import MembershipNumber from "./MemershipNumber"
import styles from "./friend.module.css"
import type { UserProps } from "@/types/components/myPages/user"
export default async function Friend({
user,
color,
}: UserProps & Pick<MembershipNumberProps, "color">) {
const { formatMessage } = await getIntl()
const membership = getMembership(user.memberships)
if (!membership?.membershipLevel) {
return null
}
// @ts-expect-error: membershiplevel needs proper fix
const isHighestLevel = isHighestMembership(membership.membershipLevel)
return (
<section className={styles.friend}>
<header className={styles.header}>
<Body color="white" textTransform="bold" textAlign="center">
{formatMessage(
isHighestLevel
? { id: "Highest level" }
: // @ts-expect-error: membershiplevel needs proper fix
{ id: `Level ${membershipLevels[membership.membershipLevel]}` }
)}
</Body>
{membership ? (
<MembershipLevel
// @ts-expect-error: membershiplevel needs proper fix
level={membershipLevels[membership.membershipLevel]}
/>
) : null}
</header>
<div className={styles.membership}>
<Title className={styles.name} color="pale" level="h3">
{user.name}
</Title>
<MembershipNumber membership={membership} color={color} />
</div>
</section>
)
}
@@ -1,39 +0,0 @@
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
}
// sv hardcoded to force space on thousands
const formatter = new Intl.NumberFormat(Lang.sv)
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: "spendable points expiring by" },
{
points: formatter.format(membership.pointsToExpire),
date: d.format(dateFormat),
}
)}
</Body>
</section>
)
}
@@ -1,13 +0,0 @@
.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);
}
}
@@ -1,5 +0,0 @@
import styles from "./container.module.css"
export default function PointsContainer({ children }: React.PropsWithChildren) {
return <section className={styles.points}>{children}</section>
}
@@ -1,71 +0,0 @@
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 {
NightsColumn,
PointsColumn,
PointsColumnProps,
} from "@/types/components/myPages/points"
export const YourPointsColumn = ({ points }: PointsColumn) =>
PointsColumn({
points,
title: "Your points to spend",
subtitle: "as of today",
})
export const NextLevelPointsColumn = ({ points, subtitle }: PointsColumn) =>
PointsColumn({
points,
title: "Points needed to level up",
subtitle,
})
export const StayOnLevelColumn = ({ points, subtitle }: PointsColumn) =>
PointsColumn({
points,
title: "Points needed to stay on level",
subtitle,
})
export const NextLevelNightsColumn = ({ nights, subtitle }: NightsColumn) =>
PointsColumn({
nights,
title: "Nights needed to level up",
subtitle,
})
async function PointsColumn({
points,
nights,
title,
subtitle,
}: PointsColumnProps) {
const { formatMessage } = await getIntl()
return (
<article className={styles.article}>
<Body
color="white"
textTransform="bold"
textAlign="center"
className={styles.firstRow}
>
{formatMessage({
id: title,
})}
</Body>
<Title color="white" level="h2" textAlign="center">
{points ?? nights ?? "N/A"}
</Title>
{subtitle ? (
<Body color="white" textAlign="center">
{subtitle}
</Body>
) : null}
</article>
)
}
@@ -1,9 +0,0 @@
@media screen and (min-width: 768px) {
.firstRow {
align-content: flex-end;
}
.article {
display: grid;
}
}
@@ -1,43 +0,0 @@
import { MembershipLevelEnum } from "@/constants/membershipLevels"
import { getIntl } from "@/i18n"
import { getLang } from "@/i18n/serverContext"
import { getMembershipLevelObject } from "@/utils/membershipLevel"
import { getMembership } from "@/utils/user"
import PointsContainer from "./Container"
import { NextLevelPointsColumn, YourPointsColumn } from "./PointsColumn"
import { UserProps } from "@/types/components/myPages/user"
export default async function Points({ user }: UserProps) {
const { formatMessage } = await getIntl()
const membership = getMembership(user.memberships)
const nextLevel = getMembershipLevelObject(
membership?.nextLevel as MembershipLevelEnum,
getLang()
)
return (
<PointsContainer>
<YourPointsColumn points={membership?.currentPoints} />
{nextLevel && (
<NextLevelPointsColumn
points={membership?.pointsRequiredToNextlevel}
subtitle={`${formatMessage({ id: "next level:" })} ${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>
)
}
@@ -1,18 +0,0 @@
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 variant="default" color="pale" />
<ExpiringPoints user={user} />
</section>
)
}
@@ -1,11 +0,0 @@
.stats {
align-content: center;
display: grid;
gap: var(--Spacing-x2);
}
@media screen and (min-width: 1367px) {
.stats {
gap: var(--Spacing-x4);
}
}
@@ -1,38 +0,0 @@
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 Divider from "@/components/TempDesignSystem/Divider"
import { getLang } from "@/i18n/serverContext"
import Hero from "./Friend/Hero"
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 serverClient().user.get()
if (!user || "error" in user) {
return null
}
return (
<SectionContainer>
<SectionHeader link={link} preamble={subtitle} title={title} topTitle />
<Hero color="red">
<Friend user={user} color="burgundy" />
<Divider className={styles.divider} color="peach" />
<Stats user={user} />
</Hero>
<SectionLink link={link} variant="mobile" />
</SectionContainer>
)
}
@@ -1,22 +0,0 @@
.divider {
padding-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;
}
}
@@ -1,23 +0,0 @@
.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);
}
@@ -1,12 +0,0 @@
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,
},
},
})
@@ -1,44 +0,0 @@
import { useIntl } from "react-intl"
import { Lang } from "@/constants/languages"
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,
})
// sv hardcoded to force space on thousands
const formatter = new Intl.NumberFormat(Lang.sv)
return (
<Body textTransform="bold" className={classNames}>
{isCalculated
? formatter.format(awardPoints)
: intl.formatMessage({ id: "Points being calculated" })}
</Body>
)
}
@@ -1,53 +0,0 @@
"use client"
import { keepPreviousData } from "@tanstack/react-query"
import { useState } from "react"
import { trpc } from "@/lib/trpc/client"
import LoadingSpinner from "@/components/LoadingSpinner"
import ClientTable from "./ClientTable"
import Pagination from "./Pagination"
import { Transactions } from "@/types/components/myPages/myPage/earnAndBurn"
export default function TransactionTable({
initialJourneyTransactions,
}: {
initialJourneyTransactions: {
data: { transactions: Transactions }
meta: { totalPages: number }
}
}) {
const limit = 5
const [page, setPage] = useState(1)
const { data, isFetching, isLoading } =
trpc.user.transaction.friendTransactions.useQuery(
{
limit,
page,
},
{
// TODO: fix the initial data issues on page load
// initialData: initialJourneyTransactions,
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}
</>
)
}
@@ -1,100 +0,0 @@
"use client"
import { usePathname } from "next/navigation"
import { useIntl } from "react-intl"
import { webviews } from "@/constants/routes/webviews"
import { dt } from "@/lib/dt"
import AwardPoints from "@/components/MyPages/Blocks/Points/EarnAndBurn/AwardPoints"
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 type { RowProps } from "@/types/components/myPages/myPage/earnAndBurn"
import { RewardTransactionTypes } from "@/types/components/myPages/myPage/enums"
export default function Row({ transaction }: RowProps) {
const intl = useIntl()
const lang = useLang()
const pathName = usePathname()
const isWebview = webviews.includes(pathName)
const nightString = `${transaction.nights} ${transaction.nights === 1 ? intl.formatMessage({ id: "night" }) : intl.formatMessage({ id: "nights" })}`
let description =
transaction.hotelName && transaction.city
? `${transaction.hotelName}, ${transaction.city} ${nightString}`
: `${nightString}`
switch (transaction.type) {
case RewardTransactionTypes.stay:
case RewardTransactionTypes.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 RewardTransactionTypes.ancillary:
description = intl.formatMessage({ id: "Extras to your booking" })
break
case RewardTransactionTypes.enrollment:
description = intl.formatMessage({ id: "Sign up bonus" })
break
case RewardTransactionTypes.mastercard_points:
description = intl.formatMessage({ id: "Scandic Friends Mastercard" })
break
case RewardTransactionTypes.tui_points:
description = intl.formatMessage({ id: "TUI Points" })
case RewardTransactionTypes.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 === RewardTransactionTypes.stay ||
transaction.type === RewardTransactionTypes.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>
)
}
@@ -1,18 +0,0 @@
.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);
}
}
@@ -1,57 +0,0 @@
"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"
const tableHeadings = [
"Points",
"Description",
"Booking number",
"Arrival date",
]
export default function ClientTable({ transactions }: ClientTableProps) {
const intl = useIntl()
return (
<div className={styles.container}>
<Table>
<Table.THead>
<Table.TR>
{tableHeadings.map((heading) => (
<Table.TH key={heading}>
<Body textTransform="bold">
{intl.formatMessage({ id: 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>
)
}
@@ -1,68 +0,0 @@
import { ChevronRightIcon } from "@/components/Icons"
import styles from "./pagination.module.css"
import {
PaginationButtonProps,
PaginationProps,
} from "@/types/components/myPages/myPage/earnAndBurn"
function PaginationButton({
children,
isActive,
handleClick,
disabled,
}: React.PropsWithChildren<PaginationButtonProps>) {
return (
<button
type={"button"}
disabled={disabled}
onClick={handleClick}
className={`${styles.paginationButton} ${isActive ? styles.paginationButtonActive : ""}`}
>
{children}
</button>
)
}
export default function Pagination({
pageCount,
isFetching,
handlePageChange,
currentPage,
}: PaginationProps) {
const isOnFirstPage = currentPage === 1
const isOnLastPage = currentPage === pageCount
return (
<div className={styles.pagination}>
<PaginationButton
disabled={isFetching || isOnFirstPage}
handleClick={() => {
handlePageChange(currentPage - 1)
}}
>
<ChevronRightIcon className={styles.chevronLeft} />
</PaginationButton>
{[...Array(pageCount)].map((_, idx) => (
<PaginationButton
isActive={currentPage === idx + 1}
disabled={isFetching || currentPage === idx + 1}
key={idx}
handleClick={() => {
handlePageChange(idx + 1)
}}
>
{idx + 1}
</PaginationButton>
))}
<PaginationButton
disabled={isFetching || isOnLastPage}
handleClick={() => {
handlePageChange(currentPage + 1)
}}
>
<ChevronRightIcon />
</PaginationButton>
</div>
)
}
@@ -1,40 +0,0 @@
.pagination {
display: flex;
justify-content: left;
padding: var(--Spacing-x2);
background-color: var(--Base-Surface-Primary-light-Normal);
border-radius: var(--Corner-radius-Rounded);
margin: auto;
gap: var(--Spacing-x5);
max-width: 100%;
overflow-x: auto;
}
.paginationButton {
background-color: transparent;
border: none;
cursor: pointer;
height: 32px;
width: 32px;
font-size: var(--typography-Body-Bold-fontSize);
font-weight: var(--typography-Body-Bold-fontWeight);
padding: 0;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.paginationButton[disabled] {
cursor: not-allowed;
}
.chevronLeft {
transform: rotate(180deg);
height: 100%;
}
.paginationButtonActive {
color: var(--WHITE);
background-color: var(--Base-Text-Accent);
border-radius: var(--Corner-radius-Rounded);
}
@@ -1,18 +0,0 @@
import { serverClient } from "@/lib/trpc/server"
import ClientJourney from "./Client"
export default async function JourneyTable() {
const initialJourneyTransactions =
await serverClient().user.transaction.friendTransactions({
page: 1,
limit: 5,
})
if (!initialJourneyTransactions?.data) {
return null
}
return (
<ClientJourney initialJourneyTransactions={initialJourneyTransactions} />
)
}
@@ -1,21 +0,0 @@
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 async function EarnAndBurn({
link,
subtitle,
title,
}: AccountPageComponentProps) {
return (
<SectionContainer>
<SectionHeader title={title} link={link} preamble={subtitle} />
<JourneyTable />
<SectionLink link={link} variant="mobile" />
</SectionContainer>
)
}
@@ -1,12 +0,0 @@
.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);
}
}
@@ -1,48 +0,0 @@
"use client"
import { useIntl } from "react-intl"
import { dt } from "@/lib/dt"
import AwardPoints from "@/components/MyPages/Blocks/Points/EarnAndBurn/AwardPoints"
import Table from "@/components/TempDesignSystem/Table"
import Body from "@/components/TempDesignSystem/Text/Body"
import useLang from "@/hooks/useLang"
const tableHeadings = ["Points", "Expiration Date"]
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")
return (
<Table>
<Table.THead>
<Table.TR>
{tableHeadings.map((heading) => (
<Table.TH key={heading}>
<Body textTransform="bold">
{intl.formatMessage({ id: 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>
)
}
@@ -1,25 +0,0 @@
.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);
}
@@ -1,30 +0,0 @@
import { serverClient } from "@/lib/trpc/server"
import SectionContainer from "@/components/Section/Container"
import SectionHeader from "@/components/Section/Header"
import ExpiringPointsTable from "./ExpiringPointsTable"
import { AccountPageComponentProps } from "@/types/components/myPages/myPage/accountPage"
export default async function ExpiringPoints({
link,
subtitle,
title,
}: AccountPageComponentProps) {
const membershipLevel = await serverClient().user.membershipLevel()
if (!membershipLevel?.pointsToExpire || !membershipLevel?.pointsExpiryDate) {
return null
}
return (
<SectionContainer>
<SectionHeader title={title} link={link} preamble={subtitle} />
<ExpiringPointsTable
points={membershipLevel.pointsToExpire}
expirationDate={membershipLevel.pointsExpiryDate}
/>
</SectionContainer>
)
}
@@ -1,61 +0,0 @@
import {
MembershipLevelEnum,
membershipLevels,
} from "@/constants/membershipLevels"
import PointsContainer from "@/components/MyPages/Blocks/Overview/Stats/Points/Container"
import {
NextLevelNightsColumn,
NextLevelPointsColumn,
StayOnLevelColumn,
YourPointsColumn,
} from "@/components/MyPages/Blocks/Overview/Stats/Points/PointsColumn"
import { getIntl } from "@/i18n"
import { getMembershipLevelObject } from "@/utils/membershipLevel"
import { getMembership } from "@/utils/user"
import { UserProps } from "@/types/components/myPages/user"
import { LangParams } from "@/types/params"
/* TODO */
export default async function Points({ user, lang }: UserProps & LangParams) {
const { formatMessage } = await getIntl()
const membership = getMembership(user.memberships)
const nextLevel = getMembershipLevelObject(
membership?.nextLevel as MembershipLevelEnum,
lang
)
return (
<PointsContainer>
<YourPointsColumn points={membership?.currentPoints} />
{nextLevel && (
<>
{membership?.currentPoints ? (
<StayOnLevelColumn
points={membership?.currentPoints} //TODO
subtitle={`${formatMessage({ id: "by" })} ${membership?.expirationDate}`}
/>
) : (
<>
<NextLevelPointsColumn
points={membership?.pointsRequiredToNextlevel}
subtitle={`${formatMessage({ id: "next level:" })} ${nextLevel.name}`}
/>
{membership?.nightsToTopTier && (
<NextLevelNightsColumn
nights={membership.nightsToTopTier}
subtitle={
membership.tierExpirationDate &&
`by ${membership.tierExpirationDate}`
}
/>
)}
</>
)}
</>
)}
</PointsContainer>
)
}
@@ -1,37 +0,0 @@
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 Divider from "@/components/TempDesignSystem/Divider"
import Friend from "../../Overview/Friend"
import Hero from "../../Overview/Friend/Hero"
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 serverClient().user.get()
if (!user || "error" in user) {
return null
}
return (
<SectionContainer>
<SectionHeader link={link} preamble={subtitle} title={title} topTitle />
<Hero color="burgundy">
<Friend user={user} color="red" />
<Divider className={styles.divider} color="peach" />
<Stats user={user} />
</Hero>
<SectionLink link={link} variant="mobile" />
</SectionContainer>
)
}
@@ -1,9 +0,0 @@
.divider {
padding-top: var(--Spacing-x2);
}
@media screen and (min-width: 768px) {
.divider {
display: none;
}
}
@@ -1,37 +0,0 @@
import { ArrowRightIcon } from "@/components/Icons"
import SectionContainer from "@/components/Section/Container"
import SectionHeader from "@/components/Section/Header"
import Link from "@/components/TempDesignSystem/Link"
import Body from "@/components/TempDesignSystem/Text/Body"
import styles from "./shortcuts.module.css"
import type { ShortcutsProps } from "@/types/components/myPages/myPage/shortcuts"
export default function Shortcuts({
firstItem = false,
shortcuts,
subtitle,
title,
}: ShortcutsProps) {
return (
<SectionContainer>
<SectionHeader preamble={subtitle} title={title} topTitle={firstItem} />
<section className={styles.links}>
{shortcuts.map((shortcut) => (
<Link
href={shortcut.url}
key={shortcut.title}
target={shortcut.openInNewTab ? "_blank" : undefined}
variant="shortcut"
>
<Body textTransform="bold" color="burgundy">
<span>{shortcut.text ? shortcut.text : shortcut.title}</span>
</Body>
<ArrowRightIcon color="burgundy" className={styles.arrowRight} />
</Link>
))}
</section>
</SectionContainer>
)
}
@@ -1,11 +0,0 @@
.links {
display: grid;
background-color: var(--Scandic-Brand-Pale-Peach);
border-radius: var(--Corner-radius-Medium);
border: 1px solid var(--Base-Border-Subtle);
}
.arrowRight {
height: 24px;
width: 24px;
}
@@ -1,4 +0,0 @@
.container {
display: grid;
gap: var(--Spacing-x2);
}
@@ -1,5 +0,0 @@
import styles from "./container.module.css"
export default function ListContainer({ children }: React.PropsWithChildren) {
return <section className={styles.container}>{children}</section>
}
@@ -1,63 +0,0 @@
"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 && 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>
)
}
@@ -1,10 +0,0 @@
.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;
}
@@ -1,17 +0,0 @@
import Title from "@/components/TempDesignSystem/Text/Title"
import { getIntl } from "@/i18n"
import styles from "./emptyPreviousStays.module.css"
export default async function EmptyPreviousStaysBlock() {
const { formatMessage } = await getIntl()
return (
<section className={styles.container}>
<Title as="h5" level="h3" color="red" textAlign="center">
{formatMessage({
id: "You have no previous stays.",
})}
</Title>
</section>
)
}
@@ -1,31 +0,0 @@
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>
)
}
@@ -1,4 +0,0 @@
.container {
display: flex;
justify-content: center;
}
@@ -1,32 +0,0 @@
"use client"
import { useIntl } from "react-intl"
import { ChevronDownIcon } from "@/components/Icons"
import Button from "@/components/TempDesignSystem/Button"
import styles from "./button.module.css"
import type { ShowMoreButtonParams } from "@/types/components/myPages/stays/button"
export default function ShowMoreButton({
disabled,
loadMoreData,
}: ShowMoreButtonParams) {
const { formatMessage } = useIntl()
return (
<div className={styles.container}>
<Button
disabled={disabled}
onClick={loadMoreData}
variant="icon"
type="button"
theme="base"
intent="text"
>
<ChevronDownIcon />
{formatMessage({ id: "Show more" })}
</Button>
</div>
)
}
@@ -1,32 +0,0 @@
.container {
display: grid;
grid-template-rows: 1fr min(50px);
background-color: var(--Base-Surface-Primary-light-Normal);
border-radius: var(--Corner-radius-Medium);
min-height: 250px;
margin-bottom: var(--Spacing-x-half);
overflow: hidden;
}
.titleContainer {
display: flex;
justify-content: center;
align-items: center;
background-color: var(--Scandic-Brand-Pale-Peach);
}
.title {
display: flex;
flex-direction: column;
align-items: center;
}
.burgundyTitle {
color: var(--Scandic-Brand-Burgundy);
}
.link {
display: flex;
justify-content: center;
align-items: center;
}
@@ -1,34 +0,0 @@
import { homeHrefs } from "@/constants/homeHrefs"
import { env } from "@/env/server"
import { ArrowRightIcon } from "@/components/Icons"
import Link from "@/components/TempDesignSystem/Link"
import Title from "@/components/TempDesignSystem/Text/Title"
import { getIntl } from "@/i18n"
import { getLang } from "@/i18n/serverContext"
import styles from "./emptyUpcomingStays.module.css"
export default async function EmptyUpcomingStaysBlock() {
const { formatMessage } = await getIntl()
return (
<section className={styles.container}>
<div className={styles.titleContainer}>
<Title as="h5" level="h3" color="red" className={styles.title}>
{formatMessage({ id: "You have no upcoming stays." })}
<span className={styles.burgundyTitle}>
{formatMessage({ id: "Where should you go next?" })}
</span>
</Title>
</div>
<Link
href={homeHrefs[env.NODE_ENV][getLang()]}
className={styles.link}
color="peach80"
>
{formatMessage({ id: "Get inspired" })}
<ArrowRightIcon color="peach80" />
</Link>
</section>
)
}
@@ -1,38 +0,0 @@
import { serverClient } from "@/lib/trpc/server"
import SectionContainer from "@/components/Section/Container"
import SectionHeader from "@/components/Section/Header"
import SectionLink from "@/components/Section/Link"
import Grids from "@/components/TempDesignSystem/Grids"
import StayCard from "../StayCard"
import EmptyUpcomingStaysBlock from "./EmptyUpcomingStays"
import { AccountPageComponentProps } from "@/types/components/myPages/myPage/accountPage"
export default async function SoonestStays({
title,
subtitle,
link,
}: AccountPageComponentProps) {
const response = await serverClient().user.stays.upcoming({ limit: 3 })
if (!response?.data) {
return null
}
return (
<SectionContainer>
<SectionHeader title={title} preamble={subtitle} link={link} />
{response.data.length ? (
<Grids.Stackable>
{response.data.map((stay) => (
<StayCard key={stay.attributes.confirmationNumber} stay={stay} />
))}
</Grids.Stackable>
) : (
<EmptyUpcomingStaysBlock />
)}
<SectionLink link={link} variant="mobile" />
</SectionContainer>
)
}
@@ -1,55 +0,0 @@
"use client"
import { dt } from "@/lib/dt"
import { CalendarIcon } from "@/components/Icons"
import Image from "@/components/Image"
import Link from "@/components/TempDesignSystem/Link"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Title from "@/components/TempDesignSystem/Text/Title"
import useLang from "@/hooks/useLang"
import styles from "./stay.module.css"
import type { StayCardProps } from "@/types/components/myPages/stays/stayCard"
export default function StayCard({ stay }: StayCardProps) {
const lang = useLang()
const { checkinDate, checkoutDate, hotelInformation, bookingUrl } =
stay.attributes
const arrival = dt(checkinDate).locale(lang)
const arrivalDate = arrival.format("DD MMM")
const arrivalDateTime = arrival.format("YYYY-MM-DD")
const depart = dt(checkoutDate).locale(lang)
const departDate = depart.format("DD MMM YYYY")
const departDateTime = depart.format("YYYY-MM-DD")
return (
<Link href={bookingUrl}>
<article className={styles.stay}>
<Image
className={styles.image}
alt={hotelInformation.hotelContent.images.metaData.altText}
src={hotelInformation.hotelContent.images.imageSizes.small}
width={420}
height={240}
/>
<footer className={styles.footer}>
<Title as="h5" className={styles.hotel} level="h3">
{hotelInformation.hotelName}
</Title>
<div className={styles.date}>
<CalendarIcon color="burgundy" height={24} width={24} />
<Caption asChild>
<time dateTime={arrivalDateTime}>{arrivalDate}</time>
</Caption>
{" - "}
<Caption asChild>
<time dateTime={departDateTime}>{departDate}</time>
</Caption>
</div>
</footer>
</article>
</Link>
)
}
@@ -1,43 +0,0 @@
.stay {
background-color: var(--Main-Grey-White);
border: 1px solid var(--Base-Border-Subtle);
border-radius: var(--Corner-radius-Medium);
display: grid;
overflow: hidden;
}
.stay:hover {
border: 1.5px solid var(--Base-Border-Hover);
}
.image {
height: auto;
min-height: 220px;
object-fit: cover;
overflow: hidden;
width: 100%;
}
.footer {
color: var(--Scandic-Brand-Burgundy);
display: grid;
gap: var(--Spacing-x2);
margin-top: auto;
overflow: hidden;
padding: var(--Spacing-x2);
width: 100%;
}
.hotel {
margin: 0;
overflow: hidden;
padding: 0;
text-overflow: ellipsis;
text-wrap: nowrap;
}
.date {
align-items: center;
display: flex;
gap: var(--Spacing-x-half);
}
@@ -1,63 +0,0 @@
"use client"
import { trpc } from "@/lib/trpc/client"
import LoadingSpinner from "@/components/LoadingSpinner"
import Grids from "@/components/TempDesignSystem/Grids"
import ListContainer from "../ListContainer"
import ShowMoreButton from "../ShowMoreButton"
import StayCard from "../StayCard"
import type {
UpcomingStaysClientProps,
UpcomingStaysNonNullResponseObject,
} from "@/types/components/myPages/stays/upcoming"
export default function ClientUpcomingStays({
initialUpcomingStays,
}: UpcomingStaysClientProps) {
const { data, isFetching, fetchNextPage, hasNextPage, isLoading } =
trpc.user.stays.upcoming.useInfiniteQuery(
{
limit: 6,
},
{
getNextPageParam: (lastPage) => {
return lastPage?.nextCursor
},
initialData: {
pageParams: [undefined, 1],
pages: [initialUpcomingStays],
},
}
)
function loadMoreData() {
if (hasNextPage) {
fetchNextPage()
}
}
// TS having a hard time with the filtered type.
// This is only temporary as we will not return null
// later on when we handle errors appropriately.
const filteredStays = (data?.pages.filter((page) => page && page.data) ??
[]) as unknown as UpcomingStaysNonNullResponseObject[]
const stays = filteredStays.flatMap((page) => page.data)
return isLoading ? (
<LoadingSpinner />
) : stays.length ? (
<ListContainer>
<Grids.Stackable>
{stays.map((stay) => (
<StayCard key={stay.attributes.confirmationNumber} stay={stay} />
))}
</Grids.Stackable>
{hasNextPage ? (
<ShowMoreButton disabled={isFetching} loadMoreData={loadMoreData} />
) : null}
</ListContainer>
) : null
}
@@ -1,32 +0,0 @@
.container {
display: grid;
grid-template-rows: 1fr min(50px);
background-color: var(--Base-Surface-Primary-light-Normal);
border-radius: var(--Corner-radius-Medium);
min-height: 250px;
margin-bottom: var(--Spacing-x-half);
overflow: hidden;
}
.titleContainer {
display: flex;
justify-content: center;
align-items: center;
background-color: var(--Scandic-Brand-Pale-Peach);
}
.title {
display: flex;
flex-direction: column;
align-items: center;
}
.burgundyTitle {
color: var(--Scandic-Brand-Burgundy);
}
.link {
display: flex;
justify-content: center;
align-items: center;
}
@@ -1,34 +0,0 @@
import { homeHrefs } from "@/constants/homeHrefs"
import { env } from "@/env/server"
import { ArrowRightIcon } from "@/components/Icons"
import Link from "@/components/TempDesignSystem/Link"
import Title from "@/components/TempDesignSystem/Text/Title"
import { getIntl } from "@/i18n"
import { getLang } from "@/i18n/serverContext"
import styles from "./emptyUpcomingStays.module.css"
export default async function EmptyUpcomingStaysBlock() {
const { formatMessage } = await getIntl()
return (
<section className={styles.container}>
<div className={styles.titleContainer}>
<Title as="h5" level="h3" color="red" className={styles.title}>
{formatMessage({ id: "You have no upcoming stays." })}
<span className={styles.burgundyTitle}>
{formatMessage({ id: "Where should you go next?" })}
</span>
</Title>
</div>
<Link
href={homeHrefs[env.NODE_ENV][getLang()]}
className={styles.link}
color="peach80"
>
{formatMessage({ id: "Get inspired" })}
<ArrowRightIcon color="peach80" />
</Link>
</section>
)
}
@@ -1,34 +0,0 @@
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 ClientUpcomingStays from "./Client"
import EmptyUpcomingStaysBlock from "./EmptyUpcomingStays"
import styles from "./upcoming.module.css"
import type { AccountPageComponentProps } from "@/types/components/myPages/myPage/accountPage"
export default async function UpcomingStays({
title,
subtitle,
link,
}: AccountPageComponentProps) {
const initialUpcomingStays = await serverClient().user.stays.upcoming({
limit: 6,
})
return (
<SectionContainer className={styles.container}>
<SectionHeader title={title} preamble={subtitle} link={link} />
{initialUpcomingStays?.data.length ? (
<ClientUpcomingStays initialUpcomingStays={initialUpcomingStays} />
) : (
<EmptyUpcomingStaysBlock />
)}
<SectionLink link={link} variant="mobile" />
</SectionContainer>
)
}
@@ -1,3 +0,0 @@
.container {
display: inline-grid;
}
@@ -1,30 +0,0 @@
.breadcrumbs {
display: block;
padding-left: var(--Spacing-x2);
padding-right: var(--Spacing-x2);
padding-top: var(--Spacing-x2);
}
.list {
align-items: center;
display: flex;
gap: var(--Spacing-x-quarter);
justify-content: flex-start;
list-style: none;
}
.listItem {
align-items: center;
display: flex;
}
.homeLink {
display: flex;
}
@media screen and (min-width: 1367px) {
.breadcrumbs {
padding-left: var(--Spacing-x5);
padding-right: var(--Spacing-x5);
}
}
-60
View File
@@ -1,60 +0,0 @@
import { serverClient } from "@/lib/trpc/server"
import { ChevronRightIcon, HouseIcon } from "@/components/Icons"
import Link from "@/components/TempDesignSystem/Link"
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
import styles from "./breadcrumbs.module.css"
export default async function Breadcrumbs() {
const breadcrumbs = await serverClient().contentstack.breadcrumbs.get()
if (!breadcrumbs?.length) {
return null
}
const homeBreadcrumb = breadcrumbs.shift()
return (
<nav className={styles.breadcrumbs}>
<ul className={styles.list}>
{homeBreadcrumb ? (
<li className={styles.listItem}>
<Link
className={styles.homeLink}
color="peach80"
href={homeBreadcrumb.href!}
variant="breadcrumb"
>
<HouseIcon color="peach80" />
</Link>
<ChevronRightIcon aria-hidden="true" color="peach80" />
</li>
) : null}
{breadcrumbs.map((breadcrumb) => {
if (breadcrumb.href) {
return (
<li key={breadcrumb.uid} className={styles.listItem}>
<Link
color="peach80"
href={breadcrumb.href}
variant="breadcrumb"
>
{breadcrumb.title}
</Link>
<ChevronRightIcon aria-hidden="true" color="peach80" />
</li>
)
}
return (
<li key={breadcrumb.uid} className={styles.listItem}>
<Footnote color="burgundy" textTransform="bold">
{breadcrumb.title}
</Footnote>
</li>
)
})}
</ul>
</nav>
)
}