Merge branch 'master' into feature/tracking
This commit is contained in:
@@ -177,7 +177,7 @@ export default function BookingWidgetClient({
|
||||
>
|
||||
<CloseLargeIcon />
|
||||
</button>
|
||||
<Form locations={locations} type={type} setIsOpen={setIsOpen} />
|
||||
<Form locations={locations} type={type} onClose={closeMobileSearch} />
|
||||
</div>
|
||||
</section>
|
||||
<div className={styles.backdrop} onClick={closeMobileSearch} />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getLocations } from "@/lib/trpc/memoizedRequests"
|
||||
import { getLocations, getSiteConfig } from "@/lib/trpc/memoizedRequests"
|
||||
|
||||
import BookingWidgetClient from "./Client"
|
||||
|
||||
@@ -13,8 +13,9 @@ export default async function BookingWidget({
|
||||
searchParams,
|
||||
}: BookingWidgetProps) {
|
||||
const locations = await getLocations()
|
||||
const siteConfig = await getSiteConfig()
|
||||
|
||||
if (!locations || "error" in locations) {
|
||||
if (!locations || "error" in locations || siteConfig?.bookingWidgetDisabled) {
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.image {
|
||||
width: 100%;
|
||||
height: 270px;
|
||||
object-fit: cover;
|
||||
border-radius: var(--Corner-radius-Medium);
|
||||
}
|
||||
|
||||
.information {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x-one-and-half);
|
||||
}
|
||||
|
||||
.openingHours {
|
||||
margin-top: var(--Spacing-x1);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import Image from "@/components/Image"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
import Title from "@/components/TempDesignSystem/Text/Title"
|
||||
import { getIntl } from "@/i18n"
|
||||
|
||||
import styles from "./facility.module.css"
|
||||
|
||||
import { FacilityProps } from "@/types/components/hotelPage/sidepeek/facility"
|
||||
|
||||
export default async function Facility({ data }: FacilityProps) {
|
||||
const intl = await getIntl()
|
||||
const image = data.content.images[0]
|
||||
const ordinaryOpeningTimes = data.openingDetails.openingHours.ordinary
|
||||
const weekendOpeningTimes = data.openingDetails.openingHours.weekends
|
||||
|
||||
return (
|
||||
<div className={styles.content}>
|
||||
{image.imageSizes.medium && (
|
||||
<Image
|
||||
src={image.imageSizes.medium}
|
||||
alt={image.metaData.altText || ""}
|
||||
className={styles.image}
|
||||
height={400}
|
||||
width={200}
|
||||
/>
|
||||
)}
|
||||
<div className={styles.information}>
|
||||
<Subtitle color="burgundy" asChild type="one">
|
||||
<Title level="h3">{intl.formatMessage({ id: `${data.type}` })}</Title>
|
||||
</Subtitle>
|
||||
<div>
|
||||
<Subtitle type="two" color="uiTextHighContrast">
|
||||
{intl.formatMessage({ id: " Opening Hours" })}
|
||||
</Subtitle>
|
||||
<div className={styles.openingHours}>
|
||||
<Body color="uiTextHighContrast">
|
||||
{ordinaryOpeningTimes.alwaysOpen
|
||||
? `${intl.formatMessage({ id: "Mon-Fri" })} ${intl.formatMessage({ id: "Always open" })}`
|
||||
: `${intl.formatMessage({ id: "Mon-Fri" })} ${ordinaryOpeningTimes.openingTime}-${ordinaryOpeningTimes.closingTime}`}
|
||||
</Body>
|
||||
<Body color="uiTextHighContrast">
|
||||
{weekendOpeningTimes.alwaysOpen
|
||||
? `${intl.formatMessage({ id: "Sat-Sun" })} ${intl.formatMessage({ id: "Always open" })}`
|
||||
: `${intl.formatMessage({ id: "Sat-Sun" })} ${weekendOpeningTimes.openingTime}-${weekendOpeningTimes.closingTime}`}
|
||||
</Body>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { wellnessAndExercise } from "@/constants/routes/hotelPageParams"
|
||||
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import SidePeek from "@/components/TempDesignSystem/SidePeek"
|
||||
import { getIntl } from "@/i18n"
|
||||
import { getLang } from "@/i18n/serverContext"
|
||||
|
||||
import Facility from "./Facility"
|
||||
|
||||
import styles from "./wellnessAndExercise.module.css"
|
||||
|
||||
import type { WellnessAndExerciseSidePeekProps } from "@/types/components/hotelPage/sidepeek/wellnessAndExercise"
|
||||
|
||||
export default async function WellnessAndExerciseSidePeek({
|
||||
healthFacilities,
|
||||
buttonUrl,
|
||||
}: WellnessAndExerciseSidePeekProps) {
|
||||
const intl = await getIntl()
|
||||
const lang = getLang()
|
||||
|
||||
return (
|
||||
<SidePeek
|
||||
contentKey={wellnessAndExercise[lang]}
|
||||
title={intl.formatMessage({ id: "Wellness & Exercise" })}
|
||||
>
|
||||
<div className={styles.wrapper}>
|
||||
{healthFacilities.map((facility) => (
|
||||
<Facility key={facility.type} data={facility} />
|
||||
))}
|
||||
</div>
|
||||
{buttonUrl && (
|
||||
<div className={styles.buttonContainer}>
|
||||
<Button fullWidth theme="base" intent="secondary" asChild>
|
||||
<Link href={buttonUrl} weight="bold" color="burgundy">
|
||||
{intl.formatMessage({ id: "Show wellness & exercise" })}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</SidePeek>
|
||||
)
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
.wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x4);
|
||||
margin-bottom: calc(
|
||||
var(--Spacing-x4) * 2 + 80px
|
||||
); /* Creates space between the wrapper and buttonContainer */
|
||||
}
|
||||
|
||||
.buttonContainer {
|
||||
background-color: var(--Base-Background-Primary-Normal);
|
||||
border-top: 1px solid var(--Base-Border-Subtle);
|
||||
padding: var(--Spacing-x4) var(--Spacing-x2);
|
||||
width: 100%;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import MapCard from "./Map/MapCard"
|
||||
import MapWithCardWrapper from "./Map/MapWithCard"
|
||||
import MobileMapToggle from "./Map/MobileMapToggle"
|
||||
import StaticMap from "./Map/StaticMap"
|
||||
import WellnessAndExerciseSidePeek from "./SidePeeks/WellnessAndExercise"
|
||||
import AmenitiesList from "./AmenitiesList"
|
||||
import Facilities from "./Facilities"
|
||||
import IntroSection from "./IntroSection"
|
||||
@@ -52,6 +53,7 @@ export default async function HotelPage() {
|
||||
facilities,
|
||||
faq,
|
||||
alerts,
|
||||
healthFacilities,
|
||||
} = hotelData
|
||||
|
||||
const topThreePois = pointsOfInterest.slice(0, 3)
|
||||
@@ -145,13 +147,10 @@ export default async function HotelPage() {
|
||||
{/* TODO */}
|
||||
Restaurant & Bar
|
||||
</SidePeek>
|
||||
<SidePeek
|
||||
contentKey={hotelPageParams.wellnessAndExercise[lang]}
|
||||
title={intl.formatMessage({ id: "Wellness & Exercise" })}
|
||||
>
|
||||
{/* TODO */}
|
||||
Wellness & Exercise
|
||||
</SidePeek>
|
||||
<WellnessAndExerciseSidePeek
|
||||
healthFacilities={healthFacilities}
|
||||
buttonUrl="#"
|
||||
/>
|
||||
<SidePeek
|
||||
contentKey={hotelPageParams.activities[lang]}
|
||||
title={intl.formatMessage({ id: "Activities" })}
|
||||
|
||||
@@ -206,11 +206,12 @@ export default function Search({ locations }: SearchProps) {
|
||||
}
|
||||
|
||||
export function SearchSkeleton() {
|
||||
const intl = useIntl()
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.label}>
|
||||
<Caption type="bold" color="red" asChild>
|
||||
<span>Where to</span>
|
||||
<span>{intl.formatMessage({ id: "Where to" })}</span>
|
||||
</Caption>
|
||||
</div>
|
||||
<div className={styles.input}>
|
||||
|
||||
@@ -20,7 +20,7 @@ const formId = "booking-widget"
|
||||
export default function Form({
|
||||
locations,
|
||||
type,
|
||||
setIsOpen,
|
||||
onClose,
|
||||
}: BookingWidgetFormProps) {
|
||||
const router = useRouter()
|
||||
const lang = useLang()
|
||||
@@ -35,7 +35,7 @@ export default function Form({
|
||||
const locationData: Location = JSON.parse(decodeURIComponent(data.location))
|
||||
|
||||
const bookingFlowPage =
|
||||
locationData.type == "cities" ? selectHotel[lang] : selectRate[lang]
|
||||
locationData.type == "cities" ? selectHotel(lang) : selectRate(lang)
|
||||
const bookingWidgetParams = new URLSearchParams(data.date)
|
||||
|
||||
if (locationData.type == "cities")
|
||||
@@ -56,7 +56,7 @@ export default function Form({
|
||||
)
|
||||
})
|
||||
})
|
||||
setIsOpen(false)
|
||||
onClose()
|
||||
router.push(`${bookingFlowPage}?${bookingWidgetParams.toString()}`)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,12 +3,14 @@
|
||||
align-self: flex-start;
|
||||
display: grid;
|
||||
gap: var(--Spacing-x2);
|
||||
container-name: addressContainer;
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.container {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x2);
|
||||
grid-template-columns: max(164px) 1fr;
|
||||
grid-template-columns: minmax(100px, 164px) 1fr;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
@@ -16,3 +18,9 @@
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@container addressContainer (max-width: 350px) {
|
||||
.container {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
"use client"
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { FormProvider, useForm } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { privacyPolicy } from "@/constants/currentWebHrefs"
|
||||
import { trpc } from "@/lib/trpc/client"
|
||||
|
||||
import { registerUser } from "@/actions/registerUser"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Checkbox from "@/components/TempDesignSystem/Form/Checkbox"
|
||||
import CountrySelect from "@/components/TempDesignSystem/Form/Country"
|
||||
@@ -30,11 +31,28 @@ import type { SignUpFormProps } from "@/types/components/form/signupForm"
|
||||
|
||||
export default function SignupForm({ link, subtitle, title }: SignUpFormProps) {
|
||||
const intl = useIntl()
|
||||
const router = useRouter()
|
||||
const lang = useLang()
|
||||
const country = intl.formatMessage({ id: "Country" })
|
||||
const email = intl.formatMessage({ id: "Email address" })
|
||||
const phoneNumber = intl.formatMessage({ id: "Phone number" })
|
||||
const zipCode = intl.formatMessage({ id: "Zip code" })
|
||||
const signupButtonText = intl.formatMessage({
|
||||
id: "Sign up to Scandic Friends",
|
||||
})
|
||||
const signingUpPendingText = intl.formatMessage({ id: "Signing up..." })
|
||||
|
||||
const signup = trpc.user.signup.useMutation({
|
||||
onSuccess: (data) => {
|
||||
if (data.success && data.redirectUrl) {
|
||||
router.push(data.redirectUrl)
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(intl.formatMessage({ id: "Something went wrong!" }))
|
||||
console.error("Component Signup error:", error)
|
||||
},
|
||||
})
|
||||
|
||||
const methods = useForm<SignUpSchema>({
|
||||
defaultValues: {
|
||||
@@ -48,7 +66,6 @@ export default function SignupForm({ link, subtitle, title }: SignUpFormProps) {
|
||||
zipCode: "",
|
||||
},
|
||||
password: "",
|
||||
termsAccepted: false,
|
||||
},
|
||||
mode: "all",
|
||||
criteriaMode: "all",
|
||||
@@ -57,19 +74,7 @@ export default function SignupForm({ link, subtitle, title }: SignUpFormProps) {
|
||||
})
|
||||
|
||||
async function onSubmit(data: SignUpSchema) {
|
||||
try {
|
||||
const result = await registerUser(data)
|
||||
if (result && !result.success) {
|
||||
toast.error(intl.formatMessage({ id: "Something went wrong!" }))
|
||||
}
|
||||
} catch (error) {
|
||||
// The server-side redirect will throw an error, which we can ignore
|
||||
// as it's handled by Next.js.
|
||||
if (error instanceof Error && error.message.includes("NEXT_REDIRECT")) {
|
||||
return
|
||||
}
|
||||
toast.error(intl.formatMessage({ id: "Something went wrong!" }))
|
||||
}
|
||||
signup.mutate({ ...data, language: lang })
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -80,11 +85,6 @@ export default function SignupForm({ link, subtitle, title }: SignUpFormProps) {
|
||||
className={styles.form}
|
||||
id="register"
|
||||
onSubmit={methods.handleSubmit(onSubmit)}
|
||||
/**
|
||||
* Ignoring since ts doesn't recognize that tRPC
|
||||
* parses FormData before reaching the route
|
||||
* @ts-ignore */
|
||||
action={registerUser}
|
||||
>
|
||||
<section className={styles.userInfo}>
|
||||
<div className={styles.container}>
|
||||
@@ -187,7 +187,7 @@ export default function SignupForm({ link, subtitle, title }: SignUpFormProps) {
|
||||
onClick={() => methods.trigger()}
|
||||
data-testid="trigger-validation"
|
||||
>
|
||||
{intl.formatMessage({ id: "Sign up to Scandic Friends" })}
|
||||
{signupButtonText}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
@@ -195,10 +195,12 @@ export default function SignupForm({ link, subtitle, title }: SignUpFormProps) {
|
||||
type="submit"
|
||||
theme="base"
|
||||
intent="primary"
|
||||
disabled={methods.formState.isSubmitting}
|
||||
disabled={methods.formState.isSubmitting || signup.isPending}
|
||||
data-testid="submit"
|
||||
>
|
||||
{intl.formatMessage({ id: "Sign up to Scandic Friends" })}
|
||||
{methods.formState.isSubmitting || signup.isPending
|
||||
? signingUpPendingText
|
||||
: signupButtonText}
|
||||
</Button>
|
||||
)}
|
||||
</form>
|
||||
|
||||
@@ -33,11 +33,10 @@ export default function ChildInfoSelector({
|
||||
const ageLabel = intl.formatMessage({ id: "Age" })
|
||||
const bedLabel = intl.formatMessage({ id: "Bed" })
|
||||
const errorMessage = intl.formatMessage({ id: "Child age is required" })
|
||||
const { setValue, formState, register, trigger } = useFormContext()
|
||||
const { setValue, formState, register } = useFormContext()
|
||||
|
||||
function updateSelectedBed(bed: number) {
|
||||
setValue(`rooms.${roomIndex}.child.${index}.bed`, bed)
|
||||
trigger()
|
||||
}
|
||||
|
||||
function updateSelectedAge(age: number) {
|
||||
@@ -95,7 +94,7 @@ export default function ChildInfoSelector({
|
||||
updateSelectedAge(key as number)
|
||||
}}
|
||||
placeholder={ageLabel}
|
||||
maxHeight={150}
|
||||
maxHeight={180}
|
||||
{...register(ageFieldName, {
|
||||
required: true,
|
||||
})}
|
||||
|
||||
@@ -99,7 +99,7 @@ export default function GuestsRoomsPickerDialog({
|
||||
<Tooltip
|
||||
heading={disabledBookingOptionsHeader}
|
||||
text={disabledBookingOptionsText}
|
||||
position="top"
|
||||
position="bottom"
|
||||
arrow="left"
|
||||
>
|
||||
{rooms.length < 4 ? (
|
||||
@@ -124,7 +124,7 @@ export default function GuestsRoomsPickerDialog({
|
||||
<Tooltip
|
||||
heading={disabledBookingOptionsHeader}
|
||||
text={disabledBookingOptionsText}
|
||||
position="top"
|
||||
position="bottom"
|
||||
arrow="left"
|
||||
>
|
||||
{rooms.length < 4 ? (
|
||||
|
||||
@@ -49,7 +49,7 @@ export default async function Details({
|
||||
</li>
|
||||
<li className={styles.listItem}>
|
||||
<Body>{intl.formatMessage({ id: "Cancellation policy" })}</Body>
|
||||
<Body>N/A</Body>
|
||||
<Body>{booking.rateDefinition.cancellationText}</Body>
|
||||
</li>
|
||||
<li className={styles.listItem}>
|
||||
<Body>{intl.formatMessage({ id: "Rebooking" })}</Body>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
.form {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x2);
|
||||
grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
|
||||
padding-bottom: var(--Spacing-x3);
|
||||
grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
|
||||
width: min(600px, 100%);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@ import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useCallback, useEffect } from "react"
|
||||
import { FormProvider, useForm } from "react-hook-form"
|
||||
|
||||
import { useEnterDetailsStore } from "@/stores/enter-details"
|
||||
import { useDetailsStore } from "@/stores/details"
|
||||
import { useStepsStore } from "@/stores/steps"
|
||||
|
||||
import { KingBedIcon } from "@/components/Icons"
|
||||
import RadioCard from "@/components/TempDesignSystem/Form/ChoiceCard/Radio"
|
||||
@@ -19,22 +20,18 @@ import type {
|
||||
} from "@/types/components/hotelReservation/enterDetails/bedType"
|
||||
|
||||
export default function BedType({ bedTypes }: BedTypeProps) {
|
||||
const bedType = useEnterDetailsStore((state) => state.userData.bedType)
|
||||
const bedType = useDetailsStore((state) => state.data.bedType?.roomTypeCode)
|
||||
const completeStep = useStepsStore((state) => state.completeStep)
|
||||
const updateBedType = useDetailsStore((state) => state.actions.updateBedType)
|
||||
|
||||
const methods = useForm<BedTypeFormSchema>({
|
||||
defaultValues: bedType?.roomTypeCode
|
||||
? {
|
||||
bedType: bedType.roomTypeCode,
|
||||
}
|
||||
: undefined,
|
||||
defaultValues: bedType ? { bedType } : undefined,
|
||||
criteriaMode: "all",
|
||||
mode: "all",
|
||||
resolver: zodResolver(bedTypeFormSchema),
|
||||
reValidateMode: "onChange",
|
||||
})
|
||||
|
||||
const completeStep = useEnterDetailsStore((state) => state.completeStep)
|
||||
|
||||
const onSubmit = useCallback(
|
||||
(bedTypeRoomCode: BedTypeFormSchema) => {
|
||||
const matchingRoom = bedTypes.find(
|
||||
@@ -45,10 +42,11 @@ export default function BedType({ bedTypes }: BedTypeProps) {
|
||||
description: matchingRoom.description,
|
||||
roomTypeCode: matchingRoom.value,
|
||||
}
|
||||
completeStep({ bedType })
|
||||
updateBedType(bedType)
|
||||
completeStep()
|
||||
}
|
||||
},
|
||||
[completeStep, bedTypes]
|
||||
[bedTypes, completeStep, updateBedType]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -2,6 +2,5 @@
|
||||
display: grid;
|
||||
gap: var(--Spacing-x2);
|
||||
grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
|
||||
padding-bottom: var(--Spacing-x3);
|
||||
width: min(600px, 100%);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,8 @@ import { useCallback, useEffect } from "react"
|
||||
import { FormProvider, useForm } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { useEnterDetailsStore } from "@/stores/enter-details"
|
||||
import { useDetailsStore } from "@/stores/details"
|
||||
import { useStepsStore } from "@/stores/steps"
|
||||
|
||||
import { Highlight } from "@/components/TempDesignSystem/Form/ChoiceCard/_Card"
|
||||
import RadioCard from "@/components/TempDesignSystem/Form/ChoiceCard/Radio"
|
||||
@@ -23,34 +24,37 @@ import { BreakfastPackageEnum } from "@/types/enums/breakfast"
|
||||
export default function Breakfast({ packages }: BreakfastProps) {
|
||||
const intl = useIntl()
|
||||
|
||||
const breakfast = useEnterDetailsStore((state) => state.userData.breakfast)
|
||||
const breakfast = useDetailsStore(({ data }) =>
|
||||
data.breakfast
|
||||
? data.breakfast.code
|
||||
: data.breakfast === false
|
||||
? "false"
|
||||
: data.breakfast
|
||||
)
|
||||
const updateBreakfast = useDetailsStore(
|
||||
(state) => state.actions.updateBreakfast
|
||||
)
|
||||
const completeStep = useStepsStore((state) => state.completeStep)
|
||||
|
||||
let defaultValues = undefined
|
||||
if (breakfast === BreakfastPackageEnum.NO_BREAKFAST) {
|
||||
defaultValues = { breakfast: BreakfastPackageEnum.NO_BREAKFAST }
|
||||
} else if (breakfast?.code) {
|
||||
defaultValues = { breakfast: breakfast.code }
|
||||
}
|
||||
const methods = useForm<BreakfastFormSchema>({
|
||||
defaultValues,
|
||||
defaultValues: breakfast ? { breakfast } : undefined,
|
||||
criteriaMode: "all",
|
||||
mode: "all",
|
||||
resolver: zodResolver(breakfastFormSchema),
|
||||
reValidateMode: "onChange",
|
||||
})
|
||||
|
||||
const completeStep = useEnterDetailsStore((state) => state.completeStep)
|
||||
|
||||
const onSubmit = useCallback(
|
||||
(values: BreakfastFormSchema) => {
|
||||
const pkg = packages?.find((p) => p.code === values.breakfast)
|
||||
if (pkg) {
|
||||
completeStep({ breakfast: pkg })
|
||||
updateBreakfast(pkg)
|
||||
} else {
|
||||
completeStep({ breakfast: BreakfastPackageEnum.NO_BREAKFAST })
|
||||
updateBreakfast(false)
|
||||
}
|
||||
completeStep()
|
||||
},
|
||||
[completeStep, packages]
|
||||
[completeStep, packages, updateBreakfast]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -61,10 +65,6 @@ export default function Breakfast({ packages }: BreakfastProps) {
|
||||
return () => subscription.unsubscribe()
|
||||
}, [methods, onSubmit])
|
||||
|
||||
if (!packages) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<FormProvider {...methods}>
|
||||
<form className={styles.form} onSubmit={methods.handleSubmit(onSubmit)}>
|
||||
@@ -100,7 +100,6 @@ export default function Breakfast({ packages }: BreakfastProps) {
|
||||
/>
|
||||
))}
|
||||
<RadioCard
|
||||
id={BreakfastPackageEnum.NO_BREAKFAST}
|
||||
name="breakfast"
|
||||
subtitle={intl.formatMessage(
|
||||
{ id: "{amount} {currency}" },
|
||||
@@ -113,7 +112,7 @@ export default function Breakfast({ packages }: BreakfastProps) {
|
||||
id: "You can always change your mind later and add breakfast at the hotel.",
|
||||
})}
|
||||
title={intl.formatMessage({ id: "No breakfast" })}
|
||||
value={BreakfastPackageEnum.NO_BREAKFAST}
|
||||
value="false"
|
||||
/>
|
||||
</form>
|
||||
</FormProvider>
|
||||
|
||||
@@ -2,14 +2,10 @@ import { z } from "zod"
|
||||
|
||||
import { breakfastPackageSchema } from "@/server/routers/hotels/output"
|
||||
|
||||
import { BreakfastPackageEnum } from "@/types/enums/breakfast"
|
||||
|
||||
export const breakfastStoreSchema = z.object({
|
||||
breakfast: breakfastPackageSchema.or(
|
||||
z.literal(BreakfastPackageEnum.NO_BREAKFAST)
|
||||
),
|
||||
breakfast: breakfastPackageSchema.or(z.literal(false)),
|
||||
})
|
||||
|
||||
export const breakfastFormSchema = z.object({
|
||||
breakfast: z.string().or(z.literal(BreakfastPackageEnum.NO_BREAKFAST)),
|
||||
breakfast: z.string().or(z.literal("false")),
|
||||
})
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
"use client"
|
||||
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { privacyPolicy } from "@/constants/currentWebHrefs"
|
||||
|
||||
import { CheckIcon } from "@/components/Icons"
|
||||
import LoginButton from "@/components/LoginButton"
|
||||
import Checkbox from "@/components/TempDesignSystem/Form/Checkbox"
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
|
||||
import useLang from "@/hooks/useLang"
|
||||
|
||||
import styles from "./joinScandicFriendsCard.module.css"
|
||||
|
||||
import { JoinScandicFriendsCardProps } from "@/types/components/hotelReservation/enterDetails/details"
|
||||
|
||||
export default function JoinScandicFriendsCard({
|
||||
name,
|
||||
memberPrice,
|
||||
}: JoinScandicFriendsCardProps) {
|
||||
const lang = useLang()
|
||||
const intl = useIntl()
|
||||
|
||||
const list = [
|
||||
{ title: intl.formatMessage({ id: "Earn bonus nights & points" }) },
|
||||
{ title: intl.formatMessage({ id: "Get member benefits & offers" }) },
|
||||
{ title: intl.formatMessage({ id: "Join at no cost" }) },
|
||||
]
|
||||
|
||||
const saveOnJoiningLabel = intl.formatMessage(
|
||||
{
|
||||
id: "Only pay {amount} {currency}",
|
||||
},
|
||||
{
|
||||
amount: intl.formatNumber(memberPrice?.price ?? 0),
|
||||
currency: memberPrice?.currency ?? "SEK",
|
||||
}
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={styles.cardContainer}>
|
||||
<Checkbox name={name} className={styles.checkBox}>
|
||||
<div>
|
||||
{memberPrice ? (
|
||||
<Caption type="label" textTransform="uppercase" color="red">
|
||||
{saveOnJoiningLabel}
|
||||
</Caption>
|
||||
) : null}
|
||||
<Caption
|
||||
type="label"
|
||||
textTransform="uppercase"
|
||||
color="uiTextHighContrast"
|
||||
>
|
||||
{intl.formatMessage({ id: "Join Scandic Friends" })}
|
||||
</Caption>
|
||||
</div>
|
||||
</Checkbox>
|
||||
|
||||
<Footnote color="uiTextHighContrast" className={styles.login}>
|
||||
{intl.formatMessage({ id: "Already a friend?" })}{" "}
|
||||
<LoginButton
|
||||
color="burgundy"
|
||||
position="enter details"
|
||||
trackingId="join-scandic-friends-enter-details"
|
||||
variant="breadcrumb"
|
||||
target="_blank"
|
||||
>
|
||||
{intl.formatMessage({ id: "Log in" })}
|
||||
</LoginButton>
|
||||
</Footnote>
|
||||
|
||||
<div className={styles.list}>
|
||||
{list.map((item) => (
|
||||
<Caption
|
||||
key={item.title}
|
||||
color="uiTextPlaceholder"
|
||||
className={styles.listItem}
|
||||
>
|
||||
<CheckIcon color="uiTextPlaceholder" height="20" /> {item.title}
|
||||
</Caption>
|
||||
))}
|
||||
</div>
|
||||
<Footnote color="uiTextPlaceholder" className={styles.terms}>
|
||||
{intl.formatMessage<React.ReactNode>(
|
||||
{
|
||||
id: "signup.terms",
|
||||
},
|
||||
{
|
||||
termsLink: (str) => (
|
||||
<Link
|
||||
variant="default"
|
||||
textDecoration="underline"
|
||||
size="tiny"
|
||||
target="_blank"
|
||||
color="uiTextPlaceholder"
|
||||
href={privacyPolicy[lang]}
|
||||
>
|
||||
{str}
|
||||
</Link>
|
||||
),
|
||||
}
|
||||
)}
|
||||
</Footnote>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
.cardContainer {
|
||||
align-self: flex-start;
|
||||
background-color: var(--Base-Surface-Primary-light-Normal);
|
||||
border: 1px solid var(--Base-Border-Subtle);
|
||||
border-radius: var(--Corner-radius-Large);
|
||||
display: grid;
|
||||
gap: var(--Spacing-x-one-and-half);
|
||||
padding: var(--Spacing-x-one-and-half) var(--Spacing-x2);
|
||||
grid-template-areas:
|
||||
"checkbox"
|
||||
"list"
|
||||
"login"
|
||||
"terms";
|
||||
width: min(100%, 600px);
|
||||
}
|
||||
|
||||
.login {
|
||||
grid-area: login;
|
||||
}
|
||||
|
||||
.checkBox {
|
||||
align-self: center;
|
||||
grid-area: checkbox;
|
||||
}
|
||||
|
||||
.list {
|
||||
display: grid;
|
||||
grid-area: list;
|
||||
gap: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.listItem {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.terms {
|
||||
border-top: 1px solid var(--Base-Border-Normal);
|
||||
grid-area: terms;
|
||||
padding-top: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
.cardContainer {
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: var(--Spacing-x2);
|
||||
grid-template-areas:
|
||||
"checkbox login"
|
||||
"list list"
|
||||
"terms terms";
|
||||
}
|
||||
.list {
|
||||
display: flex;
|
||||
gap: var(--Spacing-x1);
|
||||
}
|
||||
}
|
||||
@@ -4,14 +4,8 @@ import { useEffect, useState } from "react"
|
||||
import { useWatch } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { privacyPolicy } from "@/constants/currentWebHrefs"
|
||||
|
||||
import Checkbox from "@/components/TempDesignSystem/Form/Checkbox"
|
||||
import CheckboxCard from "@/components/TempDesignSystem/Form/ChoiceCard/Checkbox"
|
||||
import DateSelect from "@/components/TempDesignSystem/Form/Date"
|
||||
import Input from "@/components/TempDesignSystem/Form/Input"
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import useLang from "@/hooks/useLang"
|
||||
|
||||
@@ -31,67 +25,27 @@ export default function Signup({ name }: { name: string }) {
|
||||
setIsJoinChecked(joinValue)
|
||||
}, [joinValue])
|
||||
|
||||
const list = [
|
||||
{ title: intl.formatMessage({ id: "Earn bonus nights & points" }) },
|
||||
{ title: intl.formatMessage({ id: "Get member benefits & offers" }) },
|
||||
{ title: intl.formatMessage({ id: "Join at no cost" }) },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<CheckboxCard
|
||||
highlightSubtitle
|
||||
list={list}
|
||||
name={name}
|
||||
subtitle={intl.formatMessage(
|
||||
{
|
||||
id: "{difference}{amount} {currency}",
|
||||
},
|
||||
{
|
||||
amount: "491",
|
||||
currency: "SEK",
|
||||
difference: "-",
|
||||
}
|
||||
)}
|
||||
title={intl.formatMessage({ id: "Join Scandic Friends" })}
|
||||
return isJoinChecked ? (
|
||||
<div className={styles.additionalFormData}>
|
||||
<Input
|
||||
name="zipCode"
|
||||
label={intl.formatMessage({ id: "Zip code" })}
|
||||
registerOptions={{ required: true }}
|
||||
/>
|
||||
{isJoinChecked ? (
|
||||
<div className={styles.additionalFormData}>
|
||||
<div className={styles.dateField}>
|
||||
<header>
|
||||
<Caption type="bold">
|
||||
{intl.formatMessage({ id: "Birth date" })} *
|
||||
</Caption>
|
||||
</header>
|
||||
<DateSelect
|
||||
name="dateOfBirth"
|
||||
registerOptions={{ required: true }}
|
||||
/>
|
||||
<Input
|
||||
name="zipCode"
|
||||
label={intl.formatMessage({ id: "Zip code" })}
|
||||
registerOptions={{ required: true }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Checkbox name="termsAccepted" registerOptions={{ required: true }}>
|
||||
<Body>
|
||||
{intl.formatMessage({
|
||||
id: "Yes, I accept the Terms and conditions for Scandic Friends and understand that Scandic will process my personal data in accordance with",
|
||||
})}{" "}
|
||||
<Link
|
||||
variant="underscored"
|
||||
color="peach80"
|
||||
target="_blank"
|
||||
href={privacyPolicy[lang]}
|
||||
>
|
||||
{intl.formatMessage({ id: "Scandic's Privacy Policy." })}
|
||||
</Link>
|
||||
</Body>
|
||||
</Checkbox>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className={styles.dateField}>
|
||||
<header>
|
||||
<Caption type="bold">
|
||||
{intl.formatMessage({ id: "Birth date" })} *
|
||||
</Caption>
|
||||
</header>
|
||||
<DateSelect name="dateOfBirth" registerOptions={{ required: true }} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Input
|
||||
label={intl.formatMessage({ id: "Membership no" })}
|
||||
name="membershipNo"
|
||||
type="tel"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,26 +1,34 @@
|
||||
.form {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x2);
|
||||
padding: var(--Spacing-x3) 0px;
|
||||
gap: var(--Spacing-x3);
|
||||
}
|
||||
|
||||
.container {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x2);
|
||||
grid-template-columns: 1fr 1fr;
|
||||
width: min(100%, 600px);
|
||||
}
|
||||
|
||||
.header,
|
||||
.country,
|
||||
.email,
|
||||
.membershipNo,
|
||||
.signup,
|
||||
.phone {
|
||||
grid-column: 1/-1;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x3);
|
||||
justify-items: flex-start;
|
||||
margin-top: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
.form {
|
||||
gap: var(--Spacing-x3);
|
||||
}
|
||||
|
||||
.container {
|
||||
gap: var(--Spacing-x2);
|
||||
grid-template-columns: 1fr 1fr;
|
||||
width: min(100%, 600px);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"use client"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useCallback } from "react"
|
||||
import { FormProvider, useForm } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { useEnterDetailsStore } from "@/stores/enter-details"
|
||||
import { useDetailsStore } from "@/stores/details"
|
||||
import { useStepsStore } from "@/stores/steps"
|
||||
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import CountrySelect from "@/components/TempDesignSystem/Form/Country"
|
||||
@@ -11,6 +13,7 @@ import Input from "@/components/TempDesignSystem/Form/Input"
|
||||
import Phone from "@/components/TempDesignSystem/Form/Phone"
|
||||
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
|
||||
|
||||
import JoinScandicFriendsCard from "./JoinScandicFriendsCard"
|
||||
import { guestDetailsSchema, signedInDetailsSchema } from "./schema"
|
||||
import Signup from "./Signup"
|
||||
|
||||
@@ -22,21 +25,23 @@ import type {
|
||||
} from "@/types/components/hotelReservation/enterDetails/details"
|
||||
|
||||
const formID = "enter-details"
|
||||
export default function Details({ user }: DetailsProps) {
|
||||
export default function Details({ user, memberPrice }: DetailsProps) {
|
||||
const intl = useIntl()
|
||||
const initialData = useEnterDetailsStore((state) => ({
|
||||
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,
|
||||
membershipNo: state.userData.membershipNo,
|
||||
const initialData = useDetailsStore((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,
|
||||
membershipNo: state.data.membershipNo,
|
||||
}))
|
||||
|
||||
const updateDetails = useDetailsStore((state) => state.actions.updateDetails)
|
||||
const completeStep = useStepsStore((state) => state.completeStep)
|
||||
|
||||
const methods = useForm<DetailsSchema>({
|
||||
defaultValues: {
|
||||
countryCode: user?.address?.countryCode ?? initialData.countryCode,
|
||||
@@ -47,7 +52,6 @@ export default function Details({ user }: DetailsProps) {
|
||||
join: initialData.join,
|
||||
dateOfBirth: initialData.dateOfBirth,
|
||||
zipCode: initialData.zipCode,
|
||||
termsAccepted: initialData.termsAccepted,
|
||||
membershipNo: initialData.membershipNo,
|
||||
},
|
||||
criteriaMode: "all",
|
||||
@@ -56,24 +60,33 @@ export default function Details({ user }: DetailsProps) {
|
||||
reValidateMode: "onChange",
|
||||
})
|
||||
|
||||
const completeStep = useEnterDetailsStore((state) => state.completeStep)
|
||||
const onSubmit = useCallback(
|
||||
(values: DetailsSchema) => {
|
||||
updateDetails(values)
|
||||
completeStep()
|
||||
},
|
||||
[completeStep, updateDetails]
|
||||
)
|
||||
|
||||
return (
|
||||
<FormProvider {...methods}>
|
||||
<form
|
||||
className={styles.form}
|
||||
id={formID}
|
||||
onSubmit={methods.handleSubmit(completeStep)}
|
||||
onSubmit={methods.handleSubmit(onSubmit)}
|
||||
>
|
||||
{user ? null : <Signup name="join" />}
|
||||
<Footnote
|
||||
color="uiTextHighContrast"
|
||||
textTransform="uppercase"
|
||||
type="label"
|
||||
>
|
||||
{intl.formatMessage({ id: "Guest information" })}
|
||||
</Footnote>
|
||||
{user ? null : (
|
||||
<JoinScandicFriendsCard name="join" memberPrice={memberPrice} />
|
||||
)}
|
||||
<div className={styles.container}>
|
||||
<Footnote
|
||||
color="uiTextHighContrast"
|
||||
textTransform="uppercase"
|
||||
type="label"
|
||||
className={styles.header}
|
||||
>
|
||||
{intl.formatMessage({ id: "Guest information" })}
|
||||
</Footnote>
|
||||
<Input
|
||||
label={intl.formatMessage({ id: "First name" })}
|
||||
name="firstName"
|
||||
@@ -108,18 +121,14 @@ export default function Details({ user }: DetailsProps) {
|
||||
registerOptions={{ required: true }}
|
||||
/>
|
||||
{user ? null : (
|
||||
<Input
|
||||
className={styles.membershipNo}
|
||||
label={intl.formatMessage({ id: "Membership no" })}
|
||||
name="membershipNo"
|
||||
type="tel"
|
||||
/>
|
||||
<div className={styles.signup}>
|
||||
<Signup name="join" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<footer className={styles.footer}>
|
||||
<Button
|
||||
disabled={!methods.formState.isValid}
|
||||
form={formID}
|
||||
intent="secondary"
|
||||
size="small"
|
||||
theme="base"
|
||||
|
||||
@@ -15,7 +15,6 @@ export const notJoinDetailsSchema = baseDetailsSchema.merge(
|
||||
join: z.literal<boolean>(false),
|
||||
zipCode: z.string().optional(),
|
||||
dateOfBirth: z.string().optional(),
|
||||
termsAccepted: z.boolean().default(false),
|
||||
membershipNo: z
|
||||
.string()
|
||||
.optional()
|
||||
@@ -39,15 +38,6 @@ export const joinDetailsSchema = baseDetailsSchema.merge(
|
||||
join: z.literal<boolean>(true),
|
||||
zipCode: z.string().min(1, { message: "Zip code is required" }),
|
||||
dateOfBirth: z.string().min(1, { message: "Date of birth is required" }),
|
||||
termsAccepted: z.literal<boolean>(true, {
|
||||
errorMap: (err, ctx) => {
|
||||
switch (err.code) {
|
||||
case "invalid_literal":
|
||||
return { message: "You must accept the terms and conditions" }
|
||||
}
|
||||
return { message: ctx.defaultError }
|
||||
},
|
||||
}),
|
||||
membershipNo: z.string().optional(),
|
||||
})
|
||||
)
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
import { useCallback, useEffect } from "react"
|
||||
|
||||
import { useEnterDetailsStore } from "@/stores/enter-details"
|
||||
import { useStepsStore } from "@/stores/steps"
|
||||
|
||||
export default function HistoryStateManager() {
|
||||
const setCurrentStep = useEnterDetailsStore((state) => state.setCurrentStep)
|
||||
const currentStep = useEnterDetailsStore((state) => state.currentStep)
|
||||
const setCurrentStep = useStepsStore((state) => state.setStep)
|
||||
const currentStep = useStepsStore((state) => state.currentStep)
|
||||
|
||||
const handleBackButton = useCallback(
|
||||
(event: PopStateEvent) => {
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
} from "@/constants/currentWebHrefs"
|
||||
import { env } from "@/env/client"
|
||||
import { trpc } from "@/lib/trpc/client"
|
||||
import { useEnterDetailsStore } from "@/stores/enter-details"
|
||||
import { useDetailsStore } from "@/stores/details"
|
||||
|
||||
import LoadingSpinner from "@/components/LoadingSpinner"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
@@ -28,6 +28,7 @@ import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import { toast } from "@/components/TempDesignSystem/Toasts"
|
||||
import { useHandleBookingStatus } from "@/hooks/booking/useHandleBookingStatus"
|
||||
import { usePaymentFailedToast } from "@/hooks/booking/usePaymentFailedToast"
|
||||
import useLang from "@/hooks/useLang"
|
||||
|
||||
import { bedTypeMap } from "../../SelectRate/RoomSelection/utils"
|
||||
@@ -39,9 +40,8 @@ import styles from "./payment.module.css"
|
||||
|
||||
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
|
||||
import { PaymentProps } from "@/types/components/hotelReservation/selectRate/section"
|
||||
import { BreakfastPackageEnum } from "@/types/enums/breakfast"
|
||||
|
||||
const maxRetries = 40
|
||||
const maxRetries = 4
|
||||
const retryInterval = 2000
|
||||
|
||||
export const formId = "submit-booking"
|
||||
@@ -60,12 +60,9 @@ export default function Payment({
|
||||
const lang = useLang()
|
||||
const intl = useIntl()
|
||||
const queryParams = useSearchParams()
|
||||
const { userData, roomData, setIsSubmittingDisabled } = useEnterDetailsStore(
|
||||
(state) => ({
|
||||
userData: state.userData,
|
||||
roomData: state.roomData,
|
||||
setIsSubmittingDisabled: state.setIsSubmittingDisabled,
|
||||
})
|
||||
const { booking, ...userData } = useDetailsStore((state) => state.data)
|
||||
const setIsSubmittingDisabled = useDetailsStore(
|
||||
(state) => state.actions.setIsSubmittingDisabled
|
||||
)
|
||||
|
||||
const {
|
||||
@@ -77,10 +74,17 @@ export default function Payment({
|
||||
breakfast,
|
||||
bedType,
|
||||
membershipNo,
|
||||
join,
|
||||
dateOfBirth,
|
||||
zipCode,
|
||||
} = userData
|
||||
const { toDate, fromDate, rooms: rooms, hotel } = roomData
|
||||
const { toDate, fromDate, rooms, hotel } = booking
|
||||
|
||||
const [confirmationNumber, setConfirmationNumber] = useState<string>("")
|
||||
const [availablePaymentOptions, setAvailablePaymentOptions] =
|
||||
useState(otherPaymentOptions)
|
||||
|
||||
usePaymentFailedToast()
|
||||
|
||||
const methods = useForm<PaymentFormData>({
|
||||
defaultValues: {
|
||||
@@ -100,29 +104,53 @@ export default function Payment({
|
||||
if (result?.confirmationNumber) {
|
||||
setConfirmationNumber(result.confirmationNumber)
|
||||
} else {
|
||||
// TODO: add proper error message
|
||||
toast.error("Failed to create booking")
|
||||
toast.error(
|
||||
intl.formatMessage({
|
||||
id: "payment.error.failed",
|
||||
})
|
||||
)
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Error", error)
|
||||
// TODO: add proper error message
|
||||
toast.error("Failed to create booking")
|
||||
toast.error(
|
||||
intl.formatMessage({
|
||||
id: "payment.error.failed",
|
||||
})
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
const bookingStatus = useHandleBookingStatus(
|
||||
const bookingStatus = useHandleBookingStatus({
|
||||
confirmationNumber,
|
||||
BookingStatusEnum.PaymentRegistered,
|
||||
expectedStatus: BookingStatusEnum.BookingCompleted,
|
||||
maxRetries,
|
||||
retryInterval
|
||||
)
|
||||
retryInterval,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (window.ApplePaySession) {
|
||||
setAvailablePaymentOptions(otherPaymentOptions)
|
||||
} else {
|
||||
setAvailablePaymentOptions(
|
||||
otherPaymentOptions.filter(
|
||||
(option) => option !== PaymentMethodEnum.applePay
|
||||
)
|
||||
)
|
||||
}
|
||||
}, [otherPaymentOptions, setAvailablePaymentOptions])
|
||||
|
||||
useEffect(() => {
|
||||
if (bookingStatus?.data?.paymentUrl) {
|
||||
router.push(bookingStatus.data.paymentUrl)
|
||||
} else if (bookingStatus.isTimeout) {
|
||||
toast.error(
|
||||
intl.formatMessage({
|
||||
id: "payment.error.failed",
|
||||
})
|
||||
)
|
||||
}
|
||||
}, [bookingStatus, router])
|
||||
}, [bookingStatus, router, intl])
|
||||
|
||||
useEffect(() => {
|
||||
setIsSubmittingDisabled(
|
||||
@@ -167,9 +195,12 @@ export default function Payment({
|
||||
phoneNumber,
|
||||
countryCode,
|
||||
membershipNumber: membershipNo,
|
||||
becomeMember: join,
|
||||
dateOfBirth,
|
||||
postalCode: zipCode,
|
||||
},
|
||||
packages: {
|
||||
breakfast: breakfast !== BreakfastPackageEnum.NO_BREAKFAST,
|
||||
breakfast: !!(breakfast && breakfast.code),
|
||||
allergyFriendly:
|
||||
room.packages?.includes(RoomPackageCodeEnum.ALLERGY_ROOM) ?? false,
|
||||
petFriendly:
|
||||
@@ -260,7 +291,7 @@ export default function Payment({
|
||||
value={PaymentMethodEnum.card}
|
||||
label={intl.formatMessage({ id: "Credit card" })}
|
||||
/>
|
||||
{otherPaymentOptions.map((paymentMethod) => (
|
||||
{availablePaymentOptions.map((paymentMethod) => (
|
||||
<PaymentOption
|
||||
key={paymentMethod}
|
||||
name="paymentMethod"
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
"use client"
|
||||
import { useSearchParams } from "next/navigation"
|
||||
import { PropsWithChildren, useRef } from "react"
|
||||
|
||||
import {
|
||||
EnterDetailsContext,
|
||||
type EnterDetailsStore,
|
||||
initEditDetailsState,
|
||||
} from "@/stores/enter-details"
|
||||
|
||||
import { EnterDetailsProviderProps } from "@/types/components/hotelReservation/enterDetails/store"
|
||||
|
||||
export default function EnterDetailsProvider({
|
||||
step,
|
||||
isMember,
|
||||
children,
|
||||
}: PropsWithChildren<EnterDetailsProviderProps>) {
|
||||
const searchParams = useSearchParams()
|
||||
const initialStore = useRef<EnterDetailsStore>()
|
||||
if (!initialStore.current) {
|
||||
initialStore.current = initEditDetailsState(step, searchParams, isMember)
|
||||
}
|
||||
|
||||
return (
|
||||
<EnterDetailsContext.Provider value={initialStore.current}>
|
||||
{children}
|
||||
</EnterDetailsContext.Provider>
|
||||
)
|
||||
}
|
||||
@@ -2,7 +2,8 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { useEnterDetailsStore } from "@/stores/enter-details"
|
||||
import { useDetailsStore } from "@/stores/details"
|
||||
import { useStepsStore } from "@/stores/steps"
|
||||
|
||||
import { CheckIcon, ChevronDownIcon } from "@/components/Icons"
|
||||
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
|
||||
@@ -10,12 +11,9 @@ import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
|
||||
import styles from "./sectionAccordion.module.css"
|
||||
|
||||
import {
|
||||
StepEnum,
|
||||
StepStoreKeys,
|
||||
} from "@/types/components/hotelReservation/enterDetails/step"
|
||||
import { StepStoreKeys } from "@/types/components/hotelReservation/enterDetails/step"
|
||||
import { SectionAccordionProps } from "@/types/components/hotelReservation/selectRate/sectionAccordion"
|
||||
import { BreakfastPackageEnum } from "@/types/enums/breakfast"
|
||||
import { StepEnum } from "@/types/enums/step"
|
||||
|
||||
export default function SectionAccordion({
|
||||
header,
|
||||
@@ -24,12 +22,12 @@ export default function SectionAccordion({
|
||||
children,
|
||||
}: React.PropsWithChildren<SectionAccordionProps>) {
|
||||
const intl = useIntl()
|
||||
const currentStep = useEnterDetailsStore((state) => state.currentStep)
|
||||
const currentStep = useStepsStore((state) => state.currentStep)
|
||||
const [isComplete, setIsComplete] = useState(false)
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const isValid = useEnterDetailsStore((state) => state.isValid[step])
|
||||
const navigate = useEnterDetailsStore((state) => state.navigate)
|
||||
const stepData = useEnterDetailsStore((state) => state.userData)
|
||||
const isValid = useDetailsStore((state) => state.isValid[step])
|
||||
const navigate = useStepsStore((state) => state.navigate)
|
||||
const stepData = useDetailsStore((state) => state.data)
|
||||
const stepStoreKey = StepStoreKeys[step]
|
||||
const [title, setTitle] = useState(label)
|
||||
|
||||
@@ -39,9 +37,12 @@ export default function SectionAccordion({
|
||||
value && setTitle(value.description)
|
||||
}
|
||||
// If breakfast step, check if an option has been selected
|
||||
if (step === StepEnum.breakfast && stepData.breakfast) {
|
||||
if (
|
||||
step === StepEnum.breakfast &&
|
||||
(stepData.breakfast || stepData.breakfast === false)
|
||||
) {
|
||||
const value = stepData.breakfast
|
||||
if (value === BreakfastPackageEnum.NO_BREAKFAST) {
|
||||
if (value === false) {
|
||||
setTitle(intl.formatMessage({ id: "No breakfast" }))
|
||||
} else {
|
||||
setTitle(intl.formatMessage({ id: "Breakfast buffet" }))
|
||||
@@ -65,7 +66,7 @@ export default function SectionAccordion({
|
||||
const textColor =
|
||||
isComplete || isOpen ? "uiTextHighContrast" : "baseTextDisabled"
|
||||
return (
|
||||
<section className={styles.wrapper} data-open={isOpen} data-step={step}>
|
||||
<div className={styles.accordion} data-open={isOpen} data-step={step}>
|
||||
<div className={styles.iconWrapper}>
|
||||
<div className={styles.circle} data-checked={isComplete}>
|
||||
{isComplete ? (
|
||||
@@ -73,29 +74,33 @@ export default function SectionAccordion({
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.main}>
|
||||
<header>
|
||||
<button onClick={onModify} className={styles.modifyButton}>
|
||||
<Footnote
|
||||
className={styles.title}
|
||||
asChild
|
||||
textTransform="uppercase"
|
||||
type="label"
|
||||
color={textColor}
|
||||
>
|
||||
<h2>{header}</h2>
|
||||
</Footnote>
|
||||
<Subtitle className={styles.selection} type="two" color={textColor}>
|
||||
{title}
|
||||
</Subtitle>
|
||||
<header className={styles.header}>
|
||||
<button
|
||||
onClick={onModify}
|
||||
disabled={!isComplete}
|
||||
className={styles.modifyButton}
|
||||
>
|
||||
<Footnote
|
||||
className={styles.title}
|
||||
asChild
|
||||
textTransform="uppercase"
|
||||
type="label"
|
||||
color={textColor}
|
||||
>
|
||||
<h2>{header}</h2>
|
||||
</Footnote>
|
||||
<Subtitle className={styles.selection} type="two" color={textColor}>
|
||||
{title}
|
||||
</Subtitle>
|
||||
|
||||
{isComplete && !isOpen && (
|
||||
<ChevronDownIcon className={styles.button} color="burgundy" />
|
||||
)}
|
||||
</button>
|
||||
</header>
|
||||
<div className={styles.content}>{children}</div>
|
||||
{isComplete && !isOpen && (
|
||||
<ChevronDownIcon className={styles.button} color="burgundy" />
|
||||
)}
|
||||
</button>
|
||||
</header>
|
||||
<div className={styles.content}>
|
||||
<div className={styles.contentWrapper}>{children}</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+45
-29
@@ -1,15 +1,28 @@
|
||||
.wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: var(--Spacing-x-one-and-half);
|
||||
.accordion {
|
||||
--header-height: 2.4em;
|
||||
--circle-height: 24px;
|
||||
|
||||
gap: var(--Spacing-x3);
|
||||
width: 100%;
|
||||
padding-top: var(--Spacing-x3);
|
||||
transition: 0.4s ease-out;
|
||||
|
||||
display: grid;
|
||||
grid-template-areas: "circle header" "content content";
|
||||
grid-template-columns: auto 1fr;
|
||||
grid-template-rows: var(--header-height) 0fr;
|
||||
|
||||
column-gap: var(--Spacing-x-one-and-half);
|
||||
}
|
||||
|
||||
.wrapper:last-child .main {
|
||||
.accordion:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.header {
|
||||
grid-area: header;
|
||||
}
|
||||
|
||||
.modifyButton {
|
||||
display: grid;
|
||||
grid-template-areas: "title button" "selection button";
|
||||
@@ -17,6 +30,11 @@
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.modifyButton:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.title {
|
||||
@@ -29,16 +47,6 @@
|
||||
justify-self: flex-end;
|
||||
}
|
||||
|
||||
.main {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x3);
|
||||
width: 100%;
|
||||
border-bottom: 1px solid var(--Primary-Light-On-Surface-Divider-subtle);
|
||||
padding-bottom: var(--Spacing-x3);
|
||||
transition: 0.4s ease-out;
|
||||
grid-template-rows: 2em 0fr;
|
||||
}
|
||||
|
||||
.selection {
|
||||
font-weight: 450;
|
||||
font-size: var(--typography-Title-4-fontSize);
|
||||
@@ -47,11 +55,12 @@
|
||||
|
||||
.iconWrapper {
|
||||
position: relative;
|
||||
grid-area: circle;
|
||||
}
|
||||
|
||||
.circle {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
width: var(--circle-height);
|
||||
height: var(--circle-height);
|
||||
border-radius: 100px;
|
||||
transition: background-color 0.4s;
|
||||
border: 2px solid var(--Base-Border-Inverted);
|
||||
@@ -64,37 +73,44 @@
|
||||
background-color: var(--UI-Input-Controls-Fill-Selected);
|
||||
}
|
||||
|
||||
.wrapper[data-open="true"] .circle[data-checked="false"] {
|
||||
.accordion[data-open="true"] .circle[data-checked="false"] {
|
||||
background-color: var(--UI-Text-Placeholder);
|
||||
}
|
||||
|
||||
.wrapper[data-open="false"] .circle[data-checked="false"] {
|
||||
.accordion[data-open="false"] .circle[data-checked="false"] {
|
||||
background-color: var(--Base-Surface-Subtle-Hover);
|
||||
}
|
||||
|
||||
.wrapper[data-open="true"] .main {
|
||||
grid-template-rows: 2em 1fr;
|
||||
.accordion[data-open="true"] {
|
||||
grid-template-rows: var(--header-height) 1fr;
|
||||
}
|
||||
|
||||
.contentWrapper {
|
||||
padding-bottom: var(--Spacing-x3);
|
||||
}
|
||||
|
||||
.content {
|
||||
overflow: hidden;
|
||||
grid-area: content;
|
||||
border-bottom: 1px solid var(--Primary-Light-On-Surface-Divider-subtle);
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1367px) {
|
||||
.wrapper {
|
||||
gap: var(--Spacing-x3);
|
||||
@media screen and (min-width: 768px) {
|
||||
.accordion {
|
||||
column-gap: var(--Spacing-x3);
|
||||
grid-template-areas: "circle header" "circle content";
|
||||
}
|
||||
|
||||
.iconWrapper {
|
||||
top: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.wrapper:not(:last-child)::after {
|
||||
.accordion:not(:last-child) .iconWrapper::after {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
bottom: 0;
|
||||
top: var(--Spacing-x7);
|
||||
height: 100%;
|
||||
bottom: calc(0px - var(--Spacing-x7));
|
||||
top: var(--circle-height);
|
||||
|
||||
content: "";
|
||||
border-left: 1px solid var(--Primary-Light-On-Surface-Divider-subtle);
|
||||
}
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { useEnterDetailsStore } from "@/stores/enter-details"
|
||||
import { selectRate } from "@/constants/routes/hotelReservation"
|
||||
|
||||
import { CheckIcon, EditIcon } from "@/components/Icons"
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
import useLang from "@/hooks/useLang"
|
||||
|
||||
import ToggleSidePeek from "./ToggleSidePeek"
|
||||
|
||||
@@ -21,8 +22,7 @@ export default function SelectedRoom({
|
||||
rateDescription,
|
||||
}: SelectedRoomProps) {
|
||||
const intl = useIntl()
|
||||
|
||||
const selectRateUrl = useEnterDetailsStore((state) => state.selectRateUrl)
|
||||
const lang = useLang()
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
@@ -53,7 +53,8 @@ export default function SelectedRoom({
|
||||
<Link
|
||||
className={styles.button}
|
||||
color="burgundy"
|
||||
href={selectRateUrl}
|
||||
href={selectRate(lang)}
|
||||
keepSearchParams
|
||||
size="small"
|
||||
variant="icon"
|
||||
>
|
||||
|
||||
@@ -63,7 +63,7 @@
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1367px) {
|
||||
@media screen and (min-width: 768px) {
|
||||
.wrapper {
|
||||
gap: var(--Spacing-x3);
|
||||
padding-top: var(--Spacing-x3);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { PropsWithChildren } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { useEnterDetailsStore } from "@/stores/enter-details"
|
||||
import { useDetailsStore } from "@/stores/details"
|
||||
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
@@ -17,9 +17,9 @@ export function SummaryBottomSheet({ children }: PropsWithChildren) {
|
||||
const intl = useIntl()
|
||||
|
||||
const { isSummaryOpen, toggleSummaryOpen, totalPrice, isSubmittingDisabled } =
|
||||
useEnterDetailsStore((state) => ({
|
||||
useDetailsStore((state) => ({
|
||||
isSummaryOpen: state.isSummaryOpen,
|
||||
toggleSummaryOpen: state.toggleSummaryOpen,
|
||||
toggleSummaryOpen: state.actions.toggleSummaryOpen,
|
||||
totalPrice: state.totalPrice,
|
||||
isSubmittingDisabled: state.isSubmittingDisabled,
|
||||
}))
|
||||
|
||||
@@ -5,12 +5,13 @@ import { ChevronDown } from "react-feather"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { dt } from "@/lib/dt"
|
||||
import { EnterDetailsState, useEnterDetailsStore } from "@/stores/enter-details"
|
||||
import { useDetailsStore } from "@/stores/details"
|
||||
|
||||
import { ArrowRightIcon } from "@/components/Icons"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Divider from "@/components/TempDesignSystem/Divider"
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import Popover from "@/components/TempDesignSystem/Popover"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
@@ -18,45 +19,39 @@ import useLang from "@/hooks/useLang"
|
||||
|
||||
import styles from "./summary.module.css"
|
||||
|
||||
import { BedTypeSchema } from "@/types/components/hotelReservation/enterDetails/bedType"
|
||||
import { RoomsData } from "@/types/components/hotelReservation/enterDetails/bookingData"
|
||||
import { BreakfastPackage } from "@/types/components/hotelReservation/enterDetails/breakfast"
|
||||
import { BreakfastPackageEnum } from "@/types/enums/breakfast"
|
||||
import type { BedTypeSchema } from "@/types/components/hotelReservation/enterDetails/bedType"
|
||||
import type { BreakfastPackage } from "@/types/components/hotelReservation/enterDetails/breakfast"
|
||||
import type { SummaryProps } from "@/types/components/hotelReservation/enterDetails/summary"
|
||||
import type { DetailsState } from "@/types/stores/details"
|
||||
|
||||
function storeSelector(state: EnterDetailsState) {
|
||||
function storeSelector(state: DetailsState) {
|
||||
return {
|
||||
fromDate: state.roomData.fromDate,
|
||||
toDate: state.roomData.toDate,
|
||||
bedType: state.userData.bedType,
|
||||
breakfast: state.userData.breakfast,
|
||||
toggleSummaryOpen: state.toggleSummaryOpen,
|
||||
setTotalPrice: state.setTotalPrice,
|
||||
fromDate: state.data.booking.fromDate,
|
||||
toDate: state.data.booking.toDate,
|
||||
bedType: state.data.bedType,
|
||||
breakfast: state.data.breakfast,
|
||||
toggleSummaryOpen: state.actions.toggleSummaryOpen,
|
||||
setTotalPrice: state.actions.setTotalPrice,
|
||||
totalPrice: state.totalPrice,
|
||||
}
|
||||
}
|
||||
|
||||
export default function Summary({
|
||||
showMemberPrice,
|
||||
room,
|
||||
}: {
|
||||
showMemberPrice: boolean
|
||||
room: RoomsData
|
||||
}) {
|
||||
export default function Summary({ showMemberPrice, room }: SummaryProps) {
|
||||
const [chosenBed, setChosenBed] = useState<BedTypeSchema>()
|
||||
const [chosenBreakfast, setChosenBreakfast] = useState<
|
||||
BreakfastPackage | BreakfastPackageEnum.NO_BREAKFAST
|
||||
BreakfastPackage | false
|
||||
>()
|
||||
const intl = useIntl()
|
||||
const lang = useLang()
|
||||
const {
|
||||
fromDate,
|
||||
toDate,
|
||||
bedType,
|
||||
breakfast,
|
||||
fromDate,
|
||||
setTotalPrice,
|
||||
totalPrice,
|
||||
toDate,
|
||||
toggleSummaryOpen,
|
||||
} = useEnterDetailsStore(storeSelector)
|
||||
totalPrice,
|
||||
} = useDetailsStore(storeSelector)
|
||||
|
||||
const diff = dt(toDate).diff(fromDate, "days")
|
||||
|
||||
@@ -80,41 +75,53 @@ export default function Summary({
|
||||
) || { local: 0, euro: 0 }
|
||||
|
||||
const roomsPriceLocal = room.localPrice.price + additionalPackageCost.local
|
||||
const roomsPriceEuro = room.euroPrice.price + additionalPackageCost.euro
|
||||
const roomsPriceEuro = room.euroPrice
|
||||
? room.euroPrice.price + additionalPackageCost.euro
|
||||
: undefined
|
||||
|
||||
useEffect(() => {
|
||||
setChosenBed(bedType)
|
||||
setChosenBreakfast(breakfast)
|
||||
|
||||
if (breakfast && breakfast !== BreakfastPackageEnum.NO_BREAKFAST) {
|
||||
setTotalPrice({
|
||||
local: {
|
||||
price: roomsPriceLocal + parseInt(breakfast.localPrice.totalPrice),
|
||||
currency: room.localPrice.currency,
|
||||
},
|
||||
euro: {
|
||||
price: roomsPriceEuro + parseInt(breakfast.requestedPrice.totalPrice),
|
||||
currency: room.euroPrice.currency,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
setTotalPrice({
|
||||
local: {
|
||||
price: roomsPriceLocal,
|
||||
currency: room.localPrice.currency,
|
||||
},
|
||||
euro: {
|
||||
price: roomsPriceEuro,
|
||||
currency: room.euroPrice.currency,
|
||||
},
|
||||
})
|
||||
if (breakfast || breakfast === false) {
|
||||
setChosenBreakfast(breakfast)
|
||||
if (breakfast === false) {
|
||||
setTotalPrice({
|
||||
local: {
|
||||
price: roomsPriceLocal,
|
||||
currency: room.localPrice.currency,
|
||||
},
|
||||
euro:
|
||||
room.euroPrice && roomsPriceEuro
|
||||
? {
|
||||
price: roomsPriceEuro,
|
||||
currency: room.euroPrice.currency,
|
||||
}
|
||||
: undefined,
|
||||
})
|
||||
} else {
|
||||
setTotalPrice({
|
||||
local: {
|
||||
price: roomsPriceLocal + parseInt(breakfast.localPrice.totalPrice),
|
||||
currency: room.localPrice.currency,
|
||||
},
|
||||
euro:
|
||||
room.euroPrice && roomsPriceEuro
|
||||
? {
|
||||
price:
|
||||
roomsPriceEuro +
|
||||
parseInt(breakfast.requestedPrice.totalPrice),
|
||||
currency: room.euroPrice.currency,
|
||||
}
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [
|
||||
bedType,
|
||||
breakfast,
|
||||
roomsPriceLocal,
|
||||
room.localPrice.currency,
|
||||
room.euroPrice.currency,
|
||||
room.euroPrice,
|
||||
roomsPriceEuro,
|
||||
setTotalPrice,
|
||||
])
|
||||
@@ -171,9 +178,23 @@ export default function Summary({
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{room.cancellationText}
|
||||
</Caption>
|
||||
<Link color="burgundy" href="#" variant="underscored" size="small">
|
||||
{intl.formatMessage({ id: "Rate details" })}
|
||||
</Link>
|
||||
<Popover
|
||||
placement="bottom left"
|
||||
triggerContent={
|
||||
<Caption color="burgundy" type="underline">
|
||||
{intl.formatMessage({ id: "Rate details" })}
|
||||
</Caption>
|
||||
}
|
||||
>
|
||||
<aside className={styles.rateDetailsPopover}>
|
||||
<header>
|
||||
<Caption type="bold">{room.cancellationText}</Caption>
|
||||
</header>
|
||||
{room.rateDetails?.map((detail, idx) => (
|
||||
<Caption key={`rateDetails-${idx}`}>{detail}</Caption>
|
||||
))}
|
||||
</aside>
|
||||
</Popover>
|
||||
</div>
|
||||
{room.packages
|
||||
? room.packages.map((roomPackage) => (
|
||||
@@ -214,35 +235,33 @@ export default function Summary({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{chosenBreakfast ? (
|
||||
chosenBreakfast === BreakfastPackageEnum.NO_BREAKFAST ? (
|
||||
<div className={styles.entry}>
|
||||
<Body color="uiTextHighContrast">
|
||||
{intl.formatMessage({ id: "No breakfast" })}
|
||||
</Body>
|
||||
<Caption color="uiTextHighContrast">
|
||||
{intl.formatMessage(
|
||||
{ id: "{amount} {currency}" },
|
||||
{ amount: "0", currency: room.localPrice.currency }
|
||||
)}
|
||||
</Caption>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.entry}>
|
||||
<Body color="uiTextHighContrast">
|
||||
{intl.formatMessage({ id: "Breakfast buffet" })}
|
||||
</Body>
|
||||
<Caption color="uiTextHighContrast">
|
||||
{intl.formatMessage(
|
||||
{ id: "{amount} {currency}" },
|
||||
{
|
||||
amount: chosenBreakfast.localPrice.totalPrice,
|
||||
currency: chosenBreakfast.localPrice.currency,
|
||||
}
|
||||
)}
|
||||
</Caption>
|
||||
</div>
|
||||
)
|
||||
{chosenBreakfast === false ? (
|
||||
<div className={styles.entry}>
|
||||
<Body color="uiTextHighContrast">
|
||||
{intl.formatMessage({ id: "No breakfast" })}
|
||||
</Body>
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{intl.formatMessage(
|
||||
{ id: "{amount} {currency}" },
|
||||
{ amount: "0", currency: room.localPrice.currency }
|
||||
)}
|
||||
</Caption>
|
||||
</div>
|
||||
) : chosenBreakfast?.code ? (
|
||||
<div className={styles.entry}>
|
||||
<Body color="uiTextHighContrast">
|
||||
{intl.formatMessage({ id: "Breakfast buffet" })}
|
||||
</Body>
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{intl.formatMessage(
|
||||
{ id: "{amount} {currency}" },
|
||||
{
|
||||
amount: chosenBreakfast.localPrice.totalPrice,
|
||||
currency: chosenBreakfast.localPrice.currency,
|
||||
}
|
||||
)}
|
||||
</Caption>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<Divider color="primaryLightSubtle" />
|
||||
@@ -269,16 +288,18 @@ export default function Summary({
|
||||
}
|
||||
)}
|
||||
</Body>
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{intl.formatMessage({ id: "Approx." })}{" "}
|
||||
{intl.formatMessage(
|
||||
{ id: "{amount} {currency}" },
|
||||
{
|
||||
amount: intl.formatNumber(totalPrice.euro.price),
|
||||
currency: totalPrice.euro.currency,
|
||||
}
|
||||
)}
|
||||
</Caption>
|
||||
{totalPrice.euro && (
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{intl.formatMessage({ id: "Approx." })}{" "}
|
||||
{intl.formatMessage(
|
||||
{ id: "{amount} {currency}" },
|
||||
{
|
||||
amount: intl.formatNumber(totalPrice.euro.price),
|
||||
currency: totalPrice.euro.currency,
|
||||
}
|
||||
)}
|
||||
</Caption>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Divider className={styles.bottomDivider} color="primaryLightSubtle" />
|
||||
|
||||
@@ -41,6 +41,13 @@
|
||||
gap: var(--Spacing-x-one-and-half);
|
||||
}
|
||||
|
||||
.rateDetailsPopover {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x-half);
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.entry {
|
||||
display: flex;
|
||||
gap: var(--Spacing-x-half);
|
||||
@@ -50,6 +57,7 @@
|
||||
.entry > :last-child {
|
||||
justify-items: flex-end;
|
||||
}
|
||||
|
||||
.total {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import Divider from "@/components/TempDesignSystem/Divider"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
@@ -9,15 +10,14 @@ import styles from "../hotelPriceList.module.css"
|
||||
import type { PriceCardProps } from "@/types/components/hotelReservation/selectHotel/priceCardProps"
|
||||
|
||||
export default function HotelPriceCard({
|
||||
currency,
|
||||
memberAmount,
|
||||
regularAmount,
|
||||
productTypePrices,
|
||||
isMemberPrice = false,
|
||||
}: PriceCardProps) {
|
||||
const intl = useIntl()
|
||||
|
||||
return (
|
||||
<dl className={styles.priceCard}>
|
||||
{memberAmount && (
|
||||
{isMemberPrice && (
|
||||
<div className={styles.priceRow}>
|
||||
<dt>
|
||||
<Caption color="red">
|
||||
@@ -30,7 +30,7 @@ export default function HotelPriceCard({
|
||||
<dt>
|
||||
<Caption
|
||||
type="bold"
|
||||
color={memberAmount ? "red" : "uiTextHighContrast"}
|
||||
color={isMemberPrice ? "red" : "uiTextHighContrast"}
|
||||
>
|
||||
{intl.formatMessage({ id: "From" })}
|
||||
</Caption>
|
||||
@@ -39,15 +39,15 @@ export default function HotelPriceCard({
|
||||
<div className={styles.price}>
|
||||
<Subtitle
|
||||
type="two"
|
||||
color={memberAmount ? "red" : "uiTextHighContrast"}
|
||||
color={isMemberPrice ? "red" : "uiTextHighContrast"}
|
||||
>
|
||||
{memberAmount ? memberAmount : regularAmount}
|
||||
{productTypePrices.localPrice.pricePerNight}
|
||||
</Subtitle>
|
||||
<Body
|
||||
color={memberAmount ? "red" : "uiTextHighContrast"}
|
||||
color={isMemberPrice ? "red" : "uiTextHighContrast"}
|
||||
textTransform="bold"
|
||||
>
|
||||
{currency}
|
||||
{productTypePrices.localPrice.currency}
|
||||
<span className={styles.perNight}>
|
||||
/{intl.formatMessage({ id: "night" })}
|
||||
</span>
|
||||
@@ -55,17 +55,40 @@ export default function HotelPriceCard({
|
||||
</div>
|
||||
</dd>
|
||||
</div>
|
||||
{/* TODO add correct local price when API change */}
|
||||
<div className={styles.priceRow}>
|
||||
<dt>
|
||||
<Caption color={"disabled"}>
|
||||
{intl.formatMessage({ id: "Approx." })}
|
||||
</Caption>
|
||||
</dt>
|
||||
<dd>
|
||||
<Caption color="disabled"> - EUR</Caption>
|
||||
</dd>
|
||||
</div>
|
||||
{productTypePrices?.requestedPrice && (
|
||||
<div className={styles.priceRow}>
|
||||
<dt>
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{intl.formatMessage({ id: "Approx." })}
|
||||
</Caption>
|
||||
</dt>
|
||||
<dd>
|
||||
<Caption color={"uiTextMediumContrast"}>
|
||||
{productTypePrices.requestedPrice.pricePerNight}{" "}
|
||||
{productTypePrices.requestedPrice.currency}
|
||||
</Caption>
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
{productTypePrices.localPrice.pricePerStay !==
|
||||
productTypePrices.localPrice.pricePerNight && (
|
||||
<>
|
||||
<Divider color="subtle" className={styles.divider} />
|
||||
<div className={styles.priceRow}>
|
||||
<dt>
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{intl.formatMessage({ id: "Total" })}
|
||||
</Caption>
|
||||
</dt>
|
||||
<dd>
|
||||
<Caption color={"uiTextMediumContrast"}>
|
||||
{productTypePrices.localPrice.pricePerStay}{" "}
|
||||
{productTypePrices.localPrice.currency}
|
||||
</Caption>
|
||||
</dd>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</dl>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -11,6 +11,16 @@
|
||||
gap: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.prices {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x-one-and-half);
|
||||
}
|
||||
|
||||
.divider {
|
||||
margin: var(--Spacing-x-half) 0;
|
||||
}
|
||||
|
||||
.priceRow {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -27,3 +37,9 @@
|
||||
font-weight: 400;
|
||||
font-size: var(--typography-Caption-Regular-fontSize);
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1367px) {
|
||||
.prices {
|
||||
max-width: 260px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { useParams } from "next/dist/client/components/navigation"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { Lang } from "@/constants/languages"
|
||||
import { selectRate } from "@/constants/routes/hotelReservation"
|
||||
|
||||
import { ErrorCircleIcon } from "@/components/Icons"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
|
||||
import HotelPriceCard from "./HotelPriceCard"
|
||||
@@ -9,34 +15,52 @@ import styles from "./hotelPriceList.module.css"
|
||||
|
||||
import { HotelPriceListProps } from "@/types/components/hotelReservation/selectHotel/hotePriceListProps"
|
||||
|
||||
export default function HotelPriceList({ price }: HotelPriceListProps) {
|
||||
export default function HotelPriceList({
|
||||
price,
|
||||
hotelId,
|
||||
}: HotelPriceListProps) {
|
||||
const intl = useIntl()
|
||||
const params = useParams()
|
||||
const lang = params.lang as Lang
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.prices}>
|
||||
{price ? (
|
||||
<>
|
||||
<HotelPriceCard
|
||||
currency={price?.currency}
|
||||
regularAmount={price?.regularAmount}
|
||||
/>
|
||||
<HotelPriceCard
|
||||
currency={price?.currency}
|
||||
memberAmount={price?.memberAmount}
|
||||
/>
|
||||
{price.public && <HotelPriceCard productTypePrices={price.public} />}
|
||||
{price.member && (
|
||||
<HotelPriceCard productTypePrices={price.member} isMemberPrice />
|
||||
)}
|
||||
<Button
|
||||
asChild
|
||||
theme="base"
|
||||
intent="primary"
|
||||
size="small"
|
||||
className={styles.button}
|
||||
>
|
||||
<Link
|
||||
href={`${selectRate(lang)}?hotel=${hotelId}`}
|
||||
color="none"
|
||||
keepSearchParams
|
||||
>
|
||||
{intl.formatMessage({ id: "See rooms" })}
|
||||
</Link>
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<div className={styles.priceCard}>
|
||||
<div className={styles.noRooms}>
|
||||
<ErrorCircleIcon color="red" />
|
||||
<div>
|
||||
<ErrorCircleIcon color="red" />
|
||||
</div>
|
||||
<Body>
|
||||
{intl.formatMessage({
|
||||
id: "There are no rooms available that match your request",
|
||||
id: "There are no rooms available that match your request.",
|
||||
})}
|
||||
</Body>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -70,13 +70,6 @@
|
||||
gap: var(--Spacing-x-half);
|
||||
}
|
||||
|
||||
.prices {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x-one-and-half);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.detailsButton {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
@@ -3,11 +3,10 @@ import { useParams } from "next/dist/client/components/navigation"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { Lang } from "@/constants/languages"
|
||||
import { selectHotelMap } from "@/constants/routes/hotelReservation"
|
||||
import { selectHotelMap, selectRate } from "@/constants/routes/hotelReservation"
|
||||
|
||||
import { mapFacilityToIcon } from "@/components/ContentType/HotelPage/data"
|
||||
import ImageGallery from "@/components/ImageGallery"
|
||||
import Alert from "@/components/TempDesignSystem/Alert"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Divider from "@/components/TempDesignSystem/Divider"
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
@@ -93,7 +92,7 @@ export default function HotelCard({
|
||||
</address>
|
||||
<Link
|
||||
className={styles.addressMobile}
|
||||
href={`${selectHotelMap[lang]}?selectedHotel=${hotelData.name}`}
|
||||
href={`${selectHotelMap(lang)}?selectedHotel=${hotelData.name}`}
|
||||
keepSearchParams
|
||||
>
|
||||
<Caption color="baseTextMediumContrast" type="underline">
|
||||
@@ -133,33 +132,8 @@ export default function HotelCard({
|
||||
hotel={hotelData}
|
||||
showCTA={true}
|
||||
/>
|
||||
{hotelData.specialAlerts.length > 0 && (
|
||||
<div className={styles.specialAlerts}>
|
||||
{hotelData.specialAlerts.map((alert) => (
|
||||
<Alert key={alert.id} type={alert.type} text={alert.text} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
<div className={styles.prices}>
|
||||
<HotelPriceList price={price} />
|
||||
<Button
|
||||
asChild
|
||||
theme="base"
|
||||
intent="primary"
|
||||
size="small"
|
||||
className={styles.button}
|
||||
>
|
||||
{/* TODO: Localize link and also use correct search params */}
|
||||
<Link
|
||||
href={`/en/hotelreservation/select-rate?hotel=${hotelData.operaId}`}
|
||||
color="none"
|
||||
keepSearchParams
|
||||
>
|
||||
{intl.formatMessage({ id: "See rooms" })}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<HotelPriceList price={price} hotelId={hotel.hotelData.operaId} />
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
|
||||
@@ -104,7 +104,7 @@ export default function HotelCardDialog({
|
||||
|
||||
<Button asChild theme="base" size="small" className={styles.button}>
|
||||
<Link
|
||||
href={`${selectRate[lang]}?hotel=${data.operaId}`}
|
||||
href={`${selectRate(lang)}?hotel=${data.operaId}`}
|
||||
color="none"
|
||||
keepSearchParams
|
||||
>
|
||||
|
||||
@@ -8,9 +8,9 @@ export function getHotelPins(hotels: HotelData[]): HotelPin[] {
|
||||
lng: hotel.hotelData.location.longitude,
|
||||
},
|
||||
name: hotel.hotelData.name,
|
||||
publicPrice: hotel.price?.regularAmount ?? null,
|
||||
memberPrice: hotel.price?.memberAmount ?? null,
|
||||
currency: hotel.price?.currency || null,
|
||||
publicPrice: hotel.price?.public?.localPrice.pricePerNight ?? null,
|
||||
memberPrice: hotel.price?.member?.localPrice.pricePerNight ?? null,
|
||||
currency: hotel.price?.public?.localPrice.currency || null,
|
||||
images: [
|
||||
hotel.hotelData.hotelContent.images,
|
||||
...(hotel.hotelData.gallery?.heroImages ?? []),
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"use client"
|
||||
import { useSearchParams } from "next/navigation"
|
||||
import { useMemo } from "react"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
|
||||
import Title from "@/components/TempDesignSystem/Text/Title"
|
||||
import { useHotelFilterStore } from "@/stores/hotel-filters"
|
||||
|
||||
import { BackToTopButton } from "@/components/TempDesignSystem/BackToTopButton"
|
||||
|
||||
import HotelCard from "../HotelCard"
|
||||
import { DEFAULT_SORT } from "../SelectHotel/HotelSorter"
|
||||
@@ -12,6 +14,7 @@ import styles from "./hotelCardListing.module.css"
|
||||
import {
|
||||
type HotelCardListingProps,
|
||||
HotelCardListingTypeEnum,
|
||||
type HotelData,
|
||||
} from "@/types/components/hotelReservation/selectHotel/hotelCardListingProps"
|
||||
import { SortOrder } from "@/types/components/hotelReservation/selectHotel/hotelSorter"
|
||||
|
||||
@@ -22,6 +25,9 @@ export default function HotelCardListing({
|
||||
onHotelCardHover,
|
||||
}: HotelCardListingProps) {
|
||||
const searchParams = useSearchParams()
|
||||
const activeFilters = useHotelFilterStore((state) => state.activeFilters)
|
||||
const setResultCount = useHotelFilterStore((state) => state.setResultCount)
|
||||
const [showBackToTop, setShowBackToTop] = useState<boolean>(false)
|
||||
|
||||
const sortBy = useMemo(
|
||||
() => searchParams.get("sort") ?? DEFAULT_SORT,
|
||||
@@ -41,10 +47,15 @@ export default function HotelCardListing({
|
||||
(a.hotelData.ratings?.tripAdvisor.rating ?? 0)
|
||||
)
|
||||
case SortOrder.Price:
|
||||
const getPricePerNight = (hotel: HotelData): number => {
|
||||
return (
|
||||
hotel.price?.member?.localPrice?.pricePerNight ??
|
||||
hotel.price?.public?.localPrice?.pricePerNight ??
|
||||
Infinity
|
||||
)
|
||||
}
|
||||
return [...hotelData].sort(
|
||||
(a, b) =>
|
||||
parseInt(a.price?.memberAmount ?? "0", 10) -
|
||||
parseInt(b.price?.memberAmount ?? "0", 10)
|
||||
(a, b) => getPricePerNight(a) - getPricePerNight(b)
|
||||
)
|
||||
case SortOrder.Distance:
|
||||
default:
|
||||
@@ -57,17 +68,36 @@ export default function HotelCardListing({
|
||||
}, [hotelData, sortBy])
|
||||
|
||||
const hotels = useMemo(() => {
|
||||
const appliedFilters = searchParams.get("filters")?.split(",")
|
||||
if (!appliedFilters || appliedFilters.length === 0) return sortedHotels
|
||||
if (activeFilters.length === 0) {
|
||||
setResultCount(sortedHotels.length)
|
||||
return sortedHotels
|
||||
}
|
||||
|
||||
return sortedHotels.filter((hotel) =>
|
||||
appliedFilters.every((appliedFilterId) =>
|
||||
const filteredHotels = sortedHotels.filter((hotel) =>
|
||||
activeFilters.every((appliedFilterId) =>
|
||||
hotel.hotelData.detailedFacilities.some(
|
||||
(facility) => facility.id.toString() === appliedFilterId
|
||||
)
|
||||
)
|
||||
)
|
||||
}, [searchParams, sortedHotels])
|
||||
|
||||
setResultCount(filteredHotels.length)
|
||||
return filteredHotels
|
||||
}, [activeFilters, sortedHotels, setResultCount])
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
const hasScrolledPast = window.scrollY > 490
|
||||
setShowBackToTop(hasScrolledPast)
|
||||
}
|
||||
|
||||
window.addEventListener("scroll", handleScroll, { passive: true })
|
||||
return () => window.removeEventListener("scroll", handleScroll)
|
||||
}, [])
|
||||
|
||||
function scrollToTop() {
|
||||
window.scrollTo({ top: 0, behavior: "smooth" })
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={styles.hotelCards}>
|
||||
@@ -82,6 +112,7 @@ export default function HotelCardListing({
|
||||
/>
|
||||
))
|
||||
: null}
|
||||
{showBackToTop && <BackToTopButton onClick={scrollToTop} />}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
.hotelSelectionHeader {
|
||||
background-color: var(--Base-Surface-Subtle-Normal);
|
||||
padding: var(--Spacing-x3) var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.hotelSelectionHeaderWrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x3);
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.titleContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
gap: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.descriptionContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x-one-and-half);
|
||||
}
|
||||
|
||||
.address {
|
||||
display: flex;
|
||||
gap: var(--Spacing-x-one-and-half);
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.dividerContainer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.hotelSelectionHeader {
|
||||
padding: var(--Spacing-x4) 0;
|
||||
}
|
||||
|
||||
.hotelSelectionHeaderWrapper {
|
||||
flex-direction: row;
|
||||
gap: var(--Spacing-x6);
|
||||
margin: 0 auto;
|
||||
/* simulates padding on viewport smaller than --max-width-navigation */
|
||||
width: min(
|
||||
calc(100dvw - (var(--Spacing-x2) * 2)),
|
||||
var(--max-width-navigation)
|
||||
);
|
||||
}
|
||||
|
||||
.titleContainer > h1 {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dividerContainer {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.address {
|
||||
gap: var(--Spacing-x3);
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
"use client"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import Divider from "@/components/TempDesignSystem/Divider"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import Title from "@/components/TempDesignSystem/Text/Title"
|
||||
|
||||
import styles from "./hotelSelectionHeader.module.css"
|
||||
|
||||
import { HotelSelectionHeaderProps } from "@/types/components/hotelReservation/selectRate/hotelSelectionHeader"
|
||||
|
||||
export default function HotelSelectionHeader({
|
||||
hotel,
|
||||
}: HotelSelectionHeaderProps) {
|
||||
const intl = useIntl()
|
||||
|
||||
return (
|
||||
<header className={styles.hotelSelectionHeader}>
|
||||
<div className={styles.hotelSelectionHeaderWrapper}>
|
||||
<div className={styles.titleContainer}>
|
||||
<Title as="h3" level="h1">
|
||||
{hotel.name}
|
||||
</Title>
|
||||
<address className={styles.address}>
|
||||
<Caption color="textMediumContrast">
|
||||
{hotel.address.streetAddress}, {hotel.address.city}
|
||||
</Caption>
|
||||
<div>
|
||||
<Divider variant="vertical" color="subtle" />
|
||||
</div>
|
||||
<Caption color="textMediumContrast">
|
||||
{intl.formatMessage(
|
||||
{ id: "Distance in km to city centre" },
|
||||
{ number: hotel.location.distanceToCentre }
|
||||
)}
|
||||
</Caption>
|
||||
</address>
|
||||
</div>
|
||||
<div className={styles.dividerContainer}>
|
||||
<Divider variant="vertical" color="subtle" />
|
||||
</div>
|
||||
<div className={styles.descriptionContainer}>
|
||||
<Body color="baseTextHighContrast">
|
||||
{hotel.hotelContent.texts.descriptions.short}
|
||||
</Body>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
+180
@@ -0,0 +1,180 @@
|
||||
@keyframes modal-fade {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@keyframes modal-slide-up {
|
||||
from {
|
||||
bottom: -100%;
|
||||
}
|
||||
|
||||
to {
|
||||
bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.overlay {
|
||||
align-items: center;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
height: var(--visual-viewport-height);
|
||||
justify-content: center;
|
||||
left: 0;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
width: 100vw;
|
||||
z-index: 100;
|
||||
|
||||
&[data-entering] {
|
||||
animation: modal-fade 200ms;
|
||||
}
|
||||
|
||||
&[data-exiting] {
|
||||
animation: modal-fade 150ms reverse ease-in;
|
||||
}
|
||||
}
|
||||
|
||||
.modal {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
height: calc(100dvh - 20px);
|
||||
background-color: var(--Base-Surface-Primary-light-Normal);
|
||||
border-radius: var(--Corner-radius-Medium);
|
||||
box-shadow: 0px 4px 24px 0px rgba(38, 32, 30, 0.08);
|
||||
width: 100%;
|
||||
|
||||
&[data-entering] {
|
||||
animation: modal-slide-up 200ms;
|
||||
}
|
||||
|
||||
&[data-existing] {
|
||||
animation: modal-slide-up 200ms reverse;
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x3);
|
||||
display: flex;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.sorter {
|
||||
padding: var(--Spacing-x2);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.filters {
|
||||
padding: var(--Spacing-x2);
|
||||
flex: 1 1 auto;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.header {
|
||||
text-align: right;
|
||||
padding: var(--Spacing-x-one-and-half);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.title {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.close {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
justify-self: flex-end;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
gap: var(--Spacing-x1);
|
||||
padding: var(--Spacing-x2);
|
||||
flex: 0 0 auto;
|
||||
border-top: 1px solid var(--Base-Border-Subtle);
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
.modal {
|
||||
left: 50%;
|
||||
bottom: 50%;
|
||||
height: min(80dvh, 680px);
|
||||
width: min(80dvw, 960px);
|
||||
translate: -50% 50%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
padding: var(--Spacing-x2) var(--Spacing-x3);
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--Base-Border-Subtle);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--Base-Surface-Primary-light-Normal);
|
||||
z-index: 1;
|
||||
border-top-left-radius: var(--Corner-radius-large);
|
||||
border-top-right-radius: var(--Corner-radius-large);
|
||||
}
|
||||
|
||||
.title {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.content {
|
||||
gap: var(--Spacing-x4);
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.filters {
|
||||
overflow-y: unset;
|
||||
}
|
||||
|
||||
.sorter,
|
||||
.filters,
|
||||
.footer,
|
||||
.divider {
|
||||
padding: 0 var(--Spacing-x3);
|
||||
}
|
||||
|
||||
.footer {
|
||||
flex-direction: row-reverse;
|
||||
justify-content: space-between;
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
background: var(--Base-Surface-Primary-light-Normal);
|
||||
z-index: 1;
|
||||
border-bottom-left-radius: var(--Corner-radius-large);
|
||||
border-bottom-right-radius: var(--Corner-radius-large);
|
||||
padding: var(--Spacing-x2) var(--Spacing-x3);
|
||||
}
|
||||
|
||||
.filters aside h1 {
|
||||
margin-bottom: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.filters aside > div:last-child {
|
||||
margin-top: var(--Spacing-x4);
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.filters aside ul {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
margin-top: var(--Spacing-x3);
|
||||
}
|
||||
}
|
||||
@media screen and (min-width: 1024) {
|
||||
.facilities ul {
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
Dialog as AriaDialog,
|
||||
DialogTrigger,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
} from "react-aria-components"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { useHotelFilterStore } from "@/stores/hotel-filters"
|
||||
|
||||
import { CloseLargeIcon, FilterIcon } from "@/components/Icons"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Divider from "@/components/TempDesignSystem/Divider"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
|
||||
import HotelFilter from "../HotelFilter"
|
||||
import HotelSorter from "../HotelSorter"
|
||||
|
||||
import styles from "./filterAndSortModal.module.css"
|
||||
|
||||
import type { FilterAndSortModalProps } from "@/types/components/hotelReservation/selectHotel/filterAndSortModal"
|
||||
|
||||
export default function FilterAndSortModal({
|
||||
filters,
|
||||
}: FilterAndSortModalProps) {
|
||||
const intl = useIntl()
|
||||
const resultCount = useHotelFilterStore((state) => state.resultCount)
|
||||
const setFilters = useHotelFilterStore((state) => state.setFilters)
|
||||
|
||||
return (
|
||||
<>
|
||||
<DialogTrigger>
|
||||
<Button intent="secondary" size="small" theme="base">
|
||||
<FilterIcon color="burgundy" />
|
||||
{intl.formatMessage({ id: "Filter and sort" })}
|
||||
</Button>
|
||||
<ModalOverlay className={styles.overlay} isDismissable>
|
||||
<Modal className={styles.modal}>
|
||||
<AriaDialog role="alertdialog" className={styles.content}>
|
||||
{({ close }) => (
|
||||
<>
|
||||
<header className={styles.header}>
|
||||
<button
|
||||
onClick={close}
|
||||
type="button"
|
||||
className={styles.close}
|
||||
>
|
||||
<CloseLargeIcon />
|
||||
</button>
|
||||
<Subtitle
|
||||
type="two"
|
||||
textAlign="center"
|
||||
className={styles.title}
|
||||
>
|
||||
{intl.formatMessage({ id: "Filter and sort" })}
|
||||
</Subtitle>
|
||||
</header>
|
||||
<div className={styles.sorter}>
|
||||
<HotelSorter />
|
||||
</div>
|
||||
<Divider color="subtle" className="divider" />
|
||||
<div className={styles.filters}>
|
||||
<HotelFilter filters={filters} />
|
||||
</div>
|
||||
<footer className={styles.footer}>
|
||||
<Button
|
||||
intent="primary"
|
||||
size="medium"
|
||||
theme="base"
|
||||
onClick={close}
|
||||
>
|
||||
{intl.formatMessage(
|
||||
{ id: "See results" },
|
||||
{ count: resultCount }
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => setFilters([])}
|
||||
intent="text"
|
||||
size="medium"
|
||||
theme="base"
|
||||
>
|
||||
{intl.formatMessage({ id: "Clear all filters" })}
|
||||
</Button>
|
||||
</footer>
|
||||
</>
|
||||
)}
|
||||
</AriaDialog>
|
||||
</Modal>
|
||||
</ModalOverlay>
|
||||
</DialogTrigger>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
"use client"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { useHotelFilterStore } from "@/stores/hotel-filters"
|
||||
|
||||
import Preamble from "@/components/TempDesignSystem/Text/Preamble"
|
||||
|
||||
export default function HotelCount() {
|
||||
const intl = useIntl()
|
||||
const resultCount = useHotelFilterStore((state) => state.resultCount)
|
||||
|
||||
return (
|
||||
<Preamble>
|
||||
{intl.formatMessage(
|
||||
{
|
||||
id: "Hotel(s)",
|
||||
},
|
||||
{ amount: resultCount }
|
||||
)}
|
||||
</Preamble>
|
||||
)
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.container[data-selected] .checkbox {
|
||||
border: none;
|
||||
background: var(--UI-Input-Controls-Fill-Selected);
|
||||
}
|
||||
|
||||
.checkboxContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--Spacing-x-one-and-half);
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
min-width: 24px;
|
||||
border: 1px solid var(--UI-Input-Controls-Border-Normal);
|
||||
border-radius: 4px;
|
||||
transition: all 200ms;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
forced-color-adjust: none;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
"use client"
|
||||
|
||||
import { Checkbox as AriaCheckbox } from "react-aria-components"
|
||||
|
||||
import CheckIcon from "@/components/Icons/Check"
|
||||
|
||||
import styles from "./filterCheckbox.module.css"
|
||||
|
||||
import type { FilterCheckboxProps } from "@/types/components/hotelReservation/selectHotel/filterCheckbox"
|
||||
|
||||
export default function FilterCheckbox({
|
||||
isSelected,
|
||||
name,
|
||||
id,
|
||||
onChange,
|
||||
}: FilterCheckboxProps) {
|
||||
return (
|
||||
<AriaCheckbox
|
||||
className={styles.container}
|
||||
isSelected={isSelected}
|
||||
onChange={() => onChange(id)}
|
||||
>
|
||||
{({ isSelected }) => (
|
||||
<>
|
||||
<span className={styles.checkboxContainer}>
|
||||
<span className={styles.checkbox}>
|
||||
{isSelected && <CheckIcon color="white" />}
|
||||
</span>
|
||||
{name}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</AriaCheckbox>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
.container {
|
||||
min-width: 272px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.container form {
|
||||
@@ -39,9 +38,3 @@
|
||||
height: 1.25rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.container {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,37 +1,42 @@
|
||||
"use client"
|
||||
|
||||
import { usePathname, useSearchParams } from "next/navigation"
|
||||
import { useCallback, useEffect } from "react"
|
||||
import { FormProvider, useForm } from "react-hook-form"
|
||||
import { useEffect } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import Checkbox from "@/components/TempDesignSystem/Form/Checkbox"
|
||||
import { useHotelFilterStore } from "@/stores/hotel-filters"
|
||||
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
import Title from "@/components/TempDesignSystem/Text/Title"
|
||||
|
||||
import FilterCheckbox from "./FilterCheckbox"
|
||||
|
||||
import styles from "./hotelFilter.module.css"
|
||||
|
||||
import type { HotelFiltersProps } from "@/types/components/hotelReservation/selectHotel/hotelFilters"
|
||||
|
||||
export default function HotelFilter({ filters }: HotelFiltersProps) {
|
||||
export default function HotelFilter({ className, filters }: HotelFiltersProps) {
|
||||
const intl = useIntl()
|
||||
const searchParams = useSearchParams()
|
||||
const pathname = usePathname()
|
||||
const toggleFilter = useHotelFilterStore((state) => state.toggleFilter)
|
||||
const setFilters = useHotelFilterStore((state) => state.setFilters)
|
||||
const activeFilters = useHotelFilterStore((state) => state.activeFilters)
|
||||
|
||||
const methods = useForm<Record<string, boolean | undefined>>({
|
||||
defaultValues: searchParams
|
||||
?.get("filters")
|
||||
?.split(",")
|
||||
.reduce((acc, curr) => ({ ...acc, [curr]: true }), {}),
|
||||
})
|
||||
const { watch, handleSubmit, getValues, register } = methods
|
||||
// Initialize the filters from the URL
|
||||
useEffect(() => {
|
||||
const filtersFromUrl = searchParams.get("filters")
|
||||
if (filtersFromUrl) {
|
||||
setFilters(filtersFromUrl.split(","))
|
||||
} else {
|
||||
setFilters([])
|
||||
}
|
||||
}, [searchParams, setFilters])
|
||||
|
||||
const submitFilter = useCallback(() => {
|
||||
// Update the URL when the filters changes
|
||||
useEffect(() => {
|
||||
const newSearchParams = new URLSearchParams(searchParams)
|
||||
const values = Object.entries(getValues())
|
||||
.filter(([_, value]) => !!value)
|
||||
.map(([key, _]) => key)
|
||||
.join(",")
|
||||
const values = activeFilters.join(",")
|
||||
|
||||
if (values === "") {
|
||||
newSearchParams.delete("filters")
|
||||
@@ -46,49 +51,51 @@ export default function HotelFilter({ filters }: HotelFiltersProps) {
|
||||
`${pathname}?${newSearchParams.toString()}`
|
||||
)
|
||||
}
|
||||
}, [getValues, pathname, searchParams])
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = watch(() => handleSubmit(submitFilter)())
|
||||
return () => subscription.unsubscribe()
|
||||
}, [handleSubmit, watch, submitFilter])
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeFilters])
|
||||
|
||||
if (!filters.facilityFilters.length && !filters.surroundingsFilters.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className={styles.container}>
|
||||
<FormProvider {...methods}>
|
||||
<form onSubmit={handleSubmit(submitFilter)}>
|
||||
<Title as="h4">{intl.formatMessage({ id: "Filter by" })}</Title>
|
||||
<div className={styles.facilities}>
|
||||
<Subtitle>
|
||||
{intl.formatMessage({ id: "Hotel facilities" })}
|
||||
</Subtitle>
|
||||
<ul>
|
||||
{filters.facilityFilters.map((filter) => (
|
||||
<li key={`li-${filter.id}`} className={styles.filter}>
|
||||
<Checkbox name={filter.id.toString()}>{filter.name}</Checkbox>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<aside className={`${styles.container} ${className}`}>
|
||||
<Title as="h4">{intl.formatMessage({ id: "Filter by" })}</Title>
|
||||
<div className={styles.facilities}>
|
||||
<Subtitle>{intl.formatMessage({ id: "Hotel facilities" })}</Subtitle>
|
||||
<ul>
|
||||
{filters.facilityFilters.map((filter) => (
|
||||
<li key={`li-${filter.id}`} className={styles.filter}>
|
||||
<FilterCheckbox
|
||||
name={filter.name}
|
||||
id={filter.id.toString()}
|
||||
onChange={() => toggleFilter(filter.id.toString())}
|
||||
isSelected={
|
||||
!!activeFilters.find((f) => f === filter.id.toString())
|
||||
}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className={styles.facilities}>
|
||||
<Subtitle>
|
||||
{intl.formatMessage({ id: "Hotel surroundings" })}
|
||||
</Subtitle>
|
||||
<ul>
|
||||
{filters.surroundingsFilters.map((filter) => (
|
||||
<li key={`li-${filter.id}`} className={styles.filter}>
|
||||
<Checkbox name={filter.id.toString()}>{filter.name}</Checkbox>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</form>
|
||||
</FormProvider>
|
||||
<div className={styles.facilities}>
|
||||
<Subtitle>{intl.formatMessage({ id: "Hotel surroundings" })}</Subtitle>
|
||||
<ul>
|
||||
{filters.surroundingsFilters.map((filter) => (
|
||||
<li key={`li-${filter.id}`} className={styles.filter}>
|
||||
<FilterCheckbox
|
||||
name={filter.name}
|
||||
id={filter.id.toString()}
|
||||
onChange={() => toggleFilter(filter.id.toString())}
|
||||
isSelected={
|
||||
!!activeFilters.find((f) => f === filter.id.toString())
|
||||
}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
.container {
|
||||
width: 339px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -6,16 +6,15 @@ import { useIntl } from "react-intl"
|
||||
|
||||
import Select from "@/components/TempDesignSystem/Select"
|
||||
|
||||
import styles from "./hotelSorter.module.css"
|
||||
|
||||
import {
|
||||
type HotelSorterProps,
|
||||
type SortItem,
|
||||
SortOrder,
|
||||
} from "@/types/components/hotelReservation/selectHotel/hotelSorter"
|
||||
|
||||
export const DEFAULT_SORT = SortOrder.Distance
|
||||
|
||||
export default function HotelSorter() {
|
||||
export default function HotelSorter({ discreet }: HotelSorterProps) {
|
||||
const searchParams = useSearchParams()
|
||||
const pathname = usePathname()
|
||||
const intl = useIntl()
|
||||
@@ -52,16 +51,14 @@ export default function HotelSorter() {
|
||||
]
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<Select
|
||||
items={sortItems}
|
||||
defaultSelectedKey={searchParams.get("sort") ?? DEFAULT_SORT}
|
||||
label={intl.formatMessage({ id: "Sort by" })}
|
||||
name="sort"
|
||||
showRadioButton
|
||||
discreet
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
items={sortItems}
|
||||
defaultSelectedKey={searchParams.get("sort") ?? DEFAULT_SORT}
|
||||
label={intl.formatMessage({ id: "Sort by" })}
|
||||
name="sort"
|
||||
showRadioButton
|
||||
discreet={discreet}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,45 +4,34 @@ import { useIntl } from "react-intl"
|
||||
|
||||
import { selectHotelMap } from "@/constants/routes/hotelReservation"
|
||||
|
||||
import { FilterIcon, MapIcon } from "@/components/Icons"
|
||||
import { MapIcon } from "@/components/Icons"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import useLang from "@/hooks/useLang"
|
||||
|
||||
import FilterAndSortModal from "../FilterAndSortModal"
|
||||
|
||||
import styles from "./mobileMapButtonContainer.module.css"
|
||||
|
||||
export default function MobileMapButtonContainer({ city }: { city: string }) {
|
||||
import { CategorizedFilters } from "@/types/components/hotelReservation/selectHotel/hotelFilters"
|
||||
|
||||
export default function MobileMapButtonContainer({
|
||||
filters,
|
||||
}: {
|
||||
filters: CategorizedFilters
|
||||
}) {
|
||||
const intl = useIntl()
|
||||
const lang = useLang()
|
||||
|
||||
return (
|
||||
<div className={styles.buttonContainer}>
|
||||
<Button
|
||||
asChild
|
||||
variant="icon"
|
||||
intent="secondary"
|
||||
size="small"
|
||||
className={styles.button}
|
||||
>
|
||||
<Link
|
||||
href={`${selectHotelMap[lang]}`}
|
||||
keepSearchParams
|
||||
color="burgundy"
|
||||
>
|
||||
<Button asChild variant="icon" intent="secondary" size="small">
|
||||
<Link href={selectHotelMap(lang)} keepSearchParams color="burgundy">
|
||||
<MapIcon color="burgundy" />
|
||||
{intl.formatMessage({ id: "See on map" })}
|
||||
</Link>
|
||||
</Button>
|
||||
{/* TODO: Add filter toggle */}
|
||||
<Button
|
||||
variant="icon"
|
||||
intent="secondary"
|
||||
size="small"
|
||||
className={styles.button}
|
||||
>
|
||||
<FilterIcon color="burgundy" />
|
||||
{intl.formatMessage({ id: "Filter and sort" })}
|
||||
</Button>
|
||||
<FilterAndSortModal filters={filters} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+2
-2
@@ -4,8 +4,8 @@
|
||||
margin-bottom: var(--Spacing-x3);
|
||||
}
|
||||
|
||||
.button {
|
||||
flex: 1;
|
||||
.buttonContainer > * {
|
||||
flex: 1 1 50%;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
|
||||
@@ -7,11 +7,13 @@ import { useMediaQuery } from "usehooks-ts"
|
||||
|
||||
import { selectHotel } from "@/constants/routes/hotelReservation"
|
||||
|
||||
import { CloseIcon, CloseLargeIcon } from "@/components/Icons"
|
||||
import { ArrowUpIcon, CloseIcon, CloseLargeIcon } from "@/components/Icons"
|
||||
import InteractiveMap from "@/components/Maps/InteractiveMap"
|
||||
import { BackToTopButton } from "@/components/TempDesignSystem/BackToTopButton"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import useLang from "@/hooks/useLang"
|
||||
|
||||
import FilterAndSortModal from "../FilterAndSortModal"
|
||||
import HotelListing from "./HotelListing"
|
||||
import { getCentralCoordinates } from "./utils"
|
||||
|
||||
@@ -24,6 +26,7 @@ export default function SelectHotelMap({
|
||||
hotelPins,
|
||||
mapId,
|
||||
hotels,
|
||||
filterList,
|
||||
}: SelectHotelMapProps) {
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
@@ -71,7 +74,7 @@ export default function SelectHotelMap({
|
||||
}
|
||||
|
||||
function handlePageRedirect() {
|
||||
router.push(`${selectHotel[lang]}?${searchParams.toString()}`)
|
||||
router.push(`${selectHotel(lang)}?${searchParams.toString()}`)
|
||||
}
|
||||
|
||||
const closeButton = (
|
||||
@@ -101,25 +104,14 @@ export default function SelectHotelMap({
|
||||
>
|
||||
<CloseLargeIcon />
|
||||
</Button>
|
||||
<span>Filter and sort</span>
|
||||
{/* TODO: Add filter and sort button */}
|
||||
<FilterAndSortModal filters={filterList} />
|
||||
</div>
|
||||
<HotelListing
|
||||
hotels={hotels}
|
||||
activeHotelPin={activeHotelPin}
|
||||
setActiveHotelPin={setActiveHotelPin}
|
||||
/>
|
||||
{showBackToTop && (
|
||||
<Button
|
||||
intent="inverted"
|
||||
size="small"
|
||||
theme="base"
|
||||
className={styles.backToTopButton}
|
||||
onClick={scrollToTop}
|
||||
>
|
||||
{intl.formatMessage({ id: "Back to top" })}
|
||||
</Button>
|
||||
)}
|
||||
{showBackToTop && <BackToTopButton onClick={scrollToTop} />}
|
||||
</div>
|
||||
<InteractiveMap
|
||||
closeButton={closeButton}
|
||||
|
||||
@@ -23,10 +23,6 @@
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
.backToTopButton {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.container .closeButton {
|
||||
display: flex;
|
||||
@@ -34,12 +30,7 @@
|
||||
.container .listingContainer .filterContainer .filterContainerCloseButton {
|
||||
display: none;
|
||||
}
|
||||
.backToTopButton {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
left: 32px;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.listingContainer {
|
||||
background-color: var(--Base-Surface-Secondary-light-Normal);
|
||||
padding: var(--Spacing-x3) var(--Spacing-x4);
|
||||
@@ -50,4 +41,9 @@
|
||||
.container {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.filterContainer {
|
||||
justify-content: flex-end;
|
||||
padding: 0 0 var(--Spacing-x1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,11 +99,13 @@ export default function RoomFilter({
|
||||
<form onSubmit={handleSubmit(submitFilter)}>
|
||||
<div className={styles.roomsFilter}>
|
||||
{filterOptions.map((option) => {
|
||||
const { code, description } = option
|
||||
const { code, description, itemCode } = option
|
||||
const isPetRoom = code === RoomPackageCodeEnum.PET_ROOM
|
||||
const isAllergyRoom = code === RoomPackageCodeEnum.ALLERGY_ROOM
|
||||
const isDisabled =
|
||||
(isAllergyRoom && petFriendly) || (isPetRoom && allergyFriendly)
|
||||
(isAllergyRoom && petFriendly) ||
|
||||
(isPetRoom && allergyFriendly) ||
|
||||
!itemCode
|
||||
|
||||
const checkboxChip = (
|
||||
<CheckboxChip
|
||||
|
||||
-36
@@ -1,36 +0,0 @@
|
||||
import { Button, Dialog, OverlayArrow, Popover } from "react-aria-components"
|
||||
|
||||
import { CloseIcon } from "@/components/Icons"
|
||||
|
||||
import styles from "./popover.module.css"
|
||||
|
||||
import { PricePopoverProps } from "@/types/components/hotelReservation/selectRate/pricePopover"
|
||||
|
||||
export default function PricePopover({
|
||||
children,
|
||||
...props
|
||||
}: PricePopoverProps) {
|
||||
return (
|
||||
<Popover {...props}>
|
||||
<OverlayArrow className={styles.arrow}>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 12 12"
|
||||
style={{ display: "block", transform: "rotate(180deg)" }}
|
||||
>
|
||||
<path d="M0 0L6 6L12 0" fill="white" />
|
||||
</svg>
|
||||
</OverlayArrow>
|
||||
<Dialog>
|
||||
<Button
|
||||
onPress={() => props.onOpenChange?.(false)}
|
||||
className={styles.closeButton}
|
||||
>
|
||||
<CloseIcon className={styles.closeIcon} />
|
||||
</Button>
|
||||
{children}
|
||||
</Dialog>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
-12
@@ -1,12 +0,0 @@
|
||||
.arrow {
|
||||
top: -6px;
|
||||
}
|
||||
|
||||
.closeButton {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
right: 5px;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -1,13 +1,12 @@
|
||||
"use client"
|
||||
import { useRef, useState } from "react"
|
||||
import { Button } from "react-aria-components"
|
||||
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { CheckIcon, InfoCircleIcon } from "@/components/Icons"
|
||||
import Label from "@/components/TempDesignSystem/Form/Label"
|
||||
import Popover from "@/components/TempDesignSystem/Popover"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
|
||||
import PricePopover from "./Popover"
|
||||
import PriceTable from "./PriceList"
|
||||
|
||||
import styles from "./flexibilityOption.module.css"
|
||||
@@ -25,18 +24,8 @@ export default function FlexibilityOption({
|
||||
petRoomPackage,
|
||||
handleSelectRate,
|
||||
}: FlexibilityOptionProps) {
|
||||
const [rootDiv, setRootDiv] = useState<Element | undefined>(undefined)
|
||||
const [isPopoverOpen, setIsPopoverOpen] = useState(false)
|
||||
let triggerRef = useRef<HTMLButtonElement>(null)
|
||||
const buttonClickedRef = useRef(false)
|
||||
const intl = useIntl()
|
||||
|
||||
function setRef(node: Element | null) {
|
||||
if (node) {
|
||||
setRootDiv(node)
|
||||
}
|
||||
}
|
||||
|
||||
if (!product) {
|
||||
return (
|
||||
<div className={styles.noPricesCard}>
|
||||
@@ -47,7 +36,7 @@ export default function FlexibilityOption({
|
||||
</div>
|
||||
<Label size="regular" className={styles.noPricesLabel}>
|
||||
<Caption color="uiTextHighContrast" type="bold">
|
||||
{intl.formatMessage({ id: "No Prices available" })}
|
||||
{intl.formatMessage({ id: "No prices available" })}
|
||||
</Caption>
|
||||
</Label>
|
||||
</div>
|
||||
@@ -68,15 +57,6 @@ export default function FlexibilityOption({
|
||||
handleSelectRate(rate)
|
||||
}
|
||||
|
||||
function togglePopover() {
|
||||
buttonClickedRef.current = !buttonClickedRef.current
|
||||
setIsPopoverOpen(buttonClickedRef.current)
|
||||
}
|
||||
|
||||
function handlePopoverChange(isOpen: boolean) {
|
||||
setIsPopoverOpen(isOpen)
|
||||
}
|
||||
|
||||
return (
|
||||
<label>
|
||||
<input
|
||||
@@ -86,37 +66,16 @@ export default function FlexibilityOption({
|
||||
onChange={onChange}
|
||||
/>
|
||||
<div className={styles.card}>
|
||||
<div className={styles.header} ref={setRef}>
|
||||
<Button
|
||||
aria-label="Help"
|
||||
className={styles.button}
|
||||
onPress={() => {
|
||||
togglePopover()
|
||||
}}
|
||||
ref={triggerRef}
|
||||
>
|
||||
<InfoCircleIcon
|
||||
width={16}
|
||||
height={16}
|
||||
color="uiTextMediumContrast"
|
||||
/>
|
||||
</Button>
|
||||
<PricePopover
|
||||
placement="bottom"
|
||||
className={styles.popover}
|
||||
isNonModal
|
||||
shouldFlip={false}
|
||||
shouldUpdatePosition={false}
|
||||
/**
|
||||
* react-aria uses portals to render Popover in body
|
||||
* unless otherwise specified. We need it to be contained
|
||||
* by this component to both access css variables assigned
|
||||
* on the container as well as to not overflow it at any time.
|
||||
*/
|
||||
UNSTABLE_portalContainer={rootDiv}
|
||||
triggerRef={triggerRef}
|
||||
isOpen={isPopoverOpen}
|
||||
onOpenChange={handlePopoverChange}
|
||||
<div className={styles.header}>
|
||||
<Popover
|
||||
placement="bottom left"
|
||||
triggerContent={
|
||||
<InfoCircleIcon
|
||||
width={16}
|
||||
height={16}
|
||||
color="uiTextMediumContrast"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Caption
|
||||
color="uiTextHighContrast"
|
||||
@@ -134,7 +93,7 @@ export default function FlexibilityOption({
|
||||
{info}
|
||||
</Caption>
|
||||
))}
|
||||
</PricePopover>
|
||||
</Popover>
|
||||
<Caption color="uiTextHighContrast">{name}</Caption>
|
||||
<Caption color="uiTextPlaceholder">({paymentTerm})</Caption>
|
||||
</div>
|
||||
|
||||
@@ -54,9 +54,22 @@ export default function RoomCard({
|
||||
: undefined
|
||||
}
|
||||
|
||||
function getPriceInformationForRate(rate: RateDefinition | undefined) {
|
||||
function getRateDefinitionForRate(rate: RateDefinition | undefined) {
|
||||
return rateDefinitions.find((def) => def.rateCode === rate?.rateCode)
|
||||
?.generalTerms
|
||||
}
|
||||
|
||||
const getBreakfastMessage = (rate: RateDefinition | undefined) => {
|
||||
const breakfastIncluded = getRateDefinitionForRate(rate)?.breakfastIncluded
|
||||
switch (breakfastIncluded) {
|
||||
case true:
|
||||
return intl.formatMessage({ id: "Breakfast is included." })
|
||||
case false:
|
||||
return intl.formatMessage({ id: "Breakfast selection in next step." })
|
||||
default:
|
||||
return intl.formatMessage({
|
||||
id: "Breakfast deal can be purchased at the hotel.",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const petRoomPackage =
|
||||
@@ -69,7 +82,6 @@ export default function RoomCard({
|
||||
)
|
||||
|
||||
const { roomSize, occupancy, images } = selectedRoom || {}
|
||||
const mainImage = images?.[0]
|
||||
|
||||
const freeCancelation = intl.formatMessage({ id: "Free cancellation" })
|
||||
const nonRefundable = intl.formatMessage({ id: "Non-refundable" })
|
||||
@@ -101,53 +113,56 @@ export default function RoomCard({
|
||||
return (
|
||||
<div className={classNames}>
|
||||
<div>
|
||||
{mainImage && (
|
||||
<div className={styles.imageContainer}>
|
||||
<div className={styles.chipContainer}>
|
||||
{roomConfiguration.roomsLeft < 5 && (
|
||||
<span className={styles.chip}>
|
||||
<Footnote
|
||||
color="burgundy"
|
||||
textTransform="uppercase"
|
||||
>{`${roomConfiguration.roomsLeft} ${intl.formatMessage({ id: "Left" })}`}</Footnote>
|
||||
</span>
|
||||
)}
|
||||
{roomConfiguration.features
|
||||
.filter((feature) => selectedPackages.includes(feature.code))
|
||||
.map((feature) => (
|
||||
<span className={styles.chip} key={feature.code}>
|
||||
{createElement(getIconForFeatureCode(feature.code), {
|
||||
width: 16,
|
||||
height: 16,
|
||||
color: "burgundy",
|
||||
})}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{/*NOTE: images from the test API are hosted on test3.scandichotels.com,
|
||||
which can't be accessed unless on Scandic's Wifi or using Citrix. */}
|
||||
<ImageGallery
|
||||
images={images}
|
||||
title={roomConfiguration.roomType}
|
||||
fill
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.specification}>
|
||||
<Caption color="uiTextMediumContrast" className={styles.guests}>
|
||||
{intl.formatMessage(
|
||||
{
|
||||
id: "booking.guests",
|
||||
},
|
||||
{ nrOfGuests: occupancy?.total }
|
||||
<div className={styles.imageContainer}>
|
||||
<div className={styles.chipContainer}>
|
||||
{roomConfiguration.roomsLeft < 5 && (
|
||||
<span className={styles.chip}>
|
||||
<Footnote
|
||||
color="burgundy"
|
||||
textTransform="uppercase"
|
||||
>{`${roomConfiguration.roomsLeft} ${intl.formatMessage({ id: "Left" })}`}</Footnote>
|
||||
</span>
|
||||
)}
|
||||
</Caption>
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{roomSize?.min === roomSize?.max
|
||||
? roomSize?.min
|
||||
: `${roomSize?.min}-${roomSize?.max}`}
|
||||
m²
|
||||
</Caption>
|
||||
{roomConfiguration.features
|
||||
.filter((feature) => selectedPackages.includes(feature.code))
|
||||
.map((feature) => (
|
||||
<span className={styles.chip} key={feature.code}>
|
||||
{createElement(getIconForFeatureCode(feature.code), {
|
||||
width: 16,
|
||||
height: 16,
|
||||
color: "burgundy",
|
||||
})}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{/*NOTE: images from the test API are hosted on test3.scandichotels.com,
|
||||
which can't be accessed unless on Scandic's Wifi or using Citrix. */}
|
||||
<ImageGallery
|
||||
images={images}
|
||||
title={roomConfiguration.roomType}
|
||||
fill
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.specification}>
|
||||
{occupancy && (
|
||||
<Caption color="uiTextMediumContrast" className={styles.guests}>
|
||||
{intl.formatMessage(
|
||||
{
|
||||
id: "booking.guests",
|
||||
},
|
||||
{ nrOfGuests: occupancy?.total }
|
||||
)}
|
||||
</Caption>
|
||||
)}
|
||||
{roomSize && (
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{roomSize.min === roomSize.max
|
||||
? roomSize.min
|
||||
: `${roomSize.min}-${roomSize.max}`}
|
||||
m²
|
||||
</Caption>
|
||||
)}
|
||||
<div className={styles.toggleSidePeek}>
|
||||
{roomConfiguration.roomTypeCode && (
|
||||
<ToggleSidePeek
|
||||
@@ -168,9 +183,7 @@ export default function RoomCard({
|
||||
</div>
|
||||
<div className={styles.container}>
|
||||
<Caption color="uiTextHighContrast" type="bold">
|
||||
{intl.formatMessage({
|
||||
id: "Breakfast selection in next step.",
|
||||
})}
|
||||
{getBreakfastMessage(rates.flexRate)}
|
||||
</Caption>
|
||||
{roomConfiguration.status === "NotAvailable" ? (
|
||||
<div className={styles.noRoomsContainer}>
|
||||
@@ -192,7 +205,7 @@ export default function RoomCard({
|
||||
value={key.toLowerCase()}
|
||||
paymentTerm={key === "flexRate" ? payLater : payNow}
|
||||
product={findProductForRate(rate)}
|
||||
priceInformation={getPriceInformationForRate(rate)}
|
||||
priceInformation={getRateDefinitionForRate(rate)?.generalTerms}
|
||||
handleSelectRate={handleSelectRate}
|
||||
roomType={roomConfiguration.roomType}
|
||||
roomTypeCode={roomConfiguration.roomTypeCode}
|
||||
|
||||
@@ -7,23 +7,13 @@
|
||||
border: 1px solid var(--Base-Border-Subtle);
|
||||
position: relative;
|
||||
height: 100%;
|
||||
min-height: 730px;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.card.noAvailability {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.card.noAvailability:before {
|
||||
background-color: rgba(0, 0, 0, 40%);
|
||||
content: "";
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 2;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.specification {
|
||||
|
||||
@@ -14,7 +14,7 @@ export default function RoomSelection({
|
||||
roomsAvailability,
|
||||
roomCategories,
|
||||
user,
|
||||
packages,
|
||||
availablePackages,
|
||||
selectedPackages,
|
||||
setRateSummary,
|
||||
rateSummary,
|
||||
@@ -72,7 +72,7 @@ export default function RoomSelection({
|
||||
roomCategories={roomCategories}
|
||||
handleSelectRate={setRateSummary}
|
||||
selectedPackages={selectedPackages}
|
||||
packages={packages}
|
||||
packages={availablePackages}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
@@ -81,7 +81,7 @@ export default function RoomSelection({
|
||||
<RateSummary
|
||||
rateSummary={rateSummary}
|
||||
isUserLoggedIn={isUserLoggedIn}
|
||||
packages={packages}
|
||||
packages={availablePackages}
|
||||
roomsAvailability={roomsAvailability}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -53,26 +53,3 @@ export function getQueryParamsForEnterDetails(
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
export function createSelectRateUrl(roomData: BookingData) {
|
||||
const { hotel, fromDate, toDate } = roomData
|
||||
const params = new URLSearchParams({ fromDate, toDate, hotel })
|
||||
|
||||
roomData.rooms.forEach((room, index) => {
|
||||
params.set(`room[${index}].adults`, room.adults.toString())
|
||||
|
||||
if (room.children) {
|
||||
room.children.forEach((child, childIndex) => {
|
||||
params.set(
|
||||
`room[${index}].child[${childIndex}].age`,
|
||||
child.age.toString()
|
||||
)
|
||||
params.set(
|
||||
`room[${index}].child[${childIndex}].bed`,
|
||||
child.bed.toString()
|
||||
)
|
||||
})
|
||||
}
|
||||
})
|
||||
return params
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { filterDuplicateRoomTypesByLowestPrice } from "./utils"
|
||||
import styles from "./rooms.module.css"
|
||||
|
||||
import {
|
||||
DefaultFilterOptions,
|
||||
RoomPackageCodeEnum,
|
||||
type RoomPackageCodes,
|
||||
} from "@/types/components/hotelReservation/selectRate/roomFilter"
|
||||
@@ -25,7 +26,7 @@ export default function Rooms({
|
||||
roomsAvailability,
|
||||
roomCategories = [],
|
||||
user,
|
||||
packages,
|
||||
availablePackages,
|
||||
}: SelectRateProps) {
|
||||
const visibleRooms: RoomConfiguration[] =
|
||||
filterDuplicateRoomTypesByLowestPrice(roomsAvailability.roomConfigurations)
|
||||
@@ -47,6 +48,30 @@ export default function Rooms({
|
||||
(state) => state.setRoomsAvailable
|
||||
)
|
||||
|
||||
const defaultPackages: DefaultFilterOptions[] = [
|
||||
{
|
||||
code: RoomPackageCodeEnum.ACCESSIBILITY_ROOM,
|
||||
description: "Accessible Room",
|
||||
itemCode: availablePackages.find(
|
||||
(pkg) => pkg.code === RoomPackageCodeEnum.ACCESSIBILITY_ROOM
|
||||
)?.itemCode,
|
||||
},
|
||||
{
|
||||
code: RoomPackageCodeEnum.ALLERGY_ROOM,
|
||||
description: "Allergy Room",
|
||||
itemCode: availablePackages.find(
|
||||
(pkg) => pkg.code === RoomPackageCodeEnum.ALLERGY_ROOM
|
||||
)?.itemCode,
|
||||
},
|
||||
{
|
||||
code: RoomPackageCodeEnum.PET_ROOM,
|
||||
description: "Pet Room",
|
||||
itemCode: availablePackages.find(
|
||||
(pkg) => pkg.code === RoomPackageCodeEnum.PET_ROOM
|
||||
)?.itemCode,
|
||||
},
|
||||
]
|
||||
|
||||
const handleFilter = useCallback(
|
||||
(filter: Record<RoomPackageCodeEnum, boolean | undefined>) => {
|
||||
const filteredPackages = Object.keys(filter).filter(
|
||||
@@ -80,20 +105,10 @@ export default function Rooms({
|
||||
room.features.some((feature) => feature.code === filteredPackage)
|
||||
)
|
||||
)
|
||||
let notAvailableRooms = visibleRooms.filter((room) =>
|
||||
filteredPackages.every(
|
||||
(filteredPackage) =>
|
||||
!room.features.some((feature) => feature.code === filteredPackage)
|
||||
)
|
||||
)
|
||||
// Clone nested object to keep original object intact and not messup the room data
|
||||
notAvailableRooms = JSON.parse(JSON.stringify(notAvailableRooms))
|
||||
notAvailableRooms.forEach((room) => {
|
||||
room.status = "NotAvailable"
|
||||
})
|
||||
|
||||
setRooms({
|
||||
...roomsAvailability,
|
||||
roomConfigurations: [...filteredRooms, ...notAvailableRooms],
|
||||
roomConfigurations: [...filteredRooms],
|
||||
})
|
||||
|
||||
if (filteredRooms.length == 0) {
|
||||
@@ -104,7 +119,9 @@ export default function Rooms({
|
||||
|
||||
const petRoomPackage =
|
||||
(filteredPackages.includes(RoomPackageCodeEnum.PET_ROOM) &&
|
||||
packages.find((pkg) => pkg.code === RoomPackageCodeEnum.PET_ROOM)) ||
|
||||
availablePackages.find(
|
||||
(pkg) => pkg.code === RoomPackageCodeEnum.PET_ROOM
|
||||
)) ||
|
||||
undefined
|
||||
|
||||
const features = filteredRooms.find((room) =>
|
||||
@@ -124,7 +141,7 @@ export default function Rooms({
|
||||
roomsAvailability,
|
||||
visibleRooms,
|
||||
rateSummary,
|
||||
packages,
|
||||
availablePackages,
|
||||
noRoomsAvailable,
|
||||
setNoRoomsAvailable,
|
||||
setRoomsAvailable,
|
||||
@@ -136,13 +153,13 @@ export default function Rooms({
|
||||
<RoomFilter
|
||||
numberOfRooms={rooms.roomConfigurations.length}
|
||||
onFilter={handleFilter}
|
||||
filterOptions={packages}
|
||||
filterOptions={defaultPackages}
|
||||
/>
|
||||
<RoomSelection
|
||||
roomsAvailability={rooms}
|
||||
roomCategories={roomCategories}
|
||||
user={user}
|
||||
packages={packages}
|
||||
availablePackages={availablePackages}
|
||||
selectedPackages={selectedPackages}
|
||||
setRateSummary={setRateSummary}
|
||||
rateSummary={rateSummary}
|
||||
|
||||
@@ -31,7 +31,10 @@ export function filterDuplicateRoomTypesByLowestPrice(
|
||||
|
||||
products.forEach((product) => {
|
||||
const { productType } = product
|
||||
const publicProduct = productType.public
|
||||
const publicProduct = productType.public || {
|
||||
requestedPrice: null,
|
||||
localPrice: null,
|
||||
}
|
||||
const memberProduct = productType.member || {
|
||||
requestedPrice: null,
|
||||
localPrice: null,
|
||||
@@ -49,11 +52,11 @@ export function filterDuplicateRoomTypesByLowestPrice(
|
||||
const previousLowest = roomMap.get(roomType)
|
||||
|
||||
const currentRequestedPrice = Math.min(
|
||||
Number(publicRequestedPrice.pricePerNight) ?? Infinity,
|
||||
Number(publicRequestedPrice?.pricePerNight) ?? Infinity,
|
||||
Number(memberRequestedPrice?.pricePerNight) ?? Infinity
|
||||
)
|
||||
const currentLocalPrice = Math.min(
|
||||
Number(publicLocalPrice.pricePerNight) ?? Infinity,
|
||||
Number(publicLocalPrice?.pricePerNight) ?? Infinity,
|
||||
Number(memberLocalPrice?.pricePerNight) ?? Infinity
|
||||
)
|
||||
|
||||
@@ -63,7 +66,7 @@ export function filterDuplicateRoomTypesByLowestPrice(
|
||||
Math.min(
|
||||
Number(
|
||||
previousLowest.products[0].productType.public.requestedPrice
|
||||
.pricePerNight
|
||||
?.pricePerNight
|
||||
) ?? Infinity,
|
||||
Number(
|
||||
previousLowest.products[0].productType.member?.requestedPrice
|
||||
@@ -74,7 +77,7 @@ export function filterDuplicateRoomTypesByLowestPrice(
|
||||
Math.min(
|
||||
Number(
|
||||
previousLowest.products[0].productType.public.requestedPrice
|
||||
.pricePerNight
|
||||
?.pricePerNight
|
||||
) ?? Infinity,
|
||||
Number(
|
||||
previousLowest.products[0].productType.member?.requestedPrice
|
||||
@@ -85,7 +88,7 @@ export function filterDuplicateRoomTypesByLowestPrice(
|
||||
Math.min(
|
||||
Number(
|
||||
previousLowest.products[0].productType.public.localPrice
|
||||
.pricePerNight
|
||||
?.pricePerNight
|
||||
) ?? Infinity,
|
||||
Number(
|
||||
previousLowest.products[0].productType.member?.localPrice
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { iconVariants } from "./variants"
|
||||
|
||||
import type { IconProps } from "@/types/components/icon"
|
||||
|
||||
export default function ArrowUpIcon({ className, color, ...props }: IconProps) {
|
||||
const classNames = iconVariants({ className, color })
|
||||
return (
|
||||
<svg
|
||||
className={classNames}
|
||||
fill="none"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
width="20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<mask
|
||||
id="a"
|
||||
width="20"
|
||||
height="20"
|
||||
x="0"
|
||||
y="0"
|
||||
maskUnits="userSpaceOnUse"
|
||||
>
|
||||
<path fill="#D9D9D9" d="M0 0h20v20H0z" />
|
||||
</mask>
|
||||
<path
|
||||
fill="#4D001B"
|
||||
d="m9.219 6.541-4.021 4.021a.74.74 0 0 1-.552.235.778.778 0 0 1-.552-.245.796.796 0 0 1-.235-.552.74.74 0 0 1 .235-.552l5.354-5.355a.77.77 0 0 1 .849-.171.77.77 0 0 1 .255.171l5.354 5.355a.782.782 0 0 1 0 1.104.764.764 0 0 1-1.114 0l-4.01-4.01v9.135c0 .215-.077.4-.23.552a.752.752 0 0 1-.552.229.752.752 0 0 1-.552-.23.752.752 0 0 1-.23-.551V6.54Z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -6,6 +6,7 @@ export { default as AirIcon } from "./Air"
|
||||
export { default as AirplaneIcon } from "./Airplane"
|
||||
export { default as AllergyIcon } from "./Allergy"
|
||||
export { default as ArrowRightIcon } from "./ArrowRight"
|
||||
export { default as ArrowUpIcon } from "./ArrowUp"
|
||||
export { default as BarIcon } from "./Bar"
|
||||
export { default as BathtubIcon } from "./Bathtub"
|
||||
export { default as BedDoubleIcon } from "./BedDouble"
|
||||
|
||||
@@ -227,7 +227,7 @@
|
||||
|
||||
.galleryContent {
|
||||
width: 1090px;
|
||||
height: 725px;
|
||||
height: min(725px, 85dvh);
|
||||
}
|
||||
|
||||
.fullViewContent {
|
||||
|
||||
@@ -13,19 +13,16 @@ import { trackLoginClick } from "@/utils/tracking"
|
||||
import { TrackingPosition } from "@/types/components/tracking"
|
||||
|
||||
export default function LoginButton({
|
||||
className,
|
||||
position,
|
||||
trackingId,
|
||||
children,
|
||||
color = "black",
|
||||
variant = "navigation",
|
||||
}: PropsWithChildren<{
|
||||
className: string
|
||||
trackingId: string
|
||||
position: TrackingPosition
|
||||
color?: LinkProps["color"]
|
||||
variant?: "navigation" | "signupVerification"
|
||||
}>) {
|
||||
...props
|
||||
}: PropsWithChildren<
|
||||
{
|
||||
trackingId: string
|
||||
position: TrackingPosition
|
||||
} & Omit<LinkProps, "href">
|
||||
>) {
|
||||
const lang = useLang()
|
||||
const pathName = useLazyPathname({ includeSearchParams: true })
|
||||
|
||||
@@ -35,13 +32,11 @@ export default function LoginButton({
|
||||
|
||||
return (
|
||||
<Link
|
||||
className={className}
|
||||
id={trackingId}
|
||||
color={color}
|
||||
href={href}
|
||||
prefetch={false}
|
||||
variant={variant}
|
||||
onClick={() => trackLoginClick(position)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
|
||||
@@ -66,7 +66,7 @@ export function MapModal({ children }: { children: React.ReactNode }) {
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper} ref={rootDiv}>
|
||||
<Modal isDismissable isOpen={isOpen} onOpenChange={handleOnOpenChange}>
|
||||
<Modal isOpen={isOpen} onOpenChange={handleOnOpenChange}>
|
||||
<Dialog
|
||||
style={
|
||||
{
|
||||
|
||||
@@ -1,22 +1,15 @@
|
||||
import { IconName } from "@/types/components/icon"
|
||||
import {
|
||||
PointOfInterestCategoryNameEnum,
|
||||
PointOfInterestGroupEnum,
|
||||
} from "@/types/hotel"
|
||||
import { PointOfInterestGroupEnum } from "@/types/hotel"
|
||||
|
||||
export function getIconByPoiGroupAndCategory(
|
||||
group: PointOfInterestGroupEnum,
|
||||
category?: PointOfInterestCategoryNameEnum
|
||||
category?: string
|
||||
) {
|
||||
switch (group) {
|
||||
case PointOfInterestGroupEnum.PUBLIC_TRANSPORT:
|
||||
return category === PointOfInterestCategoryNameEnum.AIRPORT
|
||||
? IconName.Airplane
|
||||
: IconName.Train
|
||||
return category === "Airport" ? IconName.Airplane : IconName.Train
|
||||
case PointOfInterestGroupEnum.ATTRACTIONS:
|
||||
return category === PointOfInterestCategoryNameEnum.MUSEUM
|
||||
? IconName.Museum
|
||||
: IconName.Camera
|
||||
return category === "Museum" ? IconName.Museum : IconName.Camera
|
||||
case PointOfInterestGroupEnum.BUSINESS:
|
||||
return IconName.Business
|
||||
case PointOfInterestGroupEnum.PARKING:
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
.backToTopButton {
|
||||
border-radius: var(--Corner-radius-Rounded);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
z-index: 1000;
|
||||
background-color: var(--Base-Surface-Primary-light-Normal);
|
||||
color: var(--Base-Button-Secondary-On-Fill-Normal);
|
||||
border: 2px solid var(--Base-Button-Secondary-On-Fill-Normal);
|
||||
gap: var(--Spacing-x-half);
|
||||
padding: var(--Spacing-x1);
|
||||
text-align: center;
|
||||
transition:
|
||||
background-color 300ms ease,
|
||||
color 300ms ease;
|
||||
font-family: var(--typography-Body-Bold-fontFamily);
|
||||
font-weight: 500;
|
||||
font-size: var(--typography-Caption-Bold-fontSize);
|
||||
line-height: var(--typography-Caption-Bold-lineHeight);
|
||||
letter-spacing: 0.6%;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.backToTopButtonText {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.backToTopButtonText {
|
||||
display: initial;
|
||||
}
|
||||
.backToTopButton:hover {
|
||||
background-color: var(--Base-Button-Tertiary-Fill-Normal);
|
||||
color: var(--Base-Button-Tertiary-On-Fill-Hover);
|
||||
}
|
||||
.backToTopButton:hover > svg * {
|
||||
fill: var(--Base-Button-Tertiary-On-Fill-Hover);
|
||||
}
|
||||
.backToTopButton {
|
||||
padding: calc(var(--Spacing-x1) + 2px) var(--Spacing-x2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
"use client"
|
||||
|
||||
import { Button as ButtonRAC } from "react-aria-components"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { ArrowUpIcon } from "@/components/Icons"
|
||||
|
||||
import styles from "./backToTopButton.module.css"
|
||||
|
||||
export function BackToTopButton({ onClick }: { onClick: () => void }) {
|
||||
const intl = useIntl()
|
||||
return (
|
||||
<ButtonRAC className={styles.backToTopButton} onPress={onClick}>
|
||||
<ArrowUpIcon color="burgundy" />
|
||||
<span className={styles.backToTopButtonText}>
|
||||
{intl.formatMessage({ id: "Back to top" })}
|
||||
</span>
|
||||
</ButtonRAC>
|
||||
)
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
color: var(--text-color);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.container[data-selected] .checkbox {
|
||||
|
||||
@@ -12,6 +12,7 @@ import styles from "./checkbox.module.css"
|
||||
import { CheckboxProps } from "@/types/components/checkbox"
|
||||
|
||||
export default function Checkbox({
|
||||
className,
|
||||
name,
|
||||
children,
|
||||
registerOptions,
|
||||
@@ -25,16 +26,17 @@ export default function Checkbox({
|
||||
|
||||
return (
|
||||
<AriaCheckbox
|
||||
className={styles.container}
|
||||
className={`${styles.container} ${className}`}
|
||||
isSelected={field.value}
|
||||
onChange={field.onChange}
|
||||
data-testid={name}
|
||||
isDisabled={registerOptions?.disabled}
|
||||
excludeFromTabOrder
|
||||
>
|
||||
{({ isSelected }) => (
|
||||
<>
|
||||
<span className={styles.checkboxContainer}>
|
||||
<span className={styles.checkbox}>
|
||||
<span className={styles.checkbox} tabIndex={0}>
|
||||
{isSelected && <CheckIcon color="white" />}
|
||||
</span>
|
||||
{children}
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
import Card from "./_Card"
|
||||
|
||||
import type { CheckboxProps } from "./_Card/card"
|
||||
|
||||
export default function CheckboxCard(props: CheckboxProps) {
|
||||
return <Card {...props} type="checkbox" />
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
.label {
|
||||
align-self: flex-start;
|
||||
background-color: var(--Base-Surface-Primary-light-Normal);
|
||||
border: 1px solid var(--Base-Border-Subtle);
|
||||
border-radius: var(--Corner-radius-Large);
|
||||
|
||||
@@ -15,6 +15,7 @@ export default function Card({
|
||||
iconHeight = 32,
|
||||
iconWidth = 32,
|
||||
declined = false,
|
||||
defaultChecked,
|
||||
highlightSubtitle = false,
|
||||
id,
|
||||
list,
|
||||
@@ -45,6 +46,7 @@ export default function Card({
|
||||
<input
|
||||
{...register(name)}
|
||||
aria-hidden
|
||||
defaultChecked={defaultChecked}
|
||||
id={id || name}
|
||||
hidden
|
||||
type={type}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
/* Leaving, will most likely get deleted */
|
||||
.datePicker {
|
||||
container-name: datePickerContainer;
|
||||
container-type: inline-size;
|
||||
}
|
||||
.container {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x2);
|
||||
@@ -27,3 +31,10 @@
|
||||
.year.invalid > div > div {
|
||||
border-color: var(--Scandic-Red-60);
|
||||
}
|
||||
|
||||
@container datePickerContainer (max-width: 350px) {
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,6 +115,7 @@ export default function DateSelect({ name, registerOptions = {} }: DateProps) {
|
||||
ref={field.ref}
|
||||
value={dateValue}
|
||||
data-testid={name}
|
||||
className={styles.datePicker}
|
||||
>
|
||||
<Group>
|
||||
<DateInput className={styles.container}>
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--Spacing-x-half);
|
||||
padding: var(--Spacing-x1) var(--Spacing-x-one-and-half);
|
||||
padding: calc(var(--Spacing-x1) - 2px) var(--Spacing-x-one-and-half);
|
||||
border: 1px solid var(--Base-Border-Subtle);
|
||||
border-radius: var(--Corner-radius-Small);
|
||||
background-color: var(--Base-Surface-Secondary-light-Normal);
|
||||
cursor: pointer;
|
||||
height: 32px;
|
||||
background-color: var(--Base-Surface-Secondary-light-Normal);
|
||||
}
|
||||
|
||||
.label[data-selected="true"],
|
||||
@@ -21,8 +23,9 @@
|
||||
}
|
||||
|
||||
.label[data-disabled="true"] {
|
||||
background-color: var(--Base-Button-Primary-Fill-Disabled);
|
||||
border-color: var(--Base-Button-Primary-Fill-Disabled);
|
||||
background-color: var(--UI-Input-Controls-Surface-Disabled);
|
||||
border-color: var(--UI-Input-Controls-Border-Disabled);
|
||||
color: var(--Base-Text-Disabled);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
|
||||
@@ -78,67 +78,69 @@ export default function Phone({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`${styles.phone} ${className}`}>
|
||||
<CountrySelector
|
||||
disabled={readOnly}
|
||||
dropdownArrowClassName={styles.arrow}
|
||||
flagClassName={styles.flag}
|
||||
onSelect={handleSelectCountry}
|
||||
preferredCountries={["de", "dk", "fi", "no", "se", "gb"]}
|
||||
selectedCountry={country.iso2}
|
||||
renderButtonWrapper={(props) => (
|
||||
<button
|
||||
{...props.rootProps}
|
||||
className={styles.select}
|
||||
tabIndex={0}
|
||||
type="button"
|
||||
data-testid="country-selector"
|
||||
>
|
||||
<Label required={!!registerOptions.required} size="small">
|
||||
{intl.formatMessage({ id: "Country code" })}
|
||||
</Label>
|
||||
<span className={styles.selectContainer}>
|
||||
{props.children}
|
||||
<Body asChild fontOnly>
|
||||
<DialCodePreview
|
||||
className={styles.dialCode}
|
||||
dialCode={country.dialCode}
|
||||
prefix="+"
|
||||
<div className={`${styles.wrapper} ${className}`}>
|
||||
<div className={styles.phone}>
|
||||
<CountrySelector
|
||||
disabled={readOnly}
|
||||
dropdownArrowClassName={styles.arrow}
|
||||
flagClassName={styles.flag}
|
||||
onSelect={handleSelectCountry}
|
||||
preferredCountries={["de", "dk", "fi", "no", "se", "gb"]}
|
||||
selectedCountry={country.iso2}
|
||||
renderButtonWrapper={(props) => (
|
||||
<button
|
||||
{...props.rootProps}
|
||||
className={styles.select}
|
||||
tabIndex={0}
|
||||
type="button"
|
||||
data-testid="country-selector"
|
||||
>
|
||||
<Label required={!!registerOptions.required} size="small">
|
||||
{intl.formatMessage({ id: "Country code" })}
|
||||
</Label>
|
||||
<span className={styles.selectContainer}>
|
||||
{props.children}
|
||||
<Body asChild fontOnly>
|
||||
<DialCodePreview
|
||||
className={styles.dialCode}
|
||||
dialCode={country.dialCode}
|
||||
prefix="+"
|
||||
/>
|
||||
</Body>
|
||||
<ChevronDownIcon
|
||||
className={styles.chevron}
|
||||
color="grey80"
|
||||
height={18}
|
||||
width={18}
|
||||
/>
|
||||
</Body>
|
||||
<ChevronDownIcon
|
||||
className={styles.chevron}
|
||||
color="grey80"
|
||||
height={18}
|
||||
width={18}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
<TextField
|
||||
aria-label={ariaLabel}
|
||||
defaultValue={field.value}
|
||||
isDisabled={disabled ?? field.disabled}
|
||||
isInvalid={fieldState.invalid}
|
||||
isRequired={!!registerOptions?.required}
|
||||
isReadOnly={readOnly}
|
||||
name={field.name}
|
||||
type="tel"
|
||||
>
|
||||
<AriaInputWithLabel
|
||||
{...field}
|
||||
id={field.name}
|
||||
label={label}
|
||||
onChange={handleChange}
|
||||
placeholder={placeholder}
|
||||
readOnly={readOnly}
|
||||
required={!!registerOptions.required}
|
||||
type="tel"
|
||||
value={inputValue}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
<ErrorMessage errors={formState.errors} name={field.name} />
|
||||
</TextField>
|
||||
<TextField
|
||||
aria-label={ariaLabel}
|
||||
defaultValue={field.value}
|
||||
isDisabled={disabled ?? field.disabled}
|
||||
isInvalid={fieldState.invalid}
|
||||
isRequired={!!registerOptions?.required}
|
||||
isReadOnly={readOnly}
|
||||
name={field.name}
|
||||
type="tel"
|
||||
>
|
||||
<AriaInputWithLabel
|
||||
{...field}
|
||||
id={field.name}
|
||||
label={label}
|
||||
onChange={handleChange}
|
||||
placeholder={placeholder}
|
||||
readOnly={readOnly}
|
||||
required={!!registerOptions.required}
|
||||
type="tel"
|
||||
value={inputValue}
|
||||
/>
|
||||
<ErrorMessage errors={formState.errors} name={field.name} />
|
||||
</TextField>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
.wrapper {
|
||||
container-name: phoneContainer;
|
||||
container-type: inline-size;
|
||||
}
|
||||
.phone {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x2);
|
||||
@@ -100,3 +104,10 @@
|
||||
justify-self: flex-start;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@container phoneContainer (max-width: 350px) {
|
||||
.phone {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
.breadcrumb {
|
||||
font-family: var(--typography-Footnote-Bold-fontFamily);
|
||||
font-size: var(--typography-Footnote-Bold-fontSize);
|
||||
font-weight: var(--typography-Footnote-Bold-fontWeight);
|
||||
font-weight: 500; /* var(--typography-Footnote-Bold-fontWeight); */
|
||||
letter-spacing: var(--typography-Footnote-Bold-letterSpacing);
|
||||
line-height: var(--typography-Footnote-Bold-lineHeight);
|
||||
}
|
||||
@@ -24,7 +24,7 @@
|
||||
.link.breadcrumb {
|
||||
font-family: var(--typography-Footnote-Bold-fontFamily);
|
||||
font-size: var(--typography-Footnote-Bold-fontSize);
|
||||
font-weight: var(--typography-Footnote-Bold-fontWeight);
|
||||
font-weight: 500; /* var(--typography-Footnote-Bold-fontWeight); */
|
||||
letter-spacing: var(--typography-Footnote-Bold-letterSpacing);
|
||||
line-height: var(--typography-Footnote-Bold-lineHeight);
|
||||
}
|
||||
@@ -128,6 +128,15 @@
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.uiTextPlaceholder {
|
||||
color: var(--Base-Text-Placeholder);
|
||||
}
|
||||
|
||||
.uiTextPlaceholder:hover,
|
||||
.uiTextPlaceholder:active {
|
||||
color: var(--Base-Text-Medium-contrast);
|
||||
}
|
||||
|
||||
.burgundy {
|
||||
color: var(--Base-Text-High-contrast);
|
||||
}
|
||||
@@ -211,6 +220,14 @@
|
||||
line-height: var(--typography-Caption-Regular-lineHeight);
|
||||
}
|
||||
|
||||
.tiny {
|
||||
font-family: var(--typography-Footnote-Regular-fontFamily);
|
||||
font-size: var(--typography-Footnote-Regular-fontSize);
|
||||
font-weight: var(--typography-Footnote-Regular-fontWeight);
|
||||
letter-spacing: var(--typography-Footnote-Regular-letterSpacing);
|
||||
line-height: var(--typography-Footnote-Regular-lineHeight);
|
||||
}
|
||||
|
||||
.activeSmall {
|
||||
font-family: var(--typography-Caption-Bold-fontFamily);
|
||||
font-size: var(--typography-Caption-Bold-fontSize);
|
||||
|
||||
@@ -17,10 +17,12 @@ export const linkVariants = cva(styles.link, {
|
||||
peach80: styles.peach80,
|
||||
white: styles.white,
|
||||
red: styles.red,
|
||||
uiTextPlaceholder: styles.uiTextPlaceholder,
|
||||
},
|
||||
size: {
|
||||
small: styles.small,
|
||||
regular: styles.regular,
|
||||
tiny: styles.tiny,
|
||||
},
|
||||
textDecoration: {
|
||||
none: styles.noDecoration,
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
.arrow {
|
||||
transform-origin: center;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
[data-placement="left"] .arrow,
|
||||
[data-placement="left top"] .arrow,
|
||||
[data-placement="left bottom"] .arrow {
|
||||
transform: rotate(270deg) translateY(-6px);
|
||||
}
|
||||
|
||||
[data-placement="right"] .arrow,
|
||||
[data-placement="right top"] .arrow,
|
||||
[data-placement="right bottom"] .arrow {
|
||||
transform: rotate(90deg) translateY(-6px);
|
||||
}
|
||||
|
||||
[data-placement="bottom"] .arrow,
|
||||
[data-placement="bottom left"] .arrow,
|
||||
[data-placement="bottom right"] .arrow {
|
||||
transform: rotate(180deg) translateY(-2px);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import styles from "./arrow.module.css"
|
||||
|
||||
export function Arrow() {
|
||||
return (
|
||||
<div className={styles.arrow}>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="27"
|
||||
height="13"
|
||||
fill="none"
|
||||
>
|
||||
<path
|
||||
fill="#fff"
|
||||
d="M13.093 12.193.9 0h25.8L14.508 12.193a1 1 0 0 1-1.415 0Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogTrigger,
|
||||
OverlayArrow,
|
||||
Popover as RAPopover,
|
||||
} from "react-aria-components"
|
||||
|
||||
import { CloseLargeIcon } from "@/components/Icons"
|
||||
import useSetOverFlowVisibleOnRA from "@/hooks/useSetOverflowVisibleOnRA"
|
||||
|
||||
import { Arrow } from "./Arrow"
|
||||
import { PopoverProps } from "./popover"
|
||||
|
||||
import styles from "./popover.module.css"
|
||||
|
||||
export default function Popover({
|
||||
triggerContent,
|
||||
children,
|
||||
...props
|
||||
}: PopoverProps) {
|
||||
const setOverflowVisible = useSetOverFlowVisibleOnRA()
|
||||
|
||||
return (
|
||||
<DialogTrigger onOpenChange={setOverflowVisible}>
|
||||
<Button className={styles.trigger}>{triggerContent}</Button>
|
||||
|
||||
<RAPopover
|
||||
{...props}
|
||||
offset={16}
|
||||
crossOffset={-24}
|
||||
className={styles.root}
|
||||
>
|
||||
<OverlayArrow>
|
||||
<Arrow />
|
||||
</OverlayArrow>
|
||||
<Dialog>
|
||||
{({ close }) => (
|
||||
<>
|
||||
<Button className={styles.closeButton} onPress={close}>
|
||||
<CloseLargeIcon height={20} width={20} />
|
||||
</Button>
|
||||
{children}
|
||||
</>
|
||||
)}
|
||||
</Dialog>
|
||||
</RAPopover>
|
||||
</DialogTrigger>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
.root {
|
||||
background-color: var(--Base-Surface-Primary-light-Normal);
|
||||
border-radius: var(--Corner-radius-Medium);
|
||||
box-shadow: 0px 0px 14px 6px rgba(0, 0, 0, 0.1);
|
||||
padding: var(--Spacing-x2);
|
||||
max-width: calc(360px + var(--Spacing-x2) * 2);
|
||||
}
|
||||
|
||||
.root section:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.trigger {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
.closeButton {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { PopoverProps as RAPopoverProps } from "react-aria-components"
|
||||
|
||||
export interface PopoverProps extends Omit<RAPopoverProps, "children"> {
|
||||
triggerContent: React.ReactNode
|
||||
children: React.ReactNode
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
|
||||
import Label from "@/components/TempDesignSystem/Form/Label"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import useSetOverflowVisibleOnRA from "@/hooks/useSetOverflowVisibleOnRA"
|
||||
|
||||
import SelectChevron from "../Form/SelectChevron"
|
||||
|
||||
@@ -39,6 +40,7 @@ export default function Select({
|
||||
discreet = false,
|
||||
}: SelectProps) {
|
||||
const [rootDiv, setRootDiv] = useState<SelectPortalContainer>(undefined)
|
||||
const setOverflowVisible = useSetOverflowVisibleOnRA()
|
||||
|
||||
function setRef(node: SelectPortalContainerArgs) {
|
||||
if (node) {
|
||||
@@ -60,6 +62,7 @@ export default function Select({
|
||||
onSelectionChange={handleOnSelect}
|
||||
placeholder={placeholder}
|
||||
selectedKey={value as Key}
|
||||
onOpenChange={setOverflowVisible}
|
||||
>
|
||||
<Body asChild fontOnly>
|
||||
<Button className={styles.input} data-testid={name}>
|
||||
|
||||
@@ -28,6 +28,7 @@ export function Tooltip<P extends TooltipPosition>({
|
||||
role="tooltip"
|
||||
aria-label={text}
|
||||
onClick={handleToggle}
|
||||
onTouchStart={handleToggle}
|
||||
data-active={isActive}
|
||||
>
|
||||
<div className={className}>
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
transition: opacity 0.3s;
|
||||
max-width: 200px;
|
||||
min-width: 150px;
|
||||
height: fit-content;
|
||||
}
|
||||
|
||||
.tooltipContainer:hover .tooltip {
|
||||
|
||||
Reference in New Issue
Block a user