Merged develop into fix/friends-transaktions

This commit is contained in:
Christel Westerberg
2024-07-11 13:29:38 +00:00
26 changed files with 423 additions and 19 deletions

View File

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

View File

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

View File

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

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

View File

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

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

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

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

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

View File

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

View File

@@ -41,3 +41,8 @@
.red * {
fill: var(--Scandic-Brand-Scandic-Red);
}
.white,
.white * {
fill: var(--Scandic-Opacity-White-100);
}

View File

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

View File

@@ -13,6 +13,7 @@ const config = {
primaryLightOnSurfaceAccent: styles.plosa,
red: styles.red,
green: styles.green,
white: styles.white,
},
},
defaultVariants: {

View File

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

View File

@@ -50,6 +50,10 @@
color: var(--Scandic-Brand-Burgundy);
}
.grey {
color: var(--UI-Grey-60);
}
.pale {
color: var(--Scandic-Brand-Pale-Peach);
}

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

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>