feat: SW-1583 Implemented Reward nights on city search

This commit is contained in:
Hrishikesh Vaipurkar
2025-03-03 16:39:10 +01:00
parent 51b70f3032
commit 5058180c41
25 changed files with 176 additions and 12 deletions

View File

@@ -36,8 +36,9 @@ const hotelFacilitiesFilterNames = [
export async function fetchAvailableHotels(
input: AvailabilityInput
): Promise<NullableHotelData[]> {
const availableHotels =
await serverClient().hotel.availability.hotelsByCity(input)
const availableHotels = input.redemption
? await serverClient().hotel.availability.hotelsByCityWithRedemption(input)
: await serverClient().hotel.availability.hotelsByCity(input)
if (!availableHotels) return []

View File

@@ -28,6 +28,7 @@ interface HotelSearchDetails<T> {
childrenInRoomString?: string
childrenInRoom?: Child[]
bookingCode?: string
redemption?: boolean
}
export async function getHotelSearchDetails<
@@ -105,5 +106,6 @@ export async function getHotelSearchDetails<
childrenInRoomString,
childrenInRoom,
bookingCode: selectHotelParams.bookingCode ?? undefined,
redemption: selectHotelParams.searchType === "redemption",
}
}

View File

@@ -117,7 +117,7 @@ export async function GET(
/** Record<string, any> is next-auth typings */
const params: Record<string, any> = {
ui_locales: context.params.lang,
scope: ["openid", "profile", "booking", "profile_link"],
scope: ["openid", "profile", "booking", "profile_link", "availability"],
/**
* The `acr_values` param is used to make Curity display the proper login
* page for Scandic. Without the parameter Curity presents some choices

View File

@@ -65,7 +65,13 @@ export async function GET(
},
{
ui_locales: context.params.lang,
scope: ["openid", "profile"].join(" "),
scope: [
"openid",
"profile",
"booking",
"availability",
"profile_link",
].join(" "),
loginKey: loginKey,
for_origin: publicURL,
acr_values: "urn:com:scandichotels:scandic-email",

View File

@@ -0,0 +1,5 @@
.poinstRow {
display: flex;
gap: var(--Spacing-x1);
align-items: baseline;
}

View File

@@ -0,0 +1,36 @@
import { useIntl } from "react-intl"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import styles from "./hotelPointsCard.module.css"
import type { PointsCardProps } from "@/types/components/hotelReservation/selectHotel/priceCardProps"
export default function HotelPointsCard({
productTypePoints,
}: PointsCardProps) {
const intl = useIntl()
return (
<div className={styles.poinstRow}>
<Subtitle type="two" color="uiTextHighContrast">
{productTypePoints.localPrice.pointsPerStay}
</Subtitle>
<Caption color="uiTextHighContrast">
{intl.formatMessage({ id: "Points" })}
</Caption>
{productTypePoints.localPrice.pricePerStay ? (
<>
+
<Subtitle type="two" color="uiTextHighContrast">
{productTypePoints.localPrice.pricePerStay}
</Subtitle>
<Caption color="uiTextHighContrast">
{productTypePoints.localPrice.currency}
</Caption>
</>
) : null}
</div>
)
}

View File

@@ -96,6 +96,12 @@
text-decoration: line-through;
}
.pointsCard {
background-color: var(--Base-Surface-Secondary-light-Normal);
padding: var(--Spacing-x-one-and-half);
border-radius: var(--Corner-radius-Medium);
}
@media screen and (min-width: 768px) and (max-width: 1024px) {
.imageContainer {
height: 180px;

View File

@@ -22,6 +22,7 @@ import { getSingleDecimal } from "@/utils/numberFormatting"
import ReadMore from "../ReadMore"
import TripAdvisorChip from "../TripAdvisorChip"
import HotelPointsCard from "./HotelPointsCard"
import HotelPriceCard from "./HotelPriceCard"
import NoPriceAvailableCard from "./NoPriceAvailableCard"
import { hotelCardVariants } from "./variants"
@@ -172,7 +173,9 @@ function HotelCard({
{bookingCode}
</span>
)}
{(!isUserLoggedIn || (bookingCode && !fullPrice)) &&
{(!isUserLoggedIn ||
!price.member ||
(bookingCode && !fullPrice)) &&
price.public && (
<HotelPriceCard productTypePrices={price.public} />
)}
@@ -182,6 +185,20 @@ function HotelCard({
isMemberPrice
/>
)}
{price.redemption && (
<div className={styles.pointsCard}>
<Caption>
{intl.formatMessage({ id: "Available rates" })}
</Caption>
<HotelPointsCard productTypePoints={price.redemption} />
{price.redemptionA && (
<HotelPointsCard productTypePoints={price.redemptionA} />
)}
{price.redemptionB && (
<HotelPointsCard productTypePoints={price.redemptionB} />
)}
</div>
)}
<Button
asChild
theme="base"

View File

@@ -77,6 +77,7 @@ export default async function SelectHotel({
childrenInRoom,
hotel: isAlternativeFor,
bookingCode,
redemption,
} = searchDetails
if (!city) return notFound()
@@ -89,6 +90,7 @@ export default async function SelectHotel({
adults: adultsInRoom[0],
children: childrenInRoomString,
bookingCode,
redemption,
})
)
: bookingCode
@@ -109,6 +111,7 @@ export default async function SelectHotel({
roomStayEndDate: selectHotelParams.toDate,
adults: adultsInRoom[0],
children: childrenInRoomString,
redemption,
})
)

View File

@@ -63,6 +63,7 @@
"At latest": "Senest",
"At the hotel": "På hotellet",
"Attractions": "Attraktioner",
"Available rates": "Tilgængelige priser",
"Average price per night": "gennemsnitspris pr. nat",
"Away from elevator": "Væk fra elevator",
"Back": "Tilbage",

View File

@@ -64,6 +64,7 @@
"At the hotel": "Im Hotel",
"Attraction": "Attraktion",
"Attractions": "Attractions",
"Available rates": "Verfügbare Preise",
"Average price per night": "Durchschnittspreis pro Nacht",
"Away from elevator": "Weg vom Aufzug",
"Back": "Zurück",

View File

@@ -62,6 +62,7 @@
"At latest": "At latest",
"At the hotel": "At the hotel",
"Attractions": "Attractions",
"Available rates": "Available rates",
"Average price per night": "Average price per night",
"Away from elevator": "Away from elevator",
"Back": "Back",

View File

@@ -62,6 +62,7 @@
"At latest": "Viimeistään",
"At the hotel": "Hotellissa",
"Attractions": "Nähtävyydet",
"Available rates": "Saatavilla olevat hinnat",
"Average price per night": "keskihinta per yö",
"Away from elevator": "Kaukana hissistä",
"Back": "Takaisin",

View File

@@ -62,6 +62,7 @@
"At latest": "Senest",
"At the hotel": "På hotellet",
"Attractions": "Attraksjoner",
"Available rates": "Tilgjengelige priser",
"Average price per night": "gjennomsnittlig pris per natt",
"Away from elevator": "Bort fra heisen",
"Back": "Tilbake",

View File

@@ -62,6 +62,7 @@
"At latest": "Senast",
"At the hotel": "På hotellet",
"Attractions": "Sevärdheter",
"Available rates": "Tillgängliga priser",
"Average price per night": "Snittpris per natt",
"Away from elevator": "Bort från hissen",
"Back": "Tillbaka",

View File

@@ -5,8 +5,34 @@ import { getDefaultRequestHeaders } from "./utils"
import type { NextMiddleware } from "next/server"
import type { MiddlewareMatcher } from "@/types/middleware"
import { auth } from "@/auth"
import { getPublicNextURL } from "@/server/utils"
import { login } from "@/constants/routes/handleAuth"
import { findLang } from "@/utils/languages"
export const middleware: NextMiddleware = async (request) => {
// Redirect user to login if reward nights search and not logged in
const isRedemption =
request.nextUrl.searchParams.get("searchtype") === "redemption" ||
request.nextUrl.searchParams.get("searchType") === "redemption"
const session = await auth() // Check for user session
if (isRedemption && (!session || session?.error)) {
const lang = findLang(request.nextUrl.pathname)!
const nextUrlPublic = getPublicNextURL(request)
const headers = new Headers()
headers.append(
"set-cookie",
`redirectTo=${encodeURIComponent(nextUrlPublic.href)}; Path=/; HttpOnly; SameSite=Lax`
)
const loginUrl = login[lang]
const redirectUrl = new URL(loginUrl, nextUrlPublic)
const redirectOpts = {
headers,
}
return NextResponse.redirect(redirectUrl, redirectOpts)
}
const headers = getDefaultRequestHeaders(request)
return NextResponse.next({
request: {

View File

@@ -13,6 +13,7 @@ export const hotelsAvailabilityInputSchema = z.object({
adults: z.number(),
children: z.string().optional(),
bookingCode: z.string().optional().default(""),
redemption: z.boolean().optional().default(false),
})
export const getHotelsByHotelIdsAvailabilityInputSchema = z.object({

View File

@@ -7,6 +7,7 @@ import { dt } from "@/lib/dt"
import { badRequestError } from "@/server/errors/trpc"
import {
contentStackBaseWithServiceProcedure,
protectedProcedure,
publicProcedure,
router,
safeProtectedServiceProcedure,
@@ -215,7 +216,7 @@ export const getHotel = cache(
export const getHotelsAvailabilityByCity = async (
input: HotelsAvailabilityInputSchema,
apiLang: string,
serviceToken: string
token: string // Either service token or user access token in case of redemption search
) => {
const {
cityId,
@@ -224,6 +225,7 @@ export const getHotelsAvailabilityByCity = async (
adults,
children,
bookingCode,
redemption,
} = input
const params: Record<string, string | number> = {
@@ -232,6 +234,7 @@ export const getHotelsAvailabilityByCity = async (
adults,
...(children && { children }),
...(bookingCode && { bookingCode }),
...(redemption ? { isRedemption: "true" } : {}),
language: apiLang,
}
metrics.hotelsAvailability.counter.add(1, {
@@ -241,6 +244,7 @@ export const getHotelsAvailabilityByCity = async (
adults,
children,
bookingCode,
redemption,
})
console.info(
"api.hotels.hotelsAvailability start",
@@ -251,7 +255,7 @@ export const getHotelsAvailabilityByCity = async (
{
cache: undefined,
headers: {
Authorization: `Bearer ${serviceToken}`,
Authorization: `Bearer ${token}`,
},
next: {
revalidate: env.CACHE_TIME_CITY_SEARCH,
@@ -268,6 +272,7 @@ export const getHotelsAvailabilityByCity = async (
adults,
children,
bookingCode,
redemption,
error_type: "http_error",
error: JSON.stringify({
status: apiResponse.status,
@@ -298,6 +303,7 @@ export const getHotelsAvailabilityByCity = async (
adults,
children,
bookingCode,
redemption,
error_type: "validation_error",
error: JSON.stringify(validateAvailabilityData.error),
})
@@ -317,6 +323,7 @@ export const getHotelsAvailabilityByCity = async (
adults,
children,
bookingCode,
redemption,
})
console.info(
"api.hotels.hotelsAvailability success",
@@ -466,6 +473,17 @@ export const hotelQueryRouter = router({
const apiLang = toApiLang(lang)
return getHotelsAvailabilityByCity(input, apiLang, ctx.serviceToken)
}),
hotelsByCityWithRedemption: protectedProcedure
.input(hotelsAvailabilityInputSchema)
.query(async ({ input, ctx }) => {
const { lang } = ctx
const apiLang = toApiLang(lang)
return getHotelsAvailabilityByCity(
input,
apiLang,
ctx.session.token.access_token
)
}),
hotelsByHotelIds: serviceProcedure
.input(getHotelsByHotelIdsAvailabilityInputSchema)
.query(async ({ input, ctx }) => {

View File

@@ -1,10 +1,16 @@
import { z } from "zod"
import { productTypePriceSchema } from "../productTypePrice"
import {
productTypePriceSchema,
productTypePointsSchema,
} from "../productTypePrice"
export const productTypeSchema = z
.object({
public: productTypePriceSchema.optional(),
member: productTypePriceSchema.optional(),
redemption: productTypePointsSchema.optional(),
redemptionA: productTypePointsSchema.optional(),
redemptionB: productTypePointsSchema.optional(),
})
.optional()

View File

@@ -10,9 +10,24 @@ export const priceSchema = z.object({
regularPricePerStay: z.coerce.number().optional(),
})
export const productTypePriceSchema = z.object({
localPrice: priceSchema,
export const pointsSchema = z.object({
currency: z.nativeEnum(CurrencyEnum).optional(),
pricePerNight: z.coerce.number().optional(),
pricePerStay: z.coerce.number().optional(),
pointsPerNight: z.number(),
pointsPerStay: z.number(),
})
const partialPriceSchema = z.object({
rateCode: z.string(),
rateType: z.string().optional(),
requestedPrice: priceSchema.optional(),
})
export const productTypePriceSchema = partialPriceSchema.extend({
localPrice: priceSchema,
})
export const productTypePointsSchema = partialPriceSchema.extend({
localPrice: pointsSchema,
})

View File

@@ -5,6 +5,7 @@ export type AvailabilityInput = {
adults: number
children?: string
bookingCode?: string
redemption?: boolean
}
export type AlternativeHotelsAvailabilityInput = {
@@ -13,4 +14,5 @@ export type AlternativeHotelsAvailabilityInput = {
adults: number
children?: string
bookingCode?: string
redemption?: boolean
}

View File

@@ -1,6 +1,13 @@
import type { ProductTypePrices } from "@/types/trpc/routers/hotel/availability"
import type {
ProductTypePoints,
ProductTypePrices,
} from "@/types/trpc/routers/hotel/availability"
export type PriceCardProps = {
productTypePrices: ProductTypePrices
isMemberPrice?: boolean
}
export type PointsCardProps = {
productTypePoints: ProductTypePoints
}

View File

@@ -6,6 +6,7 @@ export interface SelectHotelSearchParams {
toDate: string
rooms: Pick<Room, "adults" | "childrenInRoom">[]
bookingCode: string
searchType?: "redemption"
}
export interface AlternativeHotelsSearchParams {
@@ -14,4 +15,5 @@ export interface AlternativeHotelsSearchParams {
toDate: string
rooms: Pick<Room, "adults" | "childrenInRoom">[]
bookingCode: string
searchType?: "redemption"
}

View File

@@ -26,6 +26,7 @@ export interface SelectRateSearchParams {
hotelId: string
rooms: Room[]
toDate: string
searchType?: "redemption"
}
export type Rate = {

View File

@@ -6,7 +6,10 @@ import type {
} from "@/server/routers/hotels/input"
import type { hotelsAvailabilitySchema } from "@/server/routers/hotels/output"
import type { productTypeSchema } from "@/server/routers/hotels/schemas/availability/productType"
import type { productTypePriceSchema } from "@/server/routers/hotels/schemas/productTypePrice"
import type {
productTypePointsSchema,
productTypePriceSchema,
} from "@/server/routers/hotels/schemas/productTypePrice"
export type HotelsAvailability = z.output<typeof hotelsAvailabilitySchema>
export type HotelsAvailabilityInputSchema = z.output<
@@ -17,6 +20,7 @@ export type HotelsByHotelIdsAvailabilityInputSchema = z.output<
>
export type ProductType = z.output<typeof productTypeSchema>
export type ProductTypePrices = z.output<typeof productTypePriceSchema>
export type ProductTypePoints = z.output<typeof productTypePointsSchema>
export type HotelsAvailabilityItem =
HotelsAvailability["data"][number]["attributes"]