Merged in feat/LOY-501-table-sorting (pull request #3321)

feat(LOY-501): add sorting to Reward Night Table

* feat(LOY-501): add sorting using Tanstack Table


Approved-by: Chuma Mcphoy (We Ahead)
This commit is contained in:
Matilda Landström
2025-12-11 14:08:01 +00:00
parent 7faa9933a2
commit 5770147af4
5 changed files with 252 additions and 119 deletions

View File

@@ -0,0 +1,39 @@
"use client"
import { useIntl } from "react-intl"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { formatDate, type HotelData } from "./util"
import styles from "./rewardNights.module.css"
export function OfferPrice(offer: HotelData["rewardNight"]["campaign"]) {
const intl = useIntl()
return (
<div className={styles.offerPrice}>
<Typography variant="Body/Paragraph/mdBold">
<p className={styles.highlightedText}>
{intl.formatMessage({
id: "rewardNights.offerPrice",
defaultMessage: "Offer price",
})}
</p>
</Typography>
<Typography variant="Label/xsBold">
<p>
{intl.formatMessage({
id: "rewardNights.stayBetween:",
defaultMessage: "Stay between:",
})}
</p>
</Typography>
<Typography variant="Label/xsRegular">
<time>
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
{formatDate(offer.start)} - {formatDate(offer.end)}
</time>
</Typography>
</div>
)
}

View File

@@ -0,0 +1,166 @@
"use client"
import {
createColumnHelper,
flexRender,
getCoreRowModel,
getSortedRowModel,
type SortingState,
useReactTable,
} from "@tanstack/react-table"
import { cx } from "class-variance-authority"
import { useState } from "react"
import { useIntl } from "react-intl"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
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 { OfferPrice } from "./OfferPrice"
import {
formatPoints,
hasActiveCampaign,
type HotelData,
nameToSort,
} from "./util"
import styles from "./rewardNights.module.css"
interface RewardNightsTableProps {
hotelData: HotelData[]
}
export function RewardNightsTable({ hotelData }: RewardNightsTableProps) {
const intl = useIntl()
const [sorting, setSorting] = useState<SortingState>([
{ id: "destination", desc: false },
])
const columnHelper = createColumnHelper<HotelData>()
const columns = [
columnHelper.accessor("name", {
header: intl.formatMessage({
id: "rewardNights.table.hotel",
defaultMessage: "Hotel",
}),
sortingFn: (a, b) =>
nameToSort(a.original.name).localeCompare(nameToSort(b.original.name)),
cell: ({ row }) => (
<TextLink href={row.original.url}>{row.original.name}</TextLink>
),
}),
columnHelper.accessor((row) => `${row.city}, ${row.country}`, {
id: "destination",
header: intl.formatMessage({
id: "rewardNights.table.destination",
defaultMessage: "Destination",
}),
sortingFn: (a, b) => a.original.city.localeCompare(b.original.city),
cell: ({ row }) => {
const hotel = row.original
const hasCampaign = hasActiveCampaign(hotel.rewardNight.campaign)
return (
<>
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
{hotel.city}, {hotel.country}
{hasCampaign ? (
<OfferPrice {...hotel.rewardNight.campaign} />
) : null}
</>
)
},
}),
columnHelper.accessor((row) => row.rewardNight.points, {
id: "points",
header: intl.formatMessage({
id: "common.points",
defaultMessage: "Points",
}),
sortingFn: (a, b) =>
a.original.rewardNight.points - b.original.rewardNight.points,
cell: ({ row }) => {
const hotel = row.original
const hasCampaign = hasActiveCampaign(hotel.rewardNight.campaign)
return (
<div className={cx({ [styles.grid]: hasCampaign })}>
{formatPoints(hotel.rewardNight.points)}
{hasCampaign ? (
<Typography
variant="Body/Paragraph/mdBold"
className={styles.highlightedText}
>
<span>{formatPoints(hotel.rewardNight.campaign.points)}</span>
</Typography>
) : null}
</div>
)
},
}),
]
const table = useReactTable({
data: hotelData,
columns,
state: { sorting },
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
enableSortingRemoval: false,
})
return (
<Table intent="striped" variant="content" style={{ textWrap: "balance" }}>
<Table.THead>
{table.getHeaderGroups().map((headerGroup) => (
<Table.TR key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<Table.TH
key={header.id}
onClick={header.column.getToggleSortingHandler()}
style={{
...(header.column.getIsSorted() && {
color: "var(--Text-Interactive-Secondary)",
}),
cursor: "pointer",
whiteSpace: "nowrap",
minWidth: "max-content",
}}
>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
<MaterialIcon
icon="keyboard_arrow_up"
color="CurrentColor"
className={cx(styles.icon, {
[styles.isTransformed]:
header.column.getIsSorted() === "desc",
})}
/>
</Table.TH>
))}
</Table.TR>
))}
</Table.THead>
<Table.TBody>
{table.getRowModel().rows.map((row) => (
<Table.TR key={row.id}>
{row.getVisibleCells().map((cell) => (
<Table.TD key={cell.id} style={{ alignContent: "flex-start" }}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</Table.TD>
))}
</Table.TR>
))}
</Table.TBody>
</Table>
)
}

View File

@@ -1,128 +1,19 @@
import { cx } from "class-variance-authority"
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 { getAllHotelData } from "@/lib/trpc/memoizedRequests"
import { getIntl } from "@/i18n"
import { RewardNightsTable } from "./Table"
import styles from "./rewardNights.module.css"
import type { RewardNight } from "@scandic-hotels/trpc/types/hotel"
import type { HotelData } from "./util"
export async function RewardNights() {
const intl = await getIntl()
const hotelData = await getAllHotelData()
return (
<Table intent="striped" variant="content" style={{ textWrap: "balance" }}>
<Table.THead>
<Table.TR>
<Table.TH>
{intl.formatMessage({
id: "rewardNights.table.hotel",
defaultMessage: "Hotel",
})}
</Table.TH>
<Table.TH>
{intl.formatMessage({
id: "rewardNights.table.destination",
defaultMessage: "Destination",
})}
</Table.TH>
<Table.TH>
{intl.formatMessage({
id: "common.points",
defaultMessage: "Points",
})}
</Table.TH>
</Table.TR>
</Table.THead>
<Table.TBody>
{hotelData.map((data) => {
const { hotel } = data
const hasCampaign = hasActiveCampaign(hotel.rewardNight.campaign)
return (
<Table.TR key={hotel.id}>
<Table.TD style={{ alignContent: "flex-start" }}>
<TextLink href={data.url ?? ""}>{hotel.name}</TextLink>
</Table.TD>
<Table.TD>
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
{`${hotel.address.city}, ${hotel.address.country}`}
{hasCampaign ? (
<OfferPrice {...hotel.rewardNight.campaign} />
) : null}
</Table.TD>
<Table.TD style={{ alignContent: "flex-start" }}>
<div className={cx({ [styles.grid]: hasCampaign })}>
{formatPoints(hotel.rewardNight.points)}
{hasCampaign ? (
<Typography
variant="Body/Paragraph/mdBold"
className={styles.highlightedText}
>
<span>
{formatPoints(hotel.rewardNight.campaign.points)}
</span>
</Typography>
) : null}
</div>
</Table.TD>
</Table.TR>
)
})}
</Table.TBody>
</Table>
)
}
interface OfferPriceProps {
points: number
start: string
end: string
}
async function OfferPrice(offer: OfferPriceProps) {
const intl = await getIntl()
const rewardNightsData: HotelData[] = hotelData.map(({ url, hotel }) => ({
url: url ?? "",
name: hotel.name,
city: hotel.address.city,
country: hotel.address.country,
rewardNight: hotel.rewardNight,
}))
return (
<div className={styles.offerPrice}>
<Typography variant="Body/Paragraph/mdBold">
<p className={styles.highlightedText}>
{intl.formatMessage({
id: "rewardNights.offerPrice",
defaultMessage: "Offer price",
})}
</p>
</Typography>
<Typography variant="Label/xsBold">
<p>
{intl.formatMessage({
id: "rewardNights.stayBetween:",
defaultMessage: "Stay between:",
})}
</p>
</Typography>
<Typography variant="Label/xsRegular">
<time>
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
{formatDate(offer.start)} - {formatDate(offer.end)}
</time>
</Typography>
</div>
)
}
function formatPoints(number: number) {
const format = new Intl.NumberFormat("fr-FR")
return format.format(number).replace(/\u202F/g, " ")
}
function formatDate(date?: string) {
return new Date(date ?? Date.now()).toISOString().split("T")[0]
}
function hasActiveCampaign(campaign: RewardNight["campaign"]) {
return campaign.points && formatDate(campaign.end) >= formatDate()
return <RewardNightsTable hotelData={rewardNightsData} />
}

View File

@@ -11,3 +11,12 @@
.grid {
display: grid;
}
.icon {
transition: transform 0.3s;
padding-left: var(--Space-x05);
&.isTransformed {
transform: rotate(180deg);
}
}

View File

@@ -0,0 +1,28 @@
import type { RewardNight } from "@scandic-hotels/trpc/types/hotel"
export interface HotelData {
url: string
name: string
city: string
country: string
rewardNight: RewardNight
}
export function formatDate(date?: string) {
return new Date(date ?? new Date()).toISOString().split("T")[0]
}
export function formatPoints(number: number) {
const format = new Intl.NumberFormat("fr-FR")
return format.format(number).replace(/\u202F/g, " ")
}
export function hasActiveCampaign(
campaign: HotelData["rewardNight"]["campaign"]
) {
return campaign.points && formatDate(campaign.end) >= formatDate()
}
export function nameToSort(name: string) {
return name.toLowerCase().replaceAll("scandic", "").trim()
}