Merge remote-tracking branch 'origin' into feature/tracking

This commit is contained in:
Linus Flood
2024-12-13 09:02:37 +01:00
329 changed files with 4494 additions and 1910 deletions

View File

@@ -12,7 +12,7 @@ import SectionWrapper from "../SectionWrapper"
import styles from "./loyaltyLevels.module.css"
import { LoyaltyLevelsProps } from "@/types/components/blocks/dynamicContent"
import type { LoyaltyLevelsProps } from "@/types/components/blocks/dynamicContent"
import type { LevelCardProps } from "@/types/components/overviewTable"
export default async function LoyaltyLevels({

View File

@@ -1,6 +1,6 @@
import { VariantProps } from "class-variance-authority"
import type { VariantProps } from "class-variance-authority"
import { heroVariants } from "./heroVariants"
import type { heroVariants } from "./heroVariants"
export interface HeroProps
extends Omit<React.HTMLAttributes<HTMLDivElement>, "color">,

View File

@@ -1,6 +1,7 @@
import { HeroProps } from "./hero"
import { heroVariants } from "./heroVariants"
import type { HeroProps } from "./hero"
export default function Hero({ className, color, children }: HeroProps) {
const classNames = heroVariants({ className, color })
return <section className={classNames}>{children}</section>

View File

@@ -7,7 +7,7 @@ import { getMembership } from "@/utils/user"
import PointsContainer from "./Container"
import { NextLevelPointsColumn, YourPointsColumn } from "./PointsColumn"
import { UserProps } from "@/types/components/myPages/user"
import type { UserProps } from "@/types/components/myPages/user"
export default async function Points({ user }: UserProps) {
const intl = await getIntl()

View File

@@ -4,7 +4,7 @@ import { useReducer } from "react"
import { useIntl } from "react-intl"
import {
MembershipLevel,
type MembershipLevel,
MembershipLevelEnum,
} from "@/constants/membershipLevels"
@@ -22,8 +22,8 @@ import styles from "./overviewTable.module.css"
import type { Key } from "react-aria-components"
import {
ComparisonLevel,
DesktopSelectColumns,
type ComparisonLevel,
type DesktopSelectColumns,
type MobileColumnHeaderProps,
OverviewTableActionsEnum,
type OverviewTableClientProps,

View File

@@ -4,7 +4,7 @@ import { serverClient } from "@/lib/trpc/server"
import SectionWrapper from "../SectionWrapper"
import OverviewTableClient from "./Client"
import { OverviewTableProps } from "@/types/components/blocks/dynamicContent"
import type { OverviewTableProps } from "@/types/components/blocks/dynamicContent"
export default async function OverviewTable({
dynamic_content,

View File

@@ -9,7 +9,7 @@ import {
type LevelWithRewards,
OverviewTableActionsEnum,
type OverviewTableClientProps,
OverviewTableReducerAction,
type OverviewTableReducerAction,
} from "@/types/components/overviewTable"
export function getLevel(

View File

@@ -6,11 +6,11 @@ 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"
import Pagination from "./Pagination"
import { Transactions } from "@/types/components/myPages/myPage/earnAndBurn"
import type { Transactions } from "@/types/components/myPages/myPage/earnAndBurn"
export default function TransactionTable({
initialJourneyTransactions,

View File

@@ -1,72 +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}
height={20}
width={20}
/>
</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 height={20} width={20} />
</PaginationButton>
</div>
)
}

View File

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

View File

@@ -5,7 +5,7 @@ import SectionHeader from "@/components/Section/Header"
import ExpiringPointsTable from "./ExpiringPointsTable"
import { AccountPageComponentProps } from "@/types/components/myPages/myPage/accountPage"
import type { AccountPageComponentProps } from "@/types/components/myPages/myPage/accountPage"
export default async function ExpiringPoints({
link,

View File

@@ -12,8 +12,8 @@ import {
YourPointsColumn,
} from "../../../Overview/Stats/Points/PointsColumn"
import { UserProps } from "@/types/components/myPages/user"
import { LangParams } from "@/types/params"
import type { UserProps } from "@/types/components/myPages/user"
import type { LangParams } from "@/types/params"
/* TODO */
export default async function Points({ user, lang }: UserProps & LangParams) {

View File

@@ -1,76 +0,0 @@
"use client"
import { trpc } from "@/lib/trpc/client"
import { Reward } from "@/server/routers/contentstack/reward/output"
import LoadingSpinner from "@/components/LoadingSpinner"
import Grids from "@/components/TempDesignSystem/Grids"
import ShowMoreButton from "@/components/TempDesignSystem/ShowMoreButton"
import Title from "@/components/TempDesignSystem/Text/Title"
import useLang from "@/hooks/useLang"
import styles from "./current.module.css"
type CurrentRewardsClientProps = {
initialCurrentRewards: { rewards: Reward[]; nextCursor: number | undefined }
}
export default function ClientCurrentRewards({
initialCurrentRewards,
}: CurrentRewardsClientProps) {
const lang = useLang()
const { data, isFetching, fetchNextPage, hasNextPage, isLoading } =
trpc.contentstack.rewards.current.useInfiniteQuery(
{
limit: 3,
lang,
},
{
getNextPageParam: (lastPage) => lastPage?.nextCursor,
initialData: {
pageParams: [undefined, 1],
pages: [initialCurrentRewards],
},
}
)
function loadMoreData() {
if (hasNextPage) {
fetchNextPage()
}
}
const filteredRewards =
data?.pages.filter((page) => page && page.rewards) ?? []
const rewards = filteredRewards.flatMap((page) => page?.rewards) as Reward[]
if (isLoading) {
return <LoadingSpinner />
}
if (!rewards.length) {
return null
}
return (
<div>
<Grids.Stackable>
{rewards.map((reward, idx) => (
<article className={styles.card} key={`${reward.reward_id}-${idx}`}>
<Title
as="h4"
level="h3"
textAlign="center"
textTransform="regular"
>
{reward.label}
</Title>
</article>
))}
</Grids.Stackable>
{hasNextPage &&
(isFetching ? (
<LoadingSpinner />
) : (
<ShowMoreButton loadMoreData={loadMoreData} />
))}
</div>
)
}

View File

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

View File

@@ -0,0 +1,73 @@
"use client"
import { useRef, useState } from "react"
import { RewardIcon } from "@/components/Blocks/DynamicContent/Rewards/RewardIcon"
import Pagination from "@/components/MyPages/Pagination"
import Grids from "@/components/TempDesignSystem/Grids"
import Title from "@/components/TempDesignSystem/Text/Title"
import Redeem from "./Redeem"
import styles from "./current.module.css"
import type { CurrentRewardsClientProps } from "@/types/components/myPages/myPage/accountPage"
export default function ClientCurrentRewards({
rewards,
pageSize,
showRedeem,
}: CurrentRewardsClientProps) {
const containerRef = useRef<HTMLDivElement>(null)
const [currentPage, setCurrentPage] = useState(1)
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} />
<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} />
</div>
)}
</article>
))}
</Grids.Stackable>
{totalPages > 1 && (
<Pagination
pageCount={totalPages}
currentPage={currentPage}
handlePageChange={handlePageChange}
/>
)}
</div>
)
}

View File

@@ -0,0 +1,191 @@
"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 Countdown from "@/components/Countdown"
import { CheckCircleIcon, CloseLargeIcon } from "@/components/Icons"
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 { RewardIcon } from "../RewardIcon"
import styles from "./current.module.css"
import type {
RedeemModalState,
RedeemProps,
RedeemStep,
} from "@/types/components/myPages/myPage/accountPage"
const MotionOverlay = motion(ModalOverlay)
const MotionModal = motion(Modal)
export default function Redeem({ reward }: RedeemProps) {
const [animation, setAnimation] = useState<RedeemModalState>("unmounted")
const intl = useIntl()
const update = trpc.contentstack.rewards.redeem.useMutation()
const [redeemStep, setRedeemStep] = useState<RedeemStep>("initial")
function onProceed() {
if (reward.id) {
update.mutate(
{ rewardId: reward.id },
{
onSuccess() {
setRedeemStep("redeemed")
},
onError(error) {
console.error("Failed to redeem", error)
},
}
)
}
}
function modalStateHandler(newAnimationState: RedeemModalState) {
setAnimation((currentAnimationState) =>
newAnimationState === "hidden" && currentAnimationState === "hidden"
? "unmounted"
: currentAnimationState
)
if (newAnimationState === "unmounted") {
setRedeemStep("initial")
}
}
return (
<DialogTrigger
onOpenChange={(isOpen) => setAnimation(isOpen ? "visible" : "hidden")}
>
<Button intent="primary" fullWidth>
{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 }) => (
<>
<header className={styles.modalHeader}>
<button
onClick={close}
type="button"
className={styles.modalClose}
>
<CloseLargeIcon />
</button>
</header>
<div className={styles.modalContent}>
{redeemStep === "redeemed" && (
<div className={styles.badge}>
<div className={styles.redeemed}>
<CheckCircleIcon color="uiSemanticSuccess" />
<Caption>
{intl.formatMessage({
id: "Redeemed & valid through:",
})}
</Caption>
</div>
<Countdown />
</div>
)}
<RewardIcon rewardId={reward.reward_id} />
<Title level="h3" textAlign="center" textTransform="regular">
{reward.label}
</Title>
{redeemStep === "initial" && (
<Body textAlign="center">{reward.description}</Body>
)}
{redeemStep === "confirmation" &&
"redeem_description" in reward && (
<Body textAlign="center">
{reward.redeem_description}
</Body>
)}
</div>
{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={onProceed}
disabled={update.isPending}
intent="primary"
theme="base"
>
{intl.formatMessage({ id: "Yes, redeem" })}
</Button>
<Button onClick={close} intent="secondary" theme="base">
{intl.formatMessage({ id: "Go back" })}
</Button>
</footer>
)}
</>
)}
</Dialog>
</MotionModal>
</MotionOverlay>
</DialogTrigger>
)
}
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" },
},
},
}

View File

@@ -0,0 +1,132 @@
.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);
}
.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: 0px 4px 24px 0px rgba(38, 32, 30, 0.08);
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;
}

View File

@@ -1,3 +1,4 @@
import { env } from "@/env/server"
import { serverClient } from "@/lib/trpc/server"
import SectionContainer from "@/components/Section/Container"
@@ -13,19 +14,20 @@ export default async function CurrentRewardsBlock({
subtitle,
link,
}: AccountPageComponentProps) {
const initialCurrentRewards =
await serverClient().contentstack.rewards.current({
limit: 3,
})
const rewardsResponse = await serverClient().contentstack.rewards.current()
if (!initialCurrentRewards) {
if (!rewardsResponse?.rewards.length) {
return null
}
return (
<SectionContainer>
<SectionHeader title={title} link={link} preamble={subtitle} />
<ClientCurrentRewards initialCurrentRewards={initialCurrentRewards} />
<ClientCurrentRewards
rewards={rewardsResponse.rewards}
pageSize={6}
showRedeem={env.USE_NEW_REWARDS_ENDPOINT && env.USE_NEW_REWARD_MODEL}
/>
<SectionLink link={link} variant="mobile" />
</SectionContainer>
)

View File

@@ -0,0 +1,68 @@
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 { RewardId } from "@/types/enums/rewards"
function getIconForRewardId(rewardId: RewardId): IconName {
switch (rewardId) {
// Food & beverage
case RewardId.TenPercentFood:
case RewardId.FifteenPercentFood:
return IconName.CroissantCoffeeEgg
case RewardId.TwoForOneBreakfast:
return IconName.CutleryTwo
case RewardId.FreeBreakfast:
return IconName.CutleryOne
case RewardId.FreeKidsDrink:
return IconName.KidsMocktail
// Monetary vouchers
case RewardId.Bonus50SEK:
case RewardId.Bonus75SEK:
case RewardId.Bonus100SEK:
case RewardId.Bonus150SEK:
case RewardId.Bonus200SEK:
return IconName.Voucher
// Hotel perks
case RewardId.EarlyCheckin:
return IconName.HandKey
case RewardId.LateCheckout:
return IconName.HotelNight
case RewardId.FreeUpgrade:
return IconName.MagicWand
case RewardId.RoomGuarantee48H:
return IconName.Bed
// Earnings
case RewardId.EarnRate25Percent:
case RewardId.EarnRate50Percent:
return IconName.MoneyHand
case RewardId.StayBoostForKids:
return IconName.Kids
case RewardId.MemberRate:
return IconName.Coin
// Special
case RewardId.YearlyExclusiveGift:
return IconName.GiftOpen
default: {
const unhandledRewardId: never = rewardId
return IconName.GiftOpen
}
}
}
export function mapRewardToIcon(rewardId: string): FC<IconProps> | null {
if (!isValidRewardId(rewardId)) {
// TODO: Update once UX has decided on fallback icon.
return getIconByIconName(IconName.GiftOpen)
}
const iconName = getIconForRewardId(rewardId)
return getIconByIconName(iconName)
}

View File

@@ -0,0 +1,27 @@
import { mapRewardToIcon } from "./data"
import type { RewardIconProps } from "@/types/components/myPages/rewards"
// Original SVG aspect ratio is 358:202 (≈1.77:1)
const sizeMap = {
small: { width: 120, height: 68 }, // 40% of card width
medium: { width: 180, height: 102 }, // 60% of card width
large: { width: 240, height: 135 }, // 80% of card width
} as const
export function RewardIcon({
rewardId,
size = "medium",
...props
}: RewardIconProps) {
const IconComponent = mapRewardToIcon(rewardId)
if (!IconComponent) return null
return (
<IconComponent
{...props}
width={sizeMap[size].width}
height={sizeMap[size].height}
/>
)
}

View File

@@ -6,7 +6,7 @@ import { getProfileSafely } from "@/lib/trpc/memoizedRequests"
import SignupForm from "@/components/Forms/Signup"
import { getLang } from "@/i18n/serverContext"
import { SignupFormWrapperProps } from "@/types/components/blocks/dynamicContent"
import type { SignupFormWrapperProps } from "@/types/components/blocks/dynamicContent"
export default async function SignupFormWrapper({
dynamic_content,

View File

@@ -8,7 +8,7 @@ import Grids from "@/components/TempDesignSystem/Grids"
import StayCard from "../StayCard"
import EmptyUpcomingStaysBlock from "./EmptyUpcomingStays"
import { AccountPageComponentProps } from "@/types/components/myPages/myPage/accountPage"
import type { AccountPageComponentProps } from "@/types/components/myPages/myPage/accountPage"
export default async function SoonestStays({
title,

View File

@@ -9,7 +9,7 @@ import OverviewTable from "@/components/Blocks/DynamicContent/OverviewTable"
import EarnAndBurn from "@/components/Blocks/DynamicContent/Points/EarnAndBurn"
import ExpiringPoints from "@/components/Blocks/DynamicContent/Points/ExpiringPoints"
import PointsOverview from "@/components/Blocks/DynamicContent/Points/Overview"
import CurrentRewardsBlock from "@/components/Blocks/DynamicContent/Rewards/CurrentLevel"
import CurrentRewardsBlock from "@/components/Blocks/DynamicContent/Rewards/CurrentRewards"
import NextLevelRewardsBlock from "@/components/Blocks/DynamicContent/Rewards/NextLevel"
import SignupFormWrapper from "@/components/Blocks/DynamicContent/SignupFormWrapper"
import SignUpVerification from "@/components/Blocks/DynamicContent/SignUpVerification"