diff --git a/apps/scandic-web/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-rate/loading.tsx b/apps/scandic-web/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-rate/loading.tsx index 2c48fbec1..34397b975 100644 --- a/apps/scandic-web/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-rate/loading.tsx +++ b/apps/scandic-web/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-rate/loading.tsx @@ -1,4 +1,5 @@ -import { HotelInfoCardSkeleton } from "@/components/HotelReservation/SelectRate/HotelInfoCard" +import { HotelInfoCardSkeleton } from "@scandic-hotels/design-system/HotelInfoCard" + import { RoomsContainerSkeleton } from "@/components/HotelReservation/SelectRate/RoomsContainer/RoomsContainerSkeleton" export default function LoadingSelectRate() { diff --git a/apps/scandic-web/components/HotelReservation/BookingConfirmation/index.tsx b/apps/scandic-web/components/HotelReservation/BookingConfirmation/index.tsx index a2919dc1b..dc9bf0a58 100644 --- a/apps/scandic-web/components/HotelReservation/BookingConfirmation/index.tsx +++ b/apps/scandic-web/components/HotelReservation/BookingConfirmation/index.tsx @@ -16,7 +16,7 @@ import SidePanel from "@/components/HotelReservation/SidePanel" import { getIntl } from "@/i18n" import BookingConfirmationProvider from "@/providers/BookingConfirmationProvider" -import { getHotelAlertsForBookingDates } from "../utils" +import { filterOverlappingDates } from "../utils" import Confirmation from "./Confirmation" import Tracking from "./Tracking" import { mapRoomState } from "./utils" @@ -79,7 +79,7 @@ export default async function BookingConfirmation({ - {getHotelAlertsForBookingDates( + {filterOverlappingDates( hotel.specialAlerts, dt(booking.checkInDate), dt(booking.checkOutDate) diff --git a/apps/scandic-web/components/HotelReservation/MyStay/index.tsx b/apps/scandic-web/components/HotelReservation/MyStay/index.tsx index 2949918cf..afc30a975 100644 --- a/apps/scandic-web/components/HotelReservation/MyStay/index.tsx +++ b/apps/scandic-web/components/HotelReservation/MyStay/index.tsx @@ -39,7 +39,7 @@ import { getIntl } from "@/i18n" import MyStayProvider from "@/providers/MyStay" import { isLoggedInUser } from "@/utils/isLoggedInUser" -import { getHotelAlertsForBookingDates } from "../utils" +import { filterOverlappingDates } from "../utils" import styles from "./index.module.css" @@ -202,7 +202,7 @@ export default async function MyStay(props: { } satisfies SafeUser) : null - hotel.specialAlerts = getHotelAlertsForBookingDates( + hotel.specialAlerts = filterOverlappingDates( hotel.specialAlerts, dt(fromDate), dt(toDate) diff --git a/apps/scandic-web/components/HotelReservation/SelectRate/HotelInfoCard/HotelDescription/index.tsx b/apps/scandic-web/components/HotelReservation/SelectRate/HotelInfoCard/HotelDescription/index.tsx deleted file mode 100644 index 309c9b6a8..000000000 --- a/apps/scandic-web/components/HotelReservation/SelectRate/HotelInfoCard/HotelDescription/index.tsx +++ /dev/null @@ -1,92 +0,0 @@ -"use client" - -import { useState } from "react" -import { Button as ButtonRAC } from "react-aria-components" -import { useIntl } from "react-intl" - -import { FacilityToIcon } from "@scandic-hotels/design-system/FacilityToIcon" -import { Typography } from "@scandic-hotels/design-system/Typography" - -import HotelDetailsSidePeek from "@/components/SidePeeks/HotelDetailsSidePeek" - -import styles from "./hotelDescription.module.css" - -import type { - AdditionalData, - Hotel, - Restaurant, -} from "@scandic-hotels/trpc/types/hotel" - -export default function HotelDescription({ - description, - hotel, - sortedFacilities, - restaurants, - additionalData, -}: { - description?: string - hotel: Hotel & { url: string | null } - sortedFacilities: Hotel["detailedFacilities"] - restaurants: Restaurant[] - additionalData: AdditionalData | undefined -}) { - const intl = useIntl() - - const [expanded, setExpanded] = useState(false) - - const handleToggle = () => { - setExpanded((prev) => !prev) - } - - const textShowMore = intl.formatMessage({ - defaultMessage: "Show more", - }) - - const textShowLess = intl.formatMessage({ - defaultMessage: "Show less", - }) - - return ( -
-
- {sortedFacilities?.map((facility) => ( -
- - -

{facility.name}

-
-
- ))} -
- -

- {description} -

-
- - - {expanded ? textShowLess : textShowMore} - - - - {expanded && ( -
- -
- )} -
- ) -} diff --git a/apps/scandic-web/components/HotelReservation/SelectRate/index.tsx b/apps/scandic-web/components/HotelReservation/SelectRate/index.tsx index 81ce73334..3088c26ff 100644 --- a/apps/scandic-web/components/HotelReservation/SelectRate/index.tsx +++ b/apps/scandic-web/components/HotelReservation/SelectRate/index.tsx @@ -1,11 +1,17 @@ import { cookies } from "next/headers" +import { dt } from "@scandic-hotels/common/dt" +import { HotelInfoCard } from "@scandic-hotels/design-system/HotelInfoCard" + import { FamilyAndFriendsCodes } from "@/constants/booking" -import { HotelInfoCard } from "@/components/HotelReservation/SelectRate/HotelInfoCard" import { RoomsContainer } from "@/components/HotelReservation/SelectRate/RoomsContainer" +import HotelDetailsSidePeek from "@/components/SidePeeks/HotelDetailsSidePeek" +import { getIntl } from "@/i18n" +import { mapApiImagesToGalleryImages } from "@/utils/imageGallery" import FnFNotAllowedAlert from "../FnFNotAllowedAlert/FnFNotAllowedAlert" +import { hasOverlappingDates } from "../utils" import AvailabilityError from "./AvailabilityError" import Tracking from "./Tracking" @@ -20,6 +26,7 @@ export default async function SelectRatePage({ hotelData: NonNullable booking: SelectRateBooking }) { + const intl = await getIntl() const bookingCode = booking.bookingCode let isInValidFNF = false @@ -27,13 +34,51 @@ export default async function SelectRatePage({ const cookieStore = await cookies() isInValidFNF = cookieStore.get("sc")?.value !== "1" } + + const validAlerts = hotelData.hotel.specialAlerts.filter((alert) => + hasOverlappingDates(alert, dt(booking.fromDate), dt(booking.toDate)) + ) + return ( <> ({ + ...alert, + heading: alert.heading ?? "", + text: alert.text ?? "", + }))} + facilities={hotelData.hotel.detailedFacilities} + slot={ + + } /> {isInValidFNF ? ( diff --git a/apps/scandic-web/components/HotelReservation/utils/index.test.ts b/apps/scandic-web/components/HotelReservation/utils/index.test.ts index adab4d01b..c16a7fbd5 100644 --- a/apps/scandic-web/components/HotelReservation/utils/index.test.ts +++ b/apps/scandic-web/components/HotelReservation/utils/index.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest" import { AlertTypeEnum } from "@scandic-hotels/common/constants/alert" import { dt } from "@scandic-hotels/common/dt" -import { getHotelAlertsForBookingDates } from "./index" +import { filterOverlappingDates } from "./index" import type { specialAlertsSchema } from "@scandic-hotels/trpc/routers/hotels/schemas/hotel/specialAlerts" import type { z } from "zod" @@ -23,11 +23,11 @@ function makeAlert(start: string, end: string): Alert { } } -describe("getHotelAlertsForBookingDates", () => { +describe("filterOverlappingDates", () => { const alert = makeAlert("2025-09-01", "2025-09-10") it("shows alert if booking starts inside alert", () => { - const result = getHotelAlertsForBookingDates( + const result = filterOverlappingDates( [alert], dt("2025-09-05"), dt("2025-09-12") @@ -36,7 +36,7 @@ describe("getHotelAlertsForBookingDates", () => { }) it("shows alert if booking ends inside alert", () => { - const result = getHotelAlertsForBookingDates( + const result = filterOverlappingDates( [alert], dt("2025-08-28"), dt("2025-09-05") @@ -45,7 +45,7 @@ describe("getHotelAlertsForBookingDates", () => { }) it("shows alert if booking fully contains alert", () => { - const result = getHotelAlertsForBookingDates( + const result = filterOverlappingDates( [alert], dt("2025-08-28"), dt("2025-09-15") @@ -54,7 +54,7 @@ describe("getHotelAlertsForBookingDates", () => { }) it("shows alert if alert fully contains booking", () => { - const result = getHotelAlertsForBookingDates( + const result = filterOverlappingDates( [alert], dt("2025-09-03"), dt("2025-09-05") @@ -63,7 +63,7 @@ describe("getHotelAlertsForBookingDates", () => { }) it("does not show alert if no overlap", () => { - const result = getHotelAlertsForBookingDates( + const result = filterOverlappingDates( [alert], dt("2025-08-01"), dt("2025-08-05") diff --git a/apps/scandic-web/components/HotelReservation/utils/index.tsx b/apps/scandic-web/components/HotelReservation/utils/index.tsx index 86f0d0a54..d6531ac55 100644 --- a/apps/scandic-web/components/HotelReservation/utils/index.tsx +++ b/apps/scandic-web/components/HotelReservation/utils/index.tsx @@ -7,7 +7,6 @@ import { ChildBedMapEnum } from "@scandic-hotels/trpc/enums/childBedMapEnum" import { ChildBedTypeEnum } from "@scandic-hotels/trpc/enums/childBedTypeEnum" import { RoomPackageCodeEnum } from "@scandic-hotels/trpc/enums/roomFilter" -import type { specialAlertsSchema } from "@scandic-hotels/trpc/routers/hotels/schemas/hotel/specialAlerts" import type { Package, Packages } from "@scandic-hotels/trpc/types/packages" import type { JSX } from "react" @@ -88,41 +87,57 @@ export function calculateVat(priceInclVat: number, vat: number) { } } -export function getHotelAlertsForBookingDates( - specialAlerts: Zod.infer, +export function filterOverlappingDates< + T extends { + startDate: Date | Dayjs | string | undefined | null + endDate: Date | Dayjs | string | undefined | null + }, +>(dateRangeItems: T[], fromDate: Date | Dayjs, toDate: Date | Dayjs) { + const startDate = dt(fromDate) + const endDate = dt(toDate) + + return dateRangeItems.filter((item) => + hasOverlappingDates(item, startDate, endDate) + ) +} + +export function hasOverlappingDates( + dateRangeItem: { + startDate: Date | Dayjs | string | undefined | null + endDate: Date | Dayjs | string | undefined | null + }, fromDate: Date | Dayjs, toDate: Date | Dayjs ) { - return specialAlerts.filter((alert) => { - if (alert.endDate && alert.startDate) { - const alertStartDate = dt(alert.startDate) - const alertEndDate = dt(alert.endDate) - const bookingStart = dt(fromDate) - const bookingEnd = dt(toDate) + const startDate = dt(fromDate) + const endDate = dt(toDate) - const fromDateIsBetweenAlertDates = bookingStart.isBetween( - alertStartDate, - alertEndDate, - "date", - "[]" - ) - const toDateIsBetweenAlertDates = bookingEnd.isBetween( - alertStartDate, - alertEndDate, - "date", - "[]" - ) + if (dateRangeItem.endDate && dateRangeItem.startDate) { + const itemStartDate = dt(dateRangeItem.startDate) + const itemEndDate = dt(dateRangeItem.endDate) - const bookingFullyContainsAlert = - bookingStart.isSameOrBefore(alertStartDate, "date") && - bookingEnd.isSameOrAfter(alertEndDate, "date") + const fromDateIsBetweenItemDates = startDate.isBetween( + itemStartDate, + itemEndDate, + "date", + "[]" + ) + const toDateIsBetweenItemDates = endDate.isBetween( + itemStartDate, + itemEndDate, + "date", + "[]" + ) - return ( - fromDateIsBetweenAlertDates || - toDateIsBetweenAlertDates || - bookingFullyContainsAlert - ) - } - return true - }) + const itemFullyContained = + startDate.isSameOrBefore(itemStartDate, "date") && + endDate.isSameOrAfter(itemEndDate, "date") + + return ( + fromDateIsBetweenItemDates || + toDateIsBetweenItemDates || + itemFullyContained + ) + } + return true } diff --git a/apps/scandic-web/components/HotelReservation/SelectRate/HotelInfoCard/HotelDescription/hotelDescription.module.css b/packages/design-system/lib/components/HotelInfoCard/HotelDescription/hotelDescription.module.css similarity index 100% rename from apps/scandic-web/components/HotelReservation/SelectRate/HotelInfoCard/HotelDescription/hotelDescription.module.css rename to packages/design-system/lib/components/HotelInfoCard/HotelDescription/hotelDescription.module.css diff --git a/packages/design-system/lib/components/HotelInfoCard/HotelDescription/index.tsx b/packages/design-system/lib/components/HotelInfoCard/HotelDescription/index.tsx new file mode 100644 index 000000000..ef4fcd7ba --- /dev/null +++ b/packages/design-system/lib/components/HotelInfoCard/HotelDescription/index.tsx @@ -0,0 +1,67 @@ +'use client' + +import { useState } from 'react' +import { Button as ButtonRAC } from 'react-aria-components' +import { useIntl } from 'react-intl' + +import { FacilityToIcon } from '../..//FacilityToIcon' +import { Typography } from '../../Typography' + +import styles from './hotelDescription.module.css' +import { FacilityEnum } from '@scandic-hotels/common/constants/facilities' + +export default function HotelDescription({ + description, + facilities, +}: { + description?: string + facilities: { + id: FacilityEnum + name: string + }[] +}) { + const intl = useIntl() + + const [expanded, setExpanded] = useState(false) + + const handleToggle = () => { + setExpanded((prev) => !prev) + } + + const textShowMore = intl.formatMessage({ + defaultMessage: 'Show more', + }) + + const textShowLess = intl.formatMessage({ + defaultMessage: 'Show less', + }) + + return ( +
+
+ {facilities?.map((facility) => ( +
+ + +

{facility.name}

+
+
+ ))} +
+ +

+ {description} +

+
+ + + {expanded ? textShowLess : textShowMore} + + +
+ ) +} diff --git a/packages/design-system/lib/components/HotelInfoCard/HotelInfoCard.stories.tsx b/packages/design-system/lib/components/HotelInfoCard/HotelInfoCard.stories.tsx new file mode 100644 index 000000000..0459d73a2 --- /dev/null +++ b/packages/design-system/lib/components/HotelInfoCard/HotelInfoCard.stories.tsx @@ -0,0 +1,141 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite' + +import { HotelInfoCard } from './index' +import { FacilityEnum } from '@scandic-hotels/common/constants/facilities' +import { AlertTypeEnum } from '@scandic-hotels/common/constants/alert' +import { Button } from '../Button' +import { fn } from 'storybook/test' +import { MaterialIcon } from '../Icons/MaterialIcon' +const meta: Meta = { + title: 'Components/HotelInfoCard', + component: HotelInfoCard, + argTypes: {}, +} + +export default meta + +type Story = StoryObj + +export const Default: Story = { + argTypes: { + alerts: { + control: 'select', + options: ['none', 'info', 'warning', 'alarm', 'success'], + mapping: { + none: [], + info: [ + { + id: '1', + heading: 'Hot dog alert', + text: `They are handing out free hot dogs available in the square outside the hotel.`, + type: AlertTypeEnum.Info, + }, + ], + warning: [ + { + id: '1', + heading: 'Construction work', + text: `There is construction work going on outside the hotel. Expect some noise during daytime.`, + type: AlertTypeEnum.Warning, + }, + ], + success: [ + { + id: '1', + heading: 'Free breakfast', + text: `We are now serving free breakfast in the lobby between 7-10am.`, + type: AlertTypeEnum.Success, + }, + ], + alarm: [ + { + id: '1', + heading: 'Fire alarm', + text: `The fire alarm is activated. Please evacuate the building immediately using the nearest exit.`, + type: AlertTypeEnum.Alarm, + }, + ], + }, + }, + + slot: { + control: 'select', + description: 'A slot where you can inject components', + options: ['none', 'button'], + table: { + defaultValue: { summary: 'button' }, + }, + mapping: { + none: null, + button: ( + + ), + }, + }, + }, + args: { + hotel: { + id: '1', + name: 'Grand Hotel Budapest', + url: 'https://www.scandichotels.com/en/hello', + ratings: { + tripAdvisor: { rating: 4.5 }, + }, + }, + address: { + city: 'Budapest', + kilometersToCentre: 0.5, + streetAddress: '1 Main St', + }, + description: + "Escape to the crown jewel of the Republic of Zubrowka, where timeless luxury awaits atop our breathtaking mountain sanctuary. The Grand Budapest Hotel stands as Europe's most distinguished retreat, a rose-colored palace that has welcomed discerning guests since the golden age of travel.", + facilities: [ + { id: FacilityEnum.AirConAirCooling, name: 'Air Conditioning' }, + { id: FacilityEnum.FoodDrinks247, name: 'Food & Drinks 24/7' }, + { id: FacilityEnum.KayaksForLoan, name: 'Kayaks for Loan' }, + ], + galleryImages: [ + { + src: './img/GrandHotelBudapest.png', + alt: 'Grand Hotel Budapest', + smallSrc: './img/GrandHotelBudapest.png', + caption: 'Grand Hotel Budapest', + }, + { + src: './img/img1.png', + alt: 'Image 1', + smallSrc: './img/img1.png', + caption: 'Image 1', + }, + { + src: './img/img2.png', + alt: 'Image 2', + smallSrc: './img/img2.png', + caption: 'Image 2', + }, + ], + alerts: [], + }, +} + +export const WithSlot: Story = { + argTypes: {}, + args: { + ...Default.args, + slot: Default.argTypes?.slot?.mapping?.button, + }, +} + +export const WithAlert: Story = { + argTypes: {}, + args: { + ...Default.args, + alerts: Default.argTypes?.alerts?.mapping?.info, + }, +} diff --git a/apps/scandic-web/components/HotelReservation/SelectRate/HotelInfoCard/hotelInfoCard.module.css b/packages/design-system/lib/components/HotelInfoCard/hotelInfoCard.module.css similarity index 93% rename from apps/scandic-web/components/HotelReservation/SelectRate/HotelInfoCard/hotelInfoCard.module.css rename to packages/design-system/lib/components/HotelInfoCard/hotelInfoCard.module.css index 780e109fd..9606b3dd2 100644 --- a/apps/scandic-web/components/HotelReservation/SelectRate/HotelInfoCard/hotelInfoCard.module.css +++ b/packages/design-system/lib/components/HotelInfoCard/hotelInfoCard.module.css @@ -54,6 +54,16 @@ display: none; } +.slotWrapper { + display: flex; + justify-content: center; + align-items: center; + + @media screen and (min-width: 1367px) { + display: none; + } +} + .hotelAlert { max-width: var(--max-width-page); margin: 0 auto; diff --git a/apps/scandic-web/components/HotelReservation/SelectRate/HotelInfoCard/index.tsx b/packages/design-system/lib/components/HotelInfoCard/index.tsx similarity index 56% rename from apps/scandic-web/components/HotelReservation/SelectRate/HotelInfoCard/index.tsx rename to packages/design-system/lib/components/HotelInfoCard/index.tsx index 3e86fda9f..72e259362 100644 --- a/apps/scandic-web/components/HotelReservation/SelectRate/HotelInfoCard/index.tsx +++ b/packages/design-system/lib/components/HotelInfoCard/index.tsx @@ -1,58 +1,57 @@ -import { dt } from "@scandic-hotels/common/dt" -import { getSingleDecimal } from "@scandic-hotels/common/utils/numberFormatting" -import { Alert } from "@scandic-hotels/design-system/Alert" -import { Divider } from "@scandic-hotels/design-system/Divider" -import { FacilityToIcon } from "@scandic-hotels/design-system/FacilityToIcon" -import ImageGallery from "@scandic-hotels/design-system/ImageGallery" -import SkeletonShimmer from "@scandic-hotels/design-system/SkeletonShimmer" -import { TripAdvisorChip } from "@scandic-hotels/design-system/TripAdvisorChip" -import { Typography } from "@scandic-hotels/design-system/Typography" +'use client' -import HotelDetailsSidePeek from "@/components/SidePeeks/HotelDetailsSidePeek" -import { getIntl } from "@/i18n" -import { mapApiImagesToGalleryImages } from "@/utils/imageGallery" +import { getSingleDecimal } from '@scandic-hotels/common/utils/numberFormatting' +import { Alert } from '../Alert' +import { Divider } from '../Divider' +import { FacilityToIcon } from '../FacilityToIcon' +import ImageGallery, { GalleryImage } from '../ImageGallery' +import SkeletonShimmer from '../SkeletonShimmer' +import { TripAdvisorChip } from '../TripAdvisorChip' +import { Typography } from '../Typography' -import { getHotelAlertsForBookingDates } from "../../utils" -import HotelDescription from "./HotelDescription" +import HotelDescription from './HotelDescription' -import styles from "./hotelInfoCard.module.css" - -import type { - AdditionalData, - Hotel, - Restaurant, -} from "@scandic-hotels/trpc/types/hotel" - -import type { SelectRateBooking } from "@/types/components/hotelReservation/selectRate/selectRate" +import styles from './hotelInfoCard.module.css' +import { useIntl } from 'react-intl' +import { AlertTypeEnum } from '@scandic-hotels/common/constants/alert' +import { FacilityEnum } from '@scandic-hotels/common/constants/facilities' export type HotelInfoCardProps = { - booking: SelectRateBooking - hotel: Hotel & { url: string | null } - restaurants: Restaurant[] - additionalData: AdditionalData | undefined + hotel: { + id: string + name: string + url: string | null + ratings?: { + tripAdvisor?: { rating: number } + } + } + description: string + address: { + streetAddress: string + city: string + kilometersToCentre: number + } + galleryImages: GalleryImage[] + alerts: SpecialAlertProps['alert'][] + facilities: { + id: FacilityEnum + name: string + }[] + slot?: React.ReactNode } -export async function HotelInfoCard({ - booking, +export function HotelInfoCard({ hotel, - restaurants, - additionalData, + galleryImages, + address, + facilities, + alerts, + description, + slot, }: HotelInfoCardProps) { - const intl = await getIntl() + const intl = useIntl() - const sortedFacilities = hotel.detailedFacilities - .sort((a, b) => b.sortOrder - a.sortOrder) - .slice(0, 5) - - const galleryImages = mapApiImagesToGalleryImages(hotel.galleryImages || []) - - const bookingFromDate = dt(booking.fromDate) - const bookingToDate = dt(booking.toDate) - const specialAlerts = getHotelAlertsForBookingDates( - hotel.specialAlerts, - bookingFromDate, - bookingToDate - ) + const firstFacilities = facilities.slice(0, 5) return (
@@ -74,37 +73,31 @@ export async function HotelInfoCard({ {intl.formatMessage( { defaultMessage: - "{address}, {city} ∙ {distanceToCityCenterInKm} km to city center", + '{address}, {city} ∙ {distanceToCityCenterInKm} km to city center', }, { - address: hotel.address.streetAddress, - city: hotel.address.city, + address: address.streetAddress, + city: address.city, distanceToCityCenterInKm: getSingleDecimal( - hotel.location.distanceToCentre / 1000 + address.kilometersToCentre ), } )}

-

- {hotel.hotelContent.texts.descriptions?.medium} -

+

{description}

- {sortedFacilities?.map((facility) => ( + {firstFacilities?.map((facility) => (
@@ -113,26 +106,22 @@ export async function HotelInfoCard({
))}
- + {slot}
- {specialAlerts.map((alert) => ( +
{slot}
+ {alerts.map((alert) => ( ))}
) } -function SpecialAlert({ alert }: { alert: Hotel["specialAlerts"][number] }) { +type SpecialAlertProps = { + alert: { id: string; type: AlertTypeEnum; heading: string; text: string } +} +function SpecialAlert({ alert }: SpecialAlertProps) { return (