Merged in feat/CJ-17-points-expiration-table (pull request #527)

Feat/CJ-17 points expiration table

* feat(CJ-17): Added point expiration table and refactored to use Table component

* feat(CJ-17): Use Table component inside Row

* fix(CJ-117): Added missing css class and update date formatting

* fix(CJ-117): Added copy of membershipLevel route with a protectedProcedure


Approved-by: Christel Westerberg
This commit is contained in:
Tobias Johansson
2024-09-05 09:28:25 +00:00
parent 650b38b409
commit 238de4cd3a
20 changed files with 260 additions and 234 deletions

View File

@@ -19,7 +19,7 @@ import type {
import { LoyaltyComponentEnum } from "@/types/components/loyalty/enums"
async function DynamicComponentBlock({ component }: DynamicComponentProps) {
const membershipLevel = await serverClient().user.membershipLevel()
const membershipLevel = await serverClient().user.safeMembershipLevel()
switch (component) {
case LoyaltyComponentEnum.how_it_works:
return <HowItWorks />

View File

@@ -3,6 +3,8 @@ import CurrentBenefitsBlock from "@/components/MyPages/Blocks/Benefits/CurrentLe
import NextLevelBenefitsBlock from "@/components/MyPages/Blocks/Benefits/NextLevel"
import Overview from "@/components/MyPages/Blocks/Overview"
import EarnAndBurn from "@/components/MyPages/Blocks/Points/EarnAndBurn"
import ExpiringPoints from "@/components/MyPages/Blocks/Points/ExpiringPoints"
import PointsOverview from "@/components/MyPages/Blocks/Points/Overview"
import Shortcuts from "@/components/MyPages/Blocks/Shortcuts"
import PreviousStays from "@/components/MyPages/Blocks/Stays/Previous"
import SoonestStays from "@/components/MyPages/Blocks/Stays/Soonest"
@@ -10,8 +12,6 @@ import UpcomingStays from "@/components/MyPages/Blocks/Stays/Upcoming"
import { getLang } from "@/i18n/serverContext"
import { removeMultipleSlashes } from "@/utils/url"
import PointsOverview from "../Blocks/Points/Overview"
import {
AccountPageContentProps,
ContentProps,
@@ -38,9 +38,7 @@ function DynamicComponent({ component, props }: AccountPageContentProps) {
case DynamicContentComponents.next_benefits:
return <NextLevelBenefitsBlock {...props} />
case DynamicContentComponents.expiring_points:
// TODO: Add once available
// return <ExpiringPoints />
return null
return <ExpiringPoints {...props} />
case DynamicContentComponents.earn_and_burn:
return <EarnAndBurn {...props} />
default:

View File

@@ -0,0 +1,23 @@
.awardPoints {
color: var(--Base-Text-High-contrast);
}
.addition {
color: var(--Secondary-Light-On-Surface-Accent);
}
.addition::before {
color: var(--Secondary-Light-On-Surface-Accent);
content: "+";
margin-right: var(--Spacing-x-half);
}
.negation {
color: var(--Base-Text-Accent);
}
.negation::before {
color: var(--Base-Text-Accent);
content: "-";
margin-right: var(--Spacing-x-half);
}

View File

@@ -1,8 +1,8 @@
import { cva } from "class-variance-authority"
import styles from "./row.module.css"
import styles from "./awardPoints.module.css"
export const awardPointsVariants = cva(styles.td, {
export const awardPointsVariants = cva(styles.awardPoints, {
variants: {
variant: {
addition: styles.addition,

View File

@@ -2,6 +2,8 @@ import { useIntl } from "react-intl"
import { Lang } from "@/constants/languages"
import Body from "@/components/TempDesignSystem/Text/Body"
import { awardPointsVariants } from "./awardPointsVariants"
import type { AwardPointsVariantProps } from "@/types/components/myPages/myPage/earnAndBurn"
@@ -9,14 +11,16 @@ import type { AwardPointsVariantProps } from "@/types/components/myPages/myPage/
export default function AwardPoints({
awardPoints,
isCalculated,
isExpiringPoints = false,
}: {
awardPoints: number
isCalculated: boolean
isExpiringPoints?: boolean
}) {
let variant: AwardPointsVariantProps["variant"] = undefined
let variant: AwardPointsVariantProps["variant"] = null
const intl = useIntl()
if (isCalculated) {
if (isCalculated && !isExpiringPoints) {
if (awardPoints > 0) {
variant = "addition"
} else if (awardPoints < 0) {
@@ -31,10 +35,10 @@ export default function AwardPoints({
// sv hardcoded to force space on thousands
const formatter = new Intl.NumberFormat(Lang.sv)
return (
<td className={classNames}>
<Body textTransform="bold" className={classNames}>
{isCalculated
? formatter.format(awardPoints)
: intl.formatMessage({ id: "Points being calculated" })}
</td>
</Body>
)
}

View File

@@ -7,8 +7,8 @@ import { trpc } from "@/lib/trpc/client"
import LoadingSpinner from "@/components/LoadingSpinner"
import ClientTable from "./ClientTable"
import Pagination from "./Pagination"
import Table from "./Table"
import { Transactions } from "@/types/components/myPages/myPage/earnAndBurn"
@@ -39,7 +39,7 @@ export default function TransactionTable({
<LoadingSpinner />
) : (
<>
<Table transactions={data?.data.transactions || []} />
<ClientTable transactions={data?.data.transactions || []} />
{data && data.meta.totalPages > 1 ? (
<Pagination
handlePageChange={setPage}

View File

@@ -6,13 +6,12 @@ import { useIntl } from "react-intl"
import { webviews } from "@/constants/routes/webviews"
import { dt } from "@/lib/dt"
import AwardPoints from "@/components/MyPages/Blocks/Points/EarnAndBurn/AwardPoints"
import Link from "@/components/TempDesignSystem/Link"
import Table from "@/components/TempDesignSystem/Table"
import Body from "@/components/TempDesignSystem/Text/Body"
import useLang from "@/hooks/useLang"
import AwardPoints from "./AwardPoints"
import styles from "./row.module.css"
import type { RowProps } from "@/types/components/myPages/myPage/earnAndBurn"
import { RewardTransactionTypes } from "@/types/components/myPages/myPage/enums"
@@ -80,18 +79,22 @@ export default function Row({ transaction }: RowProps) {
}
return (
<tr className={styles.tr}>
<AwardPoints
awardPoints={transaction.awardPoints}
isCalculated={transaction.pointsCalculated}
/>
<td className={`${styles.td} ${styles.description}`}>{description}</td>
<td className={styles.td}>{renderConfirmationNumber()}</td>
<td className={styles.td}>
<Table.TR>
<Table.TD>
<AwardPoints
awardPoints={transaction.awardPoints}
isCalculated={transaction.pointsCalculated}
/>
</Table.TD>
<Table.TD>
<Body textTransform="bold">{description}</Body>
</Table.TD>
<Table.TD>{renderConfirmationNumber()}</Table.TD>
<Table.TD>
{transaction.checkinDate && transaction.confirmationNumber !== "BALFWD"
? arrival
: null}
</td>
</tr>
</Table.TD>
</Table.TR>
)
}

View File

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

View File

@@ -0,0 +1,57 @@
"use client"
import { useIntl } from "react-intl"
import Table from "@/components/TempDesignSystem/Table"
import Body from "@/components/TempDesignSystem/Text/Body"
import Row from "./Row"
import styles from "./clientTable.module.css"
import type { ClientTableProps } from "@/types/components/myPages/myPage/earnAndBurn"
const tableHeadings = [
"Points",
"Description",
"Booking number",
"Arrival date",
]
export default function ClientTable({ transactions }: ClientTableProps) {
const intl = useIntl()
return (
<div className={styles.container}>
<Table>
<Table.THead>
<Table.TR>
{tableHeadings.map((heading) => (
<Table.TH key={heading}>
<Body textTransform="bold">
{intl.formatMessage({ id: heading })}
</Body>
</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: "No transactions available" })}
</Table.TD>
</Table.TR>
)}
</Table.TBody>
</Table>
</div>
)
}

View File

@@ -1,45 +0,0 @@
.tr {
border-bottom: 1px solid var(--Scandic-Brand-Pale-Peach);
&:last-child {
border-bottom: none;
}
}
.td {
background-color: #fff;
color: var(--UI-Text-High-contrast);
padding: var(--Spacing-x2);
position: relative;
text-align: left;
text-wrap: nowrap;
}
.description {
font-weight: var(--typography-Body-Bold-fontWeight);
}
.addition {
color: var(--Secondary-Light-On-Surface-Accent);
}
.addition::before {
color: var(--Secondary-Light-On-Surface-Accent);
content: "+";
margin-right: var(--Spacing-x-half);
}
.negation {
color: var(--Base-Text-Accent);
}
.negation::before {
color: var(--Base-Text-Accent);
content: "-";
margin-right: var(--Spacing-x-half);
}
@media screen and (min-width: 768px) {
.td {
padding: var(--Spacing-x3);
}
}

View File

@@ -1,68 +0,0 @@
"use client"
import { useIntl } from "react-intl"
import Body from "@/components/TempDesignSystem/Text/Body"
import Row from "./Row"
import styles from "./table.module.css"
import type { TableProps } from "@/types/components/myPages/myPage/earnAndBurn"
const tableHeadings = [
"Points",
"Description",
"Booking number",
"Arrival date",
]
export default function Table({ transactions }: TableProps) {
const intl = useIntl()
return (
<div className={styles.container}>
{transactions.length ? (
<table className={styles.table}>
<thead className={styles.thead}>
<tr>
{tableHeadings.map((heading) => (
<th key={heading} className={styles.th}>
<Body textTransform="bold">
{intl.formatMessage({ id: heading })}
</Body>
</th>
))}
</tr>
</thead>
<tbody>
{transactions.map((transaction, index) => (
<Row
key={`${transaction.confirmationNumber}-${index}`}
transaction={transaction}
/>
))}
</tbody>
</table>
) : (
<table className={styles.table}>
<thead className={styles.thead}>
<tr>
{tableHeadings.map((heading) => (
<th key={heading} className={styles.th}>
{heading}
</th>
))}
</tr>
</thead>
<tbody>
<tr>
<td colSpan={tableHeadings.length} className={styles.placeholder}>
{intl.formatMessage({ id: "No transactions available" })}
</td>
</tr>
</tbody>
</table>
)}
</div>
)
}

View File

@@ -1,62 +0,0 @@
.container {
display: flex;
flex-direction: column;
overflow-x: auto;
border-radius: var(--Corner-radius-Small);
}
.table {
border-collapse: collapse;
border-spacing: 0;
width: 100%;
}
.thead {
background-color: var(--Scandic-Brand-Pale-Peach);
border-left: 1px solid var(--Scandic-Brand-Pale-Peach);
border-right: 1px solid var(--Scandic-Brand-Pale-Peach);
color: var(--Main-Brand-Burgundy);
}
.th {
text-align: left;
text-wrap: nowrap;
padding: var(--Spacing-x2);
}
.placeholder {
width: 100%;
padding: 24px;
text-align: center;
border: 1px solid var(--Scandic-Brand-Pale-Peach);
background-color: #fff;
}
.footer {
background-color: var(--Scandic-Brand-Pale-Peach);
border-left: 1px solid var(--Scandic-Brand-Pale-Peach);
border-right: 1px solid var(--Scandic-Brand-Pale-Peach);
display: flex;
padding: 20px 32px;
justify-content: center;
}
.loadMoreButton {
border: none;
background-color: transparent;
color: var(--Main-Brand-Burgundy);
font-size: var(--typography-Caption-Bold-Desktop-fontSize);
display: flex;
align-items: center;
gap: var(--Spacing-x-half);
cursor: pointer;
}
@media screen and (min-width: 768px) {
.container {
border-radius: var(--Corner-radius-Large);
}
.th {
padding: var(--Spacing-x2) var(--Spacing-x3);
}
}

View File

@@ -0,0 +1,12 @@
.container {
display: flex;
flex-direction: column;
overflow-x: auto;
border-radius: var(--Corner-radius-Small);
}
@media screen and (min-width: 768px) {
.container {
border-radius: var(--Corner-radius-Large);
}
}

View File

@@ -0,0 +1,48 @@
"use client"
import { useIntl } from "react-intl"
import { dt } from "@/lib/dt"
import AwardPoints from "@/components/MyPages/Blocks/Points/EarnAndBurn/AwardPoints"
import Table from "@/components/TempDesignSystem/Table"
import Body from "@/components/TempDesignSystem/Text/Body"
import useLang from "@/hooks/useLang"
const tableHeadings = ["Points", "Expiration Date"]
export default function ExpiringPointsTable({
points,
expirationDate,
}: {
points: number
expirationDate: string
}) {
const intl = useIntl()
const lang = useLang()
const expiration = dt(expirationDate).locale(lang).format("DD MMM YYYY")
return (
<Table>
<Table.THead>
<Table.TR>
{tableHeadings.map((heading) => (
<Table.TH key={heading}>
<Body textTransform="bold">
{intl.formatMessage({ id: heading })}
</Body>
</Table.TH>
))}
</Table.TR>
</Table.THead>
<Table.TBody>
<Table.TR>
<Table.TD>
<AwardPoints awardPoints={points} isCalculated isExpiringPoints />
</Table.TD>
<Table.TD>{expiration}</Table.TD>
</Table.TR>
</Table.TBody>
</Table>
)
}

View File

@@ -1,27 +1,30 @@
import { getIntl } from "@/i18n"
import { serverClient } from "@/lib/trpc/server"
import styles from "./expiringPoints.module.css"
import SectionContainer from "@/components/Section/Container"
import SectionHeader from "@/components/Section/Header"
import ExpiringPointsTable from "./ExpiringPointsTable"
import { AccountPageComponentProps } from "@/types/components/myPages/myPage/accountPage"
export default async function ExpiringPoints({
link,
subtitle,
title,
}: AccountPageComponentProps) {
const membershipLevel = await serverClient().user.membershipLevel()
if (!membershipLevel?.pointsToExpire || !membershipLevel?.pointsExpiryDate) {
return null
}
export async function ExpiringPoints() {
const { formatMessage } = await getIntl()
return (
<table className={styles.table}>
<thead className={styles.thead}>
<tr>
<th className={styles.th}>{formatMessage({ id: "Arrival date" })}</th>
<th className={styles.th}>{formatMessage({ id: "Points" })}</th>
</tr>
</thead>
<tbody>
<tr className={styles.tr}>
<td className={styles.td}>23 May 2023</td>
<td className={styles.td}>30000</td>
</tr>
<tr className={styles.tr}>
<td className={styles.td}>23 May 2023</td>
<td className={styles.td}>-15000</td>
</tr>
</tbody>
</table>
<SectionContainer>
<SectionHeader title={title} link={link} subtitle={subtitle} />
<ExpiringPointsTable
points={membershipLevel.pointsToExpire}
expirationDate={membershipLevel.pointsExpiryDate}
/>
</SectionContainer>
)
}

View File

@@ -1,7 +1,14 @@
import styles from "./table.module.css"
function TD({ children }: React.PropsWithChildren) {
return <td className={styles.td}>{children}</td>
function TD({
children,
...rest
}: React.PropsWithChildren<React.TdHTMLAttributes<HTMLTableCellElement>>) {
return (
<td className={styles.td} {...rest}>
{children}
</td>
)
}
export default TD

View File

@@ -1,7 +1,14 @@
import styles from "./table.module.css"
function TR({ children }: React.PropsWithChildren) {
return <tr className={styles.tr}>{children}</tr>
function TR({
children,
...rest
}: React.PropsWithChildren<React.HTMLAttributes<HTMLTableRowElement>>) {
return (
<tr className={styles.tr} {...rest}>
{children}
</tr>
)
}
export default TR

View File

@@ -18,10 +18,22 @@
}
.th {
padding: var(--Spacing-x2) var(--Spacing-x3);
padding: var(--Spacing-x2);
text-align: left;
text-wrap: nowrap;
}
.td {
padding: var(--Spacing-x3);
padding: var(--Spacing-x2);
}
@media screen and (min-width: 768px) {
.th {
padding: var(--Spacing-x2) var(--Spacing-x3);
}
.td {
padding: var(--Spacing-x3);
}
}

View File

@@ -357,7 +357,16 @@ export const userQueryRouter = router({
lastName: verifiedData.data.lastName,
}
}),
membershipLevel: safeProtectedProcedure.query(async function ({ ctx }) {
membershipLevel: protectedProcedure.query(async function ({ ctx }) {
const verifiedData = await getVerifiedUser({ session: ctx.session })
if (!verifiedData || "error" in verifiedData) {
return null
}
const membershipLevel = getMembership(verifiedData.data.memberships)
return membershipLevel
}),
safeMembershipLevel: safeProtectedProcedure.query(async function ({ ctx }) {
if (!ctx.session) {
return null
}

View File

@@ -1,4 +1,4 @@
import { awardPointsVariants } from "@/components/MyPages/Blocks/Points/EarnAndBurn/JourneyTable/Table/Row/awardPointsVariants"
import { awardPointsVariants } from "@/components/MyPages/Blocks/Points/EarnAndBurn/AwardPoints/awardPointsVariants"
import type { VariantProps } from "class-variance-authority"
@@ -23,7 +23,7 @@ export type EarnAndBurnProps = {
lang: Lang
}
export interface TableProps {
export interface ClientTableProps {
transactions: Transactions
}