feat: add included param to hotel call, fetch room data, setup schema, use in hotelpage
This commit is contained in:
@@ -4,7 +4,6 @@ import AmenitiesList from "./AmenitiesList"
|
||||
import IntroSection from "./IntroSection"
|
||||
|
||||
import { Rooms } from "./Rooms"
|
||||
import { MOCK_ROOMS } from "./tempHotelPageData"
|
||||
|
||||
import styles from "./hotelPage.module.css"
|
||||
|
||||
@@ -18,11 +17,11 @@ export default async function HotelPage({ lang }: LangParams) {
|
||||
return null
|
||||
}
|
||||
|
||||
const attributes = await serverClient().hotel.getHotel({
|
||||
const { attributes, roomCategories } = await serverClient().hotel.getHotel({
|
||||
hotelId: hotelPageIdentifierData.hotel_page_id,
|
||||
language: lang,
|
||||
include: ["RoomCategories"],
|
||||
})
|
||||
const rooms = MOCK_ROOMS
|
||||
|
||||
return (
|
||||
<main className={styles.pageContainer}>
|
||||
@@ -36,7 +35,7 @@ export default async function HotelPage({ lang }: LangParams) {
|
||||
/>
|
||||
<AmenitiesList detailedFacilities={attributes.detailedFacilities} />
|
||||
</div>
|
||||
<Rooms rooms={rooms} />
|
||||
<Rooms rooms={roomCategories} />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useIntl } from "react-intl"
|
||||
|
||||
import { ImageIcon } from "@/components/Icons"
|
||||
import Image from "@/components/Image"
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Title from "@/components/TempDesignSystem/Text/Title"
|
||||
|
||||
@@ -43,12 +44,14 @@ export function RoomCard({
|
||||
<ImageIcon color="white" />
|
||||
{images.length}
|
||||
</span>
|
||||
{/*NOTE: images from the test API are hosted on test3.scandichotels.com,
|
||||
which can't be accessed unless on Scandic's Wifi or using Citrix. */}
|
||||
<Image
|
||||
className={styles.image}
|
||||
src={mainImage.src}
|
||||
alt={mainImage.alt}
|
||||
height={mainImage.height}
|
||||
width={mainImage.width}
|
||||
src={mainImage.imageSizes.large}
|
||||
alt={mainImage.metaData.altText}
|
||||
height={200}
|
||||
width={300}
|
||||
/>
|
||||
</button>
|
||||
<div className={styles.content}>
|
||||
@@ -58,9 +61,14 @@ export function RoomCard({
|
||||
</Title>
|
||||
<Body color="grey">{subtitle}</Body>
|
||||
</div>
|
||||
<button className={styles.cta} onClick={handleRoomCtaClick}>
|
||||
{formatMessage({ id: "View room" })}
|
||||
</button>
|
||||
<Link
|
||||
href="#"
|
||||
color="peach80"
|
||||
variant="underscored"
|
||||
onClick={handleRoomCtaClick}
|
||||
>
|
||||
{formatMessage({ id: "hotelPages.rooms.roomCard.seeRoomDetails" })}
|
||||
</Link>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
|
||||
@@ -65,22 +65,3 @@
|
||||
.subtitle {
|
||||
color: var(--UI-Text-Placeholder);
|
||||
}
|
||||
|
||||
.cta {
|
||||
background-color: transparent;
|
||||
border-width: 0;
|
||||
cursor: pointer;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--Spacing-x-half);
|
||||
color: var(--Base-Text-Medium-contrast);
|
||||
font-family: var(--typography-Body-Bold-fontFamily);
|
||||
font-size: var(--typography-Body-Bold-fontSize);
|
||||
font-weight: 600;
|
||||
text-decoration: underline;
|
||||
}
|
||||
.cta:hover {
|
||||
color: var(--Base-Text-High-contrast);
|
||||
}
|
||||
|
||||
@@ -1,31 +1,54 @@
|
||||
import SectionContainer from "@/components/Section/Container"
|
||||
import SectionHeader from "@/components/Section/Header"
|
||||
import { getIntl } from "@/i18n"
|
||||
|
||||
import { RoomCard } from "./RoomCard"
|
||||
import { RoomsProps } from "./types"
|
||||
|
||||
import styles from "./rooms.module.css"
|
||||
|
||||
import { RoomsProps } from "@/types/components/hotelPage/rooms"
|
||||
export async function Rooms({ rooms }: RoomsProps) {
|
||||
const { formatMessage } = await getIntl()
|
||||
const mappedRooms = rooms
|
||||
.map((room) => {
|
||||
const size = `${room.attributes.roomSize.min} - ${room.attributes.roomSize.max} m²`
|
||||
const personLabel =
|
||||
room.attributes.occupancy.total === 1
|
||||
? formatMessage({ id: "hotelPages.rooms.roomCard.person" })
|
||||
: formatMessage({ id: "hotelPages.rooms.roomCard.persons" })
|
||||
|
||||
export function Rooms({ rooms }: RoomsProps) {
|
||||
// TODO: Typings should be adjusted to match the actual data structure
|
||||
const mappedRooms = rooms.map((room) => ({
|
||||
id: room.id,
|
||||
images: room.images,
|
||||
title: room.title,
|
||||
subtitle: room.subtitle,
|
||||
popularChoice: room.popularChoice,
|
||||
}))
|
||||
const subtitle = `${size} (${room.attributes.occupancy.total} ${personLabel})`
|
||||
|
||||
return {
|
||||
id: room.id,
|
||||
images: room.attributes.content.images,
|
||||
title: room.attributes.name,
|
||||
subtitle: subtitle,
|
||||
sortOrder: room.attributes.sortOrder,
|
||||
popularChoice: null,
|
||||
}
|
||||
})
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||
.slice(0, 3) //TODO: Remove this and render all rooms once we've implemented "show more" logic in SW-203.
|
||||
return (
|
||||
<section className={styles.cardContainer}>
|
||||
{mappedRooms.map(({ id, images, title, subtitle, popularChoice }) => (
|
||||
<RoomCard
|
||||
key={id}
|
||||
id={id}
|
||||
images={images}
|
||||
title={title}
|
||||
subtitle={subtitle}
|
||||
badgeTextTransKey={popularChoice ? "Popular choice" : null}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
<SectionContainer>
|
||||
<SectionHeader
|
||||
textTransform="uppercase"
|
||||
title={formatMessage({ id: "hotelPages.rooms.title" })}
|
||||
subtitle={null}
|
||||
/>
|
||||
<section className={styles.cardContainer}>
|
||||
{mappedRooms.map(({ id, images, title, subtitle, popularChoice }) => (
|
||||
<RoomCard
|
||||
key={id}
|
||||
id={id}
|
||||
images={images}
|
||||
title={title}
|
||||
subtitle={subtitle}
|
||||
badgeTextTransKey={popularChoice ? "Popular choice" : null}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
</SectionContainer>
|
||||
)
|
||||
}
|
||||
|
||||
5
components/ContentType/HotelPage/Rooms/types.ts
Normal file
5
components/ContentType/HotelPage/Rooms/types.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { RoomData } from "@/types/hotel"
|
||||
|
||||
export type RoomsProps = {
|
||||
rooms: RoomData[]
|
||||
}
|
||||
@@ -12,7 +12,6 @@
|
||||
|
||||
@media screen and (min-width: 1367px) {
|
||||
.pageContainer {
|
||||
gap: var(--Spacing-x3);
|
||||
padding: var(--Spacing-x9) var(--Spacing-x5);
|
||||
}
|
||||
.introContainer {
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
import { RoomsProps } from "../../../types/components/hotelPage/rooms"
|
||||
|
||||
export const MOCK_ROOMS: RoomsProps["rooms"] = [
|
||||
{
|
||||
id: "1",
|
||||
title: "Cabin",
|
||||
subtitle: "15 - 20 m² (2 personer)",
|
||||
images: [
|
||||
{
|
||||
src: "https://placehold.co/300x200",
|
||||
alt: "Placeholder image",
|
||||
width: 300,
|
||||
height: 200,
|
||||
},
|
||||
{
|
||||
src: "https://placehold.co/300x200",
|
||||
alt: "Placeholder image",
|
||||
width: 300,
|
||||
height: 200,
|
||||
},
|
||||
],
|
||||
popularChoice: false,
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
title: "Standard",
|
||||
subtitle: "15 - 20 m² (2 personer)",
|
||||
images: [
|
||||
{
|
||||
src: "https://placehold.co/300x200",
|
||||
alt: "Placeholder image",
|
||||
width: 300,
|
||||
height: 200,
|
||||
},
|
||||
{
|
||||
src: "https://placehold.co/300x200",
|
||||
alt: "Placeholder image",
|
||||
width: 300,
|
||||
height: 200,
|
||||
},
|
||||
{
|
||||
src: "https://placehold.co/300x200",
|
||||
alt: "Placeholder image",
|
||||
width: 300,
|
||||
height: 200,
|
||||
},
|
||||
],
|
||||
popularChoice: true,
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
title: "Superior",
|
||||
subtitle: "15 m² (2 personer)",
|
||||
images: [
|
||||
{
|
||||
src: "https://placehold.co/300x200",
|
||||
alt: "Placeholder image",
|
||||
width: 300,
|
||||
height: 200,
|
||||
},
|
||||
{
|
||||
src: "https://placehold.co/300x200",
|
||||
alt: "Placeholder image",
|
||||
width: 300,
|
||||
height: 200,
|
||||
},
|
||||
{
|
||||
src: "https://placehold.co/300x200",
|
||||
alt: "Placeholder image",
|
||||
width: 300,
|
||||
height: 200,
|
||||
},
|
||||
{
|
||||
src: "https://placehold.co/300x200",
|
||||
alt: "Placeholder image",
|
||||
width: 300,
|
||||
height: 200,
|
||||
},
|
||||
],
|
||||
popularChoice: false,
|
||||
},
|
||||
]
|
||||
@@ -41,6 +41,10 @@
|
||||
"From": "From",
|
||||
"Get inspired": "Get inspired",
|
||||
"Go back to overview": "Go back to overview",
|
||||
"hotelPages.rooms.title": "Rooms",
|
||||
"hotelPages.rooms.roomCard.person": "person",
|
||||
"hotelPages.rooms.roomCard.persons": "persons",
|
||||
"hotelPages.rooms.roomCard.seeRoomDetails": "See room details",
|
||||
"How it works": "How it works",
|
||||
"Join Scandic Friends": "Join Scandic Friends",
|
||||
"Language": "Language",
|
||||
|
||||
@@ -41,6 +41,10 @@
|
||||
"Get inspired": "Bli inspirerad",
|
||||
"Go back to overview": "Gå tillbaka till översikten",
|
||||
"How it works": "Hur det fungerar",
|
||||
"hotelPages.rooms.title": "Rum",
|
||||
"hotelPages.rooms.roomCard.person": "person",
|
||||
"hotelPages.rooms.roomCard.persons": "personer",
|
||||
"hotelPages.rooms.roomCard.seeRoomDetails": "Se rumsdetaljer",
|
||||
"Join Scandic Friends": "Gå med i Scandic Friends",
|
||||
"Language": "Språk",
|
||||
"Level": "Nivå",
|
||||
|
||||
@@ -5,6 +5,9 @@ import { Lang } from "@/constants/languages"
|
||||
export const getHotelInputSchema = z.object({
|
||||
hotelId: z.string(),
|
||||
language: z.nativeEnum(Lang),
|
||||
include: z
|
||||
.array(z.enum(["RoomCategories", "NearbyHotels", "Restaurants", "City"]))
|
||||
.optional(),
|
||||
})
|
||||
|
||||
export const getRatesInputSchema = z.object({
|
||||
|
||||
@@ -335,6 +335,83 @@ const RelationshipsSchema = z.object({
|
||||
}),
|
||||
})
|
||||
|
||||
const RoomContentSchema = z.object({
|
||||
images: z.array(
|
||||
z.object({
|
||||
metaData: ImageMetaDataSchema,
|
||||
imageSizes: ImageSizesSchema,
|
||||
})
|
||||
),
|
||||
texts: z.object({
|
||||
descriptions: z.object({
|
||||
short: z.string(),
|
||||
medium: z.string(),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
|
||||
const RoomTypesSchema = z.object({
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
code: z.string(),
|
||||
roomCount: z.number(),
|
||||
mainBed: z.object({
|
||||
type: z.string(),
|
||||
description: z.string(),
|
||||
widthRange: z.object({
|
||||
min: z.number(),
|
||||
max: z.number(),
|
||||
}),
|
||||
}),
|
||||
fixedExtraBed: z.object({
|
||||
type: z.string(),
|
||||
description: z.string().optional(),
|
||||
widthRange: z.object({
|
||||
min: z.number(),
|
||||
max: z.number(),
|
||||
}),
|
||||
}),
|
||||
roomSize: z.object({
|
||||
min: z.number(),
|
||||
max: z.number(),
|
||||
}),
|
||||
occupancy: z.object({
|
||||
total: z.number(),
|
||||
adults: z.number(),
|
||||
children: z.number(),
|
||||
}),
|
||||
isLackingCribs: z.boolean(),
|
||||
isLackingExtraBeds: z.boolean(),
|
||||
})
|
||||
|
||||
const RoomFacilitiesSchema = z.object({
|
||||
availableInAllRooms: z.boolean(),
|
||||
name: z.string(),
|
||||
isUniqueSellingPoint: z.boolean(),
|
||||
sortOrder: z.number(),
|
||||
})
|
||||
|
||||
export const RoomSchema = z.object({
|
||||
attributes: z.object({
|
||||
name: z.string(),
|
||||
sortOrder: z.number(),
|
||||
content: RoomContentSchema,
|
||||
roomTypes: z.array(RoomTypesSchema),
|
||||
roomFacilities: z.array(RoomFacilitiesSchema),
|
||||
occupancy: z.object({
|
||||
total: z.number(),
|
||||
adults: z.number(),
|
||||
children: z.number(),
|
||||
}),
|
||||
roomSize: z.object({
|
||||
min: z.number(),
|
||||
max: z.number(),
|
||||
}),
|
||||
}),
|
||||
id: z.string(),
|
||||
type: z.enum(["roomcategories"]),
|
||||
})
|
||||
|
||||
// NOTE: Find schema at: https://aks-test.scandichotels.com/hotel/swagger/v1/index.html
|
||||
export const getHotelDataSchema = z.object({
|
||||
data: z.object({
|
||||
@@ -385,11 +462,10 @@ export const getHotelDataSchema = z.object({
|
||||
}),
|
||||
relationships: RelationshipsSchema,
|
||||
}),
|
||||
//TODO: We can pass an "included" param to the hotel API to retrieve additional data for an individual hotel.
|
||||
// - This is out of scope for current work (and I'm unsure if we need it for hotel pages specifically),
|
||||
// - but if/when we do we can extend this schema to add necessary requirements.
|
||||
// - Example "included" data available in our tempHotelData file.
|
||||
// included: z.any(),
|
||||
// NOTE: We can pass an "include" param to the hotel API to retrieve
|
||||
// additional data for an individual hotel.
|
||||
// Example "included" data can be found in our tempHotelData file.
|
||||
included: z.array(RoomSchema).optional(),
|
||||
})
|
||||
|
||||
const Rate = z.object({
|
||||
|
||||
@@ -3,7 +3,7 @@ import { badRequestError } from "@/server/errors/trpc"
|
||||
import { publicProcedure, router } from "@/server/trpc"
|
||||
|
||||
import { getHotelInputSchema, getRatesInputSchema } from "./input"
|
||||
import { getHotelDataSchema, getRatesSchema } from "./output"
|
||||
import { getHotelDataSchema, getRatesSchema, RoomSchema } from "./output"
|
||||
import tempHotelData from "./tempHotelData.json"
|
||||
import tempRatesData from "./tempRatesData.json"
|
||||
import { toApiLang } from "./utils"
|
||||
@@ -12,12 +12,15 @@ export const hotelQueryRouter = router({
|
||||
getHotel: publicProcedure
|
||||
.input(getHotelInputSchema)
|
||||
.query(async ({ input, ctx }) => {
|
||||
const { hotelId, language } = input
|
||||
const { hotelId, language, include } = input
|
||||
|
||||
const params = new URLSearchParams()
|
||||
const apiLang = toApiLang(language)
|
||||
params.set("hotelId", hotelId.toString())
|
||||
params.set("language", apiLang)
|
||||
if (include) {
|
||||
params.set("include", include.join(","))
|
||||
}
|
||||
|
||||
// TODO: Enable once we have authorized API access.
|
||||
// const apiResponse = await api.get(
|
||||
@@ -33,10 +36,9 @@ export const hotelQueryRouter = router({
|
||||
// }
|
||||
// const apiJson = await apiResponse.json()
|
||||
|
||||
//TODO: We can pass an "included" param to the hotel API to retrieve additional data for an individual hotel.
|
||||
// - This is out of scope for current work (and I'm unsure if we need it for hotel pages specifically),
|
||||
// - but if/when we do we can extend the endpoint (and schema) to add necessary requirements.
|
||||
// - Example "included" data available in our tempHotelData file.
|
||||
// NOTE: We can pass an "include" param to the hotel API to retrieve
|
||||
// additional data for an individual hotel.
|
||||
// Example "included" data can be found in our tempHotelData file.
|
||||
const { included, ...apiJsonWithoutIncluded } = tempHotelData
|
||||
const validatedHotelData = getHotelDataSchema.safeParse(
|
||||
apiJsonWithoutIncluded
|
||||
@@ -48,7 +50,24 @@ export const hotelQueryRouter = router({
|
||||
throw badRequestError()
|
||||
}
|
||||
|
||||
return validatedHotelData.data.data.attributes
|
||||
const roomCategories = included
|
||||
? included
|
||||
.filter((item) => item.type === "roomcategories")
|
||||
.map((roomCategory) => {
|
||||
const validatedRoom = RoomSchema.safeParse(roomCategory)
|
||||
if (!validatedRoom.success) {
|
||||
console.info(`Get Room Category Data - Verified Data Error`)
|
||||
console.error(validatedRoom.error)
|
||||
throw badRequestError()
|
||||
}
|
||||
return validatedRoom.data
|
||||
})
|
||||
: []
|
||||
|
||||
return {
|
||||
attributes: validatedHotelData.data.data.attributes,
|
||||
roomCategories: roomCategories,
|
||||
}
|
||||
}),
|
||||
getRates: publicProcedure
|
||||
.input(getRatesInputSchema)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { ImageProps } from "next/image"
|
||||
import { RoomData } from "@/types/hotel"
|
||||
|
||||
export interface RoomCardProps {
|
||||
id: string
|
||||
images: ImageProps[]
|
||||
images: RoomData["attributes"]["content"]["images"]
|
||||
title: string
|
||||
subtitle: string
|
||||
badgeTextTransKey?: string | null
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import { ImageProps } from "next/image"
|
||||
|
||||
// TODO: Typings should be adjusted to match the actual data structure
|
||||
export interface RoomsProps {
|
||||
rooms: {
|
||||
id: string
|
||||
title: string
|
||||
subtitle: string
|
||||
popularChoice: boolean
|
||||
images: ImageProps[]
|
||||
}[]
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { z } from "zod"
|
||||
|
||||
import { getHotelDataSchema } from "@/server/routers/hotels/output"
|
||||
import { getHotelDataSchema,RoomSchema } from "@/server/routers/hotels/output"
|
||||
|
||||
export type HotelData = z.infer<typeof getHotelDataSchema>
|
||||
|
||||
@@ -9,3 +9,5 @@ export type HotelAddress = HotelData["data"]["attributes"]["address"]
|
||||
export type HotelLocation = HotelData["data"]["attributes"]["location"]
|
||||
export type HotelTripAdvisor =
|
||||
HotelData["data"]["attributes"]["ratings"]["tripAdvisor"]
|
||||
|
||||
export type RoomData = z.infer<typeof RoomSchema>
|
||||
|
||||
Reference in New Issue
Block a user