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:
+27
@@ -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>
|
||||
}
|
||||
+161
@@ -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",
|
||||
})
|
||||
}
|
||||
}
|
||||
+145
@@ -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>
|
||||
)
|
||||
}
|
||||
+94
@@ -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>
|
||||
)
|
||||
}
|
||||
+14
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user