Merged in feat/LOY-391-my-points-transactions-table-design (pull request #3415)

Feat/LOY-391 my points transactions table design

* feat(LOY-391): Added new design to point transaction table

* fix(LOY-391): rebase fix

* fix(LOY-391): fix

* fix(LOY-391): fix

* fix(LOY-391): fixed sticky header etc.

* feat(LOY-391): added focus on the newest loaded item in the list

* fix(LOY-391): cleaned up

* fix(LOY-391): style fix

* fix(LOY-391): fixed PR-comments, types, removed the old files for earn and burn table

* fix(LOY-391): fixed PR-comments

* feat(LOY-391): added useCallback so scrolling is avoided when clicking see all on expiring points


Approved-by: Anton Gunnarsson
Approved-by: Matilda Landström
This commit is contained in:
Emma Zettervall
2026-01-20 08:41:09 +00:00
parent 8b56fa84e7
commit 5af64ef896
23 changed files with 473 additions and 347 deletions
@@ -0,0 +1,27 @@
import { Link } from "react-aria-components"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
export function ConditionalPointTransactionLink({
href,
children,
className,
canLinkBookingUrl,
focusRef,
}: {
href?: string
children: React.ReactNode
className?: string
canLinkBookingUrl: boolean
focusRef: React.Ref<HTMLAnchorElement>
}) {
if (canLinkBookingUrl) {
return (
<Link className={className} href={href} ref={focusRef}>
{children}
<MaterialIcon icon="arrow_forward" color="Icon/Interactive/Default" />
</Link>
)
}
return <div className={className}>{children}</div>
}
@@ -0,0 +1,161 @@
"use client"
import React from "react"
import { type IntlShape, useIntl } from "react-intl"
import {
BALFWD,
NON_TRANSACTIONAL,
} from "@scandic-hotels/common/constants/transactionType"
import { dt } from "@scandic-hotels/common/dt"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { Transactions } from "@scandic-hotels/trpc/enums/transactions"
import { ConditionalPointTransactionLink } from "../ConditionalPointTransactionLink"
import styles from "../pointTransactionList.module.css"
import type { Transaction } from "@/types/components/myPages/myPage/earnAndBurn"
export function PointTransactionRow({
transaction,
lang,
focusRef,
}: {
transaction: Transaction
lang: string
focusRef: React.Ref<HTMLAnchorElement>
}) {
const intl = useIntl()
const { confirmationNumber, bookingUrl, checkinDate, awardPoints } =
transaction.attributes
const balfwd = confirmationNumber === BALFWD
const nonTransactional = confirmationNumber === NON_TRANSACTIONAL
const day = checkinDate.split("-")[2].replace(/^0/, "")
const month = dt(checkinDate.split("-")[1]).locale(lang).format("MMM")
const formattedPoints = intl.formatNumber(Math.abs(awardPoints))
const calculatedPoints =
awardPoints === 0
? formattedPoints
: `${awardPoints > 0 ? "+" : "-"} ${formattedPoints}`
const canLinkBookingUrl = !balfwd && !nonTransactional
const description = getDescription(transaction, intl)
return (
<ConditionalPointTransactionLink
className={styles.row}
href={bookingUrl}
canLinkBookingUrl={canLinkBookingUrl}
focusRef={focusRef}
>
<div className={styles.date}>
<Typography variant="Title/Subtitle/lg">
<time>{day}</time>
</Typography>
<Typography variant="Tag/sm">
<time>{month}</time>
</Typography>
</div>
<div className={styles.textArea}>
<Typography variant="Body/Paragraph/mdRegular">
<p>
{intl.formatMessage(
{
id: "common.pointsInLine",
defaultMessage: "{points} points",
},
{
points: (
<Typography variant="Body/Paragraph/mdBold" key="points">
<strong>{calculatedPoints}</strong>
</Typography>
),
}
)}
</p>
</Typography>
<Typography
className={styles.description}
variant="Body/Supporting text (caption)/smRegular"
>
<p>{description}</p>
</Typography>
</div>
</ConditionalPointTransactionLink>
)
}
function getDescription(transaction: Transaction, intl: IntlShape) {
const hotelInformation = transaction.attributes.hotelInformation
const balfwd = transaction.attributes.confirmationNumber === BALFWD
const nonTransactional =
transaction.attributes.confirmationNumber === NON_TRANSACTIONAL
switch (transaction.type) {
case Transactions.rewardType.stay:
return nonTransactional && transaction.attributes.nights === 0
? intl.formatMessage({
id: "myPoints.pointTransactions.pointsActivity",
defaultMessage: "Point activity",
})
: hotelInformation?.name
? intl.formatMessage(
{
id: "myPoints.pointTransactions.stayAt",
defaultMessage: "Stay at {hotelName}",
},
{ hotelName: hotelInformation?.name }
)
: ""
case Transactions.rewardType.stayAdj:
if (transaction.attributes.hotelOperaId === "ORS") {
return intl.formatMessage({
id: "earnAndBurn.journeyTable.formerScandicHotel",
defaultMessage: "Former Scandic Hotel",
})
}
if (balfwd) {
return intl.formatMessage({
id: "myPoints.pointTransactions.pointsEarnedPriorMay2021",
defaultMessage: "Points earned prior to May 1, 2021",
})
}
break
case Transactions.rewardType.ancillary:
return intl.formatMessage({
id: "myPoints.pointTransactions.extrasToBooking",
defaultMessage: "Extras to your booking",
})
case Transactions.rewardType.enrollment:
return intl.formatMessage({
id: "myPoints.pointTransactions.signUpBonus",
defaultMessage: "Sign up bonus",
})
case Transactions.rewardType.mastercard_points:
return intl.formatMessage({
id: "myPoints.pointTransactions.scandicFriendsMastercard",
defaultMessage: "Scandic Friends Mastercard",
})
case Transactions.rewardType.tui_points:
return intl.formatMessage({
id: "myPoints.pointTransactions.tuiPoints",
defaultMessage: "TUI Points",
})
case Transactions.rewardType.pointShop:
return intl.formatMessage({
id: "myPoints.pointTransactions.pointShop",
defaultMessage: "Scandic Friends Point Shop",
})
}
}
@@ -0,0 +1,145 @@
"use client"
import { Fragment, useCallback, useRef } from "react"
import { useIntl } from "react-intl"
import useStickyPosition from "@scandic-hotels/common/hooks/useStickyPosition"
import { StickyElementNameEnum } from "@scandic-hotels/common/stores/sticky-position"
import { Divider } from "@scandic-hotels/design-system/Divider"
import { LoadingSpinner } from "@scandic-hotels/design-system/LoadingSpinner"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { trpc } from "@scandic-hotels/trpc/client"
import ShowMoreButton from "@/components/TempDesignSystem/ShowMoreButton"
import useLang from "@/hooks/useLang"
import { PointTransactionRow } from "./PointTransactionRow"
import styles from "./pointTransactionList.module.css"
import type { Transactions } from "@/types/components/myPages/myPage/earnAndBurn"
export function PointTransactionList() {
const lang = useLang()
const intl = useIntl()
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } =
trpc.user.transaction.friendTransactions.useInfiniteQuery(
{
limit: 10,
lang,
},
{
getNextPageParam: (lastPage) => {
return lastPage?.nextCursor
},
}
)
const transactions = data?.pages
.filter((page): page is Transactions => !!page?.data)
.flatMap((page) => page.data)
const groupedTransactions =
transactions?.reduce<Record<number, typeof transactions>>(
(acc, transaction) => {
const year = new Date(transaction.attributes.checkinDate).getFullYear()
if (!acc[year]) acc[year] = []
acc[year].push(transaction)
return acc
},
{}
) ?? {}
const sortedYears = Object.keys(groupedTransactions)
.map(Number)
.sort((a, b) => b - a)
const lastPage = data?.pages?.[data.pages.length - 1]
const firstNewTransaction = lastPage?.data?.[0]
const headerRef = useRef<HTMLDivElement>(null)
useStickyPosition({
ref: headerRef,
name: StickyElementNameEnum.TRANSACTION_LIST_HEADER,
})
const isEmpty = !isLoading && (transactions?.length ?? 0) === 0
function loadMoreData() {
if (hasNextPage) {
shouldFocusNextItem.current = true
fetchNextPage()
}
}
const shouldFocusNextItem = useRef(false)
const focusRef = useCallback((node: HTMLAnchorElement | null) => {
if (!node || !shouldFocusNextItem.current) return
node.focus()
shouldFocusNextItem.current = false
}, [])
return (
<div className={styles.container}>
<div className={styles.list}>
{isLoading && <LoadingSpinner />}
{isEmpty && (
<div className={styles.emptyState}>
<Typography variant="Body/Paragraph/mdRegular">
<p>
{intl.formatMessage({
id: "myPoints.pointTransactions.noTransactions",
defaultMessage: "No transactions available",
})}
</p>
</Typography>
</div>
)}
{!isLoading &&
!isEmpty &&
sortedYears.map((year) => (
<Fragment key={year}>
<div ref={headerRef} className={styles.yearHeader}>
<Typography variant="Title/Overline/sm">
<h2>{year}</h2>
</Typography>
</div>
<ul className={styles.listContent}>
{groupedTransactions[year].map((transaction, i) => {
const isFirstNewItem = transaction === firstNewTransaction
return (
<li key={`${year}-${i}`}>
<PointTransactionRow
transaction={transaction}
lang={lang}
focusRef={isFirstNewItem ? focusRef : null}
/>
{i < groupedTransactions[year].length - 1 && (
<div className={styles.divider}>
<Divider />
</div>
)}
</li>
)
})}
</ul>
</Fragment>
))}
</div>
<div className={styles.showMoreButton}>
{isFetchingNextPage ? (
<LoadingSpinner />
) : hasNextPage ? (
<ShowMoreButton
loadMoreData={loadMoreData}
textShowMore={intl.formatMessage({
id: "myPoints.pointTransactions.showMoreTransactions",
defaultMessage: "Show more transactions",
})}
/>
) : null}
</div>
</div>
)
}
@@ -0,0 +1,94 @@
.container {
display: flex;
flex-direction: column;
gap: var(--Space-x2);
}
.list {
border-radius: var(--Corner-radius-md);
border: 1px solid var(--Border-Default);
background: var(--Surface-Primary-Default);
}
.emptyState {
display: flex;
justify-content: center;
padding: var(--Space-x4);
color: var(--Text-Tertiary);
}
.yearHeader {
display: flex;
width: 100%;
padding: 0 var(--Space-x2);
background: var(--Surface-Brand-Primary-1-Default);
color: var(--Text-Brand-OnPrimary-1-Heading);
height: 37px;
align-items: center;
position: sticky;
}
.yearHeader:first-child {
border-radius: calc(var(--Corner-radius-md) - 1px)
calc(var(--Corner-radius-md) - 1px) 0 0;
}
.listContent {
list-style: none;
}
.divider {
padding: 0 var(--Space-x2);
width: 100%;
}
.row {
display: flex;
gap: var(--Space-x2);
padding: var(--Space-x2);
align-items: center;
text-decoration: none;
color: var(--Text-Default);
}
.date {
display: flex;
min-width: 50px;
min-height: 50px;
padding: var(--Space-x05);
flex-direction: column;
align-items: center;
justify-content: center;
border-radius: var(--Corner-radius-sm);
color: var(--Text-Tertiary);
background: var(--Surface-Secondary-Default, #f2ece6);
}
.date > * + * {
margin-top: -4px;
}
.textArea {
display: flex;
flex-direction: column;
justify-content: center;
align-items: flex-start;
gap: var(--Space-x05);
flex: 1 0 0;
}
.points {
display: flex;
height: var(--Space-x3);
}
.description {
display: flex;
height: var(--Space-x3);
align-items: center;
}
.showMoreButton {
display: flex;
justify-content: center;
}
@@ -0,0 +1,25 @@
import { Section } from "@/components/Section"
import { SectionHeader } from "@/components/Section/Header"
import ClaimPoints from "../ClaimPoints"
import { PointTransactionList } from "./PointTransactionList"
import styles from "./pointTransactions.module.css"
import type { AccountPageComponentProps } from "@/types/components/myPages/myPage/accountPage"
export function PointTransactions({
link,
subtitle,
title,
}: AccountPageComponentProps) {
return (
<Section>
<div className={styles.header}>
<SectionHeader link={link} preamble={subtitle} heading={title} />
<ClaimPoints />
</div>
<PointTransactionList />
</Section>
)
}
@@ -0,0 +1,14 @@
.header {
display: flex;
flex-direction: column;
justify-content: space-between;
gap: var(--Space-x1);
}
@media screen and (min-width: 1367px) {
.header {
align-items: center;
flex-direction: row;
gap: var(--Space-x6);
}
}