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( export async function fetchAvailableHotels(
input: AvailabilityInput input: AvailabilityInput
): Promise<NullableHotelData[]> { ): Promise<NullableHotelData[]> {
const availableHotels = const availableHotels = input.redemption
await serverClient().hotel.availability.hotelsByCity(input) ? await serverClient().hotel.availability.hotelsByCityWithRedemption(input)
: await serverClient().hotel.availability.hotelsByCity(input)
if (!availableHotels) return [] if (!availableHotels) return []

View File

@@ -28,6 +28,7 @@ interface HotelSearchDetails<T> {
childrenInRoomString?: string childrenInRoomString?: string
childrenInRoom?: Child[] childrenInRoom?: Child[]
bookingCode?: string bookingCode?: string
redemption?: boolean
} }
export async function getHotelSearchDetails< export async function getHotelSearchDetails<
@@ -105,5 +106,6 @@ export async function getHotelSearchDetails<
childrenInRoomString, childrenInRoomString,
childrenInRoom, childrenInRoom,
bookingCode: selectHotelParams.bookingCode ?? undefined, 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 */ /** Record<string, any> is next-auth typings */
const params: Record<string, any> = { const params: Record<string, any> = {
ui_locales: context.params.lang, 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 * The `acr_values` param is used to make Curity display the proper login
* page for Scandic. Without the parameter Curity presents some choices * 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, ui_locales: context.params.lang,
scope: ["openid", "profile"].join(" "), scope: [
"openid",
"profile",
"booking",
"availability",
"profile_link",
].join(" "),
loginKey: loginKey, loginKey: loginKey,
for_origin: publicURL, for_origin: publicURL,
acr_values: "urn:com:scandichotels:scandic-email", 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; 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) { @media screen and (min-width: 768px) and (max-width: 1024px) {
.imageContainer { .imageContainer {
height: 180px; height: 180px;

View File

@@ -22,6 +22,7 @@ import { getSingleDecimal } from "@/utils/numberFormatting"
import ReadMore from "../ReadMore" import ReadMore from "../ReadMore"
import TripAdvisorChip from "../TripAdvisorChip" import TripAdvisorChip from "../TripAdvisorChip"
import HotelPointsCard from "./HotelPointsCard"
import HotelPriceCard from "./HotelPriceCard" import HotelPriceCard from "./HotelPriceCard"
import NoPriceAvailableCard from "./NoPriceAvailableCard" import NoPriceAvailableCard from "./NoPriceAvailableCard"
import { hotelCardVariants } from "./variants" import { hotelCardVariants } from "./variants"
@@ -172,7 +173,9 @@ function HotelCard({
{bookingCode} {bookingCode}
</span> </span>
)} )}
{(!isUserLoggedIn || (bookingCode && !fullPrice)) && {(!isUserLoggedIn ||
!price.member ||
(bookingCode && !fullPrice)) &&
price.public && ( price.public && (
<HotelPriceCard productTypePrices={price.public} /> <HotelPriceCard productTypePrices={price.public} />
)} )}
@@ -182,6 +185,20 @@ function HotelCard({
isMemberPrice 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 <Button
asChild asChild
theme="base" theme="base"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,8 +5,34 @@ import { getDefaultRequestHeaders } from "./utils"
import type { NextMiddleware } from "next/server" import type { NextMiddleware } from "next/server"
import type { MiddlewareMatcher } from "@/types/middleware" 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) => { 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) const headers = getDefaultRequestHeaders(request)
return NextResponse.next({ return NextResponse.next({
request: { request: {

View File

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

View File

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

View File

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

View File

@@ -10,9 +10,24 @@ export const priceSchema = z.object({
regularPricePerStay: z.coerce.number().optional(), regularPricePerStay: z.coerce.number().optional(),
}) })
export const productTypePriceSchema = z.object({ export const pointsSchema = z.object({
localPrice: priceSchema, 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(), rateCode: z.string(),
rateType: z.string().optional(), rateType: z.string().optional(),
requestedPrice: priceSchema.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 adults: number
children?: string children?: string
bookingCode?: string bookingCode?: string
redemption?: boolean
} }
export type AlternativeHotelsAvailabilityInput = { export type AlternativeHotelsAvailabilityInput = {
@@ -13,4 +14,5 @@ export type AlternativeHotelsAvailabilityInput = {
adults: number adults: number
children?: string children?: string
bookingCode?: 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 = { export type PriceCardProps = {
productTypePrices: ProductTypePrices productTypePrices: ProductTypePrices
isMemberPrice?: boolean isMemberPrice?: boolean
} }
export type PointsCardProps = {
productTypePoints: ProductTypePoints
}

View File

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

View File

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

View File

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