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:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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} />
|
||||
}
|
||||
|
||||
@@ -11,3 +11,12 @@
|
||||
.grid {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.icon {
|
||||
transition: transform 0.3s;
|
||||
padding-left: var(--Space-x05);
|
||||
|
||||
&.isTransformed {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user