feat: SW-1583 Implemented Reward nights on city search
This commit is contained in:
@@ -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 []
|
||||
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
.poinstRow {
|
||||
display: flex;
|
||||
gap: var(--Spacing-x1);
|
||||
align-items: baseline;
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
)
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ export interface SelectRateSearchParams {
|
||||
hotelId: string
|
||||
rooms: Room[]
|
||||
toDate: string
|
||||
searchType?: "redemption"
|
||||
}
|
||||
|
||||
export type Rate = {
|
||||
|
||||
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user