From 5af64ef8964ab6ac45b6b6ea49bcebe02dc04e9b Mon Sep 17 00:00:00 2001 From: Emma Zettervall Date: Tue, 20 Jan 2026 08:41:09 +0000 Subject: [PATCH] Merged in feat/LOY-391-my-points-transactions-table-design (pull request #3415) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../EarnAndBurn/JourneyTable/Client.tsx | 45 ----- .../JourneyTable/ClientTable/Row/index.tsx | 145 ---------------- .../ClientTable/clientTable.module.css | 18 -- .../JourneyTable/ClientTable/index.tsx | 76 --------- .../Points/EarnAndBurn/JourneyTable/index.tsx | 5 - .../AwardPoints/awardPoints.module.css | 0 .../AwardPoints/awardPointsVariants.ts | 0 .../AwardPoints/index.tsx | 0 .../ExpiringPointsTable/index.tsx | 2 +- .../ConditionalPointTransactionLink.tsx | 27 +++ .../PointTransactionRow/index.tsx | 161 ++++++++++++++++++ .../PointTransactionList/index.tsx | 145 ++++++++++++++++ .../pointTransactionList.module.css | 94 ++++++++++ .../index.tsx | 13 +- .../pointTransactions.module.css} | 0 .../Blocks/DynamicContent/index.tsx | 4 +- .../Webviews/AccountPage/Blocks.tsx | 4 +- .../components/myPages/myPage/earnAndBurn.ts | 7 +- packages/common/constants/transactionType.ts | 4 + packages/common/package.json | 1 + packages/common/stores/sticky-position.ts | 2 + packages/trpc/lib/routers/user/input.ts | 6 +- packages/trpc/lib/routers/user/query/index.ts | 61 +++---- 23 files changed, 473 insertions(+), 347 deletions(-) delete mode 100644 apps/scandic-web/components/Blocks/DynamicContent/Points/EarnAndBurn/JourneyTable/Client.tsx delete mode 100644 apps/scandic-web/components/Blocks/DynamicContent/Points/EarnAndBurn/JourneyTable/ClientTable/Row/index.tsx delete mode 100644 apps/scandic-web/components/Blocks/DynamicContent/Points/EarnAndBurn/JourneyTable/ClientTable/clientTable.module.css delete mode 100644 apps/scandic-web/components/Blocks/DynamicContent/Points/EarnAndBurn/JourneyTable/ClientTable/index.tsx delete mode 100644 apps/scandic-web/components/Blocks/DynamicContent/Points/EarnAndBurn/JourneyTable/index.tsx rename apps/scandic-web/components/Blocks/DynamicContent/Points/{EarnAndBurn => ExpiringPoints}/AwardPoints/awardPoints.module.css (100%) rename apps/scandic-web/components/Blocks/DynamicContent/Points/{EarnAndBurn => ExpiringPoints}/AwardPoints/awardPointsVariants.ts (100%) rename apps/scandic-web/components/Blocks/DynamicContent/Points/{EarnAndBurn => ExpiringPoints}/AwardPoints/index.tsx (100%) create mode 100644 apps/scandic-web/components/Blocks/DynamicContent/Points/PointTransactions/PointTransactionList/ConditionalPointTransactionLink.tsx create mode 100644 apps/scandic-web/components/Blocks/DynamicContent/Points/PointTransactions/PointTransactionList/PointTransactionRow/index.tsx create mode 100644 apps/scandic-web/components/Blocks/DynamicContent/Points/PointTransactions/PointTransactionList/index.tsx create mode 100644 apps/scandic-web/components/Blocks/DynamicContent/Points/PointTransactions/PointTransactionList/pointTransactionList.module.css rename apps/scandic-web/components/Blocks/DynamicContent/Points/{EarnAndBurn => PointTransactions}/index.tsx (58%) rename apps/scandic-web/components/Blocks/DynamicContent/Points/{EarnAndBurn/earnAndBurn.module.css => PointTransactions/pointTransactions.module.css} (100%) create mode 100644 packages/common/constants/transactionType.ts diff --git a/apps/scandic-web/components/Blocks/DynamicContent/Points/EarnAndBurn/JourneyTable/Client.tsx b/apps/scandic-web/components/Blocks/DynamicContent/Points/EarnAndBurn/JourneyTable/Client.tsx deleted file mode 100644 index 3b572b68d..000000000 --- a/apps/scandic-web/components/Blocks/DynamicContent/Points/EarnAndBurn/JourneyTable/Client.tsx +++ /dev/null @@ -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 ? ( - - ) : ( - <> - - {data && data.meta.totalPages > 1 ? ( - - ) : null} - - ) -} diff --git a/apps/scandic-web/components/Blocks/DynamicContent/Points/EarnAndBurn/JourneyTable/ClientTable/Row/index.tsx b/apps/scandic-web/components/Blocks/DynamicContent/Points/EarnAndBurn/JourneyTable/ClientTable/Row/index.tsx deleted file mode 100644 index f35fe468a..000000000 --- a/apps/scandic-web/components/Blocks/DynamicContent/Points/EarnAndBurn/JourneyTable/ClientTable/Row/index.tsx +++ /dev/null @@ -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 ( - - {transaction.confirmationNumber} - - ) - } - - return transaction.confirmationNumber - } - - return ( - - - - - - -

{description}

-
-
- {renderConfirmationNumber()} - - {transaction.checkinDate && transaction.confirmationNumber !== "BALFWD" - ? arrival - : null} - -
- ) -} diff --git a/apps/scandic-web/components/Blocks/DynamicContent/Points/EarnAndBurn/JourneyTable/ClientTable/clientTable.module.css b/apps/scandic-web/components/Blocks/DynamicContent/Points/EarnAndBurn/JourneyTable/ClientTable/clientTable.module.css deleted file mode 100644 index afc051b4c..000000000 --- a/apps/scandic-web/components/Blocks/DynamicContent/Points/EarnAndBurn/JourneyTable/ClientTable/clientTable.module.css +++ /dev/null @@ -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); - } -} diff --git a/apps/scandic-web/components/Blocks/DynamicContent/Points/EarnAndBurn/JourneyTable/ClientTable/index.tsx b/apps/scandic-web/components/Blocks/DynamicContent/Points/EarnAndBurn/JourneyTable/ClientTable/index.tsx deleted file mode 100644 index 0a77a1d9d..000000000 --- a/apps/scandic-web/components/Blocks/DynamicContent/Points/EarnAndBurn/JourneyTable/ClientTable/index.tsx +++ /dev/null @@ -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 ( -
- - - - {tableHeadings.map((heading) => ( - - -

{heading}

-
-
- ))} -
-
- - {transactions.length ? ( - transactions.map((transaction, index) => ( - - )) - ) : ( - - - {intl.formatMessage({ - id: "earnAndBurn.journeyTable.noTransactions", - defaultMessage: "No transactions available", - })} - - - )} - -
-
- ) -} diff --git a/apps/scandic-web/components/Blocks/DynamicContent/Points/EarnAndBurn/JourneyTable/index.tsx b/apps/scandic-web/components/Blocks/DynamicContent/Points/EarnAndBurn/JourneyTable/index.tsx deleted file mode 100644 index 2bf170316..000000000 --- a/apps/scandic-web/components/Blocks/DynamicContent/Points/EarnAndBurn/JourneyTable/index.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import ClientJourney from "./Client" - -export default async function JourneyTable() { - return -} diff --git a/apps/scandic-web/components/Blocks/DynamicContent/Points/EarnAndBurn/AwardPoints/awardPoints.module.css b/apps/scandic-web/components/Blocks/DynamicContent/Points/ExpiringPoints/AwardPoints/awardPoints.module.css similarity index 100% rename from apps/scandic-web/components/Blocks/DynamicContent/Points/EarnAndBurn/AwardPoints/awardPoints.module.css rename to apps/scandic-web/components/Blocks/DynamicContent/Points/ExpiringPoints/AwardPoints/awardPoints.module.css diff --git a/apps/scandic-web/components/Blocks/DynamicContent/Points/EarnAndBurn/AwardPoints/awardPointsVariants.ts b/apps/scandic-web/components/Blocks/DynamicContent/Points/ExpiringPoints/AwardPoints/awardPointsVariants.ts similarity index 100% rename from apps/scandic-web/components/Blocks/DynamicContent/Points/EarnAndBurn/AwardPoints/awardPointsVariants.ts rename to apps/scandic-web/components/Blocks/DynamicContent/Points/ExpiringPoints/AwardPoints/awardPointsVariants.ts diff --git a/apps/scandic-web/components/Blocks/DynamicContent/Points/EarnAndBurn/AwardPoints/index.tsx b/apps/scandic-web/components/Blocks/DynamicContent/Points/ExpiringPoints/AwardPoints/index.tsx similarity index 100% rename from apps/scandic-web/components/Blocks/DynamicContent/Points/EarnAndBurn/AwardPoints/index.tsx rename to apps/scandic-web/components/Blocks/DynamicContent/Points/ExpiringPoints/AwardPoints/index.tsx diff --git a/apps/scandic-web/components/Blocks/DynamicContent/Points/ExpiringPoints/ExpiringPointsTable/index.tsx b/apps/scandic-web/components/Blocks/DynamicContent/Points/ExpiringPoints/ExpiringPointsTable/index.tsx index 0ae9351ce..6e942425a 100644 --- a/apps/scandic-web/components/Blocks/DynamicContent/Points/ExpiringPoints/ExpiringPointsTable/index.tsx +++ b/apps/scandic-web/components/Blocks/DynamicContent/Points/ExpiringPoints/ExpiringPointsTable/index.tsx @@ -8,7 +8,7 @@ import { Typography } from "@scandic-hotels/design-system/Typography" import useLang from "@/hooks/useLang" -import AwardPoints from "../../EarnAndBurn/AwardPoints" +import AwardPoints from "../AwardPoints" export default function ExpiringPointsTable({ points, diff --git a/apps/scandic-web/components/Blocks/DynamicContent/Points/PointTransactions/PointTransactionList/ConditionalPointTransactionLink.tsx b/apps/scandic-web/components/Blocks/DynamicContent/Points/PointTransactions/PointTransactionList/ConditionalPointTransactionLink.tsx new file mode 100644 index 000000000..aff5872e2 --- /dev/null +++ b/apps/scandic-web/components/Blocks/DynamicContent/Points/PointTransactions/PointTransactionList/ConditionalPointTransactionLink.tsx @@ -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 +}) { + if (canLinkBookingUrl) { + return ( + + {children} + + + ) + } + return
{children}
+} diff --git a/apps/scandic-web/components/Blocks/DynamicContent/Points/PointTransactions/PointTransactionList/PointTransactionRow/index.tsx b/apps/scandic-web/components/Blocks/DynamicContent/Points/PointTransactions/PointTransactionList/PointTransactionRow/index.tsx new file mode 100644 index 000000000..466c1d56f --- /dev/null +++ b/apps/scandic-web/components/Blocks/DynamicContent/Points/PointTransactions/PointTransactionList/PointTransactionRow/index.tsx @@ -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 +}) { + 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 ( + +
+ + + + + + +
+ +
+ +

+ {intl.formatMessage( + { + id: "common.pointsInLine", + defaultMessage: "{points} points", + }, + { + points: ( + + {calculatedPoints} + + ), + } + )} +

+
+ + +

{description}

+
+
+
+ ) +} + +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", + }) + } +} diff --git a/apps/scandic-web/components/Blocks/DynamicContent/Points/PointTransactions/PointTransactionList/index.tsx b/apps/scandic-web/components/Blocks/DynamicContent/Points/PointTransactions/PointTransactionList/index.tsx new file mode 100644 index 000000000..a1fd0c422 --- /dev/null +++ b/apps/scandic-web/components/Blocks/DynamicContent/Points/PointTransactions/PointTransactionList/index.tsx @@ -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>( + (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(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 ( +
+
+ {isLoading && } + {isEmpty && ( +
+ +

+ {intl.formatMessage({ + id: "myPoints.pointTransactions.noTransactions", + defaultMessage: "No transactions available", + })} +

+
+
+ )} + {!isLoading && + !isEmpty && + sortedYears.map((year) => ( + +
+ +

{year}

+
+
+
    + {groupedTransactions[year].map((transaction, i) => { + const isFirstNewItem = transaction === firstNewTransaction + return ( +
  • + + {i < groupedTransactions[year].length - 1 && ( +
    + +
    + )} +
  • + ) + })} +
+
+ ))} +
+
+ {isFetchingNextPage ? ( + + ) : hasNextPage ? ( + + ) : null} +
+
+ ) +} diff --git a/apps/scandic-web/components/Blocks/DynamicContent/Points/PointTransactions/PointTransactionList/pointTransactionList.module.css b/apps/scandic-web/components/Blocks/DynamicContent/Points/PointTransactions/PointTransactionList/pointTransactionList.module.css new file mode 100644 index 000000000..2725c99e6 --- /dev/null +++ b/apps/scandic-web/components/Blocks/DynamicContent/Points/PointTransactions/PointTransactionList/pointTransactionList.module.css @@ -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; +} diff --git a/apps/scandic-web/components/Blocks/DynamicContent/Points/EarnAndBurn/index.tsx b/apps/scandic-web/components/Blocks/DynamicContent/Points/PointTransactions/index.tsx similarity index 58% rename from apps/scandic-web/components/Blocks/DynamicContent/Points/EarnAndBurn/index.tsx rename to apps/scandic-web/components/Blocks/DynamicContent/Points/PointTransactions/index.tsx index 8d49f8457..82b458e3b 100644 --- a/apps/scandic-web/components/Blocks/DynamicContent/Points/EarnAndBurn/index.tsx +++ b/apps/scandic-web/components/Blocks/DynamicContent/Points/PointTransactions/index.tsx @@ -1,15 +1,14 @@ import { Section } from "@/components/Section" import { SectionHeader } from "@/components/Section/Header" -import SectionLink from "@/components/Section/Link" 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" -export default function EarnAndBurn({ +export function PointTransactions({ link, subtitle, title, @@ -17,12 +16,10 @@ export default function EarnAndBurn({ return (
- - +
- - +
) } diff --git a/apps/scandic-web/components/Blocks/DynamicContent/Points/EarnAndBurn/earnAndBurn.module.css b/apps/scandic-web/components/Blocks/DynamicContent/Points/PointTransactions/pointTransactions.module.css similarity index 100% rename from apps/scandic-web/components/Blocks/DynamicContent/Points/EarnAndBurn/earnAndBurn.module.css rename to apps/scandic-web/components/Blocks/DynamicContent/Points/PointTransactions/pointTransactions.module.css diff --git a/apps/scandic-web/components/Blocks/DynamicContent/index.tsx b/apps/scandic-web/components/Blocks/DynamicContent/index.tsx index 97175fed5..93614bf88 100644 --- a/apps/scandic-web/components/Blocks/DynamicContent/index.tsx +++ b/apps/scandic-web/components/Blocks/DynamicContent/index.tsx @@ -9,7 +9,6 @@ import { ManageCookieConsent } from "@/components/Blocks/DynamicContent/ManageCo import MyPagesOverviewShortcuts from "@/components/Blocks/DynamicContent/MyPagesOverviewShortcuts" import Overview from "@/components/Blocks/DynamicContent/Overview" 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/CurrentRewards" @@ -25,6 +24,7 @@ import UpcomingStays from "@/components/Blocks/DynamicContent/Stays/Upcoming" import { ProfilingConsentBanner } from "@/components/MyPages/ProfilingConsent/Banner" import { SJWidget } from "@/components/SJWidget" +import { PointTransactions } from "./Points/PointTransactions" import JobylonFeed from "./JobylonFeed" import { RewardNights } from "./RewardNights" @@ -44,7 +44,7 @@ function DynamicContentBlocks(props: DynamicContentProps) { case DynamicContentEnum.Blocks.components.current_benefits: return case DynamicContentEnum.Blocks.components.earn_and_burn: - return + return case DynamicContentEnum.Blocks.components.expiring_points: return case DynamicContentEnum.Blocks.components.how_it_works: diff --git a/apps/scandic-web/components/Webviews/AccountPage/Blocks.tsx b/apps/scandic-web/components/Webviews/AccountPage/Blocks.tsx index 32be19367..8ba49be27 100644 --- a/apps/scandic-web/components/Webviews/AccountPage/Blocks.tsx +++ b/apps/scandic-web/components/Webviews/AccountPage/Blocks.tsx @@ -3,8 +3,8 @@ import { BlocksEnums } from "@scandic-hotels/trpc/types/blocksEnum" import { DynamicContentEnum } from "@scandic-hotels/trpc/types/dynamicContent" import Overview from "@/components/Blocks/DynamicContent/Overview" -import EarnAndBurn from "@/components/Blocks/DynamicContent/Points/EarnAndBurn" 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 NextLevelRewardsBlock from "@/components/Blocks/DynamicContent/Rewards/NextLevel" import ShortcutsList from "@/components/Blocks/ShortcutsList" @@ -41,7 +41,7 @@ async function DynamicComponent({ dynamic_content }: AccountPageContentProps) { // return return null case DynamicContentEnum.Blocks.components.earn_and_burn: - return + return default: return null } diff --git a/apps/scandic-web/types/components/myPages/myPage/earnAndBurn.ts b/apps/scandic-web/types/components/myPages/myPage/earnAndBurn.ts index 3cb6b0f67..d2c71ebc6 100644 --- a/apps/scandic-web/types/components/myPages/myPage/earnAndBurn.ts +++ b/apps/scandic-web/types/components/myPages/myPage/earnAndBurn.ts @@ -1,14 +1,13 @@ 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" type TransactionResponse = Awaited< ReturnType > type TransactionsNonNullResponseObject = NonNullable -export type Transactions = - NonNullable["data"]["transactions"] -export type Transaction = Transactions[number] +export type Transactions = TransactionsNonNullResponseObject +export type Transaction = Transactions["data"][number] export type AwardPointsVariantProps = VariantProps diff --git a/packages/common/constants/transactionType.ts b/packages/common/constants/transactionType.ts new file mode 100644 index 000000000..7d759e39b --- /dev/null +++ b/packages/common/constants/transactionType.ts @@ -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" diff --git a/packages/common/package.json b/packages/common/package.json index d6b77d206..0cdca7995 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -31,6 +31,7 @@ "./constants/routes/*": "./constants/routes/*.ts", "./constants/sessionKeys": "./constants/sessionKeys.ts", "./constants/signatureHotels": "./constants/signatureHotels.ts", + "./constants/transactionType": "./constants/transactionType.ts", "./dataCache": "./dataCache/index.ts", "./dt": "./dt/dt.ts", "./dt/utils/hasOverlappingDates": "./dt/utils/hasOverlappingDates.ts", diff --git a/packages/common/stores/sticky-position.ts b/packages/common/stores/sticky-position.ts index 5d558a297..5b4e86228 100644 --- a/packages/common/stores/sticky-position.ts +++ b/packages/common/stores/sticky-position.ts @@ -7,6 +7,7 @@ export enum StickyElementNameEnum { HOTEL_TAB_NAVIGATION = "HOTEL_TAB_NAVIGATION", HOTEL_STATIC_MAP = "HOTEL_STATIC_MAP", DESTINATION_SIDEBAR = "DESTINATION_SIDEBAR", + TRANSACTION_LIST_HEADER = "TRANSACTION_LIST_HEADER", } export interface StickyElement { @@ -39,6 +40,7 @@ const priorityMap: Record = { [StickyElementNameEnum.MEETING_PACKAGE_WIDGET]: 3, [StickyElementNameEnum.DESTINATION_SIDEBAR]: 3, + [StickyElementNameEnum.TRANSACTION_LIST_HEADER]: 3, } const useStickyPositionStore = create((set, get) => ({ diff --git a/packages/trpc/lib/routers/user/input.ts b/packages/trpc/lib/routers/user/input.ts index 0464f9b2e..c1121f939 100644 --- a/packages/trpc/lib/routers/user/input.ts +++ b/packages/trpc/lib/routers/user/input.ts @@ -20,11 +20,11 @@ export const staysInput = z export const friendTransactionsInput = z .object({ - limit: z.number().int().positive(), - page: z.number().int().positive(), + cursor: z.number().optional(), + limit: z.number().min(0).default(10), lang: z.nativeEnum(Lang).optional(), }) - .default({ limit: 5, page: 1 }) + .default({}) // Mutation export const addCreditCardInput = z.object({ diff --git a/packages/trpc/lib/routers/user/query/index.ts b/packages/trpc/lib/routers/user/query/index.ts index 0e397f6ff..43cd7dfce 100644 --- a/packages/trpc/lib/routers/user/query/index.ts +++ b/packages/trpc/lib/routers/user/query/index.ts @@ -1,3 +1,4 @@ +import { BALFWD } from "@scandic-hotels/common/constants/transactionType" import { createCounter } from "@scandic-hotels/common/telemetry" import { safeTry } from "@scandic-hotels/common/utils/safeTry" @@ -239,22 +240,23 @@ export const userQueryRouter = router({ friendTransactions: languageProtectedProcedure .input(friendTransactionsInput) .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( - "trpc.user.transactions.friendTransactions" + "trpc.user.transactions.friendTransactions2" ) const metricsFriendTransactions = friendTransactionsCounter.init({ limit, - page, - lang, + cursor, + language, }) metricsFriendTransactions.start() - const language = lang ?? ctx.lang - const apiResponse = await api.get( api.endpoints.v1.Profile.Transaction.friendTransactions, { @@ -282,15 +284,13 @@ export const userQueryRouter = router({ const updatedData = await updateStaysBookingUrl( verifiedData.data.data, ctx.session, - ctx.lang + language ) - - const pageData = updatedData + const allTransactions = updatedData .filter((t) => t.type !== Transactions.rewardType.expired) .sort((a, b) => { - // 'BALFWD' are transactions from Opera migration that happended in May 2021 - if (a.attributes.confirmationNumber === "BALFWD") return 1 - if (b.attributes.confirmationNumber === "BALFWD") return -1 + if (a.attributes.confirmationNumber === BALFWD) return 1 + if (b.attributes.confirmationNumber === BALFWD) return -1 const dateA = new Date( a.attributes.checkinDate @@ -306,35 +306,20 @@ export const userQueryRouter = router({ return dateA > dateB ? -1 : 1 }) - const slicedData = pageData.slice(limit * (page - 1), limit * page) - - const result = { - data: { - transactions: slicedData.map(({ type, attributes }) => { - 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 startIndex = limit * (page - 1) + const endIndex = limit * page + const paginatedTransactions = allTransactions.slice( + startIndex, + endIndex + ) + const hasMore = endIndex < allTransactions.length metricsFriendTransactions.success() - return result + return { + data: paginatedTransactions, + nextCursor: hasMore ? Number(page + 1) : undefined, + } }), }),