feat: add included param to hotel call, fetch room data, setup schema, use in hotelpage

This commit is contained in:
Chuma McPhoy
2024-07-10 13:41:39 +02:00
parent f71d0a07d5
commit 0697c8d9ef
15 changed files with 191 additions and 162 deletions

View File

@@ -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>
)
}

View File

@@ -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>
)

View File

@@ -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);
}

View File

@@ -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}`
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>
)
}

View File

@@ -0,0 +1,5 @@
import { RoomData } from "@/types/hotel"
export type RoomsProps = {
rooms: RoomData[]
}

View File

@@ -12,7 +12,6 @@
@media screen and (min-width: 1367px) {
.pageContainer {
gap: var(--Spacing-x3);
padding: var(--Spacing-x9) var(--Spacing-x5);
}
.introContainer {

View File

@@ -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,
},
]

View File

@@ -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",

View File

@@ -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å",

View File

@@ -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({

View File

@@ -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({

View File

@@ -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)

View File

@@ -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

View File

@@ -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[]
}[]
}

View File

@@ -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>