Merged develop into fix/friends-transaktions
This commit is contained in:
@@ -9,12 +9,12 @@ import { LangParams, PageArgs } from "@/types/params"
|
||||
export default async function SelectHotelPage({
|
||||
params,
|
||||
}: PageArgs<LangParams>) {
|
||||
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 (
|
||||
<main>
|
||||
|
||||
@@ -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) {
|
||||
/>
|
||||
<AmenitiesList detailedFacilities={attributes.detailedFacilities} />
|
||||
</div>
|
||||
<Rooms rooms={roomCategories} />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
</Link>
|
||||
</div>
|
||||
<div className={styles.subtitleContent}>
|
||||
<Subtitle color="black">{hotelDescription}</Subtitle>
|
||||
<Preamble>{hotelDescription}</Preamble>
|
||||
<Link
|
||||
className={styles.introLink}
|
||||
target="_blank"
|
||||
|
||||
75
components/ContentType/HotelPage/Rooms/RoomCard/index.tsx
Normal file
75
components/ContentType/HotelPage/Rooms/RoomCard/index.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
"use client"
|
||||
|
||||
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"
|
||||
|
||||
import styles from "./roomCard.module.css"
|
||||
|
||||
import { RoomCardProps } from "@/types/components/hotelPage/roomCard"
|
||||
|
||||
export function RoomCard({
|
||||
badgeTextTransKey,
|
||||
id,
|
||||
images,
|
||||
subtitle,
|
||||
title,
|
||||
}: RoomCardProps) {
|
||||
const { formatMessage } = useIntl()
|
||||
const mainImage = images[0]
|
||||
|
||||
function handleImageClick() {
|
||||
// TODO: Implement opening of a model with carousel
|
||||
console.log("Image clicked: ", id)
|
||||
}
|
||||
|
||||
function handleRoomCtaClick() {
|
||||
// TODO: Implement opening side-peek component with room details
|
||||
console.log("Room CTA clicked: ", id)
|
||||
}
|
||||
|
||||
return (
|
||||
<article className={styles.roomCard}>
|
||||
<button className={styles.imageWrapper} onClick={handleImageClick}>
|
||||
{badgeTextTransKey && (
|
||||
<span className={styles.badge}>
|
||||
{formatMessage({ id: badgeTextTransKey })}
|
||||
</span>
|
||||
)}
|
||||
<span className={styles.imageCount}>
|
||||
<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.imageSizes.large}
|
||||
alt={mainImage.metaData.altText}
|
||||
height={200}
|
||||
width={300}
|
||||
/>
|
||||
</button>
|
||||
<div className={styles.content}>
|
||||
<div className={styles.innerContent}>
|
||||
<Title as="h4" level="h3" textTransform="capitalize" color="black">
|
||||
{title}
|
||||
</Title>
|
||||
<Body color="grey">{subtitle}</Body>
|
||||
</div>
|
||||
<Link
|
||||
href="#"
|
||||
color="peach80"
|
||||
variant="underscored"
|
||||
onClick={handleRoomCtaClick}
|
||||
>
|
||||
{formatMessage({ id: "hotelPages.rooms.roomCard.seeRoomDetails" })}
|
||||
</Link>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
53
components/ContentType/HotelPage/Rooms/index.tsx
Normal file
53
components/ContentType/HotelPage/Rooms/index.tsx
Normal file
@@ -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 (
|
||||
<SectionContainer>
|
||||
<SectionHeader
|
||||
textTransform="uppercase"
|
||||
title={formatMessage({ id: "hotelPages.rooms.title" })}
|
||||
subtitle={null}
|
||||
/>
|
||||
<Grids.Stackable>
|
||||
{mappedRooms.map(({ id, images, title, subtitle, popularChoice }) => (
|
||||
<RoomCard
|
||||
key={id}
|
||||
id={id}
|
||||
images={images}
|
||||
title={title}
|
||||
subtitle={subtitle}
|
||||
badgeTextTransKey={popularChoice ? "Popular choice" : null}
|
||||
/>
|
||||
))}
|
||||
</Grids.Stackable>
|
||||
</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 {
|
||||
|
||||
23
components/Icons/Camera.tsx
Normal file
23
components/Icons/Camera.tsx
Normal file
@@ -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 (
|
||||
<svg
|
||||
className={classNames}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="18"
|
||||
viewBox="0 0 20 18"
|
||||
fill="none"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M10 14.375C11.225 14.375 12.2625 13.95 13.1125 13.1C13.9625 12.25 14.3875 11.2125 14.3875 9.9875C14.3875 8.7625 13.9625 7.725 13.1125 6.875C12.2625 6.025 11.225 5.6 10 5.6C8.775 5.6 7.7375 6.025 6.8875 6.875C6.0375 7.725 5.6125 8.7625 5.6125 9.9875C5.6125 11.2125 6.0375 12.25 6.8875 13.1C7.7375 13.95 8.775 14.375 10 14.375ZM9.9949 12.5C9.28997 12.5 8.69583 12.2583 8.2125 11.775C7.72917 11.2917 7.4875 10.6975 7.4875 9.9926C7.4875 9.28767 7.72917 8.69183 8.2125 8.2051C8.69583 7.71837 9.28997 7.475 9.9949 7.475C10.6998 7.475 11.2957 7.71837 11.7824 8.2051C12.2691 8.69183 12.5125 9.28767 12.5125 9.9926C12.5125 10.6975 12.2691 11.2917 11.7824 11.775C11.2957 12.2583 10.6998 12.5 9.9949 12.5ZM2.125 17.75C1.60937 17.75 1.16796 17.5664 0.800775 17.1992C0.433592 16.832 0.25 16.3906 0.25 15.875V4.1125C0.25 3.59687 0.433592 3.15546 0.800775 2.78828C1.16796 2.42109 1.60937 2.2375 2.125 2.2375H5.275L6.5125 0.875C6.68652 0.6827 6.89613 0.530459 7.14133 0.418275C7.38651 0.306092 7.64357 0.25 7.9125 0.25H12.0875C12.3564 0.25 12.6135 0.306092 12.8587 0.418275C13.1039 0.530459 13.3135 0.6827 13.4875 0.875L14.725 2.2375H17.875C18.3906 2.2375 18.832 2.42109 19.1992 2.78828C19.5664 3.15546 19.75 3.59687 19.75 4.1125V15.875C19.75 16.3906 19.5664 16.832 19.1992 17.1992C18.832 17.5664 18.3906 17.75 17.875 17.75H2.125ZM2.125 15.875H17.875V4.1125H13.8853L12.0875 2.125H7.9162L6.125 4.1125H2.125V15.875Z"
|
||||
fill="#26201E"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
36
components/Icons/Image.tsx
Normal file
36
components/Icons/Image.tsx
Normal file
@@ -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 (
|
||||
<svg
|
||||
className={classNames}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
{...props}
|
||||
>
|
||||
<mask
|
||||
id="mask0_69_3274"
|
||||
style={{ maskType: "alpha" }}
|
||||
maskUnits="userSpaceOnUse"
|
||||
x="0"
|
||||
y="0"
|
||||
width="24"
|
||||
height="24"
|
||||
>
|
||||
<rect width="24" height="24" fill="#D9D9D9" />
|
||||
</mask>
|
||||
<g mask="url(#mask0_69_3274)">
|
||||
<path
|
||||
d="M5.09998 20.775C4.58434 20.775 4.14293 20.5914 3.77575 20.2242C3.40857 19.857 3.22498 19.4156 3.22498 18.9V5.09998C3.22498 4.58434 3.40857 4.14293 3.77575 3.77575C4.14293 3.40857 4.58434 3.22498 5.09998 3.22498H18.9C19.4156 3.22498 19.857 3.40857 20.2242 3.77575C20.5914 4.14293 20.775 4.58434 20.775 5.09998V18.9C20.775 19.4156 20.5914 19.857 20.2242 20.2242C19.857 20.5914 19.4156 20.775 18.9 20.775H5.09998ZM5.09998 18.9H18.9V5.09998H5.09998V18.9ZM7.13748 16.925H16.8826C17.0775 16.925 17.2208 16.8396 17.3125 16.6687C17.4041 16.4979 17.3875 16.3333 17.2625 16.175L14.575 12.6C14.4807 12.475 14.3551 12.4125 14.1981 12.4125C14.041 12.4125 13.9166 12.475 13.825 12.6L11.25 16.025L9.42498 13.6125C9.33074 13.4875 9.2051 13.425 9.04805 13.425C8.891 13.425 8.76664 13.4875 8.67498 13.6125L6.76588 16.1732C6.63861 16.3327 6.62185 16.4979 6.7156 16.6687C6.80935 16.8396 6.94998 16.925 7.13748 16.925ZM8.5007 9.93748C8.90022 9.93748 9.23956 9.79765 9.51873 9.518C9.79789 9.23833 9.93748 8.89875 9.93748 8.49925C9.93748 8.09973 9.79765 7.76039 9.518 7.48123C9.23833 7.20206 8.89875 7.06248 8.49925 7.06248C8.09973 7.06248 7.76039 7.2023 7.48123 7.48195C7.20206 7.76162 7.06248 8.1012 7.06248 8.5007C7.06248 8.90022 7.2023 9.23956 7.48195 9.51873C7.76162 9.79789 8.1012 9.93748 8.5007 9.93748Z"
|
||||
fill="#26201E"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -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<IconProps> | 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<IconProps> | null {
|
||||
return GlobeIcon
|
||||
case IconName.House:
|
||||
return HouseIcon
|
||||
case IconName.Image:
|
||||
return ImageIcon
|
||||
case IconName.InfoCircle:
|
||||
return InfoCircleIcon
|
||||
case IconName.Location:
|
||||
|
||||
@@ -41,3 +41,8 @@
|
||||
.red * {
|
||||
fill: var(--Scandic-Brand-Scandic-Red);
|
||||
}
|
||||
|
||||
.white,
|
||||
.white * {
|
||||
fill: var(--Scandic-Opacity-White-100);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -13,6 +13,7 @@ const config = {
|
||||
primaryLightOnSurfaceAccent: styles.plosa,
|
||||
red: styles.red,
|
||||
green: styles.green,
|
||||
white: styles.white,
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
@@ -12,6 +12,7 @@ export default function SectionHeader({
|
||||
subtitle,
|
||||
title,
|
||||
topTitle = false,
|
||||
textTransform,
|
||||
}: HeaderProps) {
|
||||
return (
|
||||
<header className={styles.header}>
|
||||
@@ -19,6 +20,7 @@ export default function SectionHeader({
|
||||
as={topTitle ? "h3" : "h4"}
|
||||
className={styles.title}
|
||||
level={topTitle ? "h1" : "h2"}
|
||||
textTransform={textTransform}
|
||||
>
|
||||
{title}
|
||||
</Title>
|
||||
|
||||
@@ -50,6 +50,10 @@
|
||||
color: var(--Scandic-Brand-Burgundy);
|
||||
}
|
||||
|
||||
.grey {
|
||||
color: var(--UI-Grey-60);
|
||||
}
|
||||
|
||||
.pale {
|
||||
color: var(--Scandic-Brand-Pale-Peach);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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å",
|
||||
|
||||
@@ -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)
|
||||
|
||||
9
types/components/hotelPage/roomCard.ts
Normal file
9
types/components/hotelPage/roomCard.ts
Normal file
@@ -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
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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