Merged in feat/sw-610-summary (pull request #774)

Feat/SW-610 summary

Approved-by: Tobias Johansson
Approved-by: Simon.Emanuelsson
This commit is contained in:
Christel Westerberg
2024-10-30 18:52:48 +00:00
committed by Simon.Emanuelsson
49 changed files with 858 additions and 320 deletions

View File

@@ -14,7 +14,10 @@ export default async function HotelHeader({
if (!searchParams.hotel) {
redirect(home)
}
const hotel = await getHotelData(searchParams.hotel, params.lang)
const hotel = await getHotelData({
hotelId: searchParams.hotel,
language: params.lang,
})
if (!hotel?.data) {
redirect(home)
}

View File

@@ -13,7 +13,10 @@ export default async function HotelSidePeek({
if (!searchParams.hotel) {
redirect(`/${params.lang}`)
}
const hotel = await getHotelData(searchParams.hotel, params.lang)
const hotel = await getHotelData({
hotelId: searchParams.hotel,
language: params.lang,
})
if (!hotel?.data) {
redirect(`/${params.lang}`)
}

View File

@@ -0,0 +1,78 @@
import { notFound } from "next/navigation"
import {
getProfileSafely,
getSelectedRoomAvailability,
} from "@/lib/trpc/memoizedRequests"
import Summary from "@/components/HotelReservation/EnterDetails/Summary"
import { getQueryParamsForEnterDetails } from "@/components/HotelReservation/SelectRate/RoomSelection/utils"
import { SelectRateSearchParams } from "@/types/components/hotelReservation/selectRate/selectRate"
import { LangParams, PageArgs, SearchParams } from "@/types/params"
export default async function SummaryPage({
params,
searchParams,
}: PageArgs<LangParams, SearchParams<SelectRateSearchParams>>) {
const selectRoomParams = new URLSearchParams(searchParams)
const { hotel, adults, children, roomTypeCode, rateCode, fromDate, toDate } =
getQueryParamsForEnterDetails(selectRoomParams)
if (!roomTypeCode || !rateCode) {
console.log("No roomTypeCode or rateCode")
return notFound()
}
const availability = await getSelectedRoomAvailability({
hotelId: parseInt(hotel),
adults,
children,
roomStayStartDate: fromDate,
roomStayEndDate: toDate,
rateCode,
roomTypeCode,
})
const user = await getProfileSafely()
if (!availability) {
console.error("No hotel or availability data", availability)
// TODO: handle this case
return null
}
const prices = user
? {
local: {
price: availability.memberRate?.localPrice.pricePerStay,
currency: availability.memberRate?.localPrice.currency,
},
euro: {
price: availability.memberRate?.requestedPrice?.pricePerStay,
currency: availability.memberRate?.requestedPrice?.currency,
},
}
: {
local: {
price: availability.publicRate?.localPrice.pricePerStay,
currency: availability.publicRate?.localPrice.currency,
},
euro: {
price: availability.publicRate?.requestedPrice?.pricePerStay,
currency: availability.publicRate?.requestedPrice?.currency,
},
}
return (
<Summary
isMember={!!user}
room={{
roomType: availability.selectedRoom.roomType,
localPrice: prices.local,
euroPrice: prices.euro,
adults,
cancellationText: availability.cancellationText,
}}
/>
)
}

View File

@@ -1,5 +1,4 @@
.layout {
min-height: 100dvh;
background-color: var(--Scandic-Brand-Warm-White);
}
@@ -9,7 +8,6 @@
grid-template-columns: 1fr 340px;
grid-template-rows: auto 1fr;
margin: var(--Spacing-x5) auto 0;
padding-top: var(--Spacing-x6);
/* simulates padding on viewport smaller than --max-width-navigation */
width: min(
calc(100dvw - (var(--Spacing-x2) * 2)),
@@ -17,8 +15,81 @@
);
}
.summary {
align-self: flex-start;
.summaryContainer {
grid-column: 2 / 3;
grid-row: 1/-1;
}
.summary {
background-color: var(--Main-Grey-White);
border-color: var(--Primary-Light-On-Surface-Divider-subtle);
border-style: solid;
border-width: 1px;
border-radius: var(--Corner-radius-Large);
z-index: 1;
}
.hider {
display: none;
}
.shadow {
display: none;
}
@media screen and (min-width: 950px) {
.summaryContainer {
display: grid;
grid-template-rows: auto auto 1fr;
margin-top: calc(0px - var(--Spacing-x9));
}
.summary {
position: sticky;
top: calc(
var(--booking-widget-desktop-height) +
var(--booking-widget-desktop-height) + var(--Spacing-x-one-and-half)
);
margin-top: calc(0px - var(--Spacing-x9));
border-bottom: none;
border-radius: var(--Corner-radius-Large) var(--Corner-radius-Large) 0 0;
}
.hider {
display: block;
background-color: var(--Scandic-Brand-Warm-White);
position: sticky;
margin-top: var(--Spacing-x4);
top: calc(
var(--booking-widget-desktop-height) +
var(--booking-widget-desktop-height) - 6px
);
height: 40px;
}
.shadow {
display: block;
background-color: var(--Main-Grey-White);
border-color: var(--Primary-Light-On-Surface-Divider-subtle);
border-style: solid;
border-left-width: 1px;
border-right-width: 1px;
border-top: none;
border-bottom: none;
}
}
@media screen and (min-width: 1367px) {
.summary {
top: calc(
var(--booking-widget-desktop-height) + var(--Spacing-x2) +
var(--Spacing-x-half)
);
}
.hider {
top: calc(var(--booking-widget-desktop-height) - 6px);
}
}

View File

@@ -1,16 +1,16 @@
import EnterDetailsProvider from "@/components/HotelReservation/EnterDetails/Provider"
import SelectedRoom from "@/components/HotelReservation/EnterDetails/SelectedRoom"
import Summary from "@/components/HotelReservation/EnterDetails/Summary"
import { setLang } from "@/i18n/serverContext"
import { preload } from "./page"
import styles from "./layout.module.css"
import { StepEnum } from "@/types/components/enterDetails/step"
import { StepEnum } from "@/types/components/hotelReservation/enterDetails/step"
import type { LangParams, LayoutArgs } from "@/types/params"
export default async function StepLayout({
summary,
children,
hotelHeader,
params,
@@ -19,19 +19,21 @@ export default async function StepLayout({
LayoutArgs<LangParams & { step: StepEnum }> & {
hotelHeader: React.ReactNode
sidePeek: React.ReactNode
}
>) {
summary: React.ReactNode
}>) {
setLang(params.lang)
preload()
return (
<EnterDetailsProvider step={params.step}>
<EnterDetailsProvider step={params.step} >
<main className={styles.layout}>
{hotelHeader}
<div className={styles.content}>
<SelectedRoom />
{children}
<aside className={styles.summary}>
<Summary />
<aside className={styles.summaryContainer}>
<div className={styles.hider} />
<div className={styles.summary}>{summary}</div>
<div className={styles.shadow} />
</aside>
</div>
{sidePeek}

View File

@@ -6,7 +6,9 @@ import {
getHotelData,
getProfileSafely,
getRoomAvailability,
getSelectedRoomAvailability,
} from "@/lib/trpc/memoizedRequests"
import { HotelIncludeEnum } from "@/server/routers/hotels/input"
import BedType from "@/components/HotelReservation/EnterDetails/BedType"
import Breakfast from "@/components/HotelReservation/EnterDetails/Breakfast"
@@ -14,10 +16,11 @@ import Details from "@/components/HotelReservation/EnterDetails/Details"
import HistoryStateManager from "@/components/HotelReservation/EnterDetails/HistoryStateManager"
import Payment from "@/components/HotelReservation/EnterDetails/Payment"
import SectionAccordion from "@/components/HotelReservation/EnterDetails/SectionAccordion"
import getHotelReservationQueryParams from "@/components/HotelReservation/SelectRate/RoomSelection/utils"
import { getQueryParamsForEnterDetails } from "@/components/HotelReservation/SelectRate/RoomSelection/utils"
import { getIntl } from "@/i18n"
import { StepEnum } from "@/types/components/enterDetails/step"
import { StepEnum } from "@/types/components/hotelReservation/enterDetails/step"
import { SelectRateSearchParams } from "@/types/components/hotelReservation/selectRate/selectRate"
import type { LangParams, PageArgs } from "@/types/params"
export function preload() {
@@ -32,35 +35,56 @@ function isValidStep(step: string): step is StepEnum {
export default async function StepPage({
params,
searchParams,
}: PageArgs<LangParams & { step: StepEnum }, { hotel: string }>) {
if (!searchParams.hotel) {
redirect(`/${params.lang}`)
}
void getBreakfastPackages(searchParams.hotel)
const stepParams = new URLSearchParams(searchParams)
const paramsObject = getHotelReservationQueryParams(stepParams)
void getRoomAvailability({
hotelId: paramsObject.hotel,
adults: paramsObject.room[0].adults,
roomStayStartDate: paramsObject.fromDate,
roomStayEndDate: paramsObject.toDate,
})
const intl = await getIntl()
}: PageArgs<LangParams & { step: StepEnum }, SelectRateSearchParams>) {
const { lang } = params
const hotel = await getHotelData(searchParams.hotel, params.lang)
void getBreakfastPackages(searchParams.hotel)
const intl = await getIntl()
const selectRoomParams = new URLSearchParams(searchParams)
const {
hotel: hotelId,
adults,
children,
roomTypeCode,
rateCode,
fromDate,
toDate,
} = getQueryParamsForEnterDetails(selectRoomParams)
if (!rateCode || !roomTypeCode) {
return notFound()
}
void getSelectedRoomAvailability({
hotelId: parseInt(searchParams.hotel),
adults,
children,
roomStayStartDate: fromDate,
roomStayEndDate: toDate,
rateCode,
roomTypeCode,
})
const hotelData = await getHotelData({
hotelId,
language: lang,
include: [HotelIncludeEnum.RoomCategories],
})
const roomAvailability = await getSelectedRoomAvailability({
hotelId: parseInt(searchParams.hotel),
adults,
children,
roomStayStartDate: fromDate,
roomStayEndDate: toDate,
rateCode,
roomTypeCode,
})
const breakfastPackages = await getBreakfastPackages(searchParams.hotel)
const user = await getProfileSafely()
const savedCreditCards = await getCreditCardsSafely()
const breakfastPackages = await getBreakfastPackages(searchParams.hotel)
const roomAvailability = await getRoomAvailability({
hotelId: paramsObject.hotel,
adults: paramsObject.room[0].adults,
roomStayStartDate: paramsObject.fromDate,
roomStayEndDate: paramsObject.toDate,
rateCode: paramsObject.room[0].ratecode,
})
if (!isValidStep(params.step) || !hotel || !roomAvailability) {
if (!isValidStep(params.step) || !hotelData || !roomAvailability) {
return notFound()
}
@@ -79,16 +103,30 @@ export default async function StepPage({
id: "Select payment method",
})
const availableRoom = roomAvailability.selectedRoom?.roomType
const bedTypes = hotelData.included
?.find((room) => room.name === availableRoom)
?.roomTypes.map((room) => ({
description: room.mainBed.description,
size: room.mainBed.widthRange,
value: room.code,
}))
return (
<section>
<HistoryStateManager />
<SectionAccordion
header={intl.formatMessage({ id: "Select bed" })}
step={StepEnum.selectBed}
label={intl.formatMessage({ id: "Request bedtype" })}
>
<BedType />
</SectionAccordion>
{/* TODO: How to handle no beds found? */}
{bedTypes ? (
<SectionAccordion
header="Select bed"
step={StepEnum.selectBed}
label={intl.formatMessage({ id: "Request bedtype" })}
>
<BedType bedTypes={bedTypes} />
</SectionAccordion>
) : null}
<SectionAccordion
header={intl.formatMessage({ id: "Food options" })}
step={StepEnum.breakfast}
@@ -111,7 +149,7 @@ export default async function StepPage({
<Payment
hotelId={searchParams.hotel}
otherPaymentOptions={
hotel.data.attributes.merchantInformationData
hotelData.data.attributes.merchantInformationData
.alternatePaymentOptions
}
savedCreditCards={savedCreditCards}

View File

@@ -1,4 +1,3 @@
.layout {
min-height: 100dvh;
background-color: var(--Base-Background-Primary-Normal);
}

View File

@@ -10,7 +10,7 @@ import {
} from "@/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-hotel/utils"
import HotelCardListing from "@/components/HotelReservation/HotelCardListing"
import HotelFilter from "@/components/HotelReservation/SelectHotel/HotelFilter"
import getHotelReservationQueryParams from "@/components/HotelReservation/SelectRate/RoomSelection/utils"
import { getHotelReservationQueryParams } from "@/components/HotelReservation/SelectRate/RoomSelection/utils"
import { ChevronRightIcon } from "@/components/Icons"
import StaticMap from "@/components/Maps/StaticMap"
import Link from "@/components/TempDesignSystem/Link"

View File

@@ -2,10 +2,11 @@ import { notFound } from "next/navigation"
import { getProfileSafely } from "@/lib/trpc/memoizedRequests"
import { serverClient } from "@/lib/trpc/server"
import { HotelIncludeEnum } from "@/server/routers/hotels/input"
import HotelInfoCard from "@/components/HotelReservation/SelectRate/HotelInfoCard"
import Rooms from "@/components/HotelReservation/SelectRate/Rooms"
import getHotelReservationQueryParams from "@/components/HotelReservation/SelectRate/RoomSelection/utils"
import { getHotelReservationQueryParams } from "@/components/HotelReservation/SelectRate/RoomSelection/utils"
import { setLang } from "@/i18n/serverContext"
import { generateChildrenString } from "../select-hotel/utils"
@@ -38,7 +39,7 @@ export default async function SelectRatePage({
serverClient().hotel.hotelData.get({
hotelId: searchParams.hotel,
language: params.lang,
include: ["RoomCategories"],
include: [HotelIncludeEnum.RoomCategories],
}),
serverClient().hotel.availability.rooms({
hotelId: parseInt(searchParams.hotel, 10),

View File

@@ -4,6 +4,7 @@ import { dt } from "@/lib/dt"
import Body from "@/components/TempDesignSystem/Text/Body"
import { getIntl } from "@/i18n"
import { getLang } from "@/i18n/serverContext"
import { formatNumber } from "@/utils/format"
import { getMembership } from "@/utils/user"
import type { UserProps } from "@/types/components/myPages/user"
@@ -16,9 +17,6 @@ export default async function ExpiringPoints({ user }: UserProps) {
// TODO: handle this case?
return null
}
// sv hardcoded to force space on thousands
const formatter = new Intl.NumberFormat(Lang.sv)
const d = dt(membership.pointsExpiryDate)
const dateFormat = getLang() == Lang.fi ? "DD.MM.YYYY" : "YYYY-MM-DD"
@@ -29,7 +27,7 @@ export default async function ExpiringPoints({ user }: UserProps) {
{intl.formatMessage(
{ id: "spendable points expiring by" },
{
points: formatter.format(membership.pointsToExpire),
points: formatNumber(membership.pointsToExpire),
date: d.format(dateFormat),
}
)}

View File

@@ -1,8 +1,7 @@
import { useIntl } from "react-intl"
import { Lang } from "@/constants/languages"
import Body from "@/components/TempDesignSystem/Text/Body"
import { formatNumber } from "@/utils/format"
import { awardPointsVariants } from "./awardPointsVariants"
@@ -32,12 +31,10 @@ export default function AwardPoints({
variant,
})
// sv hardcoded to force space on thousands
const formatter = new Intl.NumberFormat(Lang.sv)
return (
<Body textTransform="bold" className={classNames}>
{isCalculated
? formatter.format(awardPoints)
? formatNumber(awardPoints)
: intl.formatMessage({ id: "Points being calculated" })}
</Body>
)

View File

@@ -11,7 +11,6 @@ import { CloseLargeIcon } from "@/components/Icons"
import { debounce } from "@/utils/debounce"
import { getFormattedUrlQueryParams } from "@/utils/url"
import getHotelReservationQueryParams from "../HotelReservation/SelectRate/RoomSelection/utils"
import MobileToggleButton from "./MobileToggleButton"
import styles from "./bookingWidget.module.css"
@@ -41,10 +40,10 @@ export default function BookingWidgetClient({
const bookingWidgetSearchData: BookingWidgetSearchParams | undefined =
searchParams
? (getFormattedUrlQueryParams(new URLSearchParams(searchParams), {
adults: "number",
age: "number",
bed: "number",
}) as BookingWidgetSearchParams)
adults: "number",
age: "number",
bed: "number",
}) as BookingWidgetSearchParams)
: undefined
const getLocationObj = (destination: string): Location | undefined => {
@@ -70,9 +69,9 @@ export default function BookingWidgetClient({
const selectedLocation = bookingWidgetSearchData
? getLocationObj(
(bookingWidgetSearchData.city ??
bookingWidgetSearchData.hotel) as string
)
(bookingWidgetSearchData.city ??
bookingWidgetSearchData.hotel) as string
)
: undefined
const methods = useForm<BookingWidgetSchema>({

View File

@@ -18,7 +18,7 @@ export default function FooterSecondaryNav({
<div className={styles.secondaryNavigation}>
{appDownloads && (
<nav className={styles.secondaryNavigationGroup}>
<Body color="peach80" textTransform="uppercase">
<Body color="baseTextMediumContrast" textTransform="uppercase">
{appDownloads.title}
</Body>
<ul className={styles.secondaryNavigationList}>
@@ -50,7 +50,7 @@ export default function FooterSecondaryNav({
)}
{secondaryLinks.map((link) => (
<nav className={styles.secondaryNavigationGroup} key={link.title}>
<Body color="peach80" textTransform="uppercase">
<Body color="baseTextMediumContrast" textTransform="uppercase">
{link.title}
</Body>
<ul className={styles.secondaryNavigationList}>

View File

@@ -14,12 +14,14 @@ import { bedTypeSchema } from "./schema"
import styles from "./bedOptions.module.css"
import type { BedTypeSchema } from "@/types/components/enterDetails/bedType"
import { BedTypeEnum } from "@/types/enums/bedType"
import type {
BedTypeProps,
BedTypeSchema,
} from "@/types/components/hotelReservation/enterDetails/bedType"
export default function BedType() {
export default function BedType({ bedTypes }: BedTypeProps) {
const intl = useIntl()
const bedType = useEnterDetailsStore((state) => state.data.bedType)
const bedType = useEnterDetailsStore((state) => state.userData.bedType)
const methods = useForm<BedTypeSchema>({
defaultValues: bedType
@@ -33,10 +35,6 @@ export default function BedType() {
reValidateMode: "onChange",
})
const text = intl.formatMessage<React.ReactNode>(
{ id: "<b>Included</b> (based on availability)" },
{ b: (str) => <b>{str}</b> }
)
const completeStep = useEnterDetailsStore((state) => state.completeStep)
const onSubmit = useCallback(
@@ -58,38 +56,24 @@ export default function BedType() {
return (
<FormProvider {...methods}>
<form className={styles.form} onSubmit={methods.handleSubmit(onSubmit)}>
<RadioCard
Icon={KingBedIcon}
iconWidth={46}
id={BedTypeEnum.KING}
name="bedType"
subtitle={intl.formatMessage(
{ id: "{width} cm × {length} cm" },
{
length: "210",
width: "180",
}
)}
text={text}
title={intl.formatMessage({ id: "King bed" })}
value={BedTypeEnum.KING}
/>
<RadioCard
Icon={KingBedIcon}
iconWidth={46}
id={BedTypeEnum.QUEEN}
name="bedType"
subtitle={intl.formatMessage(
{ id: "{width} cm × {length} cm" },
{
length: "200",
width: "160",
}
)}
text={text}
title={intl.formatMessage({ id: "Queen bed" })}
value={BedTypeEnum.QUEEN}
/>
{bedTypes.map((roomType) => {
const width =
roomType.size.max === roomType.size.min
? `${roomType.size.min} cm`
: `${roomType.size.min} cm - ${roomType.size.max} cm`
return (
<RadioCard
key={roomType.value}
Icon={KingBedIcon}
iconWidth={46}
id={roomType.value}
name="bedType"
subtitle={width}
title={roomType.description}
value={roomType.description}
/>
)
})}
</form>
</FormProvider>
)

View File

@@ -3,5 +3,5 @@ import { z } from "zod"
import { BedTypeEnum } from "@/types/enums/bedType"
export const bedTypeSchema = z.object({
bedType: z.nativeEnum(BedTypeEnum),
bedType: z.string(),
})

View File

@@ -17,13 +17,13 @@ import styles from "./breakfast.module.css"
import type {
BreakfastFormSchema,
BreakfastProps,
} from "@/types/components/enterDetails/breakfast"
} from "@/types/components/hotelReservation/enterDetails/breakfast"
import { BreakfastPackageEnum } from "@/types/enums/breakfast"
export default function Breakfast({ packages }: BreakfastProps) {
const intl = useIntl()
const breakfast = useEnterDetailsStore((state) => state.data.breakfast)
const breakfast = useEnterDetailsStore((state) => state.userData.breakfast)
let defaultValues = undefined
if (breakfast === BreakfastPackageEnum.NO_BREAKFAST) {
@@ -76,21 +76,21 @@ export default function Breakfast({ packages }: BreakfastProps) {
subtitle={
pkg.code === BreakfastPackageEnum.FREE_MEMBER_BREAKFAST
? intl.formatMessage<React.ReactNode>(
{ id: "breakfast.price.free" },
{
amount: pkg.originalPrice,
currency: pkg.currency,
free: (str) => <Highlight>{str}</Highlight>,
strikethrough: (str) => <s>{str}</s>,
}
)
{ id: "breakfast.price.free" },
{
amount: pkg.originalPrice,
currency: pkg.currency,
free: (str) => <Highlight>{str}</Highlight>,
strikethrough: (str) => <s>{str}</s>,
}
)
: intl.formatMessage(
{ id: "breakfast.price" },
{
amount: pkg.packagePrice,
currency: pkg.currency,
}
)
{ id: "breakfast.price" },
{
amount: pkg.packagePrice,
currency: pkg.currency,
}
)
}
text={intl.formatMessage({
id: "All our breakfast buffets offer gluten free, vegan, and allergy-friendly options.",

View File

@@ -22,21 +22,21 @@ import styles from "./details.module.css"
import type {
DetailsProps,
DetailsSchema,
} from "@/types/components/enterDetails/details"
} from "@/types/components/hotelReservation/enterDetails/details"
const formID = "enter-details"
export default function Details({ user }: DetailsProps) {
const intl = useIntl()
const initialData = useEnterDetailsStore((state) => ({
countryCode: state.data.countryCode,
email: state.data.email,
firstName: state.data.firstName,
lastName: state.data.lastName,
phoneNumber: state.data.phoneNumber,
join: state.data.join,
dateOfBirth: state.data.dateOfBirth,
zipCode: state.data.zipCode,
termsAccepted: state.data.termsAccepted,
countryCode: state.userData.countryCode,
email: state.userData.email,
firstName: state.userData.firstName,
lastName: state.userData.lastName,
phoneNumber: state.userData.phoneNumber,
join: state.userData.join,
dateOfBirth: state.userData.dateOfBirth,
zipCode: state.userData.zipCode,
termsAccepted: state.userData.termsAccepted,
}))
const methods = useForm<DetailsSchema>({

View File

@@ -56,7 +56,7 @@ export default function Payment({
const intl = useIntl()
const queryParams = useSearchParams()
const { firstName, lastName, email, phoneNumber, countryCode } =
useEnterDetailsStore((state) => state.data)
useEnterDetailsStore((state) => state.userData)
const [confirmationNumber, setConfirmationNumber] = useState<string>("")
const methods = useForm<PaymentFormData>({

View File

@@ -1,4 +1,5 @@
"use client"
import { useSearchParams } from "next/navigation"
import { PropsWithChildren, useRef } from "react"
import {
@@ -7,15 +8,16 @@ import {
initEditDetailsState,
} from "@/stores/enter-details"
import { StepEnum } from "@/types/components/enterDetails/step"
import { StepEnum } from "@/types/components/hotelReservation/enterDetails/step"
export default function EnterDetailsProvider({
step,
children,
}: PropsWithChildren<{ step: StepEnum }>) {
const searchParams = useSearchParams()
const initialStore = useRef<EnterDetailsStore>()
if (!initialStore.current) {
initialStore.current = initEditDetailsState(step)
initialStore.current = initEditDetailsState(step, searchParams)
}
return (

View File

@@ -14,7 +14,7 @@ import styles from "./enterDetailsSidePeek.module.css"
import {
SidePeekEnum,
SidePeekProps,
} from "@/types/components/enterDetails/sidePeek"
} from "@/types/components/hotelReservation/enterDetails/sidePeek"
export default function EnterDetailsSidePeek({ hotel }: SidePeekProps) {
const activeSidePeek = useEnterDetailsStore((state) => state.activeSidePeek)

View File

@@ -7,7 +7,7 @@ import { useEnterDetailsStore } from "@/stores/enter-details"
import { ChevronRightSmallIcon } from "@/components/Icons"
import Button from "@/components/TempDesignSystem/Button"
import { SidePeekEnum } from "@/types/components/enterDetails/sidePeek"
import { SidePeekEnum } from "@/types/components/hotelReservation/enterDetails/sidePeek"
export default function ToggleSidePeek() {
const intl = useIntl()

View File

@@ -1,153 +1,201 @@
"use client"
import { useEffect, useState } from "react"
import { useIntl } from "react-intl"
import { dt } from "@/lib/dt"
import { useEnterDetailsStore } from "@/stores/enter-details"
import { ArrowRightIcon } from "@/components/Icons"
import Divider from "@/components/TempDesignSystem/Divider"
import Link from "@/components/TempDesignSystem/Link"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import { getIntl } from "@/i18n"
import { getLang } from "@/i18n/serverContext"
import ToggleSidePeek from "./ToggleSidePeek"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import useLang from "@/hooks/useLang"
import { formatNumber } from "@/utils/format"
import styles from "./summary.module.css"
// TEMP
const rooms = [
{
adults: 1,
type: "Cozy cabin",
},
]
import { RoomsData } from "@/types/components/hotelReservation/enterDetails/bookingData"
import { BreakfastPackage } from "@/types/components/hotelReservation/enterDetails/breakfast"
import { BreakfastPackageEnum } from "@/types/enums/breakfast"
export default function Summary({
isMember,
room,
}: {
isMember: boolean
room: RoomsData
}) {
const [chosenBed, setChosenBed] = useState<string>()
const [chosenBreakfast, setChosenBreakfast] = useState<
BreakfastPackage | BreakfastPackageEnum.NO_BREAKFAST
>()
const intl = useIntl()
const lang = useLang()
const { fromDate, toDate, bedType, breakfast } = useEnterDetailsStore(
(state) => ({
fromDate: state.roomData.fromDate,
toDate: state.roomData.toDate,
bedType: state.userData.bedType,
breakfast: state.userData.breakfast,
})
)
export default async function Summary() {
const intl = await getIntl()
const lang = getLang()
const fromDate = dt().locale(lang).format("ddd, D MMM")
const toDate = dt().add(1, "day").locale(lang).format("ddd, D MMM")
const diff = dt(toDate).diff(fromDate, "days")
const totalAdults = rooms.reduce((total, room) => total + room.adults, 0)
const adults = intl.formatMessage(
{ id: "booking.adults" },
{ totalAdults: totalAdults }
)
const nights = intl.formatMessage(
{ id: "booking.nights" },
{ totalNights: diff }
)
const addOns = [
{
price: intl.formatMessage({ id: "Included" }),
title: intl.formatMessage({ id: "King bed" }),
},
{
price: intl.formatMessage({ id: "Included" }),
title: intl.formatMessage({ id: "Breakfast buffet" }),
},
]
let color: "uiTextHighContrast" | "red" = "uiTextHighContrast"
if (isMember) {
color = "red"
}
const mappedRooms = Array.from(
rooms
.reduce((acc, room) => {
const currentRoom = acc.get(room.type)
acc.set(room.type, {
total: currentRoom ? currentRoom.total + 1 : 1,
type: room.type,
})
return acc
}, new Map())
.values()
)
useEffect(() => {
setChosenBed(bedType)
if (breakfast) {
setChosenBreakfast(breakfast)
}
}, [bedType, breakfast])
return (
<section className={styles.summary}>
<header>
<Body textTransform="bold">
{mappedRooms.map(
(room, idx) =>
`${room.total} x ${room.type}${mappedRooms.length > 1 && idx + 1 !== mappedRooms.length ? ", " : ""}`
)}
<Subtitle type="two">{intl.formatMessage({ id: "Summary" })}</Subtitle>
<Body className={styles.date} color="baseTextMediumContrast">
{dt(fromDate).locale(lang).format("ddd, D MMM")}
<ArrowRightIcon color="peach80" height={15} width={15} />
{dt(toDate).locale(lang).format("ddd, D MMM")} ({nights})
</Body>
<Body className={styles.date} color="textMediumContrast">
{fromDate}
<ArrowRightIcon color="uiTextMediumContrast" height={15} width={15} />
{toDate}
</Body>
<ToggleSidePeek />
</header>
<Divider color="primaryLightSubtle" />
<div className={styles.addOns}>
<div className={styles.entry}>
<div>
<div className={styles.entry}>
<Body color="textHighContrast">{room.roomType}</Body>
<Caption color={color}>
{intl.formatMessage(
{ id: "{amount} {currency}" },
{
amount: formatNumber(parseInt(room.localPrice.price ?? "0")),
currency: room.localPrice.currency,
}
)}
</Caption>
</div>
<Caption color="uiTextMediumContrast">
{`${nights}, ${adults}`}
</Caption>
<Caption color="uiTextHighContrast">
{intl.formatMessage(
{ id: "{amount} {currency}" },
{ amount: "4536", currency: "SEK" }
{ id: "booking.adults" },
{ totalAdults: room.adults }
)}
</Caption>
{room.children?.length ? (
<Caption color="uiTextMediumContrast">
{intl.formatMessage(
{ id: "booking.children" },
{ totalChildren: room.children.length }
)}
</Caption>
) : null}
<Caption color="uiTextMediumContrast">
{room.cancellationText}
</Caption>
<Link color="burgundy" href="#" variant="underscored" size="small">
{intl.formatMessage({ id: "Rate details" })}
</Link>
</div>
{addOns.map((addOn) => (
<div className={styles.entry} key={addOn.title}>
<Caption color="uiTextMediumContrast">{addOn.title}</Caption>
<Caption color="uiTextHighContrast">{addOn.price}</Caption>
{chosenBed ? (
<div className={styles.entry}>
<div>
<Body color="textHighContrast">{chosenBed}</Body>
<Caption color="uiTextMediumContrast">
{intl.formatMessage({ id: "Based on availability" })}
</Caption>
</div>
<Caption color="uiTextMediumContrast">
{intl.formatMessage(
{ id: "{amount} {currency}" },
{ amount: "0", currency: room.localPrice.currency }
)}
</Caption>
</div>
))}
) : null}
{chosenBreakfast ? (
chosenBreakfast === BreakfastPackageEnum.NO_BREAKFAST ? (
<div className={styles.entry}>
<Body color="textHighContrast">
{intl.formatMessage({ id: "No breakfast" })}
</Body>
<Caption color="uiTextMediumContrast">
{intl.formatMessage(
{ id: "{amount} {currency}" },
{ amount: "0", currency: room.localPrice.currency }
)}
</Caption>
</div>
) : (
<div className={styles.entry}>
<Body color="textHighContrast">
{intl.formatMessage({ id: "Breakfast buffet" })}
</Body>
<Caption color="uiTextMediumContrast">
{intl.formatMessage(
{ id: "{amount} {currency}" },
{
amount: chosenBreakfast.totalPrice,
currency: chosenBreakfast.currency,
}
)}
</Caption>
</div>
)
) : null}
</div>
<Divider color="primaryLightSubtle" />
<div className={styles.total}>
<div>
<div className={styles.entry}>
<Body textTransform="bold">
{intl.formatMessage({ id: "Total incl VAT" })}
</Body>
<Body textTransform="bold">
{intl.formatMessage(
{ id: "{amount} {currency}" },
{ amount: "4686", currency: "SEK" }
<div className={styles.entry}>
<div>
<Body>
{intl.formatMessage<React.ReactNode>(
{ id: "<b>Total price</b> (incl VAT)" },
{ b: (str) => <b>{str}</b> }
)}
</Body>
<Link color="burgundy" href="#" variant="underscored" size="small">
{intl.formatMessage({ id: "Price details" })}
</Link>
</div>
<div className={styles.entry}>
<Caption color="uiTextMediumContrast">
{intl.formatMessage({ id: "Approx." })}
</Caption>
<Caption color="uiTextMediumContrast">
<div>
<Body textTransform="bold">
{intl.formatMessage(
{ id: "{amount} {currency}" },
{ amount: "455", currency: "EUR" }
)}
</Caption>
</div>
</div>
<div>
<div className={styles.entry}>
<Body color="red" textTransform="bold">
{intl.formatMessage({ id: "Member price" })}
</Body>
<Body color="red" textTransform="bold">
{intl.formatMessage(
{ id: "{amount} {currency}" },
{ amount: "4219", currency: "SEK" }
)}
</Body>
</div>
<div className={styles.entry}>
<Caption color="uiTextMediumContrast">
{intl.formatMessage({ id: "Approx." })}
</Caption>
<Caption color="uiTextMediumContrast">
{intl.formatMessage(
{ id: "{amount} {currency}" },
{ amount: "412", currency: "EUR" }
{
amount: formatNumber(parseInt(room.localPrice.price ?? "0")),
currency: room.localPrice.currency,
}
)}
</Body>
<Caption color="uiTextMediumContrast">
{intl.formatMessage({ id: "Approx." })}{" "}
{intl.formatMessage(
{ id: "{amount} {currency}" },
{
amount: formatNumber(parseInt(room.euroPrice.price ?? "0")),
currency: room.euroPrice.currency,
}
)}
</Caption>
</div>
</div>
<Divider color="primaryLightSubtle" />
</div>
</section>
)

View File

@@ -1,11 +1,10 @@
.summary {
background-color: var(--Main-Grey-White);
border: 1px solid var(--Primary-Light-On-Surface-Divider-subtle);
border-radius: var(--Corner-radius-Large);
display: flex;
flex-direction: column;
gap: var(--Spacing-x2);
padding: var(--Spacing-x2);
padding: var(--Spacing-x3);
height: 100%;
}
.date {
@@ -22,7 +21,7 @@
.addOns {
display: flex;
flex-direction: column;
gap: var(--Spacing-x1);
gap: var(--Spacing-x-one-and-half);
}
.entry {
@@ -31,6 +30,9 @@
justify-content: space-between;
}
.entry > :last-child {
justify-items: flex-end;
}
.total {
display: flex;
flex-direction: column;

View File

@@ -4,7 +4,7 @@ import { useMemo, useState } from "react"
import RateSummary from "./RateSummary"
import RoomCard from "./RoomCard"
import getHotelReservationQueryParams from "./utils"
import { getHotelReservationQueryParams } from "./utils"
import styles from "./roomSelection.module.css"

View File

@@ -2,11 +2,22 @@ import { getFormattedUrlQueryParams } from "@/utils/url"
import type { SelectRateSearchParams } from "@/types/components/hotelReservation/selectRate/selectRate"
function getHotelReservationQueryParams(searchParams: URLSearchParams) {
export function getHotelReservationQueryParams(searchParams: URLSearchParams) {
return getFormattedUrlQueryParams(searchParams, {
adults: "number",
age: "number",
}) as SelectRateSearchParams
}
export default getHotelReservationQueryParams
export function getQueryParamsForEnterDetails(searchParams: URLSearchParams) {
const selectRoomParamsObject = getHotelReservationQueryParams(searchParams)
const { room } = selectRoomParamsObject
return {
...selectRoomParamsObject,
adults: room[0].adults, // TODO: Handle multiple rooms
children: room[0].child?.length.toString(), // TODO: Handle multiple rooms and children
roomTypeCode: room[0].roomtype,
rateCode: room[0].ratecode,
}
}

View File

@@ -26,7 +26,12 @@ interface TextCardProps extends BaseCardProps {
text: React.ReactNode
}
export type CardProps = ListCardProps | TextCardProps
interface CleanCardProps extends BaseCardProps {
list?: never
text?: never
}
export type CardProps = ListCardProps | TextCardProps | CleanCardProps
export type CheckboxProps =
| Omit<ListCardProps, "type">
@@ -34,6 +39,7 @@ export type CheckboxProps =
export type RadioProps =
| Omit<ListCardProps, "type">
| Omit<TextCardProps, "type">
| Omit<CleanCardProps, "type">
export interface ListProps extends Pick<ListCardProps, "declined"> {
list?: ListCardProps["list"]

View File

@@ -92,7 +92,7 @@
color: var(--Primary-Dark-On-Surface-Accent);
}
.peach80 {
.baseTextMediumContrast {
color: var(--Base-Text-Medium-contrast);
}

View File

@@ -16,7 +16,6 @@ const config = {
textHighContrast: styles.textHighContrast,
white: styles.white,
peach50: styles.peach50,
peach80: styles.peach80,
uiTextHighContrast: styles.uiTextHighContrast,
uiTextMediumContrast: styles.uiTextMediumContrast,
uiTextPlaceholder: styles.uiTextPlaceholder,

View File

@@ -1,5 +1,7 @@
{
"<b>Included</b> (based on availability)": "<b>Inkluderet</b> (baseret på tilgængelighed)",
"<b>Total price</b> (incl VAT)": "<b>Samlet pris</b> (inkl. moms)",
"<b>{amount} {currency}</b>/night per adult": "<b>{amount} {currency}</b>/nat pr. voksen",
"A destination or hotel name is needed to be able to search for a hotel room.": "Et destinations- eller hotelnavn er nødvendigt for at kunne søge efter et hotelværelse.",
"A photo of the room": "Et foto af værelset",
"ACCE": "Tilgængelighed",
@@ -35,6 +37,7 @@
"Attractions": "Attraktioner",
"Back to scandichotels.com": "Tilbage til scandichotels.com",
"Bar": "Bar",
"Based on availability": "Baseret på tilgængelighed",
"Bed type": "Seng type",
"Birth date": "Fødselsdato",
"Book": "Book",
@@ -242,12 +245,14 @@
"Points needed to stay on level": "Point nødvendige for at holde sig på niveau",
"Previous": "Forudgående",
"Previous victories": "Tidligere sejre",
"Price details": "Prisoplysninger",
"Proceed to login": "Fortsæt til login",
"Proceed to payment method": "Fortsæt til betalingsmetode",
"Provide a payment card in the next step": "Giv os dine betalingsoplysninger i næste skridt",
"Public price from": "Offentlig pris fra",
"Public transport": "Offentlig transport",
"Queen bed": "Queensize-seng",
"Rate details": "Oplysninger om værelsespris",
"Read more": "Læs mere",
"Read more & book a table": "Read more & book a table",
"Read more about the hotel": "Læs mere om hotellet",
@@ -313,8 +318,8 @@
"Things nearby HOTEL_NAME": "Ting i nærheden af {hotelName}",
"To secure your reservation, we kindly ask you to provide your payment card details. Rest assured, no charges will be made at this time.": "For at sikre din reservation, beder vi om at du giver os dine betalingsoplysninger. Du kan så være sikker på, at ingen gebyrer vil blive opkrævet på dette tidspunkt.",
"Total Points": "Samlet antal point",
"Total incl VAT": "Inkl. moms",
"Total price": "Samlet pris",
"Total price (incl VAT)": "Samlet pris (inkl. moms)",
"Tourist": "Turist",
"Transaction date": "Overførselsdato",
"Transactions": "Transaktioner",
@@ -405,6 +410,5 @@
"uppercase letter": "stort bogstav",
"{amount} out of {total}": "{amount} ud af {total}",
"{amount} {currency}": "{amount} {currency}",
"{difference}{amount} {currency}": "{difference}{amount} {currency}",
"{width} cm × {length} cm": "{width} cm × {length} cm"
"{difference}{amount} {currency}": "{difference}{amount} {currency}"
}

View File

@@ -1,5 +1,7 @@
{
"<b>Included</b> (based on availability)": "<b>Inbegriffen</b> (je nach Verfügbarkeit)",
"<b>Total price</b> (incl VAT)": "<b>Gesamtpreis</b> (inkl. MwSt.)",
"<b>{amount} {currency}</b>/night per adult": "<b>{amount} {currency}</b>/Nacht pro Erwachsener",
"A destination or hotel name is needed to be able to search for a hotel room.": "Ein Reiseziel oder Hotelname wird benötigt, um nach einem Hotelzimmer suchen zu können.",
"A photo of the room": "Ein Foto des Zimmers",
"ACCE": "Zugänglichkeit",
@@ -35,6 +37,7 @@
"Attraction": "Attraktion",
"Back to scandichotels.com": "Zurück zu scandichotels.com",
"Bar": "Bar",
"Based on availability": "Je nach Verfügbarkeit",
"Bed type": "Bettentyp",
"Birth date": "Geburtsdatum",
"Book": "Buchen",
@@ -240,12 +243,14 @@
"Points needed to stay on level": "Erforderliche Punkte, um auf diesem Level zu bleiben",
"Previous": "Früher",
"Previous victories": "Bisherige Siege",
"Price details": "Preisdetails",
"Proceed to login": "Weiter zum Login",
"Proceed to payment method": "Weiter zur Zahlungsmethode",
"Provide a payment card in the next step": "Geben Sie Ihre Zahlungskarteninformationen im nächsten Schritt an",
"Public price from": "Öffentlicher Preis ab",
"Public transport": "Öffentliche Verkehrsmittel",
"Queen bed": "Queensize-Bett",
"Rate details": "Preisdetails",
"Read more": "Mehr lesen",
"Read more & book a table": "Read more & book a table",
"Read more about the hotel": "Lesen Sie mehr über das Hotel",
@@ -312,8 +317,8 @@
"Things nearby HOTEL_NAME": "Dinge in der Nähe von {hotelName}",
"To secure your reservation, we kindly ask you to provide your payment card details. Rest assured, no charges will be made at this time.": "Um Ihre Reservierung zu sichern, bitten wir Sie, Ihre Zahlungskarteninformationen zu geben. Sie können sicher sein, dass keine Gebühren zu diesem Zeitpunkt erhoben werden.",
"Total Points": "Gesamtpunktzahl",
"Total incl VAT": "Gesamt inkl. MwSt.",
"Total price": "Gesamtpreis",
"Total price (incl VAT)": "Gesamtpreis (inkl. MwSt.)",
"Tourist": "Tourist",
"Transaction date": "Transaktionsdatum",
"Transactions": "Transaktionen",
@@ -404,6 +409,5 @@
"uppercase letter": "großbuchstabe",
"{amount} out of {total}": "{amount} von {total}",
"{amount} {currency}": "{amount} {currency}",
"{difference}{amount} {currency}": "{difference}{amount} {currency}",
"{width} cm × {length} cm": "{width} cm × {length} cm"
"{difference}{amount} {currency}": "{difference}{amount} {currency}"
}

View File

@@ -1,5 +1,7 @@
{
"<b>Included</b> (based on availability)": "<b>Included</b> (based on availability)",
"<b>Total price</b> (incl VAT)": "<b>Total price</b> (incl VAT)",
"<b>{amount} {currency}</b>/night per adult": "<b>{amount} {currency}</b>/night per adult",
"A destination or hotel name is needed to be able to search for a hotel room.": "A destination or hotel name is needed to be able to search for a hotel room.",
"A photo of the room": "A photo of the room",
"ACCE": "Accessibility",
@@ -38,6 +40,7 @@
"Attractions": "Attractions",
"Back to scandichotels.com": "Back to scandichotels.com",
"Bar": "Bar",
"Based on availability": "Based on availability",
"Bed": "Bed",
"Bed type": "Bed type",
"Birth date": "Birth date",
@@ -252,6 +255,7 @@
"Points needed to stay on level": "Points needed to stay on level",
"Previous": "Previous",
"Previous victories": "Previous victories",
"Price details": "Price details",
"Print confirmation": "Print confirmation",
"Proceed to login": "Proceed to login",
"Proceed to payment method": "Proceed to payment method",
@@ -259,6 +263,7 @@
"Public price from": "Public price from",
"Public transport": "Public transport",
"Queen bed": "Queen bed",
"Rate details": "Rate details",
"Read more": "Read more",
"Read more & book a table": "Read more & book a table",
"Read more about the hotel": "Read more about the hotel",
@@ -326,10 +331,9 @@
"There are no transactions to display": "There are no transactions to display",
"Things nearby HOTEL_NAME": "Things nearby {hotelName}",
"To secure your reservation, we kindly ask you to provide your payment card details. Rest assured, no charges will be made at this time.": "To secure your reservation, we kindly ask you to provide your payment card details. Rest assured, no charges will be made at this time.",
"Total Points": "Total Points",
"Total cost": "Total cost",
"Total incl VAT": "Total incl VAT",
"Total price": "Total price",
"Total Points": "Total Points",
"Tourist": "Tourist",
"Transaction date": "Transaction date",
"Transactions": "Transactions",
@@ -427,6 +431,5 @@
"{amount} out of {total}": "{amount} out of {total}",
"{amount} {currency}": "{amount} {currency}",
"{card} ending with {cardno}": "{card} ending with {cardno}",
"{difference}{amount} {currency}": "{difference}{amount} {currency}",
"{width} cm × {length} cm": "{width} cm × {length} cm"
"{difference}{amount} {currency}": "{difference}{amount} {currency}"
}

View File

@@ -1,5 +1,7 @@
{
"<b>Included</b> (based on availability)": "<b>Sisältyy</b> (saatavuuden mukaan)",
"<b>Total price</b> (incl VAT)": "<b>Kokonaishinta</b> (sis. ALV)",
"<b>{amount} {currency}</b>/night per adult": "<b>{amount} {currency}</b>/yö per aikuinen",
"A destination or hotel name is needed to be able to search for a hotel room.": "Kohteen tai hotellin nimi tarvitaan, jotta hotellihuonetta voidaan hakea.",
"A photo of the room": "Kuva huoneesta",
"ACCE": "Saavutettavuus",
@@ -35,6 +37,7 @@
"Attractions": "Nähtävyydet",
"Back to scandichotels.com": "Takaisin scandichotels.com",
"Bar": "Bar",
"Based on availability": "Saatavuuden mukaan",
"Bed type": "Vuodetyyppi",
"Birth date": "Syntymäaika",
"Book": "Varaa",
@@ -242,12 +245,14 @@
"Points needed to stay on level": "Tällä tasolla pysymiseen tarvittavat pisteet",
"Previous": "Aikaisempi",
"Previous victories": "Edelliset voitot",
"Price details": "Hintatiedot",
"Proceed to login": "Jatka kirjautumiseen",
"Proceed to payment method": "Siirry maksutavalle",
"Provide a payment card in the next step": "Anna maksukortin tiedot seuraavassa vaiheessa",
"Public price from": "Julkinen hinta alkaen",
"Public transport": "Julkinen liikenne",
"Queen bed": "Queen-vuode",
"Rate details": "Hintatiedot",
"Read more": "Lue lisää",
"Read more & book a table": "Read more & book a table",
"Read more about the hotel": "Lue lisää hotellista",
@@ -314,8 +319,8 @@
"Things nearby HOTEL_NAME": "Lähellä olevia asioita {hotelName}",
"To secure your reservation, we kindly ask you to provide your payment card details. Rest assured, no charges will be made at this time.": "Varmistaaksesi varauksen, pyydämme sinua antamaan meille maksukortin tiedot. Varmista, että ei veloiteta maksusi tällä hetkellä.",
"Total Points": "Kokonaispisteet",
"Total incl VAT": "Yhteensä sis. alv",
"Total price": "Kokonaishinta",
"Total price (incl VAT)": "Kokonaishinta (sis. ALV)",
"Tourist": "Turisti",
"Transaction date": "Tapahtuman päivämäärä",
"Transactions": "Tapahtumat",
@@ -404,6 +409,5 @@
"uppercase letter": "iso kirjain",
"{amount} out of {total}": "{amount}/{total}",
"{amount} {currency}": "{amount} {currency}",
"{difference}{amount} {currency}": "{difference}{amount} {currency}",
"{width} cm × {length} cm": "{width} cm × {length} cm"
"{difference}{amount} {currency}": "{difference}{amount} {currency}"
}

View File

@@ -1,5 +1,7 @@
{
"<b>Included</b> (based on availability)": "<b>Inkludert</b> (basert på tilgjengelighet)",
"<b>Total price</b> (incl VAT)": "<b>Totalpris</b> (inkl. mva)",
"<b>{amount} {currency}</b>/night per adult": "<b>{amount} {currency}</b>/natt per voksen",
"A destination or hotel name is needed to be able to search for a hotel room.": "Et reisemål eller hotellnavn er nødvendig for å kunne søke etter et hotellrom.",
"A photo of the room": "Et bilde av rommet",
"ACCE": "Tilgjengelighet",
@@ -35,6 +37,7 @@
"Attractions": "Attraksjoner",
"Back to scandichotels.com": "Tilbake til scandichotels.com",
"Bar": "Bar",
"Based on availability": "Basert på tilgjengelighet",
"Bed type": "Seng type",
"Birth date": "Fødselsdato",
"Book": "Bestill",
@@ -240,12 +243,14 @@
"Points needed to stay on level": "Poeng som trengs for å holde seg på nivå",
"Previous": "Tidligere",
"Previous victories": "Tidligere seire",
"Price details": "Prisdetaljer",
"Proceed to login": "Fortsett til innlogging",
"Proceed to payment method": "Fortsett til betalingsmetode",
"Provide a payment card in the next step": "Gi oss dine betalingskortdetaljer i neste steg",
"Public price from": "Offentlig pris fra",
"Public transport": "Offentlig transport",
"Queen bed": "Queen-size-seng",
"Rate details": "Prisdetaljer",
"Read more": "Les mer",
"Read more & book a table": "Read more & book a table",
"Read more about the hotel": "Les mer om hotellet",
@@ -402,6 +407,5 @@
"uppercase letter": "stor bokstav",
"{amount} out of {total}": "{amount} av {total}",
"{amount} {currency}": "{amount} {currency}",
"{difference}{amount} {currency}": "{difference}{amount} {currency}",
"{width} cm × {length} cm": "{width} cm × {length} cm"
"{difference}{amount} {currency}": "{difference}{amount} {currency}"
}

View File

@@ -1,5 +1,7 @@
{
"<b>Included</b> (based on availability)": "<b>Ingår</b> (baserat på tillgänglighet)",
"<b>Total price</b> (incl VAT)": "<b>Totalpris</b> (inkl moms)",
"<b>{amount} {currency}</b>/night per adult": "<b>{amount} {currency}</b>/natt per vuxen",
"A destination or hotel name is needed to be able to search for a hotel room.": "Ett destinations- eller hotellnamn behövs för att kunna söka efter ett hotellrum.",
"A photo of the room": "Ett foto av rummet",
"ACCE": "Tillgänglighet",
@@ -35,6 +37,7 @@
"Attractions": "Sevärdheter",
"Back to scandichotels.com": "Tillbaka till scandichotels.com",
"Bar": "Bar",
"Based on availability": "Baserat på tillgänglighet",
"Bed type": "Sängtyp",
"Birth date": "Födelsedatum",
"Book": "Boka",
@@ -240,12 +243,14 @@
"Points needed to stay on level": "Poäng som behövs för att hålla sig på nivå",
"Previous": "Föregående",
"Previous victories": "Tidigare segrar",
"Price details": "Prisdetaljer",
"Proceed to login": "Fortsätt till inloggning",
"Proceed to payment method": "Gå vidare till betalningsmetod",
"Provide a payment card in the next step": "Ge oss dina betalkortdetaljer i nästa steg",
"Public price from": "Offentligt pris från",
"Public transport": "Kollektivtrafik",
"Queen bed": "Queen size-säng",
"Rate details": "Detaljer om rumspriset",
"Read more": "Läs mer",
"Read more & book a table": "Read more & book a table",
"Read more about the hotel": "Läs mer om hotellet",
@@ -405,6 +410,5 @@
"paying": "betalar",
"uppercase letter": "stor bokstav",
"{amount} {currency}": "{amount} {currency}",
"{difference}{amount} {currency}": "{difference}{amount} {currency}",
"{width} cm × {length} cm": "{width} cm × {length} cm"
"{difference}{amount} {currency}": "{difference}{amount} {currency}"
}

View File

@@ -1,6 +1,11 @@
import { cache } from "react"
import { Lang } from "@/constants/languages"
import {
GetRoomsAvailabilityInput,
GetSelectedRoomAvailabilityInput,
HotelIncludeEnum,
} from "@/server/routers/hotels/input"
import { serverClient } from "../server"
@@ -50,15 +55,22 @@ export const getUserTracking = cache(async function getMemoizedUserTracking() {
return serverClient().user.tracking()
})
export const getHotelData = cache(async function getMemoizedHotelData(
hotelId: string,
language: string,
export const getHotelData = cache(async function getMemoizedHotelData({
hotelId,
language,
isCardOnlyPayment,
include,
}: {
hotelId: string
language: string
isCardOnlyPayment?: boolean
) {
include?: HotelIncludeEnum[]
}) {
return serverClient().hotel.hotelData.get({
hotelId,
language,
isCardOnlyPayment,
include,
})
})
@@ -71,17 +83,9 @@ export const getRoomAvailability = cache(
children,
promotionCode,
rateCode,
}: {
hotelId: string
adults: number
roomStayStartDate: string
roomStayEndDate: string
children?: string
promotionCode?: string
rateCode?: string
}) {
}: GetRoomsAvailabilityInput) {
return serverClient().hotel.availability.rooms({
hotelId: parseInt(hotelId),
hotelId,
adults,
roomStayStartDate,
roomStayEndDate,
@@ -92,6 +96,14 @@ export const getRoomAvailability = cache(
}
)
export const getSelectedRoomAvailability = cache(
async function getMemoizedRoomAvailability(
args: GetSelectedRoomAvailabilityInput
) {
return serverClient().hotel.availability.room(args)
}
)
export const getFooter = cache(async function getMemoizedFooter() {
return serverClient().contentstack.base.footer()
})

View File

@@ -87,12 +87,12 @@ const nextConfig = {
// value: undefined,
// },
// {
// key: "fromdate",
// key: "fromDate",
// type: "query",
// value: undefined,
// },
// {
// key: "todate",
// key: "toDate",
// type: "query",
// value: undefined,
// },

View File

@@ -29,17 +29,43 @@ export const getRoomsAvailabilityInputSchema = z.object({
rateCode: z.string().optional(),
})
export const getSelectedRoomAvailabilityInputSchema = z.object({
hotelId: z.number(),
roomStayStartDate: z.string(),
roomStayEndDate: z.string(),
adults: z.number(),
children: z.string().optional(),
promotionCode: z.string().optional(),
reservationProfileType: z.string().optional().default(""),
attachedProfileId: z.string().optional().default(""),
rateCode: z.string(),
roomTypeCode: z.string(),
})
export type GetSelectedRoomAvailabilityInput = z.input<
typeof getSelectedRoomAvailabilityInputSchema
>
export type GetRoomsAvailabilityInput = z.input<
typeof getRoomsAvailabilityInputSchema
>
export const getRatesInputSchema = z.object({
hotelId: z.string(),
})
export const getlHotelDataInputSchema = z.object({
export enum HotelIncludeEnum {
"RoomCategories",
"NearbyHotels",
"Restaurants",
"City",
}
export const getHotelDataInputSchema = z.object({
hotelId: z.string(),
language: z.string(),
isCardOnlyPayment: z.boolean().optional(),
include: z
.array(z.enum(["RoomCategories", "NearbyHotels", "Restaurants", "City"]))
.optional(),
include: z.array(z.nativeEnum(HotelIncludeEnum)).optional(),
})
export const getBreakfastPackageInput = z.object({

View File

@@ -32,11 +32,12 @@ import {
} from "./schemas/packages"
import {
getBreakfastPackageInput,
getHotelDataInputSchema,
getHotelInputSchema,
getHotelsAvailabilityInputSchema,
getlHotelDataInputSchema,
getRatesInputSchema,
getRoomsAvailabilityInputSchema,
getSelectedRoomAvailabilityInputSchema,
} from "./input"
import {
breakfastPackagesSchema,
@@ -93,6 +94,16 @@ const roomsAvailabilityFailCounter = meter.createCounter(
"trpc.hotel.availability.rooms-fail"
)
const selectedRoomAvailabilityCounter = meter.createCounter(
"trpc.hotel.availability.room"
)
const selectedRoomAvailabilitySuccessCounter = meter.createCounter(
"trpc.hotel.availability.room-success"
)
const selectedRoomAvailabilityFailCounter = meter.createCounter(
"trpc.hotel.availability.room-fail"
)
const breakfastPackagesCounter = meter.createCounter("trpc.package.breakfast")
const breakfastPackagesSuccessCounter = meter.createCounter(
"trpc.package.breakfast-success"
@@ -545,6 +556,161 @@ export const hotelQueryRouter = router({
return validateAvailabilityData.data
}),
room: serviceProcedure
.input(getSelectedRoomAvailabilityInputSchema)
.query(async ({ input, ctx }) => {
const {
hotelId,
roomStayStartDate,
roomStayEndDate,
adults,
children,
promotionCode,
reservationProfileType,
attachedProfileId,
rateCode,
roomTypeCode,
} = input
const params: Record<string, string | number | undefined> = {
roomStayStartDate,
roomStayEndDate,
adults,
...(children && { children }),
promotionCode,
reservationProfileType,
attachedProfileId,
}
selectedRoomAvailabilityCounter.add(1, {
hotelId,
roomStayStartDate,
roomStayEndDate,
adults,
children,
promotionCode,
reservationProfileType,
})
console.info(
"api.hotels.selectedRoomAvailability start",
JSON.stringify({ query: { hotelId, params } })
)
const apiResponseAvailability = await api.get(
api.endpoints.v1.Availability.hotel(hotelId.toString()),
{
headers: {
Authorization: `Bearer ${ctx.serviceToken}`,
},
},
params
)
if (!apiResponseAvailability.ok) {
const text = await apiResponseAvailability.text()
selectedRoomAvailabilityFailCounter.add(1, {
hotelId,
roomStayStartDate,
roomStayEndDate,
adults,
children,
promotionCode,
reservationProfileType,
error_type: "http_error",
error: JSON.stringify({
status: apiResponseAvailability.status,
statusText: apiResponseAvailability.statusText,
text,
}),
})
console.error(
"api.hotels.selectedRoomAvailability error",
JSON.stringify({
query: { hotelId, params },
error: {
status: apiResponseAvailability.status,
statusText: apiResponseAvailability.statusText,
text,
},
})
)
return null
}
const apiJsonAvailability = await apiResponseAvailability.json()
const validateAvailabilityData =
getRoomsAvailabilitySchema.safeParse(apiJsonAvailability)
if (!validateAvailabilityData.success) {
selectedRoomAvailabilityFailCounter.add(1, {
hotelId,
roomStayStartDate,
roomStayEndDate,
adults,
children,
promotionCode,
reservationProfileType,
error_type: "validation_error",
error: JSON.stringify(validateAvailabilityData.error),
})
console.error(
"api.hotels.selectedRoomAvailability validation error",
JSON.stringify({
query: { hotelId, params },
error: validateAvailabilityData.error,
})
)
throw badRequestError()
}
const selectedRoom = validateAvailabilityData.data.roomConfigurations
.filter((room) => room.status === "Available")
.find((room) => room.roomTypeCode === roomTypeCode)
if (!selectedRoom) {
console.error("No matching room found")
return null
}
const memberRate = selectedRoom.products.find(
(rate) => rate.productType.member?.rateCode === rateCode
)?.productType.member
const publicRate = selectedRoom.products.find(
(rate) => rate.productType.public?.rateCode === rateCode
)?.productType.public
const mustBeGuaranteed =
validateAvailabilityData.data.rateDefinitions.filter(
(rate) => rate.rateCode === rateCode
)[0].mustBeGuaranteed
const cancellationText =
validateAvailabilityData.data.rateDefinitions.find(
(rate) => rate.rateCode === rateCode
)?.cancellationText ?? ""
selectedRoomAvailabilitySuccessCounter.add(1, {
hotelId,
roomStayStartDate,
roomStayEndDate,
adults,
children,
promotionCode,
reservationProfileType,
})
console.info(
"api.hotels.selectedRoomAvailability success",
JSON.stringify({
query: { hotelId, params: params },
})
)
return {
selectedRoom,
mustBeGuaranteed,
cancellationText,
memberRate,
publicRate,
}
}),
}),
rates: router({
get: publicProcedure
@@ -584,7 +750,7 @@ export const hotelQueryRouter = router({
}),
hotelData: router({
get: serviceProcedure
.input(getlHotelDataInputSchema)
.input(getHotelDataInputSchema)
.query(async ({ ctx, input }) => {
const { hotelId, language, include, isCardOnlyPayment } = input

View File

@@ -87,8 +87,11 @@ export const roomSchema = z
name: data.attributes.name,
occupancy: data.attributes.occupancy,
roomSize: data.attributes.roomSize,
roomTypes: data.attributes.roomTypes,
sortOrder: data.attributes.sortOrder,
type: data.type,
roomFacilities: data.attributes.roomFacilities,
}
})
export type RoomType = Pick<z.output<typeof roomSchema>, "roomTypes" | "name">

View File

@@ -1,30 +1,33 @@
import { produce } from "immer"
import { ReadonlyURLSearchParams } from "next/navigation"
import { createContext, useContext } from "react"
import { create, useStore } from "zustand"
import { bedTypeSchema } from "@/components/HotelReservation/EnterDetails/BedType/schema"
import { breakfastStoreSchema } from "@/components/HotelReservation/EnterDetails/Breakfast/schema"
import { detailsSchema } from "@/components/HotelReservation/EnterDetails/Details/schema"
import { getQueryParamsForEnterDetails } from "@/components/HotelReservation/SelectRate/RoomSelection/utils"
import { BreakfastPackage } from "@/types/components/enterDetails/breakfast"
import { DetailsSchema } from "@/types/components/enterDetails/details"
import { SidePeekEnum } from "@/types/components/enterDetails/sidePeek"
import { StepEnum } from "@/types/components/enterDetails/step"
import { BedTypeEnum } from "@/types/enums/bedType"
import type { BookingData } from "@/types/components/hotelReservation/enterDetails/bookingData"
import { BreakfastPackage } from "@/types/components/hotelReservation/enterDetails/breakfast"
import type { DetailsSchema } from "@/types/components/hotelReservation/enterDetails/details"
import { SidePeekEnum } from "@/types/components/hotelReservation/enterDetails/sidePeek"
import { StepEnum } from "@/types/components/hotelReservation/enterDetails/step"
import { BreakfastPackageEnum } from "@/types/enums/breakfast"
const SESSION_STORAGE_KEY = "enterDetails"
interface EnterDetailsState {
data: {
bedType: BedTypeEnum | undefined
userData: {
bedType: string | undefined
breakfast: BreakfastPackage | BreakfastPackageEnum.NO_BREAKFAST | undefined
} & DetailsSchema
roomData: BookingData
steps: StepEnum[]
currentStep: StepEnum
activeSidePeek: SidePeekEnum | null
isValid: Record<StepEnum, boolean>
completeStep: (updatedData: Partial<EnterDetailsState["data"]>) => void
completeStep: (updatedData: Partial<EnterDetailsState["userData"]>) => void
navigate: (
step: StepEnum,
updatedData?: Record<string, string | boolean | BreakfastPackage>
@@ -34,13 +37,21 @@ interface EnterDetailsState {
closeSidePeek: () => void
}
export function initEditDetailsState(currentStep: StepEnum) {
export function initEditDetailsState(
currentStep: StepEnum,
searchParams: ReadonlyURLSearchParams
) {
const isBrowser = typeof window !== "undefined"
const sessionData = isBrowser
? sessionStorage.getItem(SESSION_STORAGE_KEY)
: null
const defaultData: EnterDetailsState["data"] = {
let roomData: BookingData
if (searchParams?.size) {
roomData = getQueryParamsForEnterDetails(searchParams)
}
const defaultUserData: EnterDetailsState["userData"] = {
bedType: undefined,
breakfast: undefined,
countryCode: "",
@@ -54,14 +65,14 @@ export function initEditDetailsState(currentStep: StepEnum) {
termsAccepted: false,
}
let inputData = {}
let inputUserData = {}
if (sessionData) {
inputData = JSON.parse(sessionData)
inputUserData = JSON.parse(sessionData)
}
const validPaths = [StepEnum.selectBed]
let initialData: EnterDetailsState["data"] = defaultData
let initialData: EnterDetailsState["userData"] = defaultUserData
const isValid = {
[StepEnum.selectBed]: false,
@@ -70,19 +81,19 @@ export function initEditDetailsState(currentStep: StepEnum) {
[StepEnum.payment]: false,
}
const validatedBedType = bedTypeSchema.safeParse(inputData)
const validatedBedType = bedTypeSchema.safeParse(inputUserData)
if (validatedBedType.success) {
validPaths.push(StepEnum.breakfast)
initialData = { ...initialData, ...validatedBedType.data }
isValid[StepEnum.selectBed] = true
}
const validatedBreakfast = breakfastStoreSchema.safeParse(inputData)
const validatedBreakfast = breakfastStoreSchema.safeParse(inputUserData)
if (validatedBreakfast.success) {
validPaths.push(StepEnum.details)
initialData = { ...initialData, ...validatedBreakfast.data }
isValid[StepEnum.breakfast] = true
}
const validatedDetails = detailsSchema.safeParse(inputData)
const validatedDetails = detailsSchema.safeParse(inputUserData)
if (validatedDetails.success) {
validPaths.push(StepEnum.payment)
initialData = { ...initialData, ...validatedDetails.data }
@@ -101,7 +112,8 @@ export function initEditDetailsState(currentStep: StepEnum) {
}
return create<EnterDetailsState>()((set, get) => ({
data: initialData,
userData: initialData,
roomData,
steps: Object.values(StepEnum),
setCurrentStep: (step) => set({ currentStep: step }),
navigate: (step, updatedData) =>
@@ -129,14 +141,17 @@ export function initEditDetailsState(currentStep: StepEnum) {
isValid,
completeStep: (updatedData) =>
set(
produce((state) => {
produce((state: EnterDetailsState) => {
state.isValid[state.currentStep] = true
const nextStep =
state.steps[state.steps.indexOf(state.currentStep) + 1]
state.data = { ...state.data, ...updatedData }
// @ts-expect-error: ts has a hard time understanding that "false | true" equals "boolean"
state.userData = {
...state.userData,
...updatedData,
}
state.currentStep = nextStep
get().navigate(nextStep, updatedData)
})

View File

@@ -2,4 +2,16 @@ import { z } from "zod"
import { bedTypeSchema } from "@/components/HotelReservation/EnterDetails/BedType/schema"
type BedType = {
description: string
size: {
min: number
max: number
}
value: string
}
export type BedTypeProps = {
bedTypes: BedType[]
}
export interface BedTypeSchema extends z.output<typeof bedTypeSchema> {}

View File

@@ -0,0 +1,31 @@
interface Child {
bed: string
age: number
}
interface Room {
adults: number
roomtype?: string
ratecode?: string
child?: Child[]
}
export interface BookingData {
hotel: string
fromDate: string
toDate: string
room: Room[]
}
type Price = {
price?: string
currency?: string
}
export type RoomsData = {
roomType: string
localPrice: Price
euroPrice: Price
adults: number
children?: Child[]
cancellationText: string
}

View File

@@ -1,4 +1,4 @@
import { StepEnum } from "../../enterDetails/step"
import { StepEnum } from "../enterDetails/step"
export interface SectionAccordionProps {
header: string

View File

@@ -7,8 +7,9 @@ export interface Child {
interface Room {
adults: number
roomcode?: string
roomtype?: string
ratecode?: string
counterratecode?: string
child?: Child[]
}

8
utils/format.ts Normal file
View File

@@ -0,0 +1,8 @@
import { Lang } from "@/constants/languages"
/// Function to format numbers with space as thousands separator
export function formatNumber(num: number) {
// sv hardcoded to force space on thousands
const formatter = new Intl.NumberFormat(Lang.sv)
return formatter.format(num)
}