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

View File

@@ -1,45 +0,0 @@
"use client"
import { keepPreviousData } from "@tanstack/react-query"
import { useState } from "react"
import { LoadingSpinner } from "@scandic-hotels/design-system/LoadingSpinner"
import { trpc } from "@scandic-hotels/trpc/client"
import Pagination from "@/components/MyPages/Pagination"
import useLang from "@/hooks/useLang"
import ClientTable from "./ClientTable"
export default function TransactionTable() {
const limit = 5
const [page, setPage] = useState(1)
const lang = useLang()
const { data, isFetching, isLoading } =
trpc.user.transaction.friendTransactions.useQuery(
{
limit,
page,
lang,
},
{
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}
</>
)
}

View File

@@ -1,145 +0,0 @@
"use client"
import { usePathname } from "next/navigation"
import { useIntl } from "react-intl"
import { dt } from "@scandic-hotels/common/dt"
import Table from "@scandic-hotels/design-system/Table"
import { TextLink } from "@scandic-hotels/design-system/TextLink"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { Transactions } from "@scandic-hotels/trpc/enums/transactions"
import { webviews } from "@/constants/routes/webviews"
import useLang from "@/hooks/useLang"
import AwardPoints from "../../../AwardPoints"
import type { Transaction } from "@/types/components/myPages/myPage/earnAndBurn"
interface RowProps {
transaction: Transaction
}
export default function Row({ transaction }: RowProps) {
const intl = useIntl()
const lang = useLang()
const pathName = usePathname()
const isWebview = webviews.includes(pathName)
const { hotelName, city } = transaction
const nightsMsg = intl.formatMessage(
{
id: "booking.numberOfNights",
defaultMessage: "{totalNights, plural, one {# night} other {# nights}}",
},
{
totalNights: transaction.nights,
}
)
let description =
transaction.confirmationNumber === "non-transactional" &&
transaction.nights === 0
? intl.formatMessage({
id: "earnAndBurn.journeyTable.pointsActivity",
defaultMessage: "Point activity",
})
: hotelName && city
? `${hotelName}, ${city} ${nightsMsg}`
: `${nightsMsg}`
switch (transaction.type) {
case Transactions.rewardType.stay:
case Transactions.rewardType.stayAdj:
if (transaction.hotelId === "ORS") {
description = intl.formatMessage({
id: "earnAndBurn.journeyTable.formerScandicHotel",
defaultMessage: "Former Scandic Hotel",
})
}
if (transaction.confirmationNumber === "BALFWD") {
description = intl.formatMessage({
id: "earnAndBurn.journeyTable.pointsEarnedPriorMay2021",
defaultMessage: "Points earned prior to May 1, 2021",
})
}
break
case Transactions.rewardType.ancillary:
description = intl.formatMessage({
id: "earnAndBurn.journeyTable.extrasToBooking",
defaultMessage: "Extras to your booking",
})
break
case Transactions.rewardType.enrollment:
description = intl.formatMessage({
id: "earnAndBurn.journeyTable.signUpBonus",
defaultMessage: "Sign up bonus",
})
break
case Transactions.rewardType.mastercard_points:
description = intl.formatMessage({
id: "earnAndBurn.journeyTable.scandicFriendsMastercard",
defaultMessage: "Scandic Friends Mastercard",
})
break
case Transactions.rewardType.tui_points:
description = intl.formatMessage({
id: "earnAndBurn.journeyTable.tuiPoints",
defaultMessage: "TUI Points",
})
case Transactions.rewardType.pointShop:
description = intl.formatMessage({
id: "earnAndBurn.journeyTable.pointShop",
defaultMessage: "Scandic Friends Point Shop",
})
break
}
const arrival = dt(transaction.checkinDate).locale(lang).format("DD MMM YYYY")
function renderConfirmationNumber() {
if (
transaction.confirmationNumber === "BALFWD" ||
transaction.confirmationNumber === "non-transactional"
)
return null
if (
!isWebview &&
transaction.bookingUrl &&
(transaction.type === Transactions.rewardType.stay ||
transaction.type === Transactions.rewardType.rewardNight)
) {
return (
<TextLink href={transaction.bookingUrl}>
{transaction.confirmationNumber}
</TextLink>
)
}
return transaction.confirmationNumber
}
return (
<Table.TR>
<Table.TD>
<AwardPoints
awardPoints={transaction.awardPoints}
isCalculated={transaction.pointsCalculated}
/>
</Table.TD>
<Table.TD>
<Typography variant="Body/Paragraph/mdBold">
<p>{description}</p>
</Typography>
</Table.TD>
<Table.TD>{renderConfirmationNumber()}</Table.TD>
<Table.TD>
{transaction.checkinDate && transaction.confirmationNumber !== "BALFWD"
? arrival
: null}
</Table.TD>
</Table.TR>
)
}

View File

@@ -1,18 +0,0 @@
.container {
overflow-x: auto;
border-radius: var(--Corner-radius-sm);
}
.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-lg);
}
}

View File

@@ -1,76 +0,0 @@
"use client"
import { useIntl } from "react-intl"
import Table from "@scandic-hotels/design-system/Table"
import { Typography } from "@scandic-hotels/design-system/Typography"
import Row from "./Row"
import styles from "./clientTable.module.css"
import type { Transactions } from "@/types/components/myPages/myPage/earnAndBurn"
interface ClientTableProps {
transactions: Transactions
}
export default function ClientTable({ transactions }: ClientTableProps) {
const intl = useIntl()
const tableHeadings = [
intl.formatMessage({
id: "common.points",
defaultMessage: "Points",
}),
intl.formatMessage({
id: "earnAndBurn.journeyTable.description",
defaultMessage: "Description",
}),
intl.formatMessage({
id: "earnAndBurn.journeyTable.bookingNumberReference",
defaultMessage: "Booking number / Reference",
}),
intl.formatMessage({
id: "earnAndBurn.journeyTable.date",
defaultMessage: "Date",
}),
]
return (
<div className={styles.container}>
<Table>
<Table.THead>
<Table.TR>
{tableHeadings.map((heading) => (
<Table.TH key={heading}>
<Typography variant="Body/Paragraph/mdBold">
<p>{heading}</p>
</Typography>
</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: "earnAndBurn.journeyTable.noTransactions",
defaultMessage: "No transactions available",
})}
</Table.TD>
</Table.TR>
)}
</Table.TBody>
</Table>
</div>
)
}

View File

@@ -1,5 +0,0 @@
import ClientJourney from "./Client"
export default async function JourneyTable() {
return <ClientJourney />
}

View File

@@ -8,7 +8,7 @@ import { Typography } from "@scandic-hotels/design-system/Typography"
import useLang from "@/hooks/useLang" import useLang from "@/hooks/useLang"
import AwardPoints from "../../EarnAndBurn/AwardPoints" import AwardPoints from "../AwardPoints"
export default function ExpiringPointsTable({ export default function ExpiringPointsTable({
points, points,

View File

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

View File

@@ -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",
})
}
}

View File

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

View File

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

View File

@@ -1,15 +1,14 @@
import { Section } from "@/components/Section" import { Section } from "@/components/Section"
import { SectionHeader } from "@/components/Section/Header" import { SectionHeader } from "@/components/Section/Header"
import SectionLink from "@/components/Section/Link"
import ClaimPoints from "../ClaimPoints" import ClaimPoints from "../ClaimPoints"
import JourneyTable from "./JourneyTable" import { PointTransactionList } from "./PointTransactionList"
import styles from "./earnAndBurn.module.css" import styles from "./pointTransactions.module.css"
import type { AccountPageComponentProps } from "@/types/components/myPages/myPage/accountPage" import type { AccountPageComponentProps } from "@/types/components/myPages/myPage/accountPage"
export default function EarnAndBurn({ export function PointTransactions({
link, link,
subtitle, subtitle,
title, title,
@@ -17,12 +16,10 @@ export default function EarnAndBurn({
return ( return (
<Section> <Section>
<div className={styles.header}> <div className={styles.header}>
<SectionHeader heading={title} link={link} preamble={subtitle} /> <SectionHeader link={link} preamble={subtitle} heading={title} />
<ClaimPoints /> <ClaimPoints />
</div> </div>
<JourneyTable /> <PointTransactionList />
<SectionLink link={link} variant="mobile" />
</Section> </Section>
) )
} }

View File

@@ -9,7 +9,6 @@ import { ManageCookieConsent } from "@/components/Blocks/DynamicContent/ManageCo
import MyPagesOverviewShortcuts from "@/components/Blocks/DynamicContent/MyPagesOverviewShortcuts" import MyPagesOverviewShortcuts from "@/components/Blocks/DynamicContent/MyPagesOverviewShortcuts"
import Overview from "@/components/Blocks/DynamicContent/Overview" import Overview from "@/components/Blocks/DynamicContent/Overview"
import OverviewTable from "@/components/Blocks/DynamicContent/OverviewTable" import OverviewTable from "@/components/Blocks/DynamicContent/OverviewTable"
import EarnAndBurn from "@/components/Blocks/DynamicContent/Points/EarnAndBurn"
import ExpiringPoints from "@/components/Blocks/DynamicContent/Points/ExpiringPoints" import ExpiringPoints from "@/components/Blocks/DynamicContent/Points/ExpiringPoints"
import PointsOverview from "@/components/Blocks/DynamicContent/Points/Overview" import PointsOverview from "@/components/Blocks/DynamicContent/Points/Overview"
import CurrentRewardsBlock from "@/components/Blocks/DynamicContent/Rewards/CurrentRewards" import CurrentRewardsBlock from "@/components/Blocks/DynamicContent/Rewards/CurrentRewards"
@@ -25,6 +24,7 @@ import UpcomingStays from "@/components/Blocks/DynamicContent/Stays/Upcoming"
import { ProfilingConsentBanner } from "@/components/MyPages/ProfilingConsent/Banner" import { ProfilingConsentBanner } from "@/components/MyPages/ProfilingConsent/Banner"
import { SJWidget } from "@/components/SJWidget" import { SJWidget } from "@/components/SJWidget"
import { PointTransactions } from "./Points/PointTransactions"
import JobylonFeed from "./JobylonFeed" import JobylonFeed from "./JobylonFeed"
import { RewardNights } from "./RewardNights" import { RewardNights } from "./RewardNights"
@@ -44,7 +44,7 @@ function DynamicContentBlocks(props: DynamicContentProps) {
case DynamicContentEnum.Blocks.components.current_benefits: case DynamicContentEnum.Blocks.components.current_benefits:
return <CurrentRewardsBlock {...dynamic_content} /> return <CurrentRewardsBlock {...dynamic_content} />
case DynamicContentEnum.Blocks.components.earn_and_burn: case DynamicContentEnum.Blocks.components.earn_and_burn:
return <EarnAndBurn {...dynamic_content} /> return <PointTransactions {...dynamic_content} />
case DynamicContentEnum.Blocks.components.expiring_points: case DynamicContentEnum.Blocks.components.expiring_points:
return <ExpiringPoints {...dynamic_content} /> return <ExpiringPoints {...dynamic_content} />
case DynamicContentEnum.Blocks.components.how_it_works: case DynamicContentEnum.Blocks.components.how_it_works:

View File

@@ -3,8 +3,8 @@ import { BlocksEnums } from "@scandic-hotels/trpc/types/blocksEnum"
import { DynamicContentEnum } from "@scandic-hotels/trpc/types/dynamicContent" import { DynamicContentEnum } from "@scandic-hotels/trpc/types/dynamicContent"
import Overview from "@/components/Blocks/DynamicContent/Overview" import Overview from "@/components/Blocks/DynamicContent/Overview"
import EarnAndBurn from "@/components/Blocks/DynamicContent/Points/EarnAndBurn"
import PointsOverview from "@/components/Blocks/DynamicContent/Points/Overview" import PointsOverview from "@/components/Blocks/DynamicContent/Points/Overview"
import { PointTransactions } from "@/components/Blocks/DynamicContent/Points/PointTransactions"
import CurrentRewardsBlock from "@/components/Blocks/DynamicContent/Rewards/CurrentRewards" import CurrentRewardsBlock from "@/components/Blocks/DynamicContent/Rewards/CurrentRewards"
import NextLevelRewardsBlock from "@/components/Blocks/DynamicContent/Rewards/NextLevel" import NextLevelRewardsBlock from "@/components/Blocks/DynamicContent/Rewards/NextLevel"
import ShortcutsList from "@/components/Blocks/ShortcutsList" import ShortcutsList from "@/components/Blocks/ShortcutsList"
@@ -41,7 +41,7 @@ async function DynamicComponent({ dynamic_content }: AccountPageContentProps) {
// return <ExpiringPoints /> // return <ExpiringPoints />
return null return null
case DynamicContentEnum.Blocks.components.earn_and_burn: case DynamicContentEnum.Blocks.components.earn_and_burn:
return <EarnAndBurn {...dynamicContent} /> return <PointTransactions {...dynamicContent} />
default: default:
return null return null
} }

View File

@@ -1,14 +1,13 @@
import type { VariantProps } from "class-variance-authority" import type { VariantProps } from "class-variance-authority"
import type { awardPointsVariants } from "@/components/Blocks/DynamicContent/Points/EarnAndBurn/AwardPoints/awardPointsVariants" import type { awardPointsVariants } from "@/components/Blocks/DynamicContent/Points/ExpiringPoints/AwardPoints/awardPointsVariants"
import type { UserQueryRouter } from "../user" import type { UserQueryRouter } from "../user"
type TransactionResponse = Awaited< type TransactionResponse = Awaited<
ReturnType<UserQueryRouter["transaction"]["friendTransactions"]> ReturnType<UserQueryRouter["transaction"]["friendTransactions"]>
> >
type TransactionsNonNullResponseObject = NonNullable<TransactionResponse> type TransactionsNonNullResponseObject = NonNullable<TransactionResponse>
export type Transactions = export type Transactions = TransactionsNonNullResponseObject
NonNullable<TransactionsNonNullResponseObject>["data"]["transactions"] export type Transaction = Transactions["data"][number]
export type Transaction = Transactions[number]
export type AwardPointsVariantProps = VariantProps<typeof awardPointsVariants> export type AwardPointsVariantProps = VariantProps<typeof awardPointsVariants>

View File

@@ -0,0 +1,4 @@
// 'BALFWD' are transactions from Opera migration that happended in May 2021
export const BALFWD = "BALFWD"
export const NON_TRANSACTIONAL = "non-transactional"

View File

@@ -31,6 +31,7 @@
"./constants/routes/*": "./constants/routes/*.ts", "./constants/routes/*": "./constants/routes/*.ts",
"./constants/sessionKeys": "./constants/sessionKeys.ts", "./constants/sessionKeys": "./constants/sessionKeys.ts",
"./constants/signatureHotels": "./constants/signatureHotels.ts", "./constants/signatureHotels": "./constants/signatureHotels.ts",
"./constants/transactionType": "./constants/transactionType.ts",
"./dataCache": "./dataCache/index.ts", "./dataCache": "./dataCache/index.ts",
"./dt": "./dt/dt.ts", "./dt": "./dt/dt.ts",
"./dt/utils/hasOverlappingDates": "./dt/utils/hasOverlappingDates.ts", "./dt/utils/hasOverlappingDates": "./dt/utils/hasOverlappingDates.ts",

View File

@@ -7,6 +7,7 @@ export enum StickyElementNameEnum {
HOTEL_TAB_NAVIGATION = "HOTEL_TAB_NAVIGATION", HOTEL_TAB_NAVIGATION = "HOTEL_TAB_NAVIGATION",
HOTEL_STATIC_MAP = "HOTEL_STATIC_MAP", HOTEL_STATIC_MAP = "HOTEL_STATIC_MAP",
DESTINATION_SIDEBAR = "DESTINATION_SIDEBAR", DESTINATION_SIDEBAR = "DESTINATION_SIDEBAR",
TRANSACTION_LIST_HEADER = "TRANSACTION_LIST_HEADER",
} }
export interface StickyElement { export interface StickyElement {
@@ -39,6 +40,7 @@ const priorityMap: Record<StickyElementNameEnum, number> = {
[StickyElementNameEnum.MEETING_PACKAGE_WIDGET]: 3, [StickyElementNameEnum.MEETING_PACKAGE_WIDGET]: 3,
[StickyElementNameEnum.DESTINATION_SIDEBAR]: 3, [StickyElementNameEnum.DESTINATION_SIDEBAR]: 3,
[StickyElementNameEnum.TRANSACTION_LIST_HEADER]: 3,
} }
const useStickyPositionStore = create<StickyStore>((set, get) => ({ const useStickyPositionStore = create<StickyStore>((set, get) => ({

View File

@@ -20,11 +20,11 @@ export const staysInput = z
export const friendTransactionsInput = z export const friendTransactionsInput = z
.object({ .object({
limit: z.number().int().positive(), cursor: z.number().optional(),
page: z.number().int().positive(), limit: z.number().min(0).default(10),
lang: z.nativeEnum(Lang).optional(), lang: z.nativeEnum(Lang).optional(),
}) })
.default({ limit: 5, page: 1 }) .default({})
// Mutation // Mutation
export const addCreditCardInput = z.object({ export const addCreditCardInput = z.object({

View File

@@ -1,3 +1,4 @@
import { BALFWD } from "@scandic-hotels/common/constants/transactionType"
import { createCounter } from "@scandic-hotels/common/telemetry" import { createCounter } from "@scandic-hotels/common/telemetry"
import { safeTry } from "@scandic-hotels/common/utils/safeTry" import { safeTry } from "@scandic-hotels/common/utils/safeTry"
@@ -239,22 +240,23 @@ export const userQueryRouter = router({
friendTransactions: languageProtectedProcedure friendTransactions: languageProtectedProcedure
.input(friendTransactionsInput) .input(friendTransactionsInput)
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const { limit, page, lang } = input const { limit, cursor, lang } = input
const language = lang ?? ctx.lang
const page = cursor ? Number(cursor) : 1
const friendTransactionsCounter = createCounter( const friendTransactionsCounter = createCounter(
"trpc.user.transactions.friendTransactions" "trpc.user.transactions.friendTransactions2"
) )
const metricsFriendTransactions = friendTransactionsCounter.init({ const metricsFriendTransactions = friendTransactionsCounter.init({
limit, limit,
page, cursor,
lang, language,
}) })
metricsFriendTransactions.start() metricsFriendTransactions.start()
const language = lang ?? ctx.lang
const apiResponse = await api.get( const apiResponse = await api.get(
api.endpoints.v1.Profile.Transaction.friendTransactions, api.endpoints.v1.Profile.Transaction.friendTransactions,
{ {
@@ -282,15 +284,13 @@ export const userQueryRouter = router({
const updatedData = await updateStaysBookingUrl( const updatedData = await updateStaysBookingUrl(
verifiedData.data.data, verifiedData.data.data,
ctx.session, ctx.session,
ctx.lang language
) )
const allTransactions = updatedData
const pageData = updatedData
.filter((t) => t.type !== Transactions.rewardType.expired) .filter((t) => t.type !== Transactions.rewardType.expired)
.sort((a, b) => { .sort((a, b) => {
// 'BALFWD' are transactions from Opera migration that happended in May 2021 if (a.attributes.confirmationNumber === BALFWD) return 1
if (a.attributes.confirmationNumber === "BALFWD") return 1 if (b.attributes.confirmationNumber === BALFWD) return -1
if (b.attributes.confirmationNumber === "BALFWD") return -1
const dateA = new Date( const dateA = new Date(
a.attributes.checkinDate a.attributes.checkinDate
@@ -306,35 +306,20 @@ export const userQueryRouter = router({
return dateA > dateB ? -1 : 1 return dateA > dateB ? -1 : 1
}) })
const slicedData = pageData.slice(limit * (page - 1), limit * page) const startIndex = limit * (page - 1)
const endIndex = limit * page
const result = { const paginatedTransactions = allTransactions.slice(
data: { startIndex,
transactions: slicedData.map(({ type, attributes }) => { endIndex
return { )
type,
awardPoints: attributes.awardPoints,
checkinDate: attributes.checkinDate,
checkoutDate: attributes.checkoutDate,
confirmationNumber: attributes.confirmationNumber,
nights: attributes.nights,
pointsCalculated: attributes.pointsCalculated,
hotelId: attributes.hotelOperaId,
transactionDate: attributes.transactionDate,
bookingUrl: attributes.bookingUrl,
hotelName: attributes.hotelInformation?.name,
city: attributes.hotelInformation?.city,
}
}),
},
meta: {
totalPages: Math.ceil(pageData.length / limit),
},
}
const hasMore = endIndex < allTransactions.length
metricsFriendTransactions.success() metricsFriendTransactions.success()
return result return {
data: paginatedTransactions,
nextCursor: hasMore ? Number(page + 1) : undefined,
}
}), }),
}), }),