diff --git a/app/[lang]/(live)/(public)/hotelreservation/select-hotel/page.tsx b/app/[lang]/(live)/(public)/hotelreservation/select-hotel/page.tsx index 9e1fc07d3..33df35812 100644 --- a/app/[lang]/(live)/(public)/hotelreservation/select-hotel/page.tsx +++ b/app/[lang]/(live)/(public)/hotelreservation/select-hotel/page.tsx @@ -9,12 +9,12 @@ import { LangParams, PageArgs } from "@/types/params" export default async function SelectHotelPage({ params, }: PageArgs) { - const hotel = await serverClient().hotel.getHotel({ + const { attributes } = await serverClient().hotel.getHotel({ hotelId: "d98c7ab1-ebaa-4102-b351-758daf1ddf55", language: params.lang, }) - const hotels = [hotel] + const hotels = [attributes] return (
diff --git a/components/ContentType/HotelPage/HotelPage.tsx b/components/ContentType/HotelPage/HotelPage.tsx index 3621be0c3..d57b3c36a 100644 --- a/components/ContentType/HotelPage/HotelPage.tsx +++ b/components/ContentType/HotelPage/HotelPage.tsx @@ -2,6 +2,7 @@ import { serverClient } from "@/lib/trpc/server" import AmenitiesList from "./AmenitiesList" import IntroSection from "./IntroSection" +import { Rooms } from "./Rooms" import styles from "./hotelPage.module.css" @@ -15,9 +16,10 @@ 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"], }) return ( @@ -32,6 +34,7 @@ export default async function HotelPage({ lang }: LangParams) { /> +
) } diff --git a/components/ContentType/HotelPage/IntroSection/index.tsx b/components/ContentType/HotelPage/IntroSection/index.tsx index 007f2c0ac..5efb41292 100644 --- a/components/ContentType/HotelPage/IntroSection/index.tsx +++ b/components/ContentType/HotelPage/IntroSection/index.tsx @@ -3,7 +3,7 @@ import TripAdvisorIcon from "@/components/Icons/TripAdvisor" import Link from "@/components/TempDesignSystem/Link" import BiroScript from "@/components/TempDesignSystem/Text/BiroScript" import Body from "@/components/TempDesignSystem/Text/Body" -import Subtitle from "@/components/TempDesignSystem/Text/Subtitle" +import Preamble from "@/components/TempDesignSystem/Text/Preamble" import Title from "@/components/TempDesignSystem/Text/Title" import { getIntl } from "@/i18n" @@ -55,7 +55,7 @@ export default async function IntroSection({
- {hotelDescription} + {hotelDescription} + +
+
+ + {title} + + {subtitle} +
+ + {formatMessage({ id: "hotelPages.rooms.roomCard.seeRoomDetails" })} + +
+ + ) +} diff --git a/components/ContentType/HotelPage/Rooms/RoomCard/roomCard.module.css b/components/ContentType/HotelPage/Rooms/RoomCard/roomCard.module.css new file mode 100644 index 000000000..5134103e0 --- /dev/null +++ b/components/ContentType/HotelPage/Rooms/RoomCard/roomCard.module.css @@ -0,0 +1,67 @@ +.roomCard { + border-radius: var(--Corner-radius-Medium); + background-color: var(--UI-Opacity-White-100); + border: 1px solid var(--Base-Border-Subtle); + display: grid; +} + +/*TODO: Build Chip/Badge component. */ +.badge { + position: absolute; + top: var(--Spacing-x1); + left: var(--Spacing-x1); + background-color: var(--Tertiary-Dark-Surface-Hover); + padding: var(--Spacing-x-half) var(--Spacing-x1); + border-radius: var(--Corner-radius-Medium); + color: var(--Tertiary-Dark-On-Surface-Text); + text-transform: uppercase; + font-size: var(--typography-Chip-fontSize-Placeholder); + font-weight: 400; +} + +.imageCount { + position: absolute; + right: var(--Spacing-x1); + bottom: var(--Spacing-x1); + display: flex; + gap: var(--Spacing-x-half); + align-items: center; + background-color: var(--Scandic-Opacity-Almost-Black-60); + color: var(--Scandic-Opacity-White-100); + padding: var(--Spacing-x-half) var(--Spacing-x1); + border-radius: var(--Corner-radius-Medium); +} + +.content { + display: grid; + justify-items: center; + gap: var(--Spacing-x-one-and-half); + padding: var(--Spacing-x2); +} + +.innerContent { + display: grid; + justify-items: center; + gap: var(--Spacing-x1); +} + +.imageWrapper { + position: relative; + background-color: transparent; + border-width: 0; + cursor: pointer; + margin: 0; + padding: 0; + display: flex; +} + +.image { + width: 100%; + object-fit: cover; + border-top-left-radius: var(--Corner-radius-Medium); + border-top-right-radius: var(--Corner-radius-Medium); +} + +.subtitle { + color: var(--UI-Text-Placeholder); +} diff --git a/components/ContentType/HotelPage/Rooms/index.tsx b/components/ContentType/HotelPage/Rooms/index.tsx new file mode 100644 index 000000000..c865e23e7 --- /dev/null +++ b/components/ContentType/HotelPage/Rooms/index.tsx @@ -0,0 +1,53 @@ +import SectionContainer from "@/components/Section/Container" +import SectionHeader from "@/components/Section/Header" +import Grids from "@/components/TempDesignSystem/Grids" +import { getIntl } from "@/i18n" + +import { RoomCard } from "./RoomCard" +import { RoomsProps } from "./types" + +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" }) + + 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 ( + + + + {mappedRooms.map(({ id, images, title, subtitle, popularChoice }) => ( + + ))} + + + ) +} diff --git a/components/ContentType/HotelPage/Rooms/types.ts b/components/ContentType/HotelPage/Rooms/types.ts new file mode 100644 index 000000000..b4c320af2 --- /dev/null +++ b/components/ContentType/HotelPage/Rooms/types.ts @@ -0,0 +1,5 @@ +import { RoomData } from "@/types/hotel" + +export type RoomsProps = { + rooms: RoomData[] +} diff --git a/components/ContentType/HotelPage/hotelPage.module.css b/components/ContentType/HotelPage/hotelPage.module.css index 666cd92db..d168ea390 100644 --- a/components/ContentType/HotelPage/hotelPage.module.css +++ b/components/ContentType/HotelPage/hotelPage.module.css @@ -12,7 +12,6 @@ @media screen and (min-width: 1367px) { .pageContainer { - gap: var(--Spacing-x3); padding: var(--Spacing-x9) var(--Spacing-x5); } .introContainer { diff --git a/components/Icons/Camera.tsx b/components/Icons/Camera.tsx new file mode 100644 index 000000000..bed8a79e6 --- /dev/null +++ b/components/Icons/Camera.tsx @@ -0,0 +1,23 @@ +import { iconVariants } from "./variants" + +import type { IconProps } from "@/types/components/icon" + +export default function CameraIcon({ className, color, ...props }: IconProps) { + const classNames = iconVariants({ className, color }) + return ( + + + + ) +} diff --git a/components/Icons/Image.tsx b/components/Icons/Image.tsx new file mode 100644 index 000000000..9fcfe4a71 --- /dev/null +++ b/components/Icons/Image.tsx @@ -0,0 +1,36 @@ +import { iconVariants } from "./variants" + +import type { IconProps } from "@/types/components/icon" + +export default function ImageIcon({ className, color, ...props }: IconProps) { + const classNames = iconVariants({ className, color }) + return ( + + + + + + + + + ) +} diff --git a/components/Icons/get-icon-by-icon-name.ts b/components/Icons/get-icon-by-icon-name.ts index ae2534a5d..897ca8b28 100644 --- a/components/Icons/get-icon-by-icon-name.ts +++ b/components/Icons/get-icon-by-icon-name.ts @@ -7,6 +7,7 @@ import { BarIcon, BikingIcon, CalendarIcon, + CameraIcon, CellphoneIcon, CheckCircleIcon, CheckIcon, @@ -21,6 +22,7 @@ import { FitnessIcon, GlobeIcon, HouseIcon, + ImageIcon, InfoCircleIcon, LocationIcon, LockIcon, @@ -51,6 +53,8 @@ export function getIconByIconName(icon?: IconName): FC | null { return BikingIcon case IconName.Calendar: return CalendarIcon + case IconName.Camera: + return CameraIcon case IconName.Cellphone: return CellphoneIcon case IconName.Check: @@ -79,6 +83,8 @@ export function getIconByIconName(icon?: IconName): FC | null { return GlobeIcon case IconName.House: return HouseIcon + case IconName.Image: + return ImageIcon case IconName.InfoCircle: return InfoCircleIcon case IconName.Location: diff --git a/components/Icons/icon.module.css b/components/Icons/icon.module.css index 73b8f38a3..bc8715040 100644 --- a/components/Icons/icon.module.css +++ b/components/Icons/icon.module.css @@ -41,3 +41,8 @@ .red * { fill: var(--Scandic-Brand-Scandic-Red); } + +.white, +.white * { + fill: var(--Scandic-Opacity-White-100); +} diff --git a/components/Icons/index.tsx b/components/Icons/index.tsx index a69a2a77e..07c9b7016 100644 --- a/components/Icons/index.tsx +++ b/components/Icons/index.tsx @@ -4,6 +4,7 @@ export { default as ArrowRightIcon } from "./ArrowRight" export { default as BarIcon } from "./Bar" export { default as BikingIcon } from "./Biking" export { default as CalendarIcon } from "./Calendar" +export { default as CameraIcon } from "./Camera" export { default as CellphoneIcon } from "./Cellphone" export { default as CheckIcon } from "./Check" export { default as CheckCircleIcon } from "./CheckCircle" @@ -18,6 +19,7 @@ export { default as EmailIcon } from "./Email" export { default as FitnessIcon } from "./Fitness" export { default as GlobeIcon } from "./Globe" export { default as HouseIcon } from "./House" +export { default as ImageIcon } from "./Image" export { default as InfoCircleIcon } from "./InfoCircle" export { default as LocationIcon } from "./Location" export { default as LockIcon } from "./Lock" diff --git a/components/Icons/variants.ts b/components/Icons/variants.ts index e39422ece..39e4ff51e 100644 --- a/components/Icons/variants.ts +++ b/components/Icons/variants.ts @@ -13,6 +13,7 @@ const config = { primaryLightOnSurfaceAccent: styles.plosa, red: styles.red, green: styles.green, + white: styles.white, }, }, defaultVariants: { diff --git a/components/Section/Header/index.tsx b/components/Section/Header/index.tsx index 228d3b6ac..e005a8c07 100644 --- a/components/Section/Header/index.tsx +++ b/components/Section/Header/index.tsx @@ -12,6 +12,7 @@ export default function SectionHeader({ subtitle, title, topTitle = false, + textTransform, }: HeaderProps) { return (
@@ -19,6 +20,7 @@ export default function SectionHeader({ as={topTitle ? "h3" : "h4"} className={styles.title} level={topTitle ? "h1" : "h2"} + textTransform={textTransform} > {title} diff --git a/components/TempDesignSystem/Text/Body/body.module.css b/components/TempDesignSystem/Text/Body/body.module.css index 2176cf473..1bda01dd8 100644 --- a/components/TempDesignSystem/Text/Body/body.module.css +++ b/components/TempDesignSystem/Text/Body/body.module.css @@ -50,6 +50,10 @@ color: var(--Scandic-Brand-Burgundy); } +.grey { + color: var(--UI-Grey-60); +} + .pale { color: var(--Scandic-Brand-Pale-Peach); } diff --git a/components/TempDesignSystem/Text/Body/variants.ts b/components/TempDesignSystem/Text/Body/variants.ts index 96d36937e..d044b4286 100644 --- a/components/TempDesignSystem/Text/Body/variants.ts +++ b/components/TempDesignSystem/Text/Body/variants.ts @@ -7,6 +7,7 @@ const config = { color: { black: styles.black, burgundy: styles.burgundy, + grey: styles.grey, pale: styles.pale, red: styles.red, textMediumContrast: styles.textMediumContrast, diff --git a/i18n/dictionaries/en.json b/i18n/dictionaries/en.json index 5ad6b88dd..2b3ebf24d 100644 --- a/i18n/dictionaries/en.json +++ b/i18n/dictionaries/en.json @@ -42,6 +42,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", diff --git a/i18n/dictionaries/sv.json b/i18n/dictionaries/sv.json index 443f0f829..e8e2d2260 100644 --- a/i18n/dictionaries/sv.json +++ b/i18n/dictionaries/sv.json @@ -42,6 +42,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å", diff --git a/server/routers/hotels/input.ts b/server/routers/hotels/input.ts index bb8a1ebdd..03ea9e11d 100644 --- a/server/routers/hotels/input.ts +++ b/server/routers/hotels/input.ts @@ -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({ diff --git a/server/routers/hotels/output.ts b/server/routers/hotels/output.ts index d62c25f5f..044280d6e 100644 --- a/server/routers/hotels/output.ts +++ b/server/routers/hotels/output.ts @@ -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({ diff --git a/server/routers/hotels/query.ts b/server/routers/hotels/query.ts index fb4ca8da3..edd888519 100644 --- a/server/routers/hotels/query.ts +++ b/server/routers/hotels/query.ts @@ -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) diff --git a/types/components/hotelPage/roomCard.ts b/types/components/hotelPage/roomCard.ts new file mode 100644 index 000000000..4a555c621 --- /dev/null +++ b/types/components/hotelPage/roomCard.ts @@ -0,0 +1,9 @@ +import { RoomData } from "@/types/hotel" + +export interface RoomCardProps { + id: string + images: RoomData["attributes"]["content"]["images"] + title: string + subtitle: string + badgeTextTransKey: string | null +} diff --git a/types/components/icon.ts b/types/components/icon.ts index e340e9aff..ff76c28eb 100644 --- a/types/components/icon.ts +++ b/types/components/icon.ts @@ -13,6 +13,7 @@ export enum IconName { Bar = "Bar", Biking = "Biking", Calendar = "Calendar", + Camera = "Camera", Cellphone = "Cellphone", Check = "Check", CheckCircle = "CheckCircle", @@ -27,6 +28,7 @@ export enum IconName { Fitness = "Fitness", Globe = "Globe", House = "House", + Image = "Image", InfoCircle = "InfoCircle", Location = "Location", Lock = "Lock", diff --git a/types/components/myPages/header.ts b/types/components/myPages/header.ts index 5eaafa0b4..af1ed6a7e 100644 --- a/types/components/myPages/header.ts +++ b/types/components/myPages/header.ts @@ -1,9 +1,12 @@ +import { HeadingProps } from "@/components/TempDesignSystem/Text/Title/title" + export type HeaderProps = { link?: { href: string text: string } subtitle: string | null + textTransform?: HeadingProps["textTransform"] title: string | null topTitle?: boolean } diff --git a/types/hotel.ts b/types/hotel.ts index a8a715c3d..9d277d145 100644 --- a/types/hotel.ts +++ b/types/hotel.ts @@ -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 @@ -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