diff --git a/app/[lang]/(live)/(public)/hotelreservation/select-rate/page.tsx b/app/[lang]/(live)/(public)/hotelreservation/select-rate/page.tsx index 235eeda52..796c0fedb 100644 --- a/app/[lang]/(live)/(public)/hotelreservation/select-rate/page.tsx +++ b/app/[lang]/(live)/(public)/hotelreservation/select-rate/page.tsx @@ -2,7 +2,6 @@ import { serverClient } from "@/lib/trpc/server" import tempHotelData from "@/server/routers/hotels/tempHotelData.json" import RoomSelection from "@/components/HotelReservation/SelectRate/RoomSelection" -import { getIntl } from "@/i18n" import { setLang } from "@/i18n/serverContext" import styles from "./page.module.css" @@ -19,18 +18,15 @@ export default async function SelectRatePage({ // TODO: Use real endpoint. const hotel = tempHotelData.data.attributes - const rates = await serverClient().hotel.rates.get({ - // TODO: pass the correct hotel ID and all other parameters that should be included in the search - hotelId: searchParams.hotel, + const roomConfigurations = await serverClient().hotel.availability.rooms({ + hotelId: parseInt(searchParams.hotel, 10), + roomStayStartDate: "2024-11-02", + roomStayEndDate: "2024-11-03", + adults: 1, }) - - // const rates = await serverClient().hotel.availability.getForHotel({ - // hotelId: 811, - // roomStayStartDate: "2024-11-02", - // roomStayEndDate: "2024-11-03", - // adults: 1, - // }) - const intl = await getIntl() + if (!roomConfigurations) { + return "No rooms found" + } return (
@@ -39,13 +35,7 @@ export default async function SelectRatePage({
- +
diff --git a/components/Blocks/Table/index.tsx b/components/Blocks/Table/index.tsx new file mode 100644 index 000000000..7d7d0c457 --- /dev/null +++ b/components/Blocks/Table/index.tsx @@ -0,0 +1,107 @@ +"use client" + +import { + flexRender, + getCoreRowModel, + getPaginationRowModel, + useReactTable, +} from "@tanstack/react-table" +import { useState } from "react" + +import SectionContainer from "@/components/Section/Container" +import SectionHeader from "@/components/Section/Header" +import ScrollWrapper from "@/components/TempDesignSystem/ScrollWrapper" +import ShowMoreButton from "@/components/TempDesignSystem/ShowMoreButton" +import Table from "@/components/TempDesignSystem/Table" + +import styles from "./table.module.css" + +import type { TableBlockProps } from "@/types/components/blocks/table" + +export default function TableBlock({ data }: TableBlockProps) { + const { columns, rows, totalWidth, heading, preamble } = data + const initialPageSize = 5 + const [pageSize, setPageSize] = useState(initialPageSize) + const showMoreVisible = rows.length > initialPageSize + const showLessVisible = pageSize >= rows.length + const columnDefs = columns.map((col) => ({ + accessorKey: col.id, + header: col.header, + size: col.width, + })) + + const table = useReactTable({ + columns: columnDefs, + data: rows, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + state: { + pagination: { + pageIndex: 0, + pageSize, + }, + }, + }) + + function handleShowMore() { + setPageSize(showLessVisible ? initialPageSize : rows.length) + } + + return ( + + {heading ? ( + + ) : null} +
+ + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ))} + + ))} + + + {table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + ))} + + ))} + +
+
+ {showMoreVisible ? ( + + ) : null} +
+
+ ) +} diff --git a/components/Blocks/Table/table.module.css b/components/Blocks/Table/table.module.css new file mode 100644 index 000000000..805cb9935 --- /dev/null +++ b/components/Blocks/Table/table.module.css @@ -0,0 +1,6 @@ +.tableWrapper { + display: grid; + border: 1px solid var(--Base-Border-Subtle); + border-radius: var(--Corner-radius-Small); + overflow: hidden; +} diff --git a/components/Blocks/index.tsx b/components/Blocks/index.tsx index 1c6d0bcbc..2423508d1 100644 --- a/components/Blocks/index.tsx +++ b/components/Blocks/index.tsx @@ -5,6 +5,8 @@ import TextCols from "@/components/Blocks/TextCols" import UspGrid from "@/components/Blocks/UspGrid" import JsonToHtml from "@/components/JsonToHtml" +import Table from "./Table" + import type { BlocksProps } from "@/types/components/blocks" import { BlocksEnums } from "@/types/enums/blocks" @@ -47,6 +49,8 @@ export default function Blocks({ blocks }: BlocksProps) { title={block.shortcuts.title} /> ) + case BlocksEnums.block.Table: + return case BlocksEnums.block.TextCols: return case BlocksEnums.block.TextContent: diff --git a/components/BookingWidget/bookingWidget.module.css b/components/BookingWidget/bookingWidget.module.css index 2827ba72d..461057bf1 100644 --- a/components/BookingWidget/bookingWidget.module.css +++ b/components/BookingWidget/bookingWidget.module.css @@ -42,7 +42,7 @@ box-shadow: 0px 4px 24px 0px rgba(0, 0, 0, 0.05); position: sticky; top: 0; - z-index: 10000; + z-index: 9; background-color: var(--Base-Surface-Primary-light-Normal); } diff --git a/components/ContentType/ContentPage/contentPage.module.css b/components/ContentType/ContentPage/contentPage.module.css index 026aa6065..aa0036f83 100644 --- a/components/ContentType/ContentPage/contentPage.module.css +++ b/components/ContentType/ContentPage/contentPage.module.css @@ -40,6 +40,8 @@ } .mainContent { + display: grid; + gap: var(--Spacing-x4); width: 100%; } diff --git a/components/HotelReservation/EnterDetails/BedType/index.tsx b/components/HotelReservation/EnterDetails/BedType/index.tsx index 61633cdcf..955ce740b 100644 --- a/components/HotelReservation/EnterDetails/BedType/index.tsx +++ b/components/HotelReservation/EnterDetails/BedType/index.tsx @@ -5,7 +5,7 @@ import { FormProvider, useForm } from "react-hook-form" import { useIntl } from "react-intl" import { KingBedIcon } from "@/components/Icons" -import RadioCard from "@/components/TempDesignSystem/Form/Card/Radio" +import RadioCard from "@/components/TempDesignSystem/Form/ChoiceCard/Radio" import { bedTypeSchema } from "./schema" @@ -24,9 +24,7 @@ export default function BedType() { reValidateMode: "onChange", }) - // @ts-expect-error - Types mismatch docs as this is - // a pattern that is allowed https://formatjs.io/docs/react-intl/api#usage - const text = intl.formatMessage( + const text = intl.formatMessage( { id: "Included (based on availability)" }, { b: (str) => {str} } ) diff --git a/components/HotelReservation/EnterDetails/Breakfast/index.tsx b/components/HotelReservation/EnterDetails/Breakfast/index.tsx index b8f00ec83..8551e12dc 100644 --- a/components/HotelReservation/EnterDetails/Breakfast/index.tsx +++ b/components/HotelReservation/EnterDetails/Breakfast/index.tsx @@ -5,7 +5,7 @@ import { FormProvider, useForm } from "react-hook-form" import { useIntl } from "react-intl" import { BreakfastIcon, NoBreakfastIcon } from "@/components/Icons" -import RadioCard from "@/components/TempDesignSystem/Form/Card/Radio" +import RadioCard from "@/components/TempDesignSystem/Form/ChoiceCard/Radio" import { breakfastSchema } from "./schema" @@ -31,9 +31,7 @@ export default function Breakfast() { Icon={BreakfastIcon} id={breakfastEnum.BREAKFAST} name="breakfast" - // @ts-expect-error - Types mismatch docs as this is - // a pattern that is allowed https://formatjs.io/docs/react-intl/api#usage - subtitle={intl.formatMessage( + subtitle={intl.formatMessage( { id: "{amount} {currency}/night per adult" }, { amount: "150", diff --git a/components/HotelReservation/EnterDetails/Details/index.tsx b/components/HotelReservation/EnterDetails/Details/index.tsx index 2dd1b1cdb..15792e384 100644 --- a/components/HotelReservation/EnterDetails/Details/index.tsx +++ b/components/HotelReservation/EnterDetails/Details/index.tsx @@ -4,7 +4,7 @@ import { FormProvider, useForm } from "react-hook-form" import { useIntl } from "react-intl" import Button from "@/components/TempDesignSystem/Button" -import CheckboxCard from "@/components/TempDesignSystem/Form/Card/Checkbox" +import CheckboxCard from "@/components/TempDesignSystem/Form/ChoiceCard/Checkbox" import CountrySelect from "@/components/TempDesignSystem/Form/Country" import Input from "@/components/TempDesignSystem/Form/Input" import Phone from "@/components/TempDesignSystem/Form/Phone" @@ -88,8 +88,9 @@ export default function Details({ user }: DetailsProps) { + @@ -32,20 +66,15 @@ export default function RoomCard({ {/*TODO: Handle pluralisation*/} {intl.formatMessage( { - id: "Nr night, nr adult", - defaultMessage: - "{nights, number} night, {adults, number} adult", + id: "Max {nrOfGuests} guests", + defaultMessage: "Max {nrOfGuests} guests", }, - { nights: nrOfNights, adults: nrOfAdults } + // TODO: Correct number + { nrOfGuests: 2 } )} - {" | "} - {breakfastIncluded - ? intl.formatMessage({ - id: "Breakfast included", - }) - : intl.formatMessage({ - id: "Breakfast excluded", - })} + {intl.formatMessage({ + id: "Breakfast included", + })} @@ -53,25 +82,19 @@ export default function RoomCard({ name={intl.formatMessage({ id: "Non-refundable" })} value="non-refundable" paymentTerm={intl.formatMessage({ id: "Pay now" })} - standardPrice={room.prices.nonRefundable.standard} - memberPrice={room.prices.nonRefundable.member} - currency={room.prices.currency} + product={saveProduct} /> + + ) } diff --git a/components/HotelReservation/SelectRate/RoomSelection/roomSelection.module.css b/components/HotelReservation/SelectRate/RoomSelection/roomSelection.module.css index 80ed0e5be..2f4fb9402 100644 --- a/components/HotelReservation/SelectRate/RoomSelection/roomSelection.module.css +++ b/components/HotelReservation/SelectRate/RoomSelection/roomSelection.module.css @@ -27,4 +27,6 @@ bottom: 0; left: 0; right: 0; + background-color: white; + padding: var(--Spacing-x3) var(--Spacing-x7) var(--Spacing-x5); } diff --git a/components/TempDesignSystem/Form/Card/Checkbox.tsx b/components/TempDesignSystem/Form/ChoiceCard/Checkbox.tsx similarity index 58% rename from components/TempDesignSystem/Form/Card/Checkbox.tsx rename to components/TempDesignSystem/Form/ChoiceCard/Checkbox.tsx index 7f0ea428a..ca32951cb 100644 --- a/components/TempDesignSystem/Form/Card/Checkbox.tsx +++ b/components/TempDesignSystem/Form/ChoiceCard/Checkbox.tsx @@ -1,6 +1,6 @@ -import Card from "." +import Card from "./_Card" -import type { CheckboxProps } from "./card" +import type { CheckboxProps } from "./_Card/card" export default function CheckboxCard(props: CheckboxProps) { return diff --git a/components/TempDesignSystem/Form/Card/Radio.tsx b/components/TempDesignSystem/Form/ChoiceCard/Radio.tsx similarity index 57% rename from components/TempDesignSystem/Form/Card/Radio.tsx rename to components/TempDesignSystem/Form/ChoiceCard/Radio.tsx index c1de94782..f35ab6a5e 100644 --- a/components/TempDesignSystem/Form/Card/Radio.tsx +++ b/components/TempDesignSystem/Form/ChoiceCard/Radio.tsx @@ -1,6 +1,6 @@ -import Card from "." +import Card from "./_Card" -import type { RadioProps } from "./card" +import type { RadioProps } from "./_Card/card" export default function RadioCard(props: RadioProps) { return diff --git a/components/TempDesignSystem/Form/Card/card.module.css b/components/TempDesignSystem/Form/ChoiceCard/_Card/card.module.css similarity index 100% rename from components/TempDesignSystem/Form/Card/card.module.css rename to components/TempDesignSystem/Form/ChoiceCard/_Card/card.module.css diff --git a/components/TempDesignSystem/Form/Card/card.ts b/components/TempDesignSystem/Form/ChoiceCard/_Card/card.ts similarity index 73% rename from components/TempDesignSystem/Form/Card/card.ts rename to components/TempDesignSystem/Form/ChoiceCard/_Card/card.ts index 167595164..d1a961150 100644 --- a/components/TempDesignSystem/Form/Card/card.ts +++ b/components/TempDesignSystem/Form/ChoiceCard/_Card/card.ts @@ -1,14 +1,15 @@ import type { IconProps } from "@/types/components/icon" -interface BaseCardProps extends React.LabelHTMLAttributes { +interface BaseCardProps + extends Omit, "title"> { Icon?: (props: IconProps) => JSX.Element declined?: boolean + highlightSubtitle?: boolean iconHeight?: number iconWidth?: number - name?: string - saving?: boolean - subtitle?: string - title: string + name: string + subtitle?: React.ReactNode + title: React.ReactNode type: "checkbox" | "radio" value?: string } @@ -22,7 +23,7 @@ interface ListCardProps extends BaseCardProps { interface TextCardProps extends BaseCardProps { list?: never - text: string + text: React.ReactNode } export type CardProps = ListCardProps | TextCardProps diff --git a/components/TempDesignSystem/Form/Card/index.tsx b/components/TempDesignSystem/Form/ChoiceCard/_Card/index.tsx similarity index 94% rename from components/TempDesignSystem/Form/Card/index.tsx rename to components/TempDesignSystem/Form/ChoiceCard/_Card/index.tsx index 82f99e80d..8d020ea4d 100644 --- a/components/TempDesignSystem/Form/Card/index.tsx +++ b/components/TempDesignSystem/Form/ChoiceCard/_Card/index.tsx @@ -13,10 +13,10 @@ export default function Card({ iconHeight = 32, iconWidth = 32, declined = false, + highlightSubtitle = false, id, list, - name = "join", - saving = false, + name, subtitle, text, title, @@ -31,7 +31,7 @@ export default function Card({ {subtitle ? ( +import type { THeadProps } from "./table" + +function TH({ children, width = "auto", ...props }: THeadProps) { + return ( + + ) } export default TH diff --git a/components/TempDesignSystem/Table/index.tsx b/components/TempDesignSystem/Table/index.tsx index 5ba745df5..87f50326a 100644 --- a/components/TempDesignSystem/Table/index.tsx +++ b/components/TempDesignSystem/Table/index.tsx @@ -3,11 +3,33 @@ import TD from "./TD" import TH from "./TH" import THead from "./THead" import TR from "./TR" +import { tableVariants } from "./variants" -import styles from "./table.module.css" +import type { TableProps } from "./table" -function Table({ children }: React.PropsWithChildren) { - return
{room.size}Room size TBI {subtitle} diff --git a/components/TempDesignSystem/ScrollWrapper/index.tsx b/components/TempDesignSystem/ScrollWrapper/index.tsx new file mode 100644 index 000000000..529836f20 --- /dev/null +++ b/components/TempDesignSystem/ScrollWrapper/index.tsx @@ -0,0 +1,36 @@ +"use client" + +import { useMemo } from "react" + +import useScrollShadows from "@/hooks/useScrollShadows" + +import styles from "./scrollWrapper.module.css" + +import type { ScrollWrapperProps } from "./scrollWrapper" + +export default function ScrollWrapper({ + className, + children, +}: ScrollWrapperProps) { + const { containerRef, showLeftShadow, showRightShadow } = + useScrollShadows() + + const classNames = useMemo(() => { + const cls = [styles.scrollWrapper, className] + if (showLeftShadow) { + cls.push(styles.leftShadow) + } + if (showRightShadow) { + cls.push(styles.rightShadow) + } + return cls.join(" ") + }, [showLeftShadow, showRightShadow, className]) + + return ( +
+
+ {children} +
+
+ ) +} diff --git a/components/TempDesignSystem/ScrollWrapper/scrollWrapper.module.css b/components/TempDesignSystem/ScrollWrapper/scrollWrapper.module.css new file mode 100644 index 000000000..b604a3c24 --- /dev/null +++ b/components/TempDesignSystem/ScrollWrapper/scrollWrapper.module.css @@ -0,0 +1,33 @@ +.scrollWrapper { + position: relative; + overflow: hidden; +} + +.scrollWrapper::before, +.scrollWrapper::after { + content: ""; + position: absolute; + top: 0; + height: 100%; + pointer-events: none; + z-index: 1; + transition: opacity 0.2s ease; + opacity: 0; + width: 50px; +} + +.scrollWrapper.leftShadow::before { + left: 0; + background: linear-gradient(to right, rgba(0, 0, 0, 0.3), transparent); + opacity: 1; +} + +.scrollWrapper.rightShadow::after { + right: 0; + background: linear-gradient(to left, rgba(0, 0, 0, 0.3), transparent); + opacity: 1; +} + +.content { + overflow-x: auto; +} diff --git a/components/TempDesignSystem/ScrollWrapper/scrollWrapper.ts b/components/TempDesignSystem/ScrollWrapper/scrollWrapper.ts new file mode 100644 index 000000000..8440abfa5 --- /dev/null +++ b/components/TempDesignSystem/ScrollWrapper/scrollWrapper.ts @@ -0,0 +1,2 @@ +export interface ScrollWrapperProps + extends React.PropsWithChildren> {} diff --git a/components/TempDesignSystem/ShowMoreButton/button.module.css b/components/TempDesignSystem/ShowMoreButton/button.module.css deleted file mode 100644 index f55557f33..000000000 --- a/components/TempDesignSystem/ShowMoreButton/button.module.css +++ /dev/null @@ -1,4 +0,0 @@ -.container { - display: flex; - justify-content: center; -} diff --git a/components/TempDesignSystem/ShowMoreButton/index.tsx b/components/TempDesignSystem/ShowMoreButton/index.tsx index 6e9cae7bf..ca8a24dcb 100644 --- a/components/TempDesignSystem/ShowMoreButton/index.tsx +++ b/components/TempDesignSystem/ShowMoreButton/index.tsx @@ -5,18 +5,29 @@ import { useIntl } from "react-intl" import { ChevronDownIcon } from "@/components/Icons" import Button from "@/components/TempDesignSystem/Button" -import styles from "./button.module.css" +import { showMoreButtonVariants } from "./variants" -import type { ShowMoreButtonParams } from "@/types/components/myPages/stays/button" +import styles from "./showMoreButton.module.css" + +import type { ShowMoreButtonProps } from "./showMoreButton" export default function ShowMoreButton({ + className, + intent, disabled, + showLess, loadMoreData, -}: ShowMoreButtonParams) { - const { formatMessage } = useIntl() +}: ShowMoreButtonProps) { + const intl = useIntl() + const classNames = showMoreButtonVariants({ + className, + intent, + }) + return ( -
+
) diff --git a/components/TempDesignSystem/ShowMoreButton/showMoreButton.module.css b/components/TempDesignSystem/ShowMoreButton/showMoreButton.module.css new file mode 100644 index 000000000..32299ec80 --- /dev/null +++ b/components/TempDesignSystem/ShowMoreButton/showMoreButton.module.css @@ -0,0 +1,23 @@ +.container { + display: flex; + justify-content: center; +} + +.table { + display: grid; + justify-content: stretch; + border-top: 1px solid var(--Base-Border-Subtle); + background-color: var(--Base-Surface-Primary-light-Normal); +} + +.table .button { + border-radius: 0; +} + +.icon { + transition: transform 0.3s; +} + +.showLess .icon { + transform: rotate(180deg); +} diff --git a/components/TempDesignSystem/ShowMoreButton/showMoreButton.ts b/components/TempDesignSystem/ShowMoreButton/showMoreButton.ts new file mode 100644 index 000000000..b6ad929f5 --- /dev/null +++ b/components/TempDesignSystem/ShowMoreButton/showMoreButton.ts @@ -0,0 +1,11 @@ +import { showMoreButtonVariants } from "./variants" + +import type { VariantProps } from "class-variance-authority" + +export interface ShowMoreButtonProps + extends React.PropsWithChildren>, + VariantProps { + disabled?: boolean + showLess?: boolean + loadMoreData: () => void +} diff --git a/components/TempDesignSystem/ShowMoreButton/variants.ts b/components/TempDesignSystem/ShowMoreButton/variants.ts new file mode 100644 index 000000000..17f8620f1 --- /dev/null +++ b/components/TempDesignSystem/ShowMoreButton/variants.ts @@ -0,0 +1,11 @@ +import { cva } from "class-variance-authority" + +import styles from "./showMoreButton.module.css" + +export const showMoreButtonVariants = cva(styles.container, { + variants: { + intent: { + table: styles.table, + }, + }, +}) diff --git a/components/TempDesignSystem/Table/TH.tsx b/components/TempDesignSystem/Table/TH.tsx index ca2377193..657dc2b1d 100644 --- a/components/TempDesignSystem/Table/TH.tsx +++ b/components/TempDesignSystem/Table/TH.tsx @@ -1,7 +1,13 @@ import styles from "./table.module.css" -function TH({ children }: React.PropsWithChildren) { - return
{children} + {children} +
{children}
+function Table({ + className, + intent, + borderRadius, + variant, + layout, + width = "100%", + children, + ...props +}: TableProps) { + const classNames = tableVariants({ + className, + borderRadius, + intent, + layout, + variant, + }) + + return ( + + {children} +
+ ) } Table.THead = THead diff --git a/components/TempDesignSystem/Table/table.module.css b/components/TempDesignSystem/Table/table.module.css index fcbb3fec2..2296b2ecd 100644 --- a/components/TempDesignSystem/Table/table.module.css +++ b/components/TempDesignSystem/Table/table.module.css @@ -1,20 +1,20 @@ .table { - border-radius: var(--Corner-radius-Medium); border-collapse: collapse; overflow: hidden; - width: 100%; + min-width: 100%; } .thead { - background-color: var(--Base-Background-Secondary-Normal, #f7e1d5); + color: var(--Base-Text-High-contrast); + background-color: var(--Base-Surface-Primary-dark-Normal); } .tbody { - background-color: var(--Base-Surface-Primary-light-Normal, #fff); + background-color: var(--Base-Surface-Primary-light-Normal); } .tr:not(:last-of-type) { - border-bottom: 1px solid var(--Primary-Light-On-Surface-Divider, #f0c1b6); + border-bottom: 1px solid var(--Base-Border-Subtle); } .th { @@ -28,6 +28,35 @@ padding: var(--Spacing-x2); } +.fixed { + table-layout: fixed; +} + +.smallRadius { + border-radius: var(--Corner-radius-Small); +} +.mediumRadius { + border-radius: var(--Corner-radius-Medium); +} +.largeRadius { + border-radius: var(--Corner-radius-Large); +} + +.content .thead { + background-color: var(--Base-Surface-Subtle-Hover); +} + +.content .tbody { + background-color: var(--Base-Background-Primary-Normal); +} + +.content.striped .tbody .tr:nth-child(odd) { + background-color: var(--Base-Surface-Subtle-Normal); +} +.content.striped .tbody .tr:nth-child(even) { + background-color: var(--Base-Background-Primary-Normal); +} + @media screen and (min-width: 768px) { .th { padding: var(--Spacing-x2) var(--Spacing-x3); diff --git a/components/TempDesignSystem/Table/table.ts b/components/TempDesignSystem/Table/table.ts new file mode 100644 index 000000000..f2759484d --- /dev/null +++ b/components/TempDesignSystem/Table/table.ts @@ -0,0 +1,14 @@ +import { tableVariants } from "./variants" + +import type { VariantProps } from "class-variance-authority" + +export interface TableProps + extends React.PropsWithChildren>, + VariantProps { + width?: string +} + +export interface THeadProps + extends React.PropsWithChildren> { + width?: string +} diff --git a/components/TempDesignSystem/Table/variants.ts b/components/TempDesignSystem/Table/variants.ts new file mode 100644 index 000000000..c70202dcf --- /dev/null +++ b/components/TempDesignSystem/Table/variants.ts @@ -0,0 +1,27 @@ +import { cva } from "class-variance-authority" + +import styles from "./table.module.css" + +export const tableVariants = cva(styles.table, { + variants: { + intent: { + light: styles.light, + striped: styles.striped, + }, + variant: { + content: styles.content, + }, + borderRadius: { + none: "", + small: styles.smallRadius, + medium: styles.mediumRadius, + large: styles.largeRadius, + }, + layout: { + fixed: styles.fixed, + }, + }, + defaultVariants: { + borderRadius: "medium", + }, +}) diff --git a/hooks/useScrollShadows.ts b/hooks/useScrollShadows.ts new file mode 100644 index 000000000..3fde382f1 --- /dev/null +++ b/hooks/useScrollShadows.ts @@ -0,0 +1,34 @@ +import { useEffect, useRef, useState } from "react" + +export default function useScrollShadows() { + const containerRef = useRef(null) + const [showLeftShadow, setShowLeftShadow] = useState(false) + const [showRightShadow, setShowRightShadow] = useState(false) + + useEffect(() => { + const handleScroll = () => { + const container = containerRef.current + if (!container) return + + setShowLeftShadow(container.scrollLeft > 0) + setShowRightShadow( + container.scrollLeft < container.scrollWidth - container.clientWidth + ) + } + + const container = containerRef.current + if (container) { + container.addEventListener("scroll", handleScroll) + } + + handleScroll() + + return () => { + if (container) { + container.removeEventListener("scroll", handleScroll) + } + } + }, []) + + return { containerRef, showLeftShadow, showRightShadow } +} diff --git a/lib/api/endpoints.ts b/lib/api/endpoints.ts index 9ead56554..66bc36ec3 100644 --- a/lib/api/endpoints.ts +++ b/lib/api/endpoints.ts @@ -7,6 +7,7 @@ export namespace endpoints { } export const enum v1 { hotelsAvailability = "availability/v1/availabilities/city", + roomsAvailability = "availability/v1/availabilities/hotel", profile = "profile/v1/Profile", booking = "booking/v1/Bookings", creditCards = `${profile}/creditCards`, diff --git a/lib/graphql/Fragments/Blocks/Table.graphql b/lib/graphql/Fragments/Blocks/Table.graphql new file mode 100644 index 000000000..66736296b --- /dev/null +++ b/lib/graphql/Fragments/Blocks/Table.graphql @@ -0,0 +1,9 @@ +fragment Table_ContentPage on ContentPageBlocksTable { + __typename + table { + heading + preamble + column_widths + table + } +} diff --git a/lib/graphql/Query/ContentPage/ContentPage.graphql b/lib/graphql/Query/ContentPage/ContentPage.graphql index 47f11229d..c3c0053fe 100644 --- a/lib/graphql/Query/ContentPage/ContentPage.graphql +++ b/lib/graphql/Query/ContentPage/ContentPage.graphql @@ -4,6 +4,7 @@ #import "../../Fragments/Blocks/Content.graphql" #import "../../Fragments/Blocks/DynamicContent.graphql" #import "../../Fragments/Blocks/Shortcuts.graphql" +#import "../../Fragments/Blocks/Table.graphql" #import "../../Fragments/Blocks/TextCols.graphql" #import "../../Fragments/Blocks/UspGrid.graphql" @@ -28,6 +29,7 @@ query GetContentPage($locale: String!, $uid: String!) { ...Content_ContentPage ...DynamicContent_ContentPage ...Shortcuts_ContentPage + ...Table_ContentPage ...TextCols_ContentPage ...UspGrid_ContentPage } diff --git a/package-lock.json b/package-lock.json index 35805d69e..c12152ff6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "@scandic-hotels/design-system": "git+https://x-token-auth:$DESIGN_SYSTEM_ACCESS_TOKEN@bitbucket.org/scandic-swap/design-system.git#v0.1.0-rc.9", "@t3-oss/env-nextjs": "^0.9.2", "@tanstack/react-query": "^5.28.6", + "@tanstack/react-table": "^8.20.5", "@trpc/client": "^11.0.0-rc.467", "@trpc/react-query": "^11.0.0-rc.467", "@trpc/server": "^11.0.0-rc.467", @@ -5971,6 +5972,37 @@ "react": "^18.0.0" } }, + "node_modules/@tanstack/react-table": { + "version": "8.20.5", + "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.20.5.tgz", + "integrity": "sha512-WEHopKw3znbUZ61s9i0+i9g8drmDo6asTWbrQh8Us63DAk/M0FkmIqERew6P71HI75ksZ2Pxyuf4vvKh9rAkiA==", + "dependencies": { + "@tanstack/table-core": "8.20.5" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/@tanstack/table-core": { + "version": "8.20.5", + "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.20.5.tgz", + "integrity": "sha512-P9dF7XbibHph2PFRz8gfBKEXEY/HJPOhym8CHmjF8y3q5mWpKx9xtZapXQUWCgkqvsK0R46Azuz+VaxD4Xl+Tg==", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@testing-library/dom": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.1.0.tgz", diff --git a/package.json b/package.json index 2be8bd832..ab79709d3 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "@scandic-hotels/design-system": "git+https://x-token-auth:$DESIGN_SYSTEM_ACCESS_TOKEN@bitbucket.org/scandic-swap/design-system.git#v0.1.0-rc.9", "@t3-oss/env-nextjs": "^0.9.2", "@tanstack/react-query": "^5.28.6", + "@tanstack/react-table": "^8.20.5", "@trpc/client": "^11.0.0-rc.467", "@trpc/react-query": "^11.0.0-rc.467", "@trpc/server": "^11.0.0-rc.467", diff --git a/server/routers/contentstack/contentPage/output.ts b/server/routers/contentstack/contentPage/output.ts index 5e13d9a61..3901a6992 100644 --- a/server/routers/contentstack/contentPage/output.ts +++ b/server/routers/contentstack/contentPage/output.ts @@ -18,6 +18,7 @@ import { shortcutsRefsSchema, shortcutsSchema, } from "../schemas/blocks/shortcuts" +import { tableSchema } from "../schemas/blocks/table" import { textColsRefsSchema, textColsSchema } from "../schemas/blocks/textCols" import { uspGridRefsSchema, uspGridSchema } from "../schemas/blocks/uspGrid" import { tempImageVaultAssetSchema } from "../schemas/imageVault" @@ -75,11 +76,18 @@ export const contentPageUspGrid = z }) .merge(uspGridSchema) +export const contentPageTable = z + .object({ + __typename: z.literal(ContentPageEnum.ContentStack.blocks.Table), + }) + .merge(tableSchema) + export const blocksSchema = z.discriminatedUnion("__typename", [ contentPageCards, contentPageContent, contentPageDynamicContent, contentPageShortcuts, + contentPageTable, contentPageTextCols, contentPageUspGrid, ]) diff --git a/server/routers/contentstack/schemas/blocks/table.ts b/server/routers/contentstack/schemas/blocks/table.ts new file mode 100644 index 000000000..905c20603 --- /dev/null +++ b/server/routers/contentstack/schemas/blocks/table.ts @@ -0,0 +1,58 @@ +import { z } from "zod" + +import { BlocksEnums } from "@/types/enums/blocks" + +export const tableSchema = z.object({ + typename: z + .literal(BlocksEnums.block.Table) + .optional() + .default(BlocksEnums.block.Table), + table: z + .object({ + heading: z.string().optional(), + preamble: z.string().optional().default(""), + column_widths: z.array(z.number()), + table: z.object({ + tableState: z.object({ + columns: z.array( + z.object({ + id: z.string(), + label: z.string().default(""), + accessor: z.string(), + dataType: z.string(), + }) + ), + data: z.array(z.object({}).catchall(z.string())), + skipReset: z.boolean(), + tableActionEnabled: z.boolean(), + headerRowAdded: z.boolean(), + }), + }), + }) + .transform((data) => { + const totalWidth = data.column_widths.reduce( + (acc, width) => acc + width, + 0 + ) + const columns = data.table.tableState.columns.map((col, idx) => ({ + id: col.id, + header: col.label || "", + width: data.column_widths[idx] || 0, + })) + + const rows = data.table.tableState.data.map((rowData) => + columns.reduce>((transformedRow, column) => { + transformedRow[column.id] = rowData[column.id] || "" + return transformedRow + }, {}) + ) + + return { + heading: data.heading, + preamble: data.preamble, + columns, + rows, + totalWidth, + } + }), +}) diff --git a/server/routers/hotels/input.ts b/server/routers/hotels/input.ts index b0ff5e679..6deff63a6 100644 --- a/server/routers/hotels/input.ts +++ b/server/routers/hotels/input.ts @@ -17,6 +17,17 @@ export const getHotelsAvailabilityInputSchema = z.object({ attachedProfileId: z.string().optional().default(""), }) +export const getRoomsAvailabilityInputSchema = z.object({ + hotelId: z.number(), + roomStayStartDate: z.string(), + roomStayEndDate: z.string(), + adults: z.number(), + children: z.number().optional().default(0), + promotionCode: z.string().optional(), + reservationProfileType: z.string().optional().default(""), + attachedProfileId: z.string().optional().default(""), +}) + export const getRatesInputSchema = z.object({ hotelId: z.string(), }) diff --git a/server/routers/hotels/output.ts b/server/routers/hotels/output.ts index d233f1a82..75d16b67b 100644 --- a/server/routers/hotels/output.ts +++ b/server/routers/hotels/output.ts @@ -572,6 +572,86 @@ export type HotelsAvailability = z.infer export type HotelsAvailabilityPrices = HotelsAvailability["data"][number]["attributes"]["bestPricePerNight"] +const productSchema = z.object({ + productType: z.object({ + public: z.object({ + rateCode: z.string(), + rateType: z.string().optional(), + localPrice: z.object({ + pricePerNight: z.string(), + pricePerStay: z.string(), + currency: z.string(), + }), + requestedPrice: z + .object({ + pricePerNight: z.string(), + pricePerStay: z.string(), + currency: z.string(), + }) + .optional(), + }), + member: z.object({ + rateCode: z.string(), + rateType: z.string().optional(), + localPrice: z.object({ + pricePerNight: z.string(), + pricePerStay: z.string(), + currency: z.string(), + }), + requestedPrice: z + .object({ + pricePerNight: z.string(), + pricePerStay: z.string(), + currency: z.string(), + }) + .optional(), + }), + }), +}) + +const roomConfigurationSchema = z.object({ + status: z.string(), + bedType: z.string(), + roomType: z.string(), + roomsLeft: z.number(), + features: z.array(z.object({ inventory: z.number(), code: z.string() })), + products: z.array(productSchema), +}) + +const rateDefinitionSchema = z.object({ + title: z.string(), + breakfastIncluded: z.boolean(), + rateType: z.string().optional(), + rateCode: z.string(), + generalTerms: z.array(z.string()), + cancellationRule: z.string(), + cancellationText: z.string(), + mustBeGuaranteed: z.boolean(), +}) + +const roomsAvailabilitySchema = z + .object({ + data: z.object({ + attributes: z.object({ + checkInDate: z.string(), + checkOutDate: z.string(), + occupancy: occupancySchema.optional(), + hotelId: z.number(), + roomConfigurations: z.array(roomConfigurationSchema), + rateDefinitions: z.array(rateDefinitionSchema), + }), + relationships: linksSchema.optional(), + type: z.string().optional(), + }), + }) + .transform((o) => o.data.attributes) + +export const getRoomsAvailabilitySchema = roomsAvailabilitySchema +export type RoomsAvailability = z.infer +export type RoomConfiguration = z.infer +export type Product = z.infer +export type RateDefinition = z.infer + const flexibilityPrice = z.object({ standard: z.number(), member: z.number(), diff --git a/server/routers/hotels/query.ts b/server/routers/hotels/query.ts index 0e319bf6e..859e5662c 100644 --- a/server/routers/hotels/query.ts +++ b/server/routers/hotels/query.ts @@ -24,11 +24,13 @@ import { getHotelsAvailabilityInputSchema, getlHotelDataInputSchema, getRatesInputSchema, + getRoomsAvailabilityInputSchema, } from "./input" import { getHotelDataSchema, getHotelsAvailabilitySchema, getRatesSchema, + getRoomsAvailabilitySchema, roomSchema, } from "./output" import tempRatesData from "./tempRatesData.json" @@ -61,6 +63,16 @@ const hotelsAvailabilityFailCounter = meter.createCounter( "trpc.hotel.availability.hotels-fail" ) +const roomsAvailabilityCounter = meter.createCounter( + "trpc.hotel.availability.rooms" +) +const roomsAvailabilitySuccessCounter = meter.createCounter( + "trpc.hotel.availability.rooms-success" +) +const roomsAvailabilityFailCounter = meter.createCounter( + "trpc.hotel.availability.rooms-fail" +) + async function getContentstackData( locale: string, uid: string | null | undefined @@ -376,6 +388,123 @@ export const hotelQueryRouter = router({ .flatMap((hotels) => hotels.attributes), } }), + rooms: hotelServiceProcedure + .input(getRoomsAvailabilityInputSchema) + .query(async ({ input, ctx }) => { + const { + hotelId, + roomStayStartDate, + roomStayEndDate, + adults, + children, + promotionCode, + reservationProfileType, + attachedProfileId, + } = input + + const params: Record = { + roomStayStartDate, + roomStayEndDate, + adults, + children, + promotionCode, + reservationProfileType, + attachedProfileId, + } + + roomsAvailabilityCounter.add(1, { + hotelId, + roomStayStartDate, + roomStayEndDate, + adults, + children, + promotionCode, + reservationProfileType, + }) + console.info( + "api.hotels.roomsAvailability start", + JSON.stringify({ query: { hotelId, params } }) + ) + const apiResponse = await api.get( + `${api.endpoints.v1.roomsAvailability}/${hotelId}`, + { + headers: { + Authorization: `Bearer ${ctx.serviceToken}`, + }, + }, + params + ) + if (!apiResponse.ok) { + const text = await apiResponse.text() + roomsAvailabilityFailCounter.add(1, { + hotelId, + roomStayStartDate, + roomStayEndDate, + adults, + children, + promotionCode, + reservationProfileType, + error_type: "http_error", + error: JSON.stringify({ + status: apiResponse.status, + statusText: apiResponse.statusText, + text, + }), + }) + console.error( + "api.hotels.roomsAvailability error", + JSON.stringify({ + query: { hotelId, params }, + error: { + status: apiResponse.status, + statusText: apiResponse.statusText, + text, + }, + }) + ) + return null + } + const apiJson = await apiResponse.json() + const validateAvailabilityData = + getRoomsAvailabilitySchema.safeParse(apiJson) + if (!validateAvailabilityData.success) { + roomsAvailabilityFailCounter.add(1, { + hotelId, + roomStayStartDate, + roomStayEndDate, + adults, + children, + promotionCode, + reservationProfileType, + error_type: "validation_error", + error: JSON.stringify(validateAvailabilityData.error), + }) + console.error( + "api.hotels.roomsAvailability validation error", + JSON.stringify({ + query: { hotelId, params }, + error: validateAvailabilityData.error, + }) + ) + throw badRequestError() + } + roomsAvailabilitySuccessCounter.add(1, { + hotelId, + roomStayStartDate, + roomStayEndDate, + adults, + children, + promotionCode, + reservationProfileType, + }) + console.info( + "api.hotels.roomsAvailability success", + JSON.stringify({ + query: { hotelId, params: params }, + }) + ) + return validateAvailabilityData.data + }), }), rates: router({ get: publicProcedure diff --git a/server/routers/user/output.ts b/server/routers/user/output.ts index 9c5e955ab..d4a6f0dd4 100644 --- a/server/routers/user/output.ts +++ b/server/routers/user/output.ts @@ -111,6 +111,7 @@ export const getStaysSchema = z.object({ limit: z.number(), totalCount: z.number(), }) + .optional() .nullable(), }) diff --git a/types/components/blocks/table.ts b/types/components/blocks/table.ts new file mode 100644 index 000000000..9438f7d43 --- /dev/null +++ b/types/components/blocks/table.ts @@ -0,0 +1,5 @@ +import type { TableData } from "@/types/trpc/routers/contentstack/blocks" + +export interface TableBlockProps { + data: TableData +} diff --git a/types/components/hotelReservation/selectRate/flexibilityOption.ts b/types/components/hotelReservation/selectRate/flexibilityOption.ts index c61c09e94..d76f5a520 100644 --- a/types/components/hotelReservation/selectRate/flexibilityOption.ts +++ b/types/components/hotelReservation/selectRate/flexibilityOption.ts @@ -1,8 +1,8 @@ +import { Product, RateDefinition } from "@/server/routers/hotels/output" + export type FlexibilityOptionProps = { + product: Product | undefined name: string value: string paymentTerm: string - standardPrice: number - memberPrice: number - currency: string } diff --git a/types/components/hotelReservation/selectRate/roomCard.ts b/types/components/hotelReservation/selectRate/roomCard.ts index ae9528b10..b51b3d8ab 100644 --- a/types/components/hotelReservation/selectRate/roomCard.ts +++ b/types/components/hotelReservation/selectRate/roomCard.ts @@ -1,8 +1,9 @@ -import { Rate } from "@/server/routers/hotels/output" +import { + RateDefinition, + RoomConfiguration, +} from "@/server/routers/hotels/output" export type RoomCardProps = { - room: Rate - nrOfNights: number - nrOfAdults: number - breakfastIncluded: boolean + roomConfiguration: RoomConfiguration + rateDefinitions: RateDefinition[] } diff --git a/types/components/hotelReservation/selectRate/roomSelection.ts b/types/components/hotelReservation/selectRate/roomSelection.ts index d5203f461..33dae631b 100644 --- a/types/components/hotelReservation/selectRate/roomSelection.ts +++ b/types/components/hotelReservation/selectRate/roomSelection.ts @@ -1,7 +1,5 @@ -import { Rate } from "@/server/routers/hotels/output" +import { RoomsAvailability } from "@/server/routers/hotels/output" export interface RoomSelectionProps { - rates: Rate[] - nrOfAdults: number - nrOfNights: number + roomConfigurations: RoomsAvailability } diff --git a/types/enums/blocks.ts b/types/enums/blocks.ts index b91989b2e..81a088bd1 100644 --- a/types/enums/blocks.ts +++ b/types/enums/blocks.ts @@ -4,6 +4,7 @@ export namespace BlocksEnums { Content = "Content", DynamicContent = "DynamicContent", Shortcuts = "Shortcuts", + Table = "Table", TextCols = "TextCols", TextContent = "TextContent", UspGrid = "UspGrid", diff --git a/types/enums/contentPage.ts b/types/enums/contentPage.ts index 99dd48911..df5dab28e 100644 --- a/types/enums/contentPage.ts +++ b/types/enums/contentPage.ts @@ -7,6 +7,7 @@ export namespace ContentPageEnum { Shortcuts = "ContentPageBlocksShortcuts", TextCols = "ContentPageBlocksTextCols", UspGrid = "ContentPageBlocksUspGrid", + Table = "ContentPageBlocksTable", } export const enum sidebar { diff --git a/types/trpc/routers/contentstack/blocks.ts b/types/trpc/routers/contentstack/blocks.ts index 6bffdfc90..1c46dda93 100644 --- a/types/trpc/routers/contentstack/blocks.ts +++ b/types/trpc/routers/contentstack/blocks.ts @@ -4,6 +4,7 @@ import { cardsGridSchema } from "@/server/routers/contentstack/schemas/blocks/ca import { contentSchema } from "@/server/routers/contentstack/schemas/blocks/content" import { dynamicContentSchema } from "@/server/routers/contentstack/schemas/blocks/dynamicContent" import { shortcutsSchema } from "@/server/routers/contentstack/schemas/blocks/shortcuts" +import { tableSchema } from "@/server/routers/contentstack/schemas/blocks/table" import { textColsSchema } from "@/server/routers/contentstack/schemas/blocks/textCols" import { uspGridSchema } from "@/server/routers/contentstack/schemas/blocks/uspGrid" @@ -12,5 +13,7 @@ export interface Content extends z.output {} export interface DynamicContent extends z.output {} export interface Shortcuts extends z.output {} export type Shortcut = Shortcuts["shortcuts"] +export interface TableBlock extends z.output {} +export type TableData = TableBlock["table"] export interface TextCols extends z.output {} export interface UspGrid extends z.output {}