Merged in feat/LOY-430-reward-nights (pull request #3295)

Feat/LOY-430 reward nights

* chore(LOY-430): add reward nights request and dynamic content

* chore(LOY-430): fix Reward Night component

* Refactor: use existing endpoint and add rewardNight data to that response instead


Approved-by: Linus Flood
This commit is contained in:
Matilda Landström
2025-12-08 07:44:58 +00:00
parent 9d8399b7c7
commit 5986828580
12 changed files with 152 additions and 12 deletions

View File

@@ -27,7 +27,7 @@ export async function GET(request: NextRequest) {
}
const caller = await serverClient()
const hotels = await caller.hotel.hotels.getDestinationsMapData({
const hotels = await caller.hotel.hotels.getAllHotelData({
lang: parsedLang.data,
warmup: true,
})

View File

@@ -0,0 +1,122 @@
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 styles from "./rewardNights.module.css"
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 = hotel.rewardNight.campaign.points
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()
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).toISOString().split("T")[0]
}

View File

@@ -0,0 +1,13 @@
.offerPrice {
display: grid;
gap: var(--Space-x05);
}
.highlightedText {
color: var(--Text-Interactive-Secondary);
padding-top: var(--Space-x2);
}
.grid {
display: grid;
}

View File

@@ -28,6 +28,7 @@ import { SJWidget } from "@/components/SJWidget"
import JobylonFeed from "./JobylonFeed"
import type { DynamicContentProps } from "@/types/components/blocks/dynamicContent"
import { RewardNights } from "./RewardNights"
export default function DynamicContent(props: DynamicContentProps) {
return (
@@ -91,6 +92,8 @@ function DynamicContentBlocks(props: DynamicContentProps) {
case DynamicContentEnum.Blocks.components.sj_widget:
return <SJWidget />
case DynamicContentEnum.Blocks.components.reward_nights:
return <RewardNights />
default:
return null
}

View File

@@ -1,5 +1,5 @@
import { env } from "@/env/server"
import { getDestinationsMapData } from "@/lib/trpc/memoizedRequests"
import { getAllHotelData } from "@/lib/trpc/memoizedRequests"
import DynamicMap from "../../Map/DynamicMap"
import MapContent from "../../Map/MapContent"
@@ -8,7 +8,7 @@ import { getHotelMapMarkers, mapMarkerDataToGeoJson } from "../../Map/utils"
import ActiveMapCard from "./ActiveMapCard"
export default async function OverviewMapContainer() {
const hotelData = await getDestinationsMapData()
const hotelData = await getAllHotelData()
if (!hotelData) {
return null

View File

@@ -203,12 +203,10 @@ export const getHotelsByCityIdentifier = cache(
})
}
)
export const getDestinationsMapData = cache(
async function getMemoizedDestinationsMapData() {
const caller = await serverClient()
return caller.hotel.hotels.getDestinationsMapData()
}
)
export const getAllHotelData = cache(async function getMemoizedAllHotelData() {
const caller = await serverClient()
return caller.hotel.hotels.getAllHotelData()
})
export const getDestinationCityPage = cache(
async function getMemoizedDestinationCityPage() {
const caller = await serverClient()

View File

@@ -16,7 +16,7 @@ export const warmupHotelData =
try {
const caller = await serverClient()
await caller.hotel.hotels.getDestinationsMapData({
await caller.hotel.hotels.getAllHotelData({
lang,
warmup: true,
})

View File

@@ -24,7 +24,7 @@ export const tableSchema = z.object({
),
data: z.array(z.object({}).catchall(z.string())),
skipReset: z.boolean(),
tableActionEnabled: z.boolean(),
tableActionEnabled: z.boolean().optional().default(false),
headerRowAdded: z.boolean().optional().default(false),
}),
}),

View File

@@ -26,6 +26,7 @@ import {
import { addressSchema } from "./schemas/hotel/address"
import { detailedFacilitiesSchema } from "./schemas/hotel/detailedFacility"
import { locationSchema } from "./schemas/hotel/location"
import { rewardNightSchema } from "./schemas/hotel/rewardNight"
import { imageSchema } from "./schemas/image"
import { relationshipsSchema } from "./schemas/relationships"
import { roomConfigurationSchema } from "./schemas/roomAvailability/configuration"
@@ -600,6 +601,7 @@ export const hotelListingHotelDataSchema = z.object({
hotelType: z.string(),
type: z.literal("hotels"), // No enum here but the standard return appears to be "hotels".
description: z.string().nullable(),
rewardNight: rewardNightSchema,
}),
url: z.string().nullable(),
meetingUrl: z.string().nullable(),

View File

@@ -229,7 +229,7 @@ export const hotelQueryRouter = router({
return hotels
}),
}),
getDestinationsMapData: serviceProcedure
getAllHotelData: serviceProcedure
.input(getDestinationsMapDataInput)
.query(async function ({ input, ctx }) {
const lang = input?.lang ?? ctx.lang

View File

@@ -94,6 +94,7 @@ export async function getHotelsByHotelIds({
address: hotel.address,
cityIdentifier: cities[0]?.cityIdentifier || null,
description: content.description || null,
rewardNight: hotel.rewardNight,
},
url: content.url,
meetingUrl: additionalData.meetingRooms.meetingOnlineLink || null,

View File

@@ -29,6 +29,7 @@ export namespace DynamicContentEnum {
sas_tier_comparison: "sas_tier_comparison",
manage_cookie_consent: "manage_cookie_consent",
sj_widget: "sj_widget",
reward_nights: "reward_nights",
unknown: "unknown",
} as const