Merge branch 'develop' into feature/tracking
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
"use server"
|
||||
|
||||
import { parsePhoneNumber } from "libphonenumber-js"
|
||||
import { redirect } from "next/navigation"
|
||||
import { z } from "zod"
|
||||
|
||||
@@ -7,7 +8,7 @@ import { signupVerify } from "@/constants/routes/signup"
|
||||
import * as api from "@/lib/api"
|
||||
import { serviceServerActionProcedure } from "@/server/trpc"
|
||||
|
||||
import { registerSchema } from "@/components/Forms/Register/schema"
|
||||
import { signUpSchema } from "@/components/Forms/Signup/schema"
|
||||
import { passwordValidator } from "@/utils/passwordValidator"
|
||||
import { phoneValidator } from "@/utils/phoneValidator"
|
||||
|
||||
@@ -29,12 +30,14 @@ const registerUserPayload = z.object({
|
||||
})
|
||||
|
||||
export const registerUser = serviceServerActionProcedure
|
||||
.input(registerSchema)
|
||||
.input(signUpSchema)
|
||||
.mutation(async function ({ ctx, input }) {
|
||||
const payload = {
|
||||
...input,
|
||||
language: ctx.lang,
|
||||
phoneNumber: input.phoneNumber.replace(/\s+/g, ""),
|
||||
phoneNumber: parsePhoneNumber(input.phoneNumber)
|
||||
.formatNational()
|
||||
.replace(/\s+/g, ""),
|
||||
}
|
||||
|
||||
const parsedPayload = registerUserPayload.safeParse(payload)
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
.details,
|
||||
.guest,
|
||||
.header,
|
||||
.hgroup,
|
||||
.hotel,
|
||||
.list,
|
||||
.main,
|
||||
.section,
|
||||
.receipt,
|
||||
.total {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.main {
|
||||
gap: var(--Spacing-x5);
|
||||
margin: 0 auto;
|
||||
width: min(calc(100dvw - (var(--Spacing-x3) * 2)), 708px);
|
||||
}
|
||||
|
||||
.header,
|
||||
.hgroup {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header {
|
||||
gap: var(--Spacing-x3);
|
||||
}
|
||||
|
||||
.hgroup {
|
||||
gap: var(--Spacing-x-half);
|
||||
}
|
||||
|
||||
.body {
|
||||
max-width: 560px;
|
||||
}
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x9);
|
||||
}
|
||||
|
||||
.booking {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x-one-and-half);
|
||||
grid-template-areas:
|
||||
"image"
|
||||
"details"
|
||||
"actions";
|
||||
}
|
||||
|
||||
.actions,
|
||||
.details {
|
||||
background-color: var(--Base-Surface-Subtle-Normal);
|
||||
border-radius: var(--Corner-radius-Medium);
|
||||
}
|
||||
|
||||
.details {
|
||||
gap: var(--Spacing-x3);
|
||||
grid-area: details;
|
||||
padding: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.tempImage {
|
||||
align-items: center;
|
||||
background-color: lightgrey;
|
||||
border-radius: var(--Corner-radius-Medium);
|
||||
display: flex;
|
||||
grid-area: image;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: grid;
|
||||
grid-area: actions;
|
||||
padding: var(--Spacing-x1) var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.list {
|
||||
gap: var(--Spacing-x-one-and-half);
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.listItem {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: var(--Spacing-x1);
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.summary {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x3);
|
||||
}
|
||||
|
||||
.guest,
|
||||
.hotel {
|
||||
gap: var(--Spacing-x-half);
|
||||
}
|
||||
|
||||
.receipt,
|
||||
.total {
|
||||
gap: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.divider {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 767px) {
|
||||
.actions {
|
||||
& > button[class*="btn"][class*="icon"][class*="small"] {
|
||||
border-bottom: 1px solid var(--Base-Border-Subtle);
|
||||
border-radius: 0;
|
||||
justify-content: space-between;
|
||||
|
||||
&:last-of-type {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
& > svg {
|
||||
order: 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tempImage {
|
||||
min-height: 250px;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
.booking {
|
||||
grid-template-areas:
|
||||
"details image"
|
||||
"actions actions";
|
||||
grid-template-columns: 1fr minmax(256px, min(256px, 100%));
|
||||
}
|
||||
|
||||
.actions {
|
||||
gap: var(--Spacing-x7);
|
||||
grid-template-columns: repeat(auto-fit, minmax(50px, auto));
|
||||
justify-content: center;
|
||||
padding: var(--Spacing-x1) var(--Spacing-x3);
|
||||
}
|
||||
|
||||
.details {
|
||||
padding: var(--Spacing-x3) var(--Spacing-x3) var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.summary {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
import { dt } from "@/lib/dt"
|
||||
import { serverClient } from "@/lib/trpc/server"
|
||||
|
||||
import {
|
||||
CalendarIcon,
|
||||
DownloadIcon,
|
||||
ImageIcon,
|
||||
PrinterIcon,
|
||||
} from "@/components/Icons"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Divider from "@/components/TempDesignSystem/Divider"
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
import Title from "@/components/TempDesignSystem/Text/Title"
|
||||
import { getIntl } from "@/i18n"
|
||||
|
||||
import styles from "./page.module.css"
|
||||
|
||||
import type { LangParams, PageArgs } from "@/types/params"
|
||||
|
||||
export default async function BookingConfirmationPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: PageArgs<LangParams, { confirmationNumber: string }>) {
|
||||
const confirmationNumber = searchParams.confirmationNumber
|
||||
const booking = await serverClient().booking.confirmation({
|
||||
confirmationNumber,
|
||||
})
|
||||
|
||||
if (!booking) {
|
||||
return null
|
||||
}
|
||||
|
||||
const intl = await getIntl()
|
||||
const text = intl.formatMessage<React.ReactNode>(
|
||||
{ id: "booking.confirmation.text" },
|
||||
{
|
||||
emailLink: (str) => (
|
||||
<Link color="burgundy" href="#" textDecoration="underline">
|
||||
{str}
|
||||
</Link>
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
const fromDate = dt(booking.temp.fromDate).locale(params.lang)
|
||||
const toDate = dt(booking.temp.toDate).locale(params.lang)
|
||||
const nights = intl.formatMessage(
|
||||
{ id: "booking.nights" },
|
||||
{
|
||||
totalNights: dt(toDate.format("YYYY-MM-DD")).diff(
|
||||
dt(fromDate.format("YYYY-MM-DD")),
|
||||
"days"
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
return (
|
||||
<main className={styles.main}>
|
||||
<header className={styles.header}>
|
||||
<hgroup className={styles.hgroup}>
|
||||
<Title
|
||||
as="h4"
|
||||
color="red"
|
||||
textAlign="center"
|
||||
textTransform="regular"
|
||||
type="h2"
|
||||
>
|
||||
{intl.formatMessage({ id: "booking.confirmation.title" })}
|
||||
</Title>
|
||||
<Title
|
||||
as="h4"
|
||||
color="burgundy"
|
||||
textAlign="center"
|
||||
textTransform="regular"
|
||||
type="h1"
|
||||
>
|
||||
{booking.hotel.name}
|
||||
</Title>
|
||||
</hgroup>
|
||||
<Body className={styles.body} textAlign="center">
|
||||
{text}
|
||||
</Body>
|
||||
</header>
|
||||
<section className={styles.section}>
|
||||
<div className={styles.booking}>
|
||||
<article className={styles.details}>
|
||||
<header>
|
||||
<Subtitle color="burgundy" type="two">
|
||||
{intl.formatMessage(
|
||||
{ id: "Reference #{bookingNr}" },
|
||||
{ bookingNr: "A92320VV" }
|
||||
)}
|
||||
</Subtitle>
|
||||
</header>
|
||||
<ul className={styles.list}>
|
||||
<li className={styles.listItem}>
|
||||
<Body>{intl.formatMessage({ id: "Check-in" })}</Body>
|
||||
<Body>
|
||||
{`${fromDate.format("ddd, D MMM")} ${intl.formatMessage({ id: "from" })} ${fromDate.format("HH:mm")}`}
|
||||
</Body>
|
||||
</li>
|
||||
<li className={styles.listItem}>
|
||||
<Body>{intl.formatMessage({ id: "Check-out" })}</Body>
|
||||
<Body>
|
||||
{`${toDate.format("ddd, D MMM")} ${intl.formatMessage({ id: "from" })} ${toDate.format("HH:mm")}`}
|
||||
</Body>
|
||||
</li>
|
||||
<li className={styles.listItem}>
|
||||
<Body>{intl.formatMessage({ id: "Breakfast" })}</Body>
|
||||
<Body>
|
||||
{booking.temp.breakfastFrom} - {booking.temp.breakfastTo}
|
||||
</Body>
|
||||
</li>
|
||||
<li className={styles.listItem}>
|
||||
<Body>{intl.formatMessage({ id: "Cancellation policy" })}</Body>
|
||||
<Body>
|
||||
{intl.formatMessage({ id: booking.temp.cancelPolicy })}
|
||||
</Body>
|
||||
</li>
|
||||
<li className={styles.listItem}>
|
||||
<Body>{intl.formatMessage({ id: "Rebooking" })}</Body>
|
||||
<Body>{`${intl.formatMessage({ id: "Free until" })} ${fromDate.subtract(3, "day").format("ddd, D MMM")}`}</Body>
|
||||
</li>
|
||||
</ul>
|
||||
</article>
|
||||
<aside className={styles.tempImage}>
|
||||
<ImageIcon height={80} width={80} />
|
||||
</aside>
|
||||
<div className={styles.actions}>
|
||||
<Button
|
||||
intent="text"
|
||||
size="small"
|
||||
theme="base"
|
||||
variant="icon"
|
||||
wrapping
|
||||
>
|
||||
<CalendarIcon />
|
||||
{intl.formatMessage({ id: "Add to calendar" })}
|
||||
</Button>
|
||||
<Button
|
||||
intent="text"
|
||||
size="small"
|
||||
theme="base"
|
||||
variant="icon"
|
||||
wrapping
|
||||
>
|
||||
<PrinterIcon />
|
||||
{intl.formatMessage({ id: "Print confirmation" })}
|
||||
</Button>
|
||||
<Button
|
||||
intent="text"
|
||||
size="small"
|
||||
theme="base"
|
||||
variant="icon"
|
||||
wrapping
|
||||
>
|
||||
<DownloadIcon />
|
||||
{intl.formatMessage({ id: "Download invoice" })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.summary}>
|
||||
<div className={styles.guest}>
|
||||
<Caption color="uiTextPlaceholder">
|
||||
{intl.formatMessage({ id: "Guest" })}
|
||||
</Caption>
|
||||
<div>
|
||||
<Body color="burgundy" textTransform="bold">
|
||||
{`${booking.guest.firstName} ${booking.guest.lastName}${booking.guest.memberbershipNumber ? ` (${intl.formatMessage({ id: "member no" })} ${booking.guest.memberbershipNumber})` : ""}`}
|
||||
</Body>
|
||||
<Body color="uiTextHighContrast">{booking.guest.email}</Body>
|
||||
<Body color="uiTextHighContrast">
|
||||
{booking.guest.phoneNumber}
|
||||
</Body>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.hotel}>
|
||||
<Caption color="uiTextPlaceholder">
|
||||
{intl.formatMessage({ id: "Your hotel" })}
|
||||
</Caption>
|
||||
<div>
|
||||
<Body color="burgundy" textTransform="bold">
|
||||
{booking.hotel.name}
|
||||
</Body>
|
||||
<Body color="uiTextHighContrast">{booking.hotel.email}</Body>
|
||||
<Body color="uiTextHighContrast">
|
||||
{booking.hotel.phoneNumber}
|
||||
</Body>
|
||||
</div>
|
||||
</div>
|
||||
<Divider className={styles.divider} color="primaryLightSubtle" />
|
||||
<div className={styles.receipt}>
|
||||
<div>
|
||||
<Body color="burgundy" textTransform="bold">
|
||||
{`${booking.temp.room.type}, ${nights}`}
|
||||
</Body>
|
||||
<Body color="uiTextHighContrast">{booking.temp.room.price}</Body>
|
||||
</div>
|
||||
{booking.temp.packages.map((pkg) => (
|
||||
<div key={pkg.name}>
|
||||
<Body color="burgundy" textTransform="bold">
|
||||
{pkg.name}
|
||||
</Body>
|
||||
<Body color="uiTextHighContrast">{pkg.price}</Body>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className={styles.total}>
|
||||
<div>
|
||||
<Body color="burgundy" textTransform="bold">
|
||||
{intl.formatMessage({ id: "VAT" })}
|
||||
</Body>
|
||||
<Body color="uiTextHighContrast">{booking.temp.room.vat}</Body>
|
||||
</div>
|
||||
<div>
|
||||
<Body color="burgundy" textTransform="bold">
|
||||
{intl.formatMessage({ id: "Total cost" })}
|
||||
</Body>
|
||||
<Body color="uiTextHighContrast">{booking.temp.total}</Body>
|
||||
<Caption color="uiTextPlaceholder">
|
||||
{`${intl.formatMessage({ id: "Approx." })} ${booking.temp.totalInEuro}`}
|
||||
</Caption>
|
||||
</div>
|
||||
</div>
|
||||
<Divider className={styles.divider} color="primaryLightSubtle" />
|
||||
<div>
|
||||
<Body color="burgundy" textTransform="bold">
|
||||
{`${intl.formatMessage({ id: "Payment received" })} ${dt(booking.temp.payment).locale(params.lang).format("D MMM YYYY, h:mm z")}`}
|
||||
</Body>
|
||||
<Caption color="uiTextPlaceholder">
|
||||
{intl.formatMessage(
|
||||
{ id: "{card} ending with {cardno}" },
|
||||
{
|
||||
card: "Mastercard",
|
||||
cardno: "2202",
|
||||
}
|
||||
)}
|
||||
</Caption>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
// const { email, hotel, stay, summary } = tempConfirmationData
|
||||
|
||||
// const confirmationNumber = useMemo(() => {
|
||||
// if (typeof window === "undefined") return ""
|
||||
|
||||
// const storedConfirmationNumber = sessionStorage.getItem(
|
||||
// BOOKING_CONFIRMATION_NUMBER
|
||||
// )
|
||||
// TODO: cleanup stored values
|
||||
// sessionStorage.removeItem(BOOKING_CONFIRMATION_NUMBER)
|
||||
// return storedConfirmationNumber
|
||||
// }, [])
|
||||
|
||||
// const bookingStatus = useHandleBookingStatus(
|
||||
// confirmationNumber,
|
||||
// BookingStatusEnum.BookingCompleted,
|
||||
// maxRetries,
|
||||
// retryInterval
|
||||
// )
|
||||
|
||||
// if (
|
||||
// confirmationNumber === null ||
|
||||
// bookingStatus.isError ||
|
||||
// (bookingStatus.isFetched && !bookingStatus.data)
|
||||
// ) {
|
||||
// // TODO: handle error
|
||||
// throw new Error("Error fetching booking status")
|
||||
// }
|
||||
|
||||
// if (
|
||||
// bookingStatus.data?.reservationStatus === BookingStatusEnum.BookingCompleted
|
||||
// ) {
|
||||
// return <LoadingSpinner />
|
||||
@@ -0,0 +1,5 @@
|
||||
.layout {
|
||||
background-color: var(--Base-Surface-Primary-light-Normal);
|
||||
min-height: 100dvh;
|
||||
padding: 80px 0 160px;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { notFound } from "next/navigation"
|
||||
|
||||
import { env } from "@/env/server"
|
||||
|
||||
import styles from "./layout.module.css"
|
||||
|
||||
// route groups needed as layouts have different bgc
|
||||
export default function ConfirmedBookingLayout({
|
||||
children,
|
||||
}: React.PropsWithChildren) {
|
||||
if (env.HIDE_FOR_NEXT_RELEASE) {
|
||||
return notFound()
|
||||
}
|
||||
return <div className={styles.layout}>{children}</div>
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
import { redirect } from "next/navigation"
|
||||
|
||||
import { serverClient } from "@/lib/trpc/server"
|
||||
import {
|
||||
getCreditCardsSafely,
|
||||
getHotelData,
|
||||
getProfileSafely,
|
||||
} from "@/lib/trpc/memoizedRequests"
|
||||
|
||||
import EnterDetailsProvider from "@/components/HotelReservation/EnterDetails/Provider"
|
||||
import SelectedRoom from "@/components/HotelReservation/EnterDetails/SelectedRoom"
|
||||
@@ -14,15 +18,20 @@ import styles from "./layout.module.css"
|
||||
import { StepEnum } from "@/types/components/enterDetails/step"
|
||||
import type { LangParams, LayoutArgs } from "@/types/params"
|
||||
|
||||
function preload(id: string, lang: string) {
|
||||
void getHotelData(id, lang)
|
||||
void getProfileSafely()
|
||||
void getCreditCardsSafely()
|
||||
}
|
||||
|
||||
export default async function StepLayout({
|
||||
children,
|
||||
params,
|
||||
}: React.PropsWithChildren<LayoutArgs<LangParams & { step: StepEnum }>>) {
|
||||
setLang(params.lang)
|
||||
const hotel = await serverClient().hotel.hotelData.get({
|
||||
hotelId: "811",
|
||||
language: params.lang,
|
||||
})
|
||||
preload("811", params.lang)
|
||||
|
||||
const hotel = await getHotelData("811", params.lang)
|
||||
|
||||
if (!hotel?.data) {
|
||||
redirect(`/${params.lang}`)
|
||||
@@ -1,13 +1,17 @@
|
||||
import { notFound } from "next/navigation"
|
||||
|
||||
import { getProfileSafely } from "@/lib/trpc/memoizedRequests"
|
||||
import { serverClient } from "@/lib/trpc/server"
|
||||
import {
|
||||
getCreditCardsSafely,
|
||||
getHotelData,
|
||||
getProfileSafely,
|
||||
} from "@/lib/trpc/memoizedRequests"
|
||||
|
||||
import BedType from "@/components/HotelReservation/EnterDetails/BedType"
|
||||
import Breakfast from "@/components/HotelReservation/EnterDetails/Breakfast"
|
||||
import Details from "@/components/HotelReservation/EnterDetails/Details"
|
||||
import HistoryStateManager from "@/components/HotelReservation/EnterDetails/HistoryStateManager"
|
||||
import Payment from "@/components/HotelReservation/EnterDetails/Payment"
|
||||
import SectionAccordion from "@/components/HotelReservation/EnterDetails/SectionAccordion"
|
||||
import Payment from "@/components/HotelReservation/SelectRate/Payment"
|
||||
import { getIntl } from "@/i18n"
|
||||
|
||||
import { StepEnum } from "@/types/components/enterDetails/step"
|
||||
@@ -24,12 +28,9 @@ export default async function StepPage({
|
||||
|
||||
const intl = await getIntl()
|
||||
|
||||
const hotel = await serverClient().hotel.hotelData.get({
|
||||
hotelId: "811",
|
||||
language: lang,
|
||||
})
|
||||
|
||||
const hotel = await getHotelData("811", lang)
|
||||
const user = await getProfileSafely()
|
||||
const savedCreditCards = await getCreditCardsSafely()
|
||||
|
||||
if (!isValidStep(step) || !hotel) {
|
||||
return notFound()
|
||||
@@ -37,6 +38,7 @@ export default async function StepPage({
|
||||
|
||||
return (
|
||||
<section>
|
||||
<HistoryStateManager />
|
||||
<SectionAccordion
|
||||
header="Select bed"
|
||||
step={StepEnum.selectBed}
|
||||
@@ -63,7 +65,14 @@ export default async function StepPage({
|
||||
step={StepEnum.payment}
|
||||
label={intl.formatMessage({ id: "Select payment method" })}
|
||||
>
|
||||
<Payment hotel={hotel.data.attributes} />
|
||||
<Payment
|
||||
hotelId={hotel.data.attributes.operaId}
|
||||
otherPaymentOptions={
|
||||
hotel.data.attributes.merchantInformationData
|
||||
.alternatePaymentOptions
|
||||
}
|
||||
savedCreditCards={savedCreditCards}
|
||||
/>
|
||||
</SectionAccordion>
|
||||
</section>
|
||||
)
|
||||
@@ -3,7 +3,7 @@ import { env } from "@/env/server"
|
||||
import {
|
||||
fetchAvailableHotels,
|
||||
getFiltersFromHotels,
|
||||
} from "@/app/[lang]/(live)/(public)/hotelreservation/select-hotel/utils"
|
||||
} from "@/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-hotel/utils"
|
||||
import SelectHotelMap from "@/components/HotelReservation/SelectHotel/SelectHotelMap"
|
||||
import { setLang } from "@/i18n/serverContext"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { differenceInCalendarDays, format,isWeekend } from "date-fns"
|
||||
import { differenceInCalendarDays, format, isWeekend } from "date-fns"
|
||||
|
||||
import { Lang } from "@/constants/languages"
|
||||
import { selectHotelMap } from "@/constants/routes/hotelReservation"
|
||||
@@ -6,7 +6,7 @@ import { selectHotelMap } from "@/constants/routes/hotelReservation"
|
||||
import {
|
||||
fetchAvailableHotels,
|
||||
getFiltersFromHotels,
|
||||
} from "@/app/[lang]/(live)/(public)/hotelreservation/select-hotel/utils"
|
||||
} from "@/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-hotel/utils"
|
||||
import HotelCardListing from "@/components/HotelReservation/HotelCardListing"
|
||||
import HotelFilter from "@/components/HotelReservation/SelectHotel/HotelFilter"
|
||||
import { ChevronRightIcon } from "@/components/Icons"
|
||||
@@ -1,7 +1,9 @@
|
||||
import { getProfileSafely } from "@/lib/trpc/memoizedRequests"
|
||||
import { serverClient } from "@/lib/trpc/server"
|
||||
|
||||
import HotelInfoCard from "@/components/HotelReservation/SelectRate/HotelInfoCard"
|
||||
import RoomSelection from "@/components/HotelReservation/SelectRate/RoomSelection"
|
||||
import getHotelReservationQueryParams from "@/components/HotelReservation/SelectRate/RoomSelection/utils"
|
||||
import { setLang } from "@/i18n/serverContext"
|
||||
|
||||
import styles from "./page.module.css"
|
||||
@@ -15,6 +17,12 @@ export default async function SelectRatePage({
|
||||
}: PageArgs<LangParams & { section: string }, SelectRateSearchParams>) {
|
||||
setLang(params.lang)
|
||||
|
||||
const selectRoomParams = new URLSearchParams(searchParams)
|
||||
const selectRoomParamsObject =
|
||||
getHotelReservationQueryParams(selectRoomParams)
|
||||
const adults = selectRoomParamsObject.room[0].adults // TODO: Handle multiple rooms
|
||||
const children = selectRoomParamsObject.room[0].child?.length // TODO: Handle multiple rooms
|
||||
|
||||
const [hotelData, roomConfigurations, user] = await Promise.all([
|
||||
serverClient().hotel.hotelData.get({
|
||||
hotelId: searchParams.hotel,
|
||||
@@ -23,9 +31,10 @@ export default async function SelectRatePage({
|
||||
}),
|
||||
serverClient().hotel.availability.rooms({
|
||||
hotelId: parseInt(searchParams.hotel, 10),
|
||||
roomStayStartDate: "2024-11-02",
|
||||
roomStayEndDate: "2024-11-03",
|
||||
adults: 1,
|
||||
roomStayStartDate: searchParams.fromDate,
|
||||
roomStayEndDate: searchParams.toDate,
|
||||
adults,
|
||||
children,
|
||||
}),
|
||||
getProfileSafely(),
|
||||
])
|
||||
@@ -42,9 +51,8 @@ export default async function SelectRatePage({
|
||||
|
||||
return (
|
||||
<div>
|
||||
<HotelInfoCard hotelData={hotelData} />
|
||||
<div className={styles.content}>
|
||||
{/* TODO: Add Hotel Listing Card */}
|
||||
<div>Hotel Listing Card TBI</div>
|
||||
<div className={styles.main}>
|
||||
<RoomSelection
|
||||
roomConfigurations={roomConfigurations}
|
||||
@@ -1,16 +0,0 @@
|
||||
.main {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: var(--Spacing-x4);
|
||||
background-color: var(--Scandic-Brand-Warm-White);
|
||||
min-height: 100dvh;
|
||||
max-width: var(--max-width);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x4);
|
||||
width: 100%;
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
|
||||
import {
|
||||
BOOKING_CONFIRMATION_NUMBER,
|
||||
BookingStatusEnum,
|
||||
} from "@/constants/booking"
|
||||
|
||||
import IntroSection from "@/components/HotelReservation/BookingConfirmation/IntroSection"
|
||||
import StaySection from "@/components/HotelReservation/BookingConfirmation/StaySection"
|
||||
import SummarySection from "@/components/HotelReservation/BookingConfirmation/SummarySection"
|
||||
import { tempConfirmationData } from "@/components/HotelReservation/BookingConfirmation/tempConfirmationData"
|
||||
import LoadingSpinner from "@/components/LoadingSpinner"
|
||||
import { useHandleBookingStatus } from "@/hooks/booking/useHandleBookingStatus"
|
||||
|
||||
import styles from "./page.module.css"
|
||||
|
||||
const maxRetries = 10
|
||||
const retryInterval = 2000
|
||||
|
||||
export default function BookingConfirmationPage() {
|
||||
const { email, hotel, stay, summary } = tempConfirmationData
|
||||
|
||||
const confirmationNumber = useMemo(() => {
|
||||
if (typeof window === "undefined") return ""
|
||||
|
||||
const storedConfirmationNumber = sessionStorage.getItem(
|
||||
BOOKING_CONFIRMATION_NUMBER
|
||||
)
|
||||
// TODO: cleanup stored values
|
||||
// sessionStorage.removeItem(BOOKING_CONFIRMATION_NUMBER)
|
||||
return storedConfirmationNumber
|
||||
}, [])
|
||||
|
||||
const bookingStatus = useHandleBookingStatus(
|
||||
confirmationNumber,
|
||||
BookingStatusEnum.BookingCompleted,
|
||||
maxRetries,
|
||||
retryInterval
|
||||
)
|
||||
|
||||
if (
|
||||
confirmationNumber === null ||
|
||||
bookingStatus.isError ||
|
||||
(bookingStatus.isFetched && !bookingStatus.data)
|
||||
) {
|
||||
// TODO: handle error
|
||||
throw new Error("Error fetching booking status")
|
||||
}
|
||||
|
||||
if (
|
||||
bookingStatus.data?.reservationStatus === BookingStatusEnum.BookingCompleted
|
||||
) {
|
||||
return (
|
||||
<main className={styles.main}>
|
||||
<section className={styles.section}>
|
||||
<IntroSection email={email} />
|
||||
<StaySection hotel={hotel} stay={stay} />
|
||||
<SummarySection summary={summary} />
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
return <LoadingSpinner />
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from "../../page"
|
||||
@@ -0,0 +1,3 @@
|
||||
export default function ConfirmedBookingSlot() {
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from "../page"
|
||||
@@ -1,8 +1,14 @@
|
||||
import { env } from "@/env/server"
|
||||
|
||||
import LoadingSpinner from "@/components/LoadingSpinner"
|
||||
|
||||
import styles from "./loading.module.css"
|
||||
|
||||
export default function LoadingBookingWidget() {
|
||||
if (env.HIDE_FOR_NEXT_RELEASE) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<LoadingSpinner />
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { env } from "@/env/server"
|
||||
import { serverClient } from "@/lib/trpc/server"
|
||||
|
||||
import BookingWidget, { preload } from "@/components/BookingWidget"
|
||||
|
||||
export default async function BookingWidgetPage() {
|
||||
if (env.HIDE_FOR_NEXT_RELEASE) {
|
||||
return null
|
||||
}
|
||||
|
||||
preload()
|
||||
|
||||
// Get the booking widget show/hide status based on page specific settings
|
||||
|
||||
1
app/[lang]/(live)/@sitewidealert/[...paths]/page.tsx
Normal file
1
app/[lang]/(live)/@sitewidealert/[...paths]/page.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from "../page"
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from "../../page"
|
||||
1
app/[lang]/(live)/@sitewidealert/default.tsx
Normal file
1
app/[lang]/(live)/@sitewidealert/default.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from "./page"
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from "../../page"
|
||||
23
app/[lang]/(live)/@sitewidealert/page.tsx
Normal file
23
app/[lang]/(live)/@sitewidealert/page.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Suspense } from "react"
|
||||
|
||||
import { env } from "@/env/server"
|
||||
|
||||
import SitewideAlert, { preload } from "@/components/SitewideAlert"
|
||||
import { setLang } from "@/i18n/serverContext"
|
||||
|
||||
import type { LangParams, PageArgs } from "@/types/params"
|
||||
|
||||
export default function SitewideAlertPage({ params }: PageArgs<LangParams>) {
|
||||
if (env.HIDE_FOR_NEXT_RELEASE) {
|
||||
return null
|
||||
}
|
||||
|
||||
setLang(params.lang)
|
||||
preload()
|
||||
|
||||
return (
|
||||
<Suspense>
|
||||
<SitewideAlert />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@@ -25,12 +25,14 @@ export default async function RootLayout({
|
||||
children,
|
||||
footer,
|
||||
header,
|
||||
sitewidealert,
|
||||
params,
|
||||
}: React.PropsWithChildren<
|
||||
LayoutArgs<LangParams> & {
|
||||
bookingwidget: React.ReactNode
|
||||
footer: React.ReactNode
|
||||
header: React.ReactNode
|
||||
sitewidealert: React.ReactNode
|
||||
}
|
||||
>) {
|
||||
setLang(params.lang)
|
||||
@@ -60,6 +62,7 @@ export default async function RootLayout({
|
||||
<ServerIntlProvider intl={{ defaultLocale, locale, messages }}>
|
||||
<TrpcProvider>
|
||||
<RouterTracking>
|
||||
{!env.HIDE_FOR_NEXT_RELEASE && <>{sitewidealert}</>}
|
||||
{header}
|
||||
{!env.HIDE_FOR_NEXT_RELEASE && <>{bookingwidget}</>}
|
||||
{children}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { env } from "process"
|
||||
|
||||
import { BOOKING_CONFIRMATION_NUMBER } from "@/constants/booking"
|
||||
import { Lang } from "@/constants/languages"
|
||||
import {
|
||||
bookingConfirmation,
|
||||
@@ -17,14 +17,24 @@ export async function GET(
|
||||
console.log(`[payment-callback] callback started`)
|
||||
const lang = params.lang as Lang
|
||||
const status = params.status
|
||||
const returnUrl = new URL(`${publicURL}/${payment[lang]}`)
|
||||
|
||||
if (status === "success") {
|
||||
const queryParams = request.nextUrl.searchParams
|
||||
const confirmationNumber = queryParams.get(BOOKING_CONFIRMATION_NUMBER)
|
||||
|
||||
if (status === "success" && confirmationNumber) {
|
||||
const confirmationUrl = new URL(`${publicURL}/${bookingConfirmation[lang]}`)
|
||||
confirmationUrl.searchParams.set(
|
||||
BOOKING_CONFIRMATION_NUMBER,
|
||||
confirmationNumber
|
||||
)
|
||||
|
||||
console.log(`[payment-callback] redirecting to: ${confirmationUrl}`)
|
||||
return NextResponse.redirect(confirmationUrl)
|
||||
}
|
||||
|
||||
const returnUrl = new URL(`${publicURL}/${payment[lang]}`)
|
||||
returnUrl.search = queryParams.toString()
|
||||
|
||||
if (status === "cancel") {
|
||||
returnUrl.searchParams.set("cancel", "true")
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { redirect } from "next/navigation"
|
||||
import { overview } from "@/constants/routes/myPages"
|
||||
|
||||
import { auth } from "@/auth"
|
||||
import Form from "@/components/Forms/Register"
|
||||
import SignupForm from "@/components/Forms/Signup"
|
||||
import { getLang } from "@/i18n/serverContext"
|
||||
|
||||
import { SignupFormWrapperProps } from "@/types/components/blocks/dynamicContent"
|
||||
@@ -16,5 +16,5 @@ export default async function SignupFormWrapper({
|
||||
// We don't want to allow users to access signup if they are already authenticated.
|
||||
redirect(overview[getLang()])
|
||||
}
|
||||
return <Form {...dynamic_content} />
|
||||
return <SignupForm {...dynamic_content} />
|
||||
}
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
|
||||
import { dt } from "@/lib/dt"
|
||||
|
||||
import { CalendarIcon } from "@/components/Icons"
|
||||
import Image from "@/components/Image"
|
||||
import LoadingSpinner from "@/components/LoadingSpinner"
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import Title from "@/components/TempDesignSystem/Text/Title"
|
||||
@@ -14,6 +18,10 @@ import type { StayCardProps } from "@/types/components/myPages/stays/stayCard"
|
||||
|
||||
export default function StayCard({ stay }: StayCardProps) {
|
||||
const lang = useLang()
|
||||
|
||||
// TODO: Temporary loading. Remove when current web is deleted.
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const { checkinDate, checkoutDate, hotelInformation, bookingUrl } =
|
||||
stay.attributes
|
||||
|
||||
@@ -25,7 +33,11 @@ export default function StayCard({ stay }: StayCardProps) {
|
||||
const departDateTime = depart.format("YYYY-MM-DD")
|
||||
|
||||
return (
|
||||
<Link href={bookingUrl}>
|
||||
<Link
|
||||
href={bookingUrl}
|
||||
className={styles.link}
|
||||
onClick={() => setLoading(true)}
|
||||
>
|
||||
<article className={styles.stay}>
|
||||
<Image
|
||||
className={styles.image}
|
||||
@@ -50,6 +62,11 @@ export default function StayCard({ stay }: StayCardProps) {
|
||||
</div>
|
||||
</footer>
|
||||
</article>
|
||||
{loading && (
|
||||
<div className={styles.loadingcontainer}>
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,11 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.link {
|
||||
text-decoration: none;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.stay:hover {
|
||||
border: 1.5px solid var(--Base-Border-Hover);
|
||||
}
|
||||
@@ -41,3 +46,15 @@
|
||||
display: flex;
|
||||
gap: var(--Spacing-x-half);
|
||||
}
|
||||
|
||||
.loadingcontainer {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 70px;
|
||||
background: rgb(255 255 255 / 80%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@@ -18,17 +18,20 @@ export default function ShortcutsList({
|
||||
const leftColumn = shortcuts.slice(0, middleIndex)
|
||||
const rightColumn = shortcuts.slice(middleIndex)
|
||||
|
||||
const classNames = hasTwoColumns
|
||||
? {
|
||||
section: styles.twoColumnSection,
|
||||
leftColumn: styles.leftColumn,
|
||||
rightColumn: styles.rightColumn,
|
||||
}
|
||||
: {
|
||||
section: styles.oneColumnSection,
|
||||
leftColumn: styles.leftColumnBottomBorder,
|
||||
rightColumn: "",
|
||||
}
|
||||
const classNames =
|
||||
hasTwoColumns && shortcuts.length > 1
|
||||
? {
|
||||
section: styles.twoColumnSection,
|
||||
leftColumn: styles.leftColumn,
|
||||
rightColumn: styles.rightColumn,
|
||||
}
|
||||
: {
|
||||
section: styles.oneColumnSection,
|
||||
leftColumn:
|
||||
shortcuts.length === 1
|
||||
? styles.leftColumnBorderBottomNone
|
||||
: styles.leftColumnBorderBottom,
|
||||
}
|
||||
|
||||
return (
|
||||
<SectionContainer>
|
||||
|
||||
@@ -7,10 +7,14 @@
|
||||
}
|
||||
|
||||
.leftColumn,
|
||||
.leftColumnBottomBorder {
|
||||
.leftColumnBorderBottom {
|
||||
border-bottom: 1px solid var(--Base-Border-Subtle);
|
||||
}
|
||||
|
||||
.leftColumnBorderBottomNone {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1367px) {
|
||||
.twoColumnSection {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x3);
|
||||
padding: var(--Spacing-x3) var(--Spacing-x4);
|
||||
}
|
||||
|
||||
.column {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
.grid {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x3);
|
||||
padding: var(--Spacing-x3) var(--Spacing-x4);
|
||||
}
|
||||
@media screen and (min-width: 767px) {
|
||||
.grid {
|
||||
|
||||
@@ -42,8 +42,8 @@ export default function BookingWidgetClient({
|
||||
date: {
|
||||
// UTC is required to handle requests from far away timezones https://scandichotels.atlassian.net/browse/SWAP-6375 & PET-507
|
||||
// This is specifically to handle timezones falling in different dates.
|
||||
from: dt().utc().format("YYYY-MM-DD"),
|
||||
to: dt().utc().add(1, "day").format("YYYY-MM-DD"),
|
||||
fromDate: dt().utc().format("YYYY-MM-DD"),
|
||||
toDate: dt().utc().add(1, "day").format("YYYY-MM-DD"),
|
||||
},
|
||||
bookingCode: "",
|
||||
redemption: false,
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
box-shadow: 0px 4px 24px 0px rgba(0, 0, 0, 0.05);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 9;
|
||||
z-index: 10;
|
||||
background-color: var(--Base-Surface-Primary-light-Normal);
|
||||
}
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
display: grid;
|
||||
padding: var(--Spacing-x4) var(--Spacing-x2) 0;
|
||||
gap: var(--Spacing-x4);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.mainContent {
|
||||
|
||||
@@ -44,6 +44,7 @@ export default async function ContentPage() {
|
||||
<Hero
|
||||
alt={hero_image.meta.alt || hero_image.meta.caption || ""}
|
||||
src={hero_image.url}
|
||||
focalPoint={hero_image.focalPoint}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -20,6 +20,10 @@
|
||||
gap: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.showAllAmenities {
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
@@ -16,9 +16,7 @@ export default async function AmenitiesList({
|
||||
detailedFacilities,
|
||||
}: AmenitiesListProps) {
|
||||
const intl = await getIntl()
|
||||
const sortedAmenities = detailedFacilities
|
||||
.sort((a, b) => b.sortOrder - a.sortOrder)
|
||||
.slice(0, 5)
|
||||
const facilities = detailedFacilities.slice(0, 5)
|
||||
const lang = getLang()
|
||||
return (
|
||||
<section className={styles.amenitiesContainer}>
|
||||
@@ -26,11 +24,13 @@ export default async function AmenitiesList({
|
||||
{intl.formatMessage({ id: "At the hotel" })}
|
||||
</Subtitle>
|
||||
<div className={styles.amenityItemList}>
|
||||
{sortedAmenities.map((facility) => {
|
||||
const IconComponent = mapFacilityToIcon(facility.name)
|
||||
{facilities.map((facility) => {
|
||||
const IconComponent = mapFacilityToIcon(facility.id)
|
||||
return (
|
||||
<div className={styles.amenityItem} key={facility.id}>
|
||||
{IconComponent && <IconComponent color="grey80" />}
|
||||
{IconComponent && (
|
||||
<IconComponent className={styles.icon} color="grey80" />
|
||||
)}
|
||||
<Body color="textMediumContrast">{facility.name}</Body>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -88,7 +88,7 @@ export default function TabNavigation({
|
||||
scroll={true}
|
||||
onClick={pauseScrollSpy}
|
||||
>
|
||||
{intl.formatMessage({ id: link.text })}
|
||||
{link.text}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -3,21 +3,287 @@ import { FC } from "react"
|
||||
import { getIconByIconName } from "@/components/Icons/get-icon-by-icon-name"
|
||||
|
||||
import { IconName, IconProps } from "@/types/components/icon"
|
||||
import { FacilityEnum } from "@/types/enums/facilities"
|
||||
|
||||
const facilityToIconMap: { [key: string]: IconName } = {
|
||||
Bar: IconName.Bar,
|
||||
"Bikes for loan": IconName.Biking,
|
||||
Gym: IconName.Fitness,
|
||||
"Free WiFi": IconName.Wifi,
|
||||
//TODO: Ask design team what icon(s) should be used for meetings.
|
||||
"Meeting rooms": IconName.People2,
|
||||
"Meeting / conference facilities": IconName.People2,
|
||||
"Pet-friendly rooms": IconName.Pets,
|
||||
Sauna: IconName.Sauna,
|
||||
Restaurant: IconName.Restaurant,
|
||||
const facilityToIconMap: Record<FacilityEnum, IconName> = {
|
||||
[FacilityEnum.Bar]: IconName.LocalBar,
|
||||
[FacilityEnum.Skybar]: IconName.LocalBar,
|
||||
[FacilityEnum.RooftopBar]: IconName.LocalBar,
|
||||
[FacilityEnum.BikesForLoan]: IconName.Biking,
|
||||
[FacilityEnum.Gym]: IconName.Fitness,
|
||||
[FacilityEnum.GymTrainingFacilities]: IconName.Fitness,
|
||||
[FacilityEnum.KeyAccessOnlyToHealthClubGym]: IconName.Fitness,
|
||||
[FacilityEnum.FreeWiFi]: IconName.Wifi,
|
||||
[FacilityEnum.MeetingRooms]: IconName.People2,
|
||||
[FacilityEnum.MeetingConferenceFacilities]: IconName.People2,
|
||||
[FacilityEnum.PetFriendlyRooms]: IconName.Pets,
|
||||
[FacilityEnum.Sauna]: IconName.Sauna,
|
||||
[FacilityEnum.Restaurant]: IconName.Restaurant,
|
||||
[FacilityEnum.ParkingGarage]: IconName.Garage,
|
||||
[FacilityEnum.ParkingElectricCharging]: IconName.ElectricCar,
|
||||
[FacilityEnum.ParkingFreeParking]: IconName.Parking,
|
||||
[FacilityEnum.ParkingOutdoor]: IconName.Parking,
|
||||
[FacilityEnum.ParkingAdditionalCost]: IconName.Parking,
|
||||
[FacilityEnum.DisabledParking]: IconName.Parking,
|
||||
[FacilityEnum.OutdoorTerrace]: IconName.OutdoorFurniture,
|
||||
[FacilityEnum.RoomService]: IconName.RoomService,
|
||||
[FacilityEnum.LaundryRoom]: IconName.LaundryMachine,
|
||||
[FacilityEnum.LaundryService]: IconName.LaundryMachine,
|
||||
[FacilityEnum.LaundryServiceExpress]: IconName.LaundryMachine,
|
||||
[FacilityEnum.ScandicShop24Hrs]: IconName.ConvenienceStore24h,
|
||||
[FacilityEnum.ServesBreakfastAlwaysIncluded]: IconName.CoffeeAlt,
|
||||
[FacilityEnum.ServesBreakfastNotAlwaysIncluded]: IconName.CoffeeAlt,
|
||||
[FacilityEnum.ServesOrganicBreakfastAlwaysIncluded]: IconName.CoffeeAlt,
|
||||
[FacilityEnum.ServesOrganicBreakfastNotAlwaysIncluded]: IconName.CoffeeAlt,
|
||||
[FacilityEnum.Breakfast]: IconName.CoffeeAlt,
|
||||
[FacilityEnum.EBikesChargingStation]: IconName.ElectricBike,
|
||||
[FacilityEnum.Shopping]: IconName.Shopping,
|
||||
[FacilityEnum.Golf]: IconName.Golf,
|
||||
[FacilityEnum.GolfCourse0To30Km]: IconName.Golf,
|
||||
[FacilityEnum.TVWithChromecast1]: IconName.TvCasting,
|
||||
[FacilityEnum.TVWithChromecast2]: IconName.TvCasting,
|
||||
[FacilityEnum.DJLiveMusic]: IconName.Nightlife,
|
||||
[FacilityEnum.DiscoNightClub]: IconName.Nightlife,
|
||||
[FacilityEnum.CoffeeInReceptionAtCharge]: IconName.CoffeeAlt,
|
||||
[FacilityEnum.CoffeeShop]: IconName.CoffeeAlt,
|
||||
[FacilityEnum.CoffeeTeaFacilities]: IconName.CoffeeAlt,
|
||||
[FacilityEnum.SkateboardsForLoan]: IconName.Skateboarding,
|
||||
[FacilityEnum.KayaksForLoan]: IconName.Kayaking,
|
||||
[FacilityEnum.LifestyleConcierge]: IconName.Concierge,
|
||||
[FacilityEnum.WellnessAndSaunaEntranceFeeAdmission16PlusYears]:
|
||||
IconName.Sauna,
|
||||
[FacilityEnum.WellnessPoolSaunaEntranceFeeAdmission16PlusYears]:
|
||||
IconName.Sauna,
|
||||
[FacilityEnum.Cafe]: IconName.Restaurant,
|
||||
[FacilityEnum.Pool]: IconName.Swim,
|
||||
[FacilityEnum.PoolSwimmingPoolJacuzziAtHotel]: IconName.Swim,
|
||||
[FacilityEnum.VendingMachineWithNecessities]: IconName.Groceries,
|
||||
|
||||
[FacilityEnum.Jacuzzi]: IconName.StarFilled,
|
||||
[FacilityEnum.JacuzziInRoom]: IconName.StarFilled,
|
||||
|
||||
[FacilityEnum.AccessibleBathingControls]: IconName.StarFilled,
|
||||
[FacilityEnum.AccessibleBathtubs]: IconName.StarFilled,
|
||||
[FacilityEnum.AccessibleElevators]: IconName.StarFilled,
|
||||
[FacilityEnum.AccessibleLightSwitch]: IconName.StarFilled,
|
||||
[FacilityEnum.AccessibleRoomsAtHotel1]: IconName.StarFilled,
|
||||
[FacilityEnum.AccessibleRoomsAtHotel2]: IconName.StarFilled,
|
||||
[FacilityEnum.AccessibleToilets]: IconName.StarFilled,
|
||||
[FacilityEnum.AccessibleWashBasins]: IconName.StarFilled,
|
||||
[FacilityEnum.AdaptedRoomDoors]: IconName.StarFilled,
|
||||
[FacilityEnum.AdjoiningConventionCentre]: IconName.StarFilled,
|
||||
[FacilityEnum.AirConAirCooling]: IconName.StarFilled,
|
||||
[FacilityEnum.AirConditioningInRoom]: IconName.StarFilled,
|
||||
[FacilityEnum.AirportMaxDistance8Km]: IconName.StarFilled,
|
||||
[FacilityEnum.AlarmsContinuouslyMonitored]: IconName.StarFilled,
|
||||
[FacilityEnum.AlarmsHaveStrobeLightsForDeafHardHearingInAllGuestRooms]:
|
||||
IconName.StarFilled,
|
||||
[FacilityEnum.AlarmsHaveStrobeLightsForDeafHardHearingInAllHallways]:
|
||||
IconName.StarFilled,
|
||||
[FacilityEnum.AlarmsHaveStrobeLightsForDeafHardHearingInAllPublicAreas]:
|
||||
IconName.StarFilled,
|
||||
[FacilityEnum.AllAudibleSmokeAlarmsHardwired]: IconName.StarFilled,
|
||||
[FacilityEnum.AllExteriorDoorsRequireKeyAccessAtNightOrAutomaticallyLock]:
|
||||
IconName.StarFilled,
|
||||
[FacilityEnum.AllGuestRoomDoorsHaveViewports]: IconName.StarFilled,
|
||||
[FacilityEnum.AllGuestRoomDoorsSelfClosing]: IconName.StarFilled,
|
||||
[FacilityEnum.AllParkingAreasPatrolled]: IconName.StarFilled,
|
||||
[FacilityEnum.AllParkingAreasWellLit]: IconName.StarFilled,
|
||||
[FacilityEnum.AllStairsWellsVentilated]: IconName.StarFilled,
|
||||
[FacilityEnum.ArmchairBed]: IconName.StarFilled,
|
||||
[FacilityEnum.AudibleAlarms]: IconName.StarFilled,
|
||||
[FacilityEnum.AudibleSmokeAlarmsInAllHalls]: IconName.StarFilled,
|
||||
[FacilityEnum.AudibleSmokeAlarmsInAllPublicAreas]: IconName.StarFilled,
|
||||
[FacilityEnum.AudibleSmokeAlarmsInAllRooms]: IconName.StarFilled,
|
||||
[FacilityEnum.AudioVisualEquipmentAvailable]: IconName.StarFilled,
|
||||
[FacilityEnum.AutolinkFireDepartment]: IconName.StarFilled,
|
||||
[FacilityEnum.AutomatedExternalDefibrillatorOnSiteAED]: IconName.StarFilled,
|
||||
[FacilityEnum.AutomaticFireDoors]: IconName.StarFilled,
|
||||
[FacilityEnum.AutoRecallElevators]: IconName.StarFilled,
|
||||
[FacilityEnum.BalconiesAccessibleToAdjoiningRooms]: IconName.StarFilled,
|
||||
[FacilityEnum.Ballroom]: IconName.StarFilled,
|
||||
[FacilityEnum.Banquet]: IconName.StarFilled,
|
||||
[FacilityEnum.BasicMedicalEquipmentOnSite]: IconName.StarFilled,
|
||||
[FacilityEnum.BathroomsAdaptedForDisabledGuests]: IconName.StarFilled,
|
||||
[FacilityEnum.Beach]: IconName.StarFilled,
|
||||
[FacilityEnum.Beach0To1Km]: IconName.StarFilled,
|
||||
[FacilityEnum.BeautySalon]: IconName.StarFilled,
|
||||
[FacilityEnum.BedroomsWithWheelchairAccess]: IconName.StarFilled,
|
||||
[FacilityEnum.Bowling]: IconName.StarFilled,
|
||||
[FacilityEnum.BrailleLargePrintHotelLiterature]: IconName.StarFilled,
|
||||
[FacilityEnum.BrailleLargePrintMenus]: IconName.StarFilled,
|
||||
[FacilityEnum.Business1]: IconName.StarFilled,
|
||||
[FacilityEnum.Business2]: IconName.StarFilled,
|
||||
[FacilityEnum.BusinessCentre]: IconName.StarFilled,
|
||||
[FacilityEnum.CashFree8pmTill6am]: IconName.StarFilled,
|
||||
[FacilityEnum.CashFreeHotel]: IconName.StarFilled,
|
||||
[FacilityEnum.ChildrenWelcome]: IconName.StarFilled,
|
||||
[FacilityEnum.City]: IconName.StarFilled,
|
||||
[FacilityEnum.ColourTVInRoomsAllScandicHotels]: IconName.StarFilled,
|
||||
[FacilityEnum.ComplimentaryColdRefreshments]: IconName.StarFilled,
|
||||
[FacilityEnum.CongressHall]: IconName.StarFilled,
|
||||
[FacilityEnum.ConventionCentre]: IconName.StarFilled,
|
||||
[FacilityEnum.Couples]: IconName.StarFilled,
|
||||
[FacilityEnum.DeadboltsOnConnectingDoors]: IconName.StarFilled,
|
||||
[FacilityEnum.DeadboltsSecondaryLocksOnAllGuestRoomDoors]:
|
||||
IconName.StarFilled,
|
||||
[FacilityEnum.Defibrillator]: IconName.StarFilled,
|
||||
[FacilityEnum.Desk]: IconName.StarFilled,
|
||||
[FacilityEnum.DirectDialPhoneInRoomsAllScandic]: IconName.StarFilled,
|
||||
[FacilityEnum.DisabledEmergencyPlan1]: IconName.StarFilled,
|
||||
[FacilityEnum.DisabledEmergencyPlan2]: IconName.StarFilled,
|
||||
[FacilityEnum.DO_NOT_USE_Restaurant]: IconName.StarFilled,
|
||||
[FacilityEnum.Downtown]: IconName.StarFilled,
|
||||
[FacilityEnum.DrinkableTapWater]: IconName.StarFilled,
|
||||
[FacilityEnum.DVDPlayer]: IconName.StarFilled,
|
||||
[FacilityEnum.ElectronicKeyCards]: IconName.StarFilled,
|
||||
[FacilityEnum.Elevator]: IconName.StarFilled,
|
||||
[FacilityEnum.EmergencyBackUpGenerators]: IconName.StarFilled,
|
||||
[FacilityEnum.EmergencyCallButtonOnPhone]: IconName.StarFilled,
|
||||
[FacilityEnum.EmergencyCodesOrButtonsInRooms]: IconName.StarFilled,
|
||||
[FacilityEnum.EmergencyEvacuationPlan1]: IconName.StarFilled,
|
||||
[FacilityEnum.EmergencyEvacuationPlan2]: IconName.StarFilled,
|
||||
[FacilityEnum.EmergencyEvaluationDrillFrequency]: IconName.StarFilled,
|
||||
[FacilityEnum.EmergencyInfoInAllRooms]: IconName.StarFilled,
|
||||
[FacilityEnum.EmergencyLightingAllScandic]: IconName.StarFilled,
|
||||
[FacilityEnum.EmergencyLightningInAllPublicAreas]: IconName.StarFilled,
|
||||
[FacilityEnum.EmergencyServiceResponseTimeInMinutes]: IconName.StarFilled,
|
||||
[FacilityEnum.Entertainment]: IconName.StarFilled,
|
||||
[FacilityEnum.EventVenue]: IconName.StarFilled,
|
||||
[FacilityEnum.ExchangeFacility]: IconName.StarFilled,
|
||||
[FacilityEnum.ExitMapsInRooms]: IconName.StarFilled,
|
||||
[FacilityEnum.ExitSignsLit]: IconName.StarFilled,
|
||||
[FacilityEnum.ExtraFamilyFriendly]: IconName.StarFilled,
|
||||
[FacilityEnum.Families]: IconName.StarFilled,
|
||||
[FacilityEnum.FaxFacilityInRoom]: IconName.StarFilled,
|
||||
[FacilityEnum.Financial]: IconName.StarFilled,
|
||||
[FacilityEnum.FireDetectorsAllScandic]: IconName.StarFilled,
|
||||
[FacilityEnum.FireDetectorsInAllHalls]: IconName.StarFilled,
|
||||
[FacilityEnum.FireDetectorsInAllPublicAreas]: IconName.StarFilled,
|
||||
[FacilityEnum.FireDetectorsInAllRooms]: IconName.StarFilled,
|
||||
[FacilityEnum.FireExtinguishersInAllPublicAreas]: IconName.StarFilled,
|
||||
[FacilityEnum.FireExtinguishersInPublicAreasAllScandic]: IconName.StarFilled,
|
||||
[FacilityEnum.FireSafetyAllScandic]: IconName.StarFilled,
|
||||
[FacilityEnum.FirstAidAvailable]: IconName.StarFilled,
|
||||
[FacilityEnum.FoodDrinks247]: IconName.StarFilled,
|
||||
[FacilityEnum.GiftShop]: IconName.StarFilled,
|
||||
[FacilityEnum.GuestRoomDoorsHaveASecondLock]: IconName.StarFilled,
|
||||
[FacilityEnum.Hairdresser]: IconName.StarFilled,
|
||||
[FacilityEnum.HairdryerInRoomAllScandic]: IconName.StarFilled,
|
||||
[FacilityEnum.HandicapFacilities]: IconName.StarFilled,
|
||||
[FacilityEnum.HandrailsInBathrooms]: IconName.StarFilled,
|
||||
[FacilityEnum.HearingInductionLoops]: IconName.StarFilled,
|
||||
[FacilityEnum.Highway1]: IconName.StarFilled,
|
||||
[FacilityEnum.Highway2]: IconName.StarFilled,
|
||||
[FacilityEnum.Hiking0To3Km]: IconName.StarFilled,
|
||||
[FacilityEnum.HotelCompliesWithAAASecurityStandards]: IconName.StarFilled,
|
||||
[FacilityEnum.HotelIsFollowingScandicsSafetySecurityPolicy]:
|
||||
IconName.StarFilled,
|
||||
[FacilityEnum.HotelWorksAccordingToScandicsAccessibilityConcepts]:
|
||||
IconName.StarFilled,
|
||||
[FacilityEnum.IceMachine]: IconName.StarFilled,
|
||||
[FacilityEnum.IceMachineReception]: IconName.StarFilled,
|
||||
[FacilityEnum.IDRequiredToReplaceAGuestRoomKey]: IconName.StarFilled,
|
||||
[FacilityEnum.IfNoWhatAreTheHoursUse24ClockEx0000To0600]: IconName.StarFilled,
|
||||
[FacilityEnum.InCountry]: IconName.StarFilled,
|
||||
[FacilityEnum.IndustrialPark]: IconName.StarFilled,
|
||||
[FacilityEnum.InternetHighSpeedInternetConnectionAllScandic]:
|
||||
IconName.StarFilled,
|
||||
[FacilityEnum.InternetHotSpotsAllScandic]: IconName.StarFilled,
|
||||
[FacilityEnum.IroningRoom]: IconName.StarFilled,
|
||||
[FacilityEnum.IronIroningBoardAllScandic]: IconName.StarFilled,
|
||||
[FacilityEnum.KeyAccessOnlySecuredFloorsAvailable]: IconName.StarFilled,
|
||||
[FacilityEnum.KidsPlayRoom]: IconName.StarFilled,
|
||||
[FacilityEnum.KidsUpToAndIncluding12YearsStayForFree]: IconName.StarFilled,
|
||||
[FacilityEnum.KitchenInRoom]: IconName.StarFilled,
|
||||
[FacilityEnum.Lake0To1Km]: IconName.StarFilled,
|
||||
[FacilityEnum.LakeOrSea0To1Km]: IconName.StarFilled,
|
||||
[FacilityEnum.LaptopSafe]: IconName.StarFilled,
|
||||
[FacilityEnum.Leisure]: IconName.StarFilled,
|
||||
[FacilityEnum.LuggageLockers]: IconName.StarFilled,
|
||||
[FacilityEnum.Massage]: IconName.StarFilled,
|
||||
[FacilityEnum.MinibarInRoom]: IconName.StarFilled,
|
||||
[FacilityEnum.MobileLift]: IconName.StarFilled,
|
||||
[FacilityEnum.Mountains0To1Km]: IconName.StarFilled,
|
||||
[FacilityEnum.MovieChannelsInRoomAllScandic]: IconName.StarFilled,
|
||||
[FacilityEnum.MultipleExitsOnEachFloor]: IconName.StarFilled,
|
||||
[FacilityEnum.NonSmokingRoomsAllScandic]: IconName.StarFilled,
|
||||
[FacilityEnum.OnSiteTrainingFacilities]: IconName.StarFilled,
|
||||
[FacilityEnum.OtherExplainInBriefDescription]: IconName.StarFilled,
|
||||
[FacilityEnum.OvernightSecurity]: IconName.StarFilled,
|
||||
[FacilityEnum.ParkingAttendant]: IconName.StarFilled,
|
||||
[FacilityEnum.PCHookUpInRoom]: IconName.StarFilled,
|
||||
[FacilityEnum.PillowAlarmsAvailable]: IconName.StarFilled,
|
||||
[FacilityEnum.PlayStationInPlayArea]: IconName.StarFilled,
|
||||
[FacilityEnum.PrintingService]: IconName.StarFilled,
|
||||
[FacilityEnum.PropertyMeetsRequirementsFireSafety]: IconName.StarFilled,
|
||||
[FacilityEnum.PublicAddressSystem]: IconName.StarFilled,
|
||||
[FacilityEnum.RelaxationSuite]: IconName.StarFilled,
|
||||
[FacilityEnum.RestrictedRoomAccessAllScandic]: IconName.StarFilled,
|
||||
[FacilityEnum.RoomsAccessibleFromTheInterior]: IconName.StarFilled,
|
||||
[FacilityEnum.RoomWindowsOpen]: IconName.StarFilled,
|
||||
[FacilityEnum.RoomWindowsThatOpenHaveLockingDevice]: IconName.StarFilled,
|
||||
[FacilityEnum.Rural1]: IconName.StarFilled,
|
||||
[FacilityEnum.Rural2]: IconName.StarFilled,
|
||||
[FacilityEnum.SafeDepositBoxInRoomsAllScandic]: IconName.StarFilled,
|
||||
[FacilityEnum.SafeDepositBoxInRoomsCanHoldA17InchLaptop]: IconName.StarFilled,
|
||||
[FacilityEnum.SafeDepositBoxInRoomsCannotHoldALaptop]: IconName.StarFilled,
|
||||
[FacilityEnum.SafetyChainsOnGuestRoomDoor]: IconName.StarFilled,
|
||||
[FacilityEnum.SecondaryLocksOnSlidingGlassDoors]: IconName.StarFilled,
|
||||
[FacilityEnum.SecondaryLocksOnWindows]: IconName.StarFilled,
|
||||
[FacilityEnum.Security24Hours]: IconName.StarFilled,
|
||||
[FacilityEnum.SecurityEscortsAvailableOnRequest]: IconName.StarFilled,
|
||||
[FacilityEnum.SecurityPersonnelOnSite]: IconName.StarFilled,
|
||||
[FacilityEnum.SeparateFloorsForWomen]: IconName.StarFilled,
|
||||
[FacilityEnum.ServiceGuideDogsAllowed]: IconName.StarFilled,
|
||||
[FacilityEnum.ServiceSecurity24Hrs]: IconName.StarFilled,
|
||||
[FacilityEnum.Skiing0To1Km]: IconName.StarFilled,
|
||||
[FacilityEnum.SmokeDetectorsAllScandic]: IconName.StarFilled,
|
||||
[FacilityEnum.Solarium]: IconName.StarFilled,
|
||||
[FacilityEnum.SpecialNeedsMenus]: IconName.StarFilled,
|
||||
[FacilityEnum.Sports]: IconName.StarFilled,
|
||||
[FacilityEnum.SprinklersAllScandic]: IconName.StarFilled,
|
||||
[FacilityEnum.SprinklersInAllHalls]: IconName.StarFilled,
|
||||
[FacilityEnum.SprinklersInAllPublicAreas]: IconName.StarFilled,
|
||||
[FacilityEnum.SprinklersInAllRooms]: IconName.StarFilled,
|
||||
[FacilityEnum.StaffInDuplicateKeys]: IconName.StarFilled,
|
||||
[FacilityEnum.StaffRedCrossCertifiedInCPR]: IconName.StarFilled,
|
||||
[FacilityEnum.StaffTrainedForDisabledGuests]: IconName.StarFilled,
|
||||
[FacilityEnum.StaffTrainedInAutomatedExternalDefibrillatorUsageAED]:
|
||||
IconName.StarFilled,
|
||||
[FacilityEnum.StaffTrainedInCPR]: IconName.StarFilled,
|
||||
[FacilityEnum.StaffTrainedInFirstAid]: IconName.StarFilled,
|
||||
[FacilityEnum.StaffTrainedInFirstAidTechniques]: IconName.StarFilled,
|
||||
[FacilityEnum.StaffTrainedToCaterForDisabledGuestsAllScandic]:
|
||||
IconName.StarFilled,
|
||||
[FacilityEnum.Suburbs]: IconName.StarFilled,
|
||||
[FacilityEnum.SwingboltLock]: IconName.StarFilled,
|
||||
[FacilityEnum.TeleConferencingFacilitiesAvailable]: IconName.StarFilled,
|
||||
[FacilityEnum.TelevisionsWithSubtitlesOrClosedCaptions]: IconName.StarFilled,
|
||||
[FacilityEnum.Tennis1]: IconName.StarFilled,
|
||||
[FacilityEnum.Tennis2]: IconName.StarFilled,
|
||||
[FacilityEnum.TennisPadel]: IconName.StarFilled,
|
||||
[FacilityEnum.Theatre]: IconName.StarFilled,
|
||||
[FacilityEnum.TrouserPress]: IconName.StarFilled,
|
||||
[FacilityEnum.UniformSecurityOnPremises]: IconName.StarFilled,
|
||||
[FacilityEnum.UtilityRoomForIroning]: IconName.StarFilled,
|
||||
[FacilityEnum.VideoSurveillanceInHallways]: IconName.StarFilled,
|
||||
[FacilityEnum.VideoSurveillanceInPublicAreas]: IconName.StarFilled,
|
||||
[FacilityEnum.VideoSurveillanceMonitored24HrsADay]: IconName.StarFilled,
|
||||
[FacilityEnum.VideoSurveillanceOfAllParkingAreas]: IconName.StarFilled,
|
||||
[FacilityEnum.VideoSurveillanceOfExteriorFrontEntrance]: IconName.StarFilled,
|
||||
[FacilityEnum.VideoSurveillanceRecorded24HrsADayParkingArea]:
|
||||
IconName.StarFilled,
|
||||
[FacilityEnum.WallMountedCycleRack]: IconName.StarFilled,
|
||||
[FacilityEnum.WellLitWalkways]: IconName.StarFilled,
|
||||
[FacilityEnum.WheelchairAccess]: IconName.StarFilled,
|
||||
[FacilityEnum.WideCorridors]: IconName.StarFilled,
|
||||
[FacilityEnum.WideEntrance]: IconName.StarFilled,
|
||||
[FacilityEnum.WideRestaurantEntrance]: IconName.StarFilled,
|
||||
[FacilityEnum.WiFiWirelessInternetAccessAllScandic]: IconName.StarFilled,
|
||||
}
|
||||
|
||||
export function mapFacilityToIcon(facilityName: string): FC<IconProps> | null {
|
||||
const iconName = facilityToIconMap[facilityName]
|
||||
export function mapFacilityToIcon(id: FacilityEnum): FC<IconProps> | null {
|
||||
const iconName = facilityToIconMap[id]
|
||||
return getIconByIconName(iconName) || null
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
"mapContainer";
|
||||
margin: 0 auto;
|
||||
max-width: var(--max-width);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.hotelImages {
|
||||
@@ -30,6 +29,11 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
.overview {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x3);
|
||||
}
|
||||
|
||||
.introContainer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -38,6 +42,11 @@
|
||||
scroll-margin-top: var(--hotel-page-scroll-margin-top);
|
||||
}
|
||||
|
||||
.alertsContainer {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1367px) {
|
||||
.pageContainer {
|
||||
grid-template-areas:
|
||||
@@ -77,10 +86,4 @@
|
||||
padding-left: var(--Spacing-x5);
|
||||
padding-right: var(--Spacing-x5);
|
||||
}
|
||||
.introContainer {
|
||||
grid-template-columns: 38rem minmax(max-content, 16rem);
|
||||
justify-content: space-between;
|
||||
gap: var(--Spacing-x2);
|
||||
align-items: end;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { serverClient } from "@/lib/trpc/server"
|
||||
|
||||
import AccordionSection from "@/components/Blocks/Accordion"
|
||||
import SidePeekProvider from "@/components/SidePeekProvider"
|
||||
import Alert from "@/components/TempDesignSystem/Alert"
|
||||
import SidePeek from "@/components/TempDesignSystem/SidePeek"
|
||||
import { getIntl } from "@/i18n"
|
||||
import { getLang } from "@/i18n/serverContext"
|
||||
@@ -49,6 +50,7 @@ export default async function HotelPage() {
|
||||
pointsOfInterest,
|
||||
facilities,
|
||||
faq,
|
||||
alerts,
|
||||
} = hotelData
|
||||
|
||||
const topThreePois = pointsOfInterest.slice(0, 3)
|
||||
@@ -69,16 +71,30 @@ export default async function HotelPage() {
|
||||
hasFAQ={!!faq}
|
||||
/>
|
||||
<main className={styles.mainSection}>
|
||||
<div id={HotelHashValues.overview} className={styles.introContainer}>
|
||||
<IntroSection
|
||||
hotelName={hotelName}
|
||||
hotelDescription={hotelDescription}
|
||||
location={hotelLocation}
|
||||
address={hotelAddress}
|
||||
tripAdvisor={hotelRatings?.tripAdvisor}
|
||||
/>
|
||||
<div id={HotelHashValues.overview} className={styles.overview}>
|
||||
<div className={styles.introContainer}>
|
||||
<IntroSection
|
||||
hotelName={hotelName}
|
||||
hotelDescription={hotelDescription}
|
||||
location={hotelLocation}
|
||||
address={hotelAddress}
|
||||
tripAdvisor={hotelRatings?.tripAdvisor}
|
||||
/>
|
||||
|
||||
<AmenitiesList detailedFacilities={hotelDetailedFacilities} />
|
||||
<AmenitiesList detailedFacilities={hotelDetailedFacilities} />
|
||||
</div>
|
||||
{alerts.length ? (
|
||||
<div className={styles.alertsContainer}>
|
||||
{alerts.map((alert) => (
|
||||
<Alert
|
||||
key={alert.id}
|
||||
type={alert.type}
|
||||
heading={alert.heading}
|
||||
text={alert.text}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<Rooms rooms={roomCategories} />
|
||||
<Facilities facilities={facilities} activitiesCard={activitiesCard} />
|
||||
|
||||
@@ -44,22 +44,22 @@ export default function DatePickerForm({ name = "date" }: DatePickerFormProps) {
|
||||
function handleSelectDate(selected: Date) {
|
||||
if (isSelectingFrom) {
|
||||
setValue(name, {
|
||||
from: dt(selected).format("YYYY-MM-DD"),
|
||||
to: undefined,
|
||||
fromDate: dt(selected).format("YYYY-MM-DD"),
|
||||
toDate: undefined,
|
||||
})
|
||||
setIsSelectingFrom(false)
|
||||
} else {
|
||||
const fromDate = dt(selectedDate.from)
|
||||
const fromDate = dt(selectedDate.fromDate)
|
||||
const toDate = dt(selected)
|
||||
if (toDate.isAfter(fromDate)) {
|
||||
setValue(name, {
|
||||
from: selectedDate.from,
|
||||
to: toDate.format("YYYY-MM-DD"),
|
||||
fromDate: selectedDate.fromDate,
|
||||
toDate: toDate.format("YYYY-MM-DD"),
|
||||
})
|
||||
} else {
|
||||
setValue(name, {
|
||||
from: toDate.format("YYYY-MM-DD"),
|
||||
to: selectedDate.from,
|
||||
fromDate: toDate.format("YYYY-MM-DD"),
|
||||
toDate: selectedDate.fromDate,
|
||||
})
|
||||
}
|
||||
setIsSelectingFrom(true)
|
||||
@@ -79,11 +79,11 @@ export default function DatePickerForm({ name = "date" }: DatePickerFormProps) {
|
||||
}
|
||||
}, [setIsOpen])
|
||||
|
||||
const selectedFromDate = dt(selectedDate.from)
|
||||
const selectedFromDate = dt(selectedDate.fromDate)
|
||||
.locale(lang)
|
||||
.format("ddd D MMM")
|
||||
const selectedToDate = !!selectedDate.to
|
||||
? dt(selectedDate.to).locale(lang).format("ddd D MMM")
|
||||
const selectedToDate = !!selectedDate.toDate
|
||||
? dt(selectedDate.toDate).locale(lang).format("ddd D MMM")
|
||||
: ""
|
||||
|
||||
return (
|
||||
@@ -93,8 +93,8 @@ export default function DatePickerForm({ name = "date" }: DatePickerFormProps) {
|
||||
{selectedFromDate} - {selectedToDate}
|
||||
</Body>
|
||||
</button>
|
||||
<input {...register("date.from")} type="hidden" />
|
||||
<input {...register("date.to")} type="hidden" />
|
||||
<input {...register("date.fromDate")} type="hidden" />
|
||||
<input {...register("date.toDate")} type="hidden" />
|
||||
<div aria-modal className={styles.hideWrapper} role="dialog">
|
||||
<DatePickerDesktop
|
||||
close={close}
|
||||
|
||||
@@ -397,6 +397,7 @@ export const renderOptions: RenderOptions = {
|
||||
height={365}
|
||||
src={image.url}
|
||||
width={width}
|
||||
focalPoint={image.focalPoint}
|
||||
{...props}
|
||||
/>
|
||||
<Caption>{image.meta.caption}</Caption>
|
||||
|
||||
@@ -27,7 +27,7 @@ export default function FormContent({
|
||||
|
||||
const rooms = intl.formatMessage({ id: "Guests & Rooms" })
|
||||
|
||||
const nights = dt(selectedDate.to).diff(dt(selectedDate.from), "days")
|
||||
const nights = dt(selectedDate.toDate).diff(dt(selectedDate.fromDate), "days")
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -18,8 +18,8 @@ export const bookingWidgetSchema = z.object({
|
||||
bookingCode: z.string(), // Update this as required when working with booking codes component
|
||||
date: z.object({
|
||||
// Update this as required once started working with Date picker in Nights component
|
||||
from: z.string(),
|
||||
to: z.string(),
|
||||
fromDate: z.string(),
|
||||
toDate: z.string(),
|
||||
}),
|
||||
location: z.string().refine(
|
||||
(value) => {
|
||||
|
||||
@@ -46,4 +46,8 @@
|
||||
.nameInputs {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.signUpButton {
|
||||
width: fit-content;
|
||||
}
|
||||
}
|
||||
@@ -22,16 +22,21 @@ import Title from "@/components/TempDesignSystem/Text/Title"
|
||||
import { toast } from "@/components/TempDesignSystem/Toasts"
|
||||
import useLang from "@/hooks/useLang"
|
||||
|
||||
import { RegisterSchema, registerSchema } from "./schema"
|
||||
import { SignUpSchema, signUpSchema } from "./schema"
|
||||
|
||||
import styles from "./form.module.css"
|
||||
|
||||
import type { RegisterFormProps } from "@/types/components/form/registerForm"
|
||||
import type { SignUpFormProps } from "@/types/components/form/signupForm"
|
||||
|
||||
export default function Form({ link, subtitle, title }: RegisterFormProps) {
|
||||
export default function SignupForm({ link, subtitle, title }: SignUpFormProps) {
|
||||
const intl = useIntl()
|
||||
const lang = useLang()
|
||||
const methods = useForm<RegisterSchema>({
|
||||
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 methods = useForm<SignUpSchema>({
|
||||
defaultValues: {
|
||||
firstName: "",
|
||||
lastName: "",
|
||||
@@ -47,15 +52,11 @@ export default function Form({ link, subtitle, title }: RegisterFormProps) {
|
||||
},
|
||||
mode: "all",
|
||||
criteriaMode: "all",
|
||||
resolver: zodResolver(registerSchema),
|
||||
resolver: zodResolver(signUpSchema),
|
||||
reValidateMode: "onChange",
|
||||
})
|
||||
const country = intl.formatMessage({ id: "Country" })
|
||||
const email = `${intl.formatMessage({ id: "Email" })} ${intl.formatMessage({ id: "Address" }).toLowerCase()}`
|
||||
const phoneNumber = intl.formatMessage({ id: "Phone number" })
|
||||
const zipCode = intl.formatMessage({ id: "Zip code" })
|
||||
|
||||
async function handleSubmit(data: RegisterSchema) {
|
||||
async function onSubmit(data: SignUpSchema) {
|
||||
try {
|
||||
const result = await registerUser(data)
|
||||
if (result && !result.success) {
|
||||
@@ -78,12 +79,12 @@ export default function Form({ link, subtitle, title }: RegisterFormProps) {
|
||||
<form
|
||||
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}
|
||||
onSubmit={methods.handleSubmit(handleSubmit)}
|
||||
>
|
||||
<section className={styles.userInfo}>
|
||||
<div className={styles.container}>
|
||||
@@ -94,12 +95,12 @@ export default function Form({ link, subtitle, title }: RegisterFormProps) {
|
||||
</header>
|
||||
<div className={styles.nameInputs}>
|
||||
<Input
|
||||
label={"firstName"}
|
||||
label={intl.formatMessage({ id: "First name" })}
|
||||
name="firstName"
|
||||
registerOptions={{ required: true }}
|
||||
/>
|
||||
<Input
|
||||
label={"lastName"}
|
||||
label={intl.formatMessage({ id: "Last name" })}
|
||||
name="lastName"
|
||||
registerOptions={{ required: true }}
|
||||
/>
|
||||
@@ -170,14 +171,36 @@ export default function Form({ link, subtitle, title }: RegisterFormProps) {
|
||||
</Body>
|
||||
</Checkbox>
|
||||
</section>
|
||||
<Button
|
||||
type="submit"
|
||||
intent="primary"
|
||||
disabled={methods.formState.isSubmitting}
|
||||
data-testid="submit"
|
||||
>
|
||||
{intl.formatMessage({ id: "Sign up to Scandic Friends" })}
|
||||
</Button>
|
||||
|
||||
{/*
|
||||
This is a manual validation trigger workaround:
|
||||
- The Controller component (which Input uses) doesn't re-render on submit,
|
||||
which prevents automatic error display.
|
||||
- Future fix requires Input component refactoring (out of scope for now).
|
||||
*/}
|
||||
{!methods.formState.isValid ? (
|
||||
<Button
|
||||
className={styles.signUpButton}
|
||||
type="button"
|
||||
theme="base"
|
||||
intent="primary"
|
||||
onClick={() => methods.trigger()}
|
||||
data-testid="trigger-validation"
|
||||
>
|
||||
{intl.formatMessage({ id: "Sign up to Scandic Friends" })}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
className={styles.signUpButton}
|
||||
type="submit"
|
||||
theme="base"
|
||||
intent="primary"
|
||||
disabled={methods.formState.isSubmitting}
|
||||
data-testid="submit"
|
||||
>
|
||||
{intl.formatMessage({ id: "Sign up to Scandic Friends" })}
|
||||
</Button>
|
||||
)}
|
||||
</form>
|
||||
</FormProvider>
|
||||
</section>
|
||||
@@ -3,19 +3,14 @@ import { z } from "zod"
|
||||
import { passwordValidator } from "@/utils/passwordValidator"
|
||||
import { phoneValidator } from "@/utils/phoneValidator"
|
||||
|
||||
export const registerSchema = z.object({
|
||||
firstName: z
|
||||
.string()
|
||||
.max(250)
|
||||
.refine((value) => value.trim().length > 0, {
|
||||
message: "First name is required",
|
||||
}),
|
||||
lastName: z
|
||||
.string()
|
||||
.max(250)
|
||||
.refine((value) => value.trim().length > 0, {
|
||||
message: "Last name is required",
|
||||
}),
|
||||
const countryRequiredMsg = "Country is required"
|
||||
export const signUpSchema = z.object({
|
||||
firstName: z.string().max(250).trim().min(1, {
|
||||
message: "First name is required",
|
||||
}),
|
||||
lastName: z.string().max(250).trim().min(1, {
|
||||
message: "Last name is required",
|
||||
}),
|
||||
email: z.string().max(250).email(),
|
||||
phoneNumber: phoneValidator(
|
||||
"Phone is required",
|
||||
@@ -23,7 +18,12 @@ export const registerSchema = z.object({
|
||||
),
|
||||
dateOfBirth: z.string().min(1),
|
||||
address: z.object({
|
||||
countryCode: z.string(),
|
||||
countryCode: z
|
||||
.string({
|
||||
required_error: countryRequiredMsg,
|
||||
invalid_type_error: countryRequiredMsg,
|
||||
})
|
||||
.min(1, countryRequiredMsg),
|
||||
zipCode: z.string().min(1),
|
||||
}),
|
||||
password: passwordValidator("Password is required"),
|
||||
@@ -32,4 +32,4 @@ export const registerSchema = z.object({
|
||||
}),
|
||||
})
|
||||
|
||||
export type RegisterSchema = z.infer<typeof registerSchema>
|
||||
export type SignUpSchema = z.infer<typeof signUpSchema>
|
||||
@@ -70,7 +70,7 @@ export default function ChildSelector({ roomIndex = 0 }: ChildSelectorProps) {
|
||||
roomIndex={roomIndex}
|
||||
index={index}
|
||||
child={child}
|
||||
key={index}
|
||||
key={"child_" + index}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useIntl } from "react-intl"
|
||||
|
||||
import { useGuestsRoomsStore } from "@/stores/guests-rooms"
|
||||
|
||||
import { CloseLargeIcon, PlusCircleIcon } from "../Icons"
|
||||
import { CloseLargeIcon, PlusCircleIcon, PlusIcon } from "../Icons"
|
||||
import Button from "../TempDesignSystem/Button"
|
||||
import Divider from "../TempDesignSystem/Divider"
|
||||
import Subtitle from "../TempDesignSystem/Text/Subtitle"
|
||||
@@ -65,28 +65,51 @@ export default function GuestsRoomsPicker({
|
||||
<Divider color="primaryLightSubtle" />
|
||||
</div>
|
||||
))}
|
||||
<div className={styles.addRoomMobileContainer}>
|
||||
<Tooltip
|
||||
heading={disabledBookingOptionsHeader}
|
||||
text={disabledBookingOptionsText}
|
||||
position="top"
|
||||
arrow="left"
|
||||
>
|
||||
{rooms.length < 4 ? (
|
||||
<Button
|
||||
intent="text"
|
||||
variant="icon"
|
||||
wrapping
|
||||
disabled
|
||||
theme="base"
|
||||
fullWidth
|
||||
>
|
||||
<PlusIcon />
|
||||
{addRoomLabel}
|
||||
</Button>
|
||||
) : null}
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<footer className={styles.footer}>
|
||||
<Tooltip
|
||||
heading={disabledBookingOptionsHeader}
|
||||
text={disabledBookingOptionsText}
|
||||
position="bottom"
|
||||
arrow="left"
|
||||
>
|
||||
{rooms.length < 4 ? (
|
||||
<Button
|
||||
intent="text"
|
||||
variant="icon"
|
||||
wrapping
|
||||
disabled
|
||||
theme="base"
|
||||
className={styles.addRoom}
|
||||
>
|
||||
<PlusCircleIcon />
|
||||
{addRoomLabel}
|
||||
</Button>
|
||||
) : null}
|
||||
</Tooltip>
|
||||
<div className={styles.hideOnMobile}>
|
||||
<Tooltip
|
||||
heading={disabledBookingOptionsHeader}
|
||||
text={disabledBookingOptionsText}
|
||||
position="top"
|
||||
arrow="left"
|
||||
>
|
||||
{rooms.length < 4 ? (
|
||||
<Button
|
||||
intent="text"
|
||||
variant="icon"
|
||||
wrapping
|
||||
disabled
|
||||
theme="base"
|
||||
>
|
||||
<PlusCircleIcon />
|
||||
{addRoomLabel}
|
||||
</Button>
|
||||
) : null}
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Button
|
||||
onClick={closePicker}
|
||||
disabled={getFieldState("rooms").invalid}
|
||||
|
||||
@@ -34,12 +34,13 @@
|
||||
.footer {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x1);
|
||||
grid-template-columns: auto auto;
|
||||
grid-template-columns: auto;
|
||||
margin-top: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1366px) {
|
||||
.hideWrapper {
|
||||
border-radius: var(--Corner-radius-Large) var(--Corner-radius-Large) 0 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
position: fixed;
|
||||
@@ -47,10 +48,10 @@
|
||||
top: 100%;
|
||||
transition: top 300ms ease;
|
||||
z-index: 10002;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.container[data-isopen="true"] .hideWrapper {
|
||||
border-radius: var(--Corner-radius-Large) var(--Corner-radius-Large) 0 0;
|
||||
top: 20px;
|
||||
}
|
||||
|
||||
@@ -106,16 +107,15 @@
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.footer button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.footer .hideOnMobile {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.footer .addRoom {
|
||||
justify-content: start;
|
||||
.addRoomMobileContainer {
|
||||
display: grid;
|
||||
width: 150px;
|
||||
margin: 0 auto;
|
||||
padding-bottom: calc(var(--sticky-button-height) + 20px);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,13 +129,20 @@
|
||||
position: absolute;
|
||||
top: calc(100% + var(--Spacing-x2) + 1px + var(--Spacing-x4));
|
||||
width: 360px;
|
||||
max-height: calc(100dvh - 77px - var(--Spacing-x6));
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.footer .hideOnDesktop {
|
||||
.footer {
|
||||
grid-template-columns: auto auto;
|
||||
}
|
||||
|
||||
.footer .hideOnDesktop,
|
||||
.addRoomMobileContainer {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { FocalPoint } from "@/types/components/image"
|
||||
|
||||
export interface HeroProps {
|
||||
alt: string
|
||||
src: string
|
||||
focalPoint?: FocalPoint
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { HeroProps } from "./hero"
|
||||
|
||||
import styles from "./hero.module.css"
|
||||
|
||||
export default async function Hero({ alt, src }: HeroProps) {
|
||||
export default async function Hero({ alt, src, focalPoint }: HeroProps) {
|
||||
return (
|
||||
<Image
|
||||
className={styles.hero}
|
||||
@@ -12,6 +12,7 @@ export default async function Hero({ alt, src }: HeroProps) {
|
||||
height={480}
|
||||
width={1196}
|
||||
src={src}
|
||||
focalPoint={focalPoint}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
import Title from "@/components/TempDesignSystem/Text/Title"
|
||||
|
||||
import styles from "./introSection.module.css"
|
||||
|
||||
import { IntroSectionProps } from "@/types/components/hotelReservation/bookingConfirmation/bookingConfirmation"
|
||||
|
||||
export default function IntroSection({ email }: IntroSectionProps) {
|
||||
const intl = useIntl()
|
||||
|
||||
return (
|
||||
<section className={styles.section}>
|
||||
<div>
|
||||
<Title textAlign="center" as="h2">
|
||||
{intl.formatMessage({ id: "Thank you" })}
|
||||
</Title>
|
||||
<Subtitle textAlign="center" textTransform="uppercase">
|
||||
{intl.formatMessage({ id: "We look forward to your visit!" })}
|
||||
</Subtitle>
|
||||
</div>
|
||||
<Body color="burgundy" textAlign="center">
|
||||
{intl.formatMessage({
|
||||
id: "We have sent a detailed confirmation of your booking to your email: ",
|
||||
})}
|
||||
{email}
|
||||
</Body>
|
||||
<div className={styles.buttons}>
|
||||
<Button
|
||||
asChild
|
||||
size="small"
|
||||
theme="base"
|
||||
intent="secondary"
|
||||
className={styles.button}
|
||||
>
|
||||
<Link href="#" color="none">
|
||||
{intl.formatMessage({ id: "Download the Scandic app" })}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
asChild
|
||||
size="small"
|
||||
theme="base"
|
||||
intent="secondary"
|
||||
className={styles.button}
|
||||
>
|
||||
<Link href="#" color="none">
|
||||
{intl.formatMessage({ id: "View your booking" })}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x3);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.button {
|
||||
width: 100%;
|
||||
max-width: 240px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1367px) {
|
||||
.buttons {
|
||||
flex-direction: row;
|
||||
justify-content: space-around;
|
||||
}
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { ArrowRightIcon, ScandicLogoIcon } from "@/components/Icons"
|
||||
import Image from "@/components/Image"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import Title from "@/components/TempDesignSystem/Text/Title"
|
||||
|
||||
import styles from "./staySection.module.css"
|
||||
|
||||
import { StaySectionProps } from "@/types/components/hotelReservation/bookingConfirmation/bookingConfirmation"
|
||||
|
||||
export default function StaySection({ hotel, stay }: StaySectionProps) {
|
||||
const intl = useIntl()
|
||||
|
||||
const nightsText =
|
||||
stay.nights > 1
|
||||
? intl.formatMessage({ id: "nights" })
|
||||
: intl.formatMessage({ id: "night" })
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className={styles.card}>
|
||||
<Image
|
||||
src={hotel.image}
|
||||
alt=""
|
||||
height={400}
|
||||
width={200}
|
||||
className={styles.image}
|
||||
/>
|
||||
<div className={styles.info}>
|
||||
<div className={styles.hotel}>
|
||||
<ScandicLogoIcon color="red" />
|
||||
<Title as="h5" textTransform="capitalize">
|
||||
{hotel.name}
|
||||
</Title>
|
||||
<Caption color="burgundy" className={styles.caption}>
|
||||
<span>{hotel.address}</span>
|
||||
<span>{hotel.phone}</span>
|
||||
</Caption>
|
||||
</div>
|
||||
<Body className={styles.stay}>
|
||||
<span>{`${stay.nights} ${nightsText}`}</span>
|
||||
<span className={styles.dates}>
|
||||
<span>{stay.start}</span>
|
||||
<ArrowRightIcon height={15} width={15} />
|
||||
<span>{stay.end}</span>
|
||||
</span>
|
||||
</Body>
|
||||
</div>
|
||||
</section>
|
||||
<section className={styles.table}>
|
||||
<div className={styles.breakfast}>
|
||||
<Body color="burgundy">
|
||||
{intl.formatMessage({ id: "Breakfast" })}
|
||||
</Body>
|
||||
<Caption className={styles.caption}>
|
||||
<span>{`${intl.formatMessage({ id: "Weekdays" })} ${hotel.breakfast.start}-${hotel.breakfast.end}`}</span>
|
||||
<span>{`${intl.formatMessage({ id: "Weekends" })} ${hotel.breakfast.start}-${hotel.breakfast.end}`}</span>
|
||||
</Caption>
|
||||
</div>
|
||||
<div className={styles.checkIn}>
|
||||
<Body color="burgundy">{intl.formatMessage({ id: "Check in" })}</Body>
|
||||
<Caption className={styles.caption}>
|
||||
<span>{intl.formatMessage({ id: "From" })}</span>
|
||||
<span>{hotel.checkIn}</span>
|
||||
</Caption>
|
||||
</div>
|
||||
<div className={styles.checkOut}>
|
||||
<Body color="burgundy">
|
||||
{intl.formatMessage({ id: "Check out" })}
|
||||
</Body>
|
||||
<Caption className={styles.caption}>
|
||||
<span>{intl.formatMessage({ id: "At latest" })}</span>
|
||||
<span>{hotel.checkOut}</span>
|
||||
</Caption>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
.card {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
background-color: var(--Base-Surface-Primary-light-Normal);
|
||||
border: 1px solid var(--Base-Border-Subtle);
|
||||
border-radius: var(--Corner-radius-Small);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.image {
|
||||
height: 100%;
|
||||
width: 105px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
gap: var(--Spacing-x1);
|
||||
padding: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.hotel,
|
||||
.stay {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x-half);
|
||||
}
|
||||
|
||||
.caption {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.dates {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--Spacing-x-half);
|
||||
}
|
||||
|
||||
.table {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: var(--Spacing-x2);
|
||||
border-radius: var(--Corner-radius-Small);
|
||||
background-color: var(--Base-Surface-Primary-dark-Normal);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.breakfast,
|
||||
.checkIn,
|
||||
.checkOut {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x-half);
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1367px) {
|
||||
.card {
|
||||
flex-direction: column;
|
||||
}
|
||||
.image {
|
||||
width: 100%;
|
||||
max-height: 195px;
|
||||
}
|
||||
|
||||
.info {
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.hotel,
|
||||
.stay {
|
||||
width: 100%;
|
||||
max-width: 230px;
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import Title from "@/components/TempDesignSystem/Text/Title"
|
||||
|
||||
import styles from "./summarySection.module.css"
|
||||
|
||||
import { SummarySectionProps } from "@/types/components/hotelReservation/bookingConfirmation/bookingConfirmation"
|
||||
|
||||
export default function SummarySection({ summary }: SummarySectionProps) {
|
||||
const intl = useIntl()
|
||||
const roomType = `${intl.formatMessage({ id: "Type of room" })}: ${summary.roomType}`
|
||||
const bedType = `${intl.formatMessage({ id: "Type of bed" })}: ${summary.bedType}`
|
||||
const breakfast = `${intl.formatMessage({ id: "Breakfast" })}: ${summary.breakfast}`
|
||||
const flexibility = `${intl.formatMessage({ id: "Flexibility" })}: ${summary.flexibility}`
|
||||
|
||||
return (
|
||||
<section className={styles.section}>
|
||||
<Title as="h4" textAlign="center">
|
||||
{intl.formatMessage({ id: "Summary" })}
|
||||
</Title>
|
||||
<Caption className={styles.summary}>
|
||||
<span>{roomType}</span>
|
||||
<span>1648 SEK</span>
|
||||
</Caption>
|
||||
<Caption className={styles.summary}>
|
||||
<span>{bedType}</span>
|
||||
<span>0 SEK</span>
|
||||
</Caption>
|
||||
<Caption className={styles.summary}>
|
||||
<span>{breakfast}</span>
|
||||
<span>198 SEK</span>
|
||||
</Caption>
|
||||
<Caption className={styles.summary}>
|
||||
<span>{flexibility}</span>
|
||||
<span>200 SEK</span>
|
||||
</Caption>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
.section {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.summary {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid var(--Base-Border-Subtle);
|
||||
}
|
||||
|
||||
.summary span {
|
||||
padding: var(--Spacing-x2) var(--Spacing-x0);
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import { BookingConfirmation } from "@/types/components/hotelReservation/bookingConfirmation/bookingConfirmation"
|
||||
|
||||
export const tempConfirmationData: BookingConfirmation = {
|
||||
email: "lisa.andersson@outlook.com",
|
||||
hotel: {
|
||||
name: "Helsinki Hub",
|
||||
address: "Kaisaniemenkatu 7, Helsinki",
|
||||
location: "Helsinki",
|
||||
phone: "+358 300 870680",
|
||||
image:
|
||||
"https://test3.scandichotels.com/imagevault/publishedmedia/i11isd60bh119s9486b7/downtown-camper-by-scandic-lobby-reception-desk-ch.jpg?w=640",
|
||||
checkIn: "15.00",
|
||||
checkOut: "12.00",
|
||||
breakfast: { start: "06:30", end: "10:00" },
|
||||
},
|
||||
stay: {
|
||||
nights: 1,
|
||||
start: "2024.03.09",
|
||||
end: "2024.03.10",
|
||||
},
|
||||
summary: {
|
||||
roomType: "Standard Room",
|
||||
bedType: "King size",
|
||||
breakfast: "Yes",
|
||||
flexibility: "Yes",
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect } from "react"
|
||||
|
||||
import { useEnterDetailsStore } from "@/stores/enter-details"
|
||||
|
||||
export default function HistoryStateManager() {
|
||||
const setCurrentStep = useEnterDetailsStore((state) => state.setCurrentStep)
|
||||
const currentStep = useEnterDetailsStore((state) => state.currentStep)
|
||||
|
||||
const handleBackButton = useCallback(
|
||||
(event: PopStateEvent) => {
|
||||
if (event.state.step) {
|
||||
setCurrentStep(event.state.step)
|
||||
}
|
||||
},
|
||||
[setCurrentStep]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener("popstate", handleBackButton)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("popstate", handleBackButton)
|
||||
}
|
||||
}, [handleBackButton])
|
||||
|
||||
useEffect(() => {
|
||||
if (!window.history.state.step) {
|
||||
window.history.replaceState(
|
||||
{ step: currentStep },
|
||||
"",
|
||||
document.location.href
|
||||
)
|
||||
}
|
||||
}, [currentStep])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import Image from "next/image"
|
||||
import { useFormContext } from "react-hook-form"
|
||||
|
||||
import { PAYMENT_METHOD_ICONS, PaymentMethodEnum } from "@/constants/booking"
|
||||
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
|
||||
import { PaymentOptionProps } from "./paymentOption"
|
||||
|
||||
import styles from "./paymentOption.module.css"
|
||||
|
||||
export default function PaymentOption({
|
||||
name,
|
||||
value,
|
||||
label,
|
||||
cardNumber,
|
||||
registerOptions = {},
|
||||
}: PaymentOptionProps) {
|
||||
const { register } = useFormContext()
|
||||
|
||||
return (
|
||||
<label key={value} className={styles.paymentOption}>
|
||||
<div className={styles.titleContainer}>
|
||||
<input
|
||||
aria-hidden
|
||||
hidden
|
||||
type="radio"
|
||||
id={value}
|
||||
value={value}
|
||||
{...register(name, registerOptions)}
|
||||
/>
|
||||
<span className={styles.radio} />
|
||||
<Body>{label}</Body>
|
||||
</div>
|
||||
{cardNumber ? (
|
||||
<Caption color="uiTextMediumContrast">•••• {cardNumber}</Caption>
|
||||
) : (
|
||||
<Image
|
||||
className={styles.paymentOptionIcon}
|
||||
src={PAYMENT_METHOD_ICONS[value as PaymentMethodEnum]}
|
||||
alt={label}
|
||||
width={48}
|
||||
height={32}
|
||||
/>
|
||||
)}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import { RegisterOptions } from "react-hook-form"
|
||||
|
||||
import { PaymentMethodEnum } from "@/constants/booking"
|
||||
|
||||
export interface PaymentOptionProps {
|
||||
name: string
|
||||
value: PaymentMethodEnum
|
||||
value: string
|
||||
label: string
|
||||
cardNumber?: string
|
||||
registerOptions?: RegisterOptions
|
||||
onChange?: () => void
|
||||
}
|
||||
278
components/HotelReservation/EnterDetails/Payment/index.tsx
Normal file
278
components/HotelReservation/EnterDetails/Payment/index.tsx
Normal file
@@ -0,0 +1,278 @@
|
||||
"use client"
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { Label as AriaLabel } from "react-aria-components"
|
||||
import { FormProvider, useForm } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import {
|
||||
BookingStatusEnum,
|
||||
PAYMENT_METHOD_TITLES,
|
||||
PaymentMethodEnum,
|
||||
} from "@/constants/booking"
|
||||
import {
|
||||
bookingTermsAndConditions,
|
||||
privacyPolicy,
|
||||
} from "@/constants/currentWebHrefs"
|
||||
import { env } from "@/env/client"
|
||||
import { trpc } from "@/lib/trpc/client"
|
||||
import { useEnterDetailsStore } from "@/stores/enter-details"
|
||||
|
||||
import LoadingSpinner from "@/components/LoadingSpinner"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Checkbox from "@/components/TempDesignSystem/Checkbox"
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
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 useLang from "@/hooks/useLang"
|
||||
|
||||
import PaymentOption from "./PaymentOption"
|
||||
import { PaymentFormData, paymentSchema } from "./schema"
|
||||
|
||||
import styles from "./payment.module.css"
|
||||
|
||||
import { PaymentProps } from "@/types/components/hotelReservation/selectRate/section"
|
||||
|
||||
const maxRetries = 40
|
||||
const retryInterval = 2000
|
||||
|
||||
function isPaymentMethodEnum(value: string): value is PaymentMethodEnum {
|
||||
return Object.values(PaymentMethodEnum).includes(value as PaymentMethodEnum)
|
||||
}
|
||||
|
||||
export default function Payment({
|
||||
hotelId,
|
||||
otherPaymentOptions,
|
||||
savedCreditCards,
|
||||
}: PaymentProps) {
|
||||
const router = useRouter()
|
||||
const lang = useLang()
|
||||
const intl = useIntl()
|
||||
const queryParams = useSearchParams()
|
||||
const { firstName, lastName, email, phoneNumber, countryCode } =
|
||||
useEnterDetailsStore((state) => state.data)
|
||||
const [confirmationNumber, setConfirmationNumber] = useState<string>("")
|
||||
|
||||
const methods = useForm<PaymentFormData>({
|
||||
defaultValues: {
|
||||
paymentMethod: savedCreditCards?.length
|
||||
? savedCreditCards[0].id
|
||||
: PaymentMethodEnum.card,
|
||||
smsConfirmation: false,
|
||||
termsAndConditions: false,
|
||||
},
|
||||
mode: "all",
|
||||
reValidateMode: "onChange",
|
||||
resolver: zodResolver(paymentSchema),
|
||||
})
|
||||
|
||||
const initiateBooking = trpc.booking.create.useMutation({
|
||||
onSuccess: (result) => {
|
||||
if (result?.confirmationNumber) {
|
||||
setConfirmationNumber(result.confirmationNumber)
|
||||
} else {
|
||||
// TODO: add proper error message
|
||||
toast.error("Failed to create booking")
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Error", error)
|
||||
// TODO: add proper error message
|
||||
toast.error("Failed to create booking")
|
||||
},
|
||||
})
|
||||
|
||||
const bookingStatus = useHandleBookingStatus(
|
||||
confirmationNumber,
|
||||
BookingStatusEnum.PaymentRegistered,
|
||||
maxRetries,
|
||||
retryInterval
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (bookingStatus?.data?.paymentUrl) {
|
||||
router.push(bookingStatus.data.paymentUrl)
|
||||
}
|
||||
}, [bookingStatus, router])
|
||||
|
||||
function handleSubmit(data: PaymentFormData) {
|
||||
const allQueryParams =
|
||||
queryParams.size > 0 ? `?${queryParams.toString()}` : ""
|
||||
|
||||
// set payment method to card if saved card is submitted
|
||||
const paymentMethod = isPaymentMethodEnum(data.paymentMethod)
|
||||
? data.paymentMethod
|
||||
: PaymentMethodEnum.card
|
||||
|
||||
const savedCreditCard = savedCreditCards?.find(
|
||||
(card) => card.id === data.paymentMethod
|
||||
)
|
||||
|
||||
initiateBooking.mutate({
|
||||
hotelId: hotelId,
|
||||
checkInDate: "2024-12-10",
|
||||
checkOutDate: "2024-12-11",
|
||||
rooms: [
|
||||
{
|
||||
adults: 1,
|
||||
childrenAges: [],
|
||||
rateCode: "SAVEEU",
|
||||
roomTypeCode: "QC",
|
||||
guest: {
|
||||
title: "Mr", // TODO: do we need title?
|
||||
firstName,
|
||||
lastName,
|
||||
email,
|
||||
phoneCountryCodePrefix: phoneNumber.slice(0, 3),
|
||||
phoneNumber: phoneNumber.slice(3),
|
||||
countryCode,
|
||||
},
|
||||
packages: {
|
||||
breakfast: true,
|
||||
allergyFriendly: true,
|
||||
petFriendly: true,
|
||||
accessibility: true,
|
||||
},
|
||||
smsConfirmationRequested: data.smsConfirmation,
|
||||
},
|
||||
],
|
||||
payment: {
|
||||
paymentMethod,
|
||||
card: savedCreditCard
|
||||
? {
|
||||
alias: savedCreditCard.alias,
|
||||
expiryDate: savedCreditCard.expirationDate,
|
||||
cardType: savedCreditCard.cardType,
|
||||
}
|
||||
: undefined,
|
||||
cardHolder: {
|
||||
email: "test.user@scandichotels.com",
|
||||
name: "Test User",
|
||||
phoneCountryCode: "",
|
||||
phoneSubscriber: "",
|
||||
},
|
||||
success: `${env.NEXT_PUBLIC_PAYMENT_CALLBACK_URL}/${lang}/success`,
|
||||
error: `${env.NEXT_PUBLIC_PAYMENT_CALLBACK_URL}/${lang}/error${allQueryParams}`,
|
||||
cancel: `${env.NEXT_PUBLIC_PAYMENT_CALLBACK_URL}/${lang}/cancel${allQueryParams}`,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (
|
||||
initiateBooking.isPending ||
|
||||
(confirmationNumber && !bookingStatus.data?.paymentUrl)
|
||||
) {
|
||||
return <LoadingSpinner />
|
||||
}
|
||||
|
||||
return (
|
||||
<FormProvider {...methods}>
|
||||
<form
|
||||
className={styles.paymentContainer}
|
||||
onSubmit={methods.handleSubmit(handleSubmit)}
|
||||
>
|
||||
{savedCreditCards?.length ? (
|
||||
<section className={styles.section}>
|
||||
<Body color="uiTextHighContrast" textTransform="bold">
|
||||
{intl.formatMessage({ id: "MY SAVED CARDS" })}
|
||||
</Body>
|
||||
<div className={styles.paymentOptionContainer}>
|
||||
{savedCreditCards?.map((savedCreditCard) => (
|
||||
<PaymentOption
|
||||
key={savedCreditCard.id}
|
||||
name="paymentMethod"
|
||||
value={savedCreditCard.id}
|
||||
label={
|
||||
PAYMENT_METHOD_TITLES[
|
||||
savedCreditCard.cardType as PaymentMethodEnum
|
||||
]
|
||||
}
|
||||
cardNumber={savedCreditCard.truncatedNumber}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
<section className={styles.section}>
|
||||
{savedCreditCards?.length ? (
|
||||
<Body color="uiTextHighContrast" textTransform="bold">
|
||||
{intl.formatMessage({ id: "OTHER PAYMENT METHODS" })}
|
||||
</Body>
|
||||
) : null}
|
||||
<div className={styles.paymentOptionContainer}>
|
||||
<PaymentOption
|
||||
name="paymentMethod"
|
||||
value={PaymentMethodEnum.card}
|
||||
label={intl.formatMessage({ id: "Credit card" })}
|
||||
/>
|
||||
{otherPaymentOptions.map((paymentMethod) => (
|
||||
<PaymentOption
|
||||
key={paymentMethod}
|
||||
name="paymentMethod"
|
||||
value={paymentMethod}
|
||||
label={
|
||||
PAYMENT_METHOD_TITLES[paymentMethod as PaymentMethodEnum]
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<section className={styles.section}>
|
||||
<Checkbox name="smsConfirmation">
|
||||
<Caption>
|
||||
{intl.formatMessage({
|
||||
id: "I would like to get my booking confirmation via sms",
|
||||
})}
|
||||
</Caption>
|
||||
</Checkbox>
|
||||
|
||||
<AriaLabel className={styles.terms}>
|
||||
<Checkbox name="termsAndConditions" />
|
||||
<Caption>
|
||||
{intl.formatMessage<React.ReactNode>(
|
||||
{
|
||||
id: "booking.terms",
|
||||
},
|
||||
{
|
||||
termsLink: (str) => (
|
||||
<Link
|
||||
className={styles.link}
|
||||
variant="underscored"
|
||||
href={bookingTermsAndConditions[lang]}
|
||||
target="_blank"
|
||||
>
|
||||
{str}
|
||||
</Link>
|
||||
),
|
||||
privacyLink: (str) => (
|
||||
<Link
|
||||
className={styles.link}
|
||||
variant="underscored"
|
||||
href={privacyPolicy[lang]}
|
||||
target="_blank"
|
||||
>
|
||||
{str}
|
||||
</Link>
|
||||
),
|
||||
}
|
||||
)}
|
||||
</Caption>
|
||||
</AriaLabel>
|
||||
</section>
|
||||
<Button
|
||||
type="submit"
|
||||
className={styles.submitButton}
|
||||
disabled={
|
||||
!methods.formState.isValid || methods.formState.isSubmitting
|
||||
}
|
||||
>
|
||||
{intl.formatMessage({ id: "Complete booking & go to payment" })}
|
||||
</Button>
|
||||
</form>
|
||||
</FormProvider>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,16 @@
|
||||
.paymentContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x3);
|
||||
gap: var(--Spacing-x4);
|
||||
max-width: 480px;
|
||||
}
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.paymentOptionContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -1,9 +1,7 @@
|
||||
import { z } from "zod"
|
||||
|
||||
import { PaymentMethodEnum } from "@/constants/booking"
|
||||
|
||||
export const paymentSchema = z.object({
|
||||
paymentMethod: z.nativeEnum(PaymentMethodEnum),
|
||||
paymentMethod: z.string(),
|
||||
smsConfirmation: z.boolean(),
|
||||
termsAndConditions: z.boolean().refine((value) => value === true, {
|
||||
message: "You must accept the terms and conditions",
|
||||
@@ -1,5 +1,5 @@
|
||||
"use client"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { useEffect, useState } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { useEnterDetailsStore } from "@/stores/enter-details"
|
||||
|
||||
@@ -27,7 +27,6 @@
|
||||
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;
|
||||
}
|
||||
@@ -79,16 +78,3 @@
|
||||
.content {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@keyframes allowOverflow {
|
||||
0% {
|
||||
overflow: hidden;
|
||||
}
|
||||
100% {
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
|
||||
.wrapper[data-open="true"] .content {
|
||||
animation: allowOverflow 0.4s 0.4s ease;
|
||||
}
|
||||
|
||||
@@ -33,8 +33,12 @@ export default function EnterDetailsSidePeek({ hotel }: SidePeekProps) {
|
||||
<Divider />
|
||||
<section className={styles.spacing}>
|
||||
<Body>{hotel.hotelContent.texts.descriptions.medium}</Body>
|
||||
|
||||
<Body>{hotel.hotelContent.texts.facilityInformation}</Body>
|
||||
{hotel.hotelContent.texts.facilityInformation
|
||||
.split(/[\n\r]/g)
|
||||
.filter((p) => p)
|
||||
.map((paragraph, idx) => (
|
||||
<Body key={`facilityInfo-${idx}`}>{paragraph}</Body>
|
||||
))}
|
||||
</section>
|
||||
</article>
|
||||
</SidePeek>
|
||||
|
||||
@@ -14,15 +14,16 @@
|
||||
|
||||
.imageContainer {
|
||||
grid-area: image;
|
||||
position: relative;
|
||||
height: 100%;
|
||||
width: 116px;
|
||||
}
|
||||
|
||||
.tripAdvisor {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.image {
|
||||
height: 100%;
|
||||
width: 116px;
|
||||
.imageContainer img {
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
@@ -77,6 +78,8 @@
|
||||
|
||||
.imageContainer {
|
||||
position: relative;
|
||||
min-height: 200px;
|
||||
width: 518px;
|
||||
}
|
||||
|
||||
.tripAdvisor {
|
||||
@@ -86,10 +89,6 @@
|
||||
top: 7px;
|
||||
}
|
||||
|
||||
.image {
|
||||
width: 518px;
|
||||
}
|
||||
|
||||
.hotelInformation {
|
||||
padding-top: var(--Spacing-x2);
|
||||
padding-right: var(--Spacing-x2);
|
||||
|
||||
@@ -11,10 +11,11 @@ import Title from "@/components/TempDesignSystem/Text/Title"
|
||||
import { getIntl } from "@/i18n"
|
||||
|
||||
import ReadMore from "../ReadMore"
|
||||
import ImageGallery from "../SelectRate/ImageGallery"
|
||||
|
||||
import styles from "./hotelCard.module.css"
|
||||
|
||||
import { HotelCardProps } from "@/types/components/hotelReservation/selectHotel/hotelCardProps"
|
||||
import type { HotelCardProps } from "@/types/components/hotelReservation/selectHotel/hotelCardProps"
|
||||
|
||||
export default async function HotelCard({ hotel }: HotelCardProps) {
|
||||
const intl = await getIntl()
|
||||
@@ -22,20 +23,20 @@ export default async function HotelCard({ hotel }: HotelCardProps) {
|
||||
const { hotelData } = hotel
|
||||
const { price } = hotel
|
||||
|
||||
const sortedAmenities = hotelData.detailedFacilities
|
||||
.sort((a, b) => b.sortOrder - a.sortOrder)
|
||||
.slice(0, 5)
|
||||
const amenities = hotelData.detailedFacilities.slice(0, 5)
|
||||
|
||||
return (
|
||||
<article className={styles.card}>
|
||||
<section className={styles.imageContainer}>
|
||||
<Image
|
||||
src={hotelData.hotelContent.images.imageSizes.medium}
|
||||
alt={hotelData.hotelContent.images.metaData.altText}
|
||||
width={300}
|
||||
height={200}
|
||||
className={styles.image}
|
||||
/>
|
||||
{hotelData.gallery && (
|
||||
<ImageGallery
|
||||
title={hotelData.name}
|
||||
images={[
|
||||
hotelData.hotelContent.images,
|
||||
...hotelData.gallery.heroImages,
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
<div className={styles.tripAdvisor}>
|
||||
<Chip intent="primary" className={styles.tripAdvisor}>
|
||||
<TripAdvisorIcon color="white" />
|
||||
@@ -57,8 +58,8 @@ export default async function HotelCard({ hotel }: HotelCardProps) {
|
||||
</section>
|
||||
<section className={styles.hotel}>
|
||||
<div className={styles.facilities}>
|
||||
{sortedAmenities.map((facility) => {
|
||||
const IconComponent = mapFacilityToIcon(facility.name)
|
||||
{amenities.map((facility) => {
|
||||
const IconComponent = mapFacilityToIcon(facility.id)
|
||||
return (
|
||||
<div className={styles.facilitiesItem} key={facility.id}>
|
||||
{IconComponent && <IconComponent color="grey80" />}
|
||||
@@ -67,7 +68,11 @@ export default async function HotelCard({ hotel }: HotelCardProps) {
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<ReadMore hotelId={hotelData.operaId} hotel={hotelData} />
|
||||
<ReadMore
|
||||
label={intl.formatMessage({ id: "See hotel details" })}
|
||||
hotelId={hotelData.operaId}
|
||||
hotel={hotelData}
|
||||
/>
|
||||
</section>
|
||||
<section className={styles.prices}>
|
||||
<div>
|
||||
@@ -100,7 +105,11 @@ export default async function HotelCard({ hotel }: HotelCardProps) {
|
||||
className={styles.button}
|
||||
>
|
||||
{/* TODO: Localize link and also use correct search params */}
|
||||
<Link href="/en/hotelreservation/select-rate" color="none">
|
||||
<Link
|
||||
href={`/en/hotelreservation/select-rate?hotel=${hotelData.operaId}`}
|
||||
color="none"
|
||||
keepSearchParams
|
||||
>
|
||||
{intl.formatMessage({ id: "See rooms" })}
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
@@ -34,7 +34,7 @@ function getAmenitiesList(hotel: Hotel) {
|
||||
return [...detailedAmenities, ...simpleAmenities]
|
||||
}
|
||||
|
||||
export default function ReadMore({ hotel, hotelId }: ReadMoreProps) {
|
||||
export default function ReadMore({ label, hotel, hotelId }: ReadMoreProps) {
|
||||
const intl = useIntl()
|
||||
|
||||
const [sidePeekOpen, setSidePeekOpen] = useState(false)
|
||||
@@ -46,11 +46,12 @@ export default function ReadMore({ hotel, hotelId }: ReadMoreProps) {
|
||||
onPress={() => {
|
||||
setSidePeekOpen(true)
|
||||
}}
|
||||
intent={"text"}
|
||||
color="burgundy"
|
||||
intent="text"
|
||||
theme="base"
|
||||
wrapping
|
||||
className={styles.detailsButton}
|
||||
>
|
||||
{intl.formatMessage({ id: "See hotel details" })}
|
||||
{label}
|
||||
<ChevronRightIcon color="burgundy" />
|
||||
</Button>
|
||||
<SidePeek
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
.container {
|
||||
background-color: var(--Base-Surface-Subtle-Normal);
|
||||
padding: var(--Spacing-x3) var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
display: flex;
|
||||
margin: 0 auto;
|
||||
max-width: var(--max-width-navigation);
|
||||
position: relative;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.imageWrapper {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
height: 200px;
|
||||
max-width: 360px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.imageWrapper img {
|
||||
border-radius: var(--Corner-radius-Medium);
|
||||
}
|
||||
|
||||
.tripAdvisor {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--Spacing-x-half);
|
||||
background-color: var(--Base-Surface-Primary-light-Normal);
|
||||
position: absolute;
|
||||
left: var(--Spacing-x2);
|
||||
top: var(--Spacing-x2);
|
||||
padding: 0 var(--Spacing-x1);
|
||||
border-radius: var(--Corner-radius-Small);
|
||||
}
|
||||
|
||||
.hotelContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.hotelInformation {
|
||||
gap: var(--Spacing-x1);
|
||||
width: min(607px, 100%);
|
||||
}
|
||||
|
||||
.title {
|
||||
margin-bottom: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.body {
|
||||
margin-top: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.facilities {
|
||||
padding: var(--Spacing-x3) 0 var(--Spacing-x-quarter);
|
||||
gap: var(--Spacing-x-one-and-half);
|
||||
}
|
||||
|
||||
.facilityList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--Spacing-x-one-and-half);
|
||||
padding-bottom: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.facilitiesItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.facilityName {
|
||||
color: var(--UI-Text-Medium-contrast);
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1367px) {
|
||||
.container {
|
||||
padding: var(--Spacing-x4) var(--Spacing-x5);
|
||||
}
|
||||
|
||||
.hotelContent {
|
||||
gap: var(--Spacing-x6);
|
||||
}
|
||||
|
||||
.hotelInformation {
|
||||
padding-right: var(--Spacing-x3);
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
gap: var(--Spacing-x3);
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.facilities {
|
||||
padding: var(--Spacing-x3) var(--Spacing-x3) var(--Spacing-x-half);
|
||||
}
|
||||
|
||||
.facilityList {
|
||||
gap: var(--Spacing-x1);
|
||||
padding-bottom: var(--Spacing-x-half);
|
||||
}
|
||||
.facilityTitle {
|
||||
display: none;
|
||||
}
|
||||
.hotelContent {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
.imageWrapper {
|
||||
align-self: center;
|
||||
}
|
||||
}
|
||||
100
components/HotelReservation/SelectRate/HotelInfoCard/index.tsx
Normal file
100
components/HotelReservation/SelectRate/HotelInfoCard/index.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
"use client"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { mapFacilityToIcon } from "@/components/ContentType/HotelPage/data"
|
||||
import TripAdvisorIcon from "@/components/Icons/TripAdvisor"
|
||||
import Image from "@/components/Image"
|
||||
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 ReadMore from "../../ReadMore"
|
||||
import ImageGallery from "../ImageGallery"
|
||||
|
||||
import styles from "./hotelInfoCard.module.css"
|
||||
|
||||
import type { HotelInfoCardProps } from "@/types/components/hotelReservation/selectRate/hotelInfoCardProps"
|
||||
|
||||
export default function HotelInfoCard({ hotelData }: HotelInfoCardProps) {
|
||||
const hotelAttributes = hotelData?.data.attributes
|
||||
const intl = useIntl()
|
||||
|
||||
const sortedFacilities = hotelAttributes?.detailedFacilities
|
||||
.sort((a, b) => b.sortOrder - a.sortOrder)
|
||||
.slice(0, 5)
|
||||
|
||||
return (
|
||||
<article className={styles.container}>
|
||||
{hotelAttributes && (
|
||||
<section className={styles.wrapper}>
|
||||
<div className={styles.imageWrapper}>
|
||||
{hotelAttributes.ratings?.tripAdvisor && (
|
||||
<div className={styles.tripAdvisor}>
|
||||
<TripAdvisorIcon color="burgundy" />
|
||||
<Caption color="burgundy">
|
||||
{hotelAttributes.ratings.tripAdvisor.rating}
|
||||
</Caption>
|
||||
</div>
|
||||
)}
|
||||
{hotelAttributes.gallery && (
|
||||
<ImageGallery
|
||||
title={hotelAttributes.name}
|
||||
images={[
|
||||
hotelAttributes.hotelContent.images,
|
||||
...hotelAttributes.gallery.heroImages,
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.hotelContent}>
|
||||
<div className={styles.hotelInformation}>
|
||||
<Title
|
||||
level="h3"
|
||||
textTransform="uppercase"
|
||||
className={styles.title}
|
||||
>
|
||||
{hotelAttributes.name}
|
||||
</Title>
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{`${hotelAttributes.address.streetAddress}, ${hotelAttributes.address.city} ∙ ${hotelAttributes.location.distanceToCentre} ${intl.formatMessage({ id: "km to city center" })}`}
|
||||
</Caption>
|
||||
<Body color="uiTextHighContrast" className={styles.body}>
|
||||
{hotelAttributes.hotelContent.texts.descriptions.medium}
|
||||
</Body>
|
||||
</div>
|
||||
<Divider color="subtle" variant="vertical" />
|
||||
<div className={styles.facilities}>
|
||||
<div className={styles.facilityList}>
|
||||
<Body textTransform="bold" className={styles.facilityTitle}>
|
||||
{intl.formatMessage({ id: "At the hotel" })}
|
||||
</Body>
|
||||
{sortedFacilities?.map((facility) => {
|
||||
const IconComponent = mapFacilityToIcon(facility.id)
|
||||
return (
|
||||
<div className={styles.facilitiesItem} key={facility.id}>
|
||||
{IconComponent && (
|
||||
<IconComponent
|
||||
className={styles.facilitiesIcon}
|
||||
color="grey80"
|
||||
/>
|
||||
)}
|
||||
<Caption className={styles.facilityName}>
|
||||
{facility.name}
|
||||
</Caption>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<ReadMore
|
||||
label={intl.formatMessage({ id: "Show all amenities" })}
|
||||
hotelId={hotelAttributes.operaId}
|
||||
hotel={hotelAttributes}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
.galleryIcon {
|
||||
position: absolute;
|
||||
bottom: 16px;
|
||||
right: 16px;
|
||||
max-height: 32px;
|
||||
width: 48px;
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
padding: var(--Spacing-x-quarter) var(--Spacing-x-half);
|
||||
border-radius: var(--Corner-radius-Small);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--Spacing-x-quarter);
|
||||
}
|
||||
|
||||
.triggerArea {
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { GalleryIcon } from "@/components/Icons"
|
||||
import Image from "@/components/Image"
|
||||
import Lightbox from "@/components/Lightbox"
|
||||
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
|
||||
|
||||
import styles from "./imageGallery.module.css"
|
||||
|
||||
import type { ImageGalleryProps } from "@/types/components/hotelReservation/selectRate/imageGallery"
|
||||
|
||||
export default function ImageGallery({ images, title }: ImageGalleryProps) {
|
||||
return (
|
||||
<Lightbox
|
||||
images={images.map((image) => ({
|
||||
url: image.imageSizes.small,
|
||||
alt: image.metaData.altText,
|
||||
title: image.metaData.title,
|
||||
}))}
|
||||
dialogTitle={title}
|
||||
>
|
||||
<div className={styles.triggerArea} id="lightboxTrigger">
|
||||
<Image
|
||||
src={images[0].imageSizes.medium}
|
||||
alt={images[0].metaData.altText}
|
||||
className={styles.image}
|
||||
fill
|
||||
/>
|
||||
<div className={styles.galleryIcon}>
|
||||
<GalleryIcon color="white" />
|
||||
<Footnote color="white" type="label">
|
||||
{images.length}
|
||||
</Footnote>
|
||||
</div>
|
||||
</div>
|
||||
</Lightbox>
|
||||
)
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import Image from "next/image"
|
||||
import { useFormContext } from "react-hook-form"
|
||||
|
||||
import { PAYMENT_METHOD_ICONS } from "@/constants/booking"
|
||||
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
|
||||
import { PaymentOptionProps } from "./paymentOption"
|
||||
|
||||
import styles from "./paymentOption.module.css"
|
||||
|
||||
export default function PaymentOption({
|
||||
name,
|
||||
value,
|
||||
label,
|
||||
}: PaymentOptionProps) {
|
||||
const { register } = useFormContext()
|
||||
return (
|
||||
<label key={value} className={styles.paymentOption} htmlFor={value}>
|
||||
<div className={styles.titleContainer}>
|
||||
<input
|
||||
aria-hidden
|
||||
hidden
|
||||
type="radio"
|
||||
id={value}
|
||||
value={value}
|
||||
{...register(name)}
|
||||
/>
|
||||
<span className={styles.radio} />
|
||||
<Body asChild>
|
||||
<label htmlFor={value}>{label}</label>
|
||||
</Body>
|
||||
</div>
|
||||
<Image
|
||||
className={styles.paymentOptionIcon}
|
||||
src={PAYMENT_METHOD_ICONS[value]}
|
||||
alt={label}
|
||||
width={48}
|
||||
height={32}
|
||||
/>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
@@ -1,217 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useEffect, useState } from "react"
|
||||
import { Label as AriaLabel } from "react-aria-components"
|
||||
import { FormProvider, useForm } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import {
|
||||
BOOKING_CONFIRMATION_NUMBER,
|
||||
BookingStatusEnum,
|
||||
PAYMENT_METHOD_TITLES,
|
||||
PaymentMethodEnum,
|
||||
} from "@/constants/booking"
|
||||
import {
|
||||
bookingTermsAndConditions,
|
||||
privacyPolicy,
|
||||
} from "@/constants/currentWebHrefs"
|
||||
import { trpc } from "@/lib/trpc/client"
|
||||
|
||||
import LoadingSpinner from "@/components/LoadingSpinner"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Checkbox from "@/components/TempDesignSystem/Checkbox"
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import { toast } from "@/components/TempDesignSystem/Toasts"
|
||||
import { useHandleBookingStatus } from "@/hooks/booking/useHandleBookingStatus"
|
||||
import useLang from "@/hooks/useLang"
|
||||
|
||||
import PaymentOption from "./PaymentOption"
|
||||
import { PaymentFormData, paymentSchema } from "./schema"
|
||||
|
||||
import styles from "./payment.module.css"
|
||||
|
||||
import { PaymentProps } from "@/types/components/hotelReservation/selectRate/section"
|
||||
|
||||
const maxRetries = 40
|
||||
const retryInterval = 2000
|
||||
|
||||
export default function Payment({ hotel }: PaymentProps) {
|
||||
const router = useRouter()
|
||||
const lang = useLang()
|
||||
const intl = useIntl()
|
||||
const [confirmationNumber, setConfirmationNumber] = useState<string>("")
|
||||
|
||||
const methods = useForm<PaymentFormData>({
|
||||
defaultValues: {
|
||||
paymentMethod: PaymentMethodEnum.card,
|
||||
smsConfirmation: false,
|
||||
termsAndConditions: false,
|
||||
},
|
||||
mode: "all",
|
||||
reValidateMode: "onChange",
|
||||
resolver: zodResolver(paymentSchema),
|
||||
})
|
||||
|
||||
const initiateBooking = trpc.booking.booking.create.useMutation({
|
||||
onSuccess: (result) => {
|
||||
if (result?.confirmationNumber) {
|
||||
setConfirmationNumber(result.confirmationNumber)
|
||||
} else {
|
||||
// TODO: add proper error message
|
||||
toast.error("Failed to create booking")
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Error", error)
|
||||
// TODO: add proper error message
|
||||
toast.error("Failed to create booking")
|
||||
},
|
||||
})
|
||||
|
||||
const bookingStatus = useHandleBookingStatus(
|
||||
confirmationNumber,
|
||||
BookingStatusEnum.PaymentRegistered,
|
||||
maxRetries,
|
||||
retryInterval
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (confirmationNumber && bookingStatus?.data?.paymentUrl) {
|
||||
// Planet doesn't support query params so we have to store values in session storage
|
||||
sessionStorage.setItem(BOOKING_CONFIRMATION_NUMBER, confirmationNumber)
|
||||
router.push(bookingStatus.data.paymentUrl)
|
||||
}
|
||||
}, [confirmationNumber, bookingStatus, router])
|
||||
|
||||
function handleSubmit(data: PaymentFormData) {
|
||||
initiateBooking.mutate({
|
||||
hotelId: hotel.operaId,
|
||||
checkInDate: "2024-12-10",
|
||||
checkOutDate: "2024-12-11",
|
||||
rooms: [
|
||||
{
|
||||
adults: 1,
|
||||
childrenAges: [],
|
||||
rateCode: "SAVEEU",
|
||||
roomTypeCode: "QC",
|
||||
guest: {
|
||||
title: "Mr",
|
||||
firstName: "Test",
|
||||
lastName: "User",
|
||||
email: "test.user@scandichotels.com",
|
||||
phoneCountryCodePrefix: "string",
|
||||
phoneNumber: "string",
|
||||
countryCode: "string",
|
||||
},
|
||||
packages: {
|
||||
breakfast: true,
|
||||
allergyFriendly: true,
|
||||
petFriendly: true,
|
||||
accessibility: true,
|
||||
},
|
||||
smsConfirmationRequested: data.smsConfirmation,
|
||||
},
|
||||
],
|
||||
payment: {
|
||||
paymentMethod: data.paymentMethod,
|
||||
cardHolder: {
|
||||
email: "test.user@scandichotels.com",
|
||||
name: "Test User",
|
||||
phoneCountryCode: "",
|
||||
phoneSubscriber: "",
|
||||
},
|
||||
success: `api/web/payment-callback/${lang}/success`,
|
||||
error: `api/web/payment-callback/${lang}/error`,
|
||||
cancel: `api/web/payment-callback/${lang}/cancel`,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (
|
||||
initiateBooking.isPending ||
|
||||
(confirmationNumber && !bookingStatus.data?.paymentUrl)
|
||||
) {
|
||||
return <LoadingSpinner />
|
||||
}
|
||||
|
||||
return (
|
||||
<FormProvider {...methods}>
|
||||
<form
|
||||
className={styles.paymentContainer}
|
||||
onSubmit={methods.handleSubmit(handleSubmit)}
|
||||
>
|
||||
<div className={styles.paymentOptionContainer}>
|
||||
<PaymentOption
|
||||
name="paymentMethod"
|
||||
value={PaymentMethodEnum.card}
|
||||
label={intl.formatMessage({ id: "Credit card" })}
|
||||
/>
|
||||
{hotel.merchantInformationData.alternatePaymentOptions.map(
|
||||
(paymentMethod) => (
|
||||
<PaymentOption
|
||||
key={paymentMethod}
|
||||
name="paymentMethod"
|
||||
value={paymentMethod as PaymentMethodEnum}
|
||||
label={
|
||||
PAYMENT_METHOD_TITLES[paymentMethod as PaymentMethodEnum]
|
||||
}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
<Checkbox name="smsConfirmation">
|
||||
<Caption>
|
||||
{intl.formatMessage({
|
||||
id: "I would like to get my booking confirmation via sms",
|
||||
})}
|
||||
</Caption>
|
||||
</Checkbox>
|
||||
|
||||
<AriaLabel className={styles.terms}>
|
||||
<Checkbox name="termsAndConditions" />
|
||||
<Caption>
|
||||
{intl.formatMessage<React.ReactNode>(
|
||||
{
|
||||
id: "booking.terms",
|
||||
},
|
||||
{
|
||||
termsLink: (str) => (
|
||||
<Link
|
||||
className={styles.link}
|
||||
variant="underscored"
|
||||
href={bookingTermsAndConditions[lang]}
|
||||
target="_blank"
|
||||
>
|
||||
{str}
|
||||
</Link>
|
||||
),
|
||||
privacyLink: (str) => (
|
||||
<Link
|
||||
className={styles.link}
|
||||
variant="underscored"
|
||||
href={privacyPolicy[lang]}
|
||||
target="_blank"
|
||||
>
|
||||
{str}
|
||||
</Link>
|
||||
),
|
||||
}
|
||||
)}
|
||||
</Caption>
|
||||
</AriaLabel>
|
||||
<Button
|
||||
type="submit"
|
||||
className={styles.submitButton}
|
||||
disabled={
|
||||
!methods.formState.isValid || methods.formState.isSubmitting
|
||||
}
|
||||
>
|
||||
{intl.formatMessage({ id: "Complete booking & go to payment" })}
|
||||
</Button>
|
||||
</form>
|
||||
</FormProvider>
|
||||
)
|
||||
}
|
||||
@@ -18,6 +18,7 @@ export default function FlexibilityOption({
|
||||
paymentTerm,
|
||||
priceInformation,
|
||||
roomType,
|
||||
roomTypeCode,
|
||||
handleSelectRate,
|
||||
}: FlexibilityOptionProps) {
|
||||
const [rootDiv, setRootDiv] = useState<Element | undefined>(undefined)
|
||||
@@ -46,7 +47,8 @@ export default function FlexibilityOption({
|
||||
|
||||
function onChange() {
|
||||
const rate = {
|
||||
roomType: roomType,
|
||||
roomTypeCode,
|
||||
roomType,
|
||||
priceName: name,
|
||||
public: publicPrice,
|
||||
member: memberPrice,
|
||||
|
||||
@@ -24,17 +24,15 @@ export default function RateSummary({
|
||||
</div>
|
||||
<div className={styles.summaryPrice}>
|
||||
<div className={styles.summaryPriceText}>
|
||||
<>
|
||||
<Subtitle color={isUserLoggedIn ? "red" : "uiTextHighContrast"}>
|
||||
{priceToShow?.localPrice.pricePerStay}{" "}
|
||||
{priceToShow?.localPrice.currency}
|
||||
</Subtitle>
|
||||
<Body color="uiTextMediumContrast">
|
||||
{intl.formatMessage({ id: "Approx." })}{" "}
|
||||
{priceToShow?.requestedPrice?.pricePerStay}{" "}
|
||||
{priceToShow?.requestedPrice?.currency}
|
||||
</Body>
|
||||
</>
|
||||
<Subtitle color={isUserLoggedIn ? "red" : "uiTextHighContrast"}>
|
||||
{priceToShow?.localPrice.pricePerStay}{" "}
|
||||
{priceToShow?.localPrice.currency}
|
||||
</Subtitle>
|
||||
<Body color="uiTextMediumContrast">
|
||||
{intl.formatMessage({ id: "Approx." })}{" "}
|
||||
{priceToShow?.requestedPrice?.pricePerStay}{" "}
|
||||
{priceToShow?.requestedPrice?.currency}
|
||||
</Body>
|
||||
</div>
|
||||
<Button type="submit" theme="base">
|
||||
{intl.formatMessage({ id: "Continue" })}
|
||||
|
||||
@@ -5,18 +5,18 @@ import { useIntl } from "react-intl"
|
||||
import { RateDefinition } from "@/server/routers/hotels/output"
|
||||
|
||||
import FlexibilityOption from "@/components/HotelReservation/SelectRate/RoomSelection/FlexibilityOption"
|
||||
import { ChevronRightSmallIcon, GalleryIcon } from "@/components/Icons"
|
||||
import Image from "@/components/Image"
|
||||
import Lightbox from "@/components/Lightbox"
|
||||
import { ChevronRightSmallIcon } from "@/components/Icons"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
|
||||
import ImageGallery from "../../ImageGallery"
|
||||
|
||||
import styles from "./roomCard.module.css"
|
||||
|
||||
import { RoomCardProps } from "@/types/components/hotelReservation/selectRate/roomCard"
|
||||
import type { RoomCardProps } from "@/types/components/hotelReservation/selectRate/roomCard"
|
||||
|
||||
export default function RoomCard({
|
||||
rateDefinitions,
|
||||
@@ -25,7 +25,6 @@ export default function RoomCard({
|
||||
handleSelectRate,
|
||||
}: RoomCardProps) {
|
||||
const intl = useIntl()
|
||||
|
||||
const saveRate = rateDefinitions.find(
|
||||
// TODO: Update string when API has decided
|
||||
(rate) => rate.cancellationRule === "NonCancellable"
|
||||
@@ -116,6 +115,7 @@ export default function RoomCard({
|
||||
priceInformation={getPriceForRate(saveRate)}
|
||||
handleSelectRate={handleSelectRate}
|
||||
roomType={roomConfiguration.roomType}
|
||||
roomTypeCode={roomConfiguration.roomTypeCode}
|
||||
/>
|
||||
<FlexibilityOption
|
||||
name={intl.formatMessage({ id: "Free rebooking" })}
|
||||
@@ -125,6 +125,7 @@ export default function RoomCard({
|
||||
priceInformation={getPriceForRate(changeRate)}
|
||||
handleSelectRate={handleSelectRate}
|
||||
roomType={roomConfiguration.roomType}
|
||||
roomTypeCode={roomConfiguration.roomTypeCode}
|
||||
/>
|
||||
<FlexibilityOption
|
||||
name={intl.formatMessage({ id: "Free cancellation" })}
|
||||
@@ -134,6 +135,7 @@ export default function RoomCard({
|
||||
priceInformation={getPriceForRate(flexRate)}
|
||||
handleSelectRate={handleSelectRate}
|
||||
roomType={roomConfiguration.roomType}
|
||||
roomTypeCode={roomConfiguration.roomTypeCode}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -150,26 +152,8 @@ export default function RoomCard({
|
||||
)}
|
||||
{/*NOTE: images from the test API are hosted on test3.scandichotels.com,
|
||||
which can't be accessed unless on Scandic's Wifi or using Citrix. */}
|
||||
<Image
|
||||
src={mainImage.imageSizes.small}
|
||||
alt={mainImage.metaData.altText}
|
||||
width={330}
|
||||
height={185}
|
||||
/>
|
||||
{images && (
|
||||
<Lightbox
|
||||
images={images.map((image) => ({
|
||||
url: image.imageSizes.small,
|
||||
alt: image.metaData.altText,
|
||||
title: image.metaData.title,
|
||||
}))}
|
||||
dialogTitle={roomConfiguration.roomType}
|
||||
>
|
||||
<div className={styles.galleryIcon} id="lightboxTrigger">
|
||||
<GalleryIcon color="white" />
|
||||
<Footnote color="white">{images.length}</Footnote>
|
||||
</div>
|
||||
</Lightbox>
|
||||
<ImageGallery images={images} title={roomConfiguration.roomType} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -77,17 +77,3 @@
|
||||
min-height: 185px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.galleryIcon {
|
||||
position: absolute;
|
||||
bottom: 16px;
|
||||
right: 16px;
|
||||
height: 24px;
|
||||
background-color: rgba(64, 57, 55, 0.9);
|
||||
padding: 0 var(--Spacing-x-half);
|
||||
border-radius: var(--Corner-radius-Small);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--Spacing-x-quarter);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState } from "react"
|
||||
|
||||
import RateSummary from "./RateSummary"
|
||||
import RoomCard from "./RoomCard"
|
||||
import getHotelReservationQueryParams from "./utils"
|
||||
|
||||
import styles from "./roomSelection.module.css"
|
||||
|
||||
@@ -19,12 +20,29 @@ export default function RoomSelection({
|
||||
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const isUserLoggedIn = !!user
|
||||
|
||||
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
const searchParamsObject = getHotelReservationQueryParams(searchParams)
|
||||
|
||||
const queryParams = new URLSearchParams(searchParams)
|
||||
queryParams.set("roomClass", e.currentTarget.roomClass?.value)
|
||||
queryParams.set("flexibility", e.currentTarget.flexibility?.value)
|
||||
|
||||
searchParamsObject.room.forEach((item, index) => {
|
||||
if (rateSummary?.roomTypeCode) {
|
||||
queryParams.set(`room[${index}].roomtype`, rateSummary.roomTypeCode)
|
||||
}
|
||||
if (rateSummary?.public?.rateCode) {
|
||||
queryParams.set(`room[${index}].ratecode`, rateSummary.public.rateCode)
|
||||
}
|
||||
if (rateSummary?.member?.rateCode) {
|
||||
queryParams.set(
|
||||
`room[${index}].counterratecode`,
|
||||
rateSummary.member.rateCode
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
router.push(`select-bed?${queryParams}`)
|
||||
}
|
||||
|
||||
@@ -48,7 +66,10 @@ export default function RoomSelection({
|
||||
))}
|
||||
</ul>
|
||||
{rateSummary && (
|
||||
<RateSummary rateSummary={rateSummary} isUserLoggedIn={!!user} />
|
||||
<RateSummary
|
||||
rateSummary={rateSummary}
|
||||
isUserLoggedIn={isUserLoggedIn}
|
||||
/>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { SelectRateSearchParams } from "@/types/components/hotelReservation/selectRate/selectRate"
|
||||
|
||||
function getHotelReservationQueryParams(searchParams: URLSearchParams) {
|
||||
const searchParamsObject: Record<string, unknown> = Array.from(
|
||||
searchParams.entries()
|
||||
).reduce<Record<string, unknown>>(
|
||||
(acc, [key, value]) => {
|
||||
const keys = key.replace(/\]/g, "").split(/\[|\./) // Split keys by '[' or '.'
|
||||
keys.reduce((nestedAcc, k, i) => {
|
||||
if (i === keys.length - 1) {
|
||||
// Convert value to number if the key is 'adults' or 'age'
|
||||
;(nestedAcc as Record<string, unknown>)[k] =
|
||||
k === "adults" || k === "age" ? Number(value) : value
|
||||
} else {
|
||||
if (!nestedAcc[k]) {
|
||||
nestedAcc[k] = isNaN(Number(keys[i + 1])) ? {} : [] // Initialize as object or array
|
||||
}
|
||||
}
|
||||
return nestedAcc[k] as Record<string, unknown>
|
||||
}, acc)
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, unknown>
|
||||
)
|
||||
return searchParamsObject as SelectRateSearchParams
|
||||
}
|
||||
|
||||
export default getHotelReservationQueryParams
|
||||
40
components/Icons/Accesories.tsx
Normal file
40
components/Icons/Accesories.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { iconVariants } from "./variants"
|
||||
|
||||
import type { IconProps } from "@/types/components/icon"
|
||||
|
||||
export default function AccesoriesIcon({
|
||||
className,
|
||||
color,
|
||||
...props
|
||||
}: IconProps) {
|
||||
const classNames = iconVariants({ className, color })
|
||||
return (
|
||||
<svg
|
||||
className={classNames}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
{...props}
|
||||
>
|
||||
<mask
|
||||
id="mask0_4039_3291"
|
||||
style={{ maskType: "alpha" }}
|
||||
maskUnits="userSpaceOnUse"
|
||||
x="0"
|
||||
y="0"
|
||||
width="24"
|
||||
height="24"
|
||||
>
|
||||
<rect width="24" height="24" fill="#D9D9D9" />
|
||||
</mask>
|
||||
<g mask="url(#mask0_4039_3291)">
|
||||
<path
|
||||
d="M6.40085 22C5.55984 22 4.80868 21.4739 4.52127 20.6835L1.56362 12.5499C1.23633 11.6499 1.593 10.6442 2.41421 10.1515L6 8V3C6 2.44772 6.44772 2 7 2H9C9.55229 2 10 2.44772 10 3V8L13.5858 10.1515C14.407 10.6442 14.7637 11.6499 14.4364 12.5499L11.4787 20.6835C11.1913 21.4739 10.4402 22 9.59915 22H6.40085ZM17 22C16.7167 22 16.4792 21.9042 16.2875 21.7125C16.0958 21.5208 16 21.2833 16 21C16 20.7167 16.0958 20.4792 16.2875 20.2875C16.4792 20.0958 16.7167 20 17 20H20V18H17C16.7167 18 16.4792 17.9042 16.2875 17.7125C16.0958 17.5208 16 17.2833 16 17C16 16.7167 16.0958 16.4792 16.2875 16.2875C16.4792 16.0958 16.7167 16 17 16H20V14H17C16.7167 14 16.4792 13.9042 16.2875 13.7125C16.0958 13.5208 16 13.2833 16 13C16 12.7167 16.0958 12.4792 16.2875 12.2875C16.4792 12.0958 16.7167 12 17 12H20V10H17C16.7167 10 16.4792 9.90417 16.2875 9.7125C16.0958 9.52083 16 9.28333 16 9C16 8.71667 16.0958 8.47917 16.2875 8.2875C16.4792 8.09583 16.7167 8 17 8H20V6H17C16.7167 6 16.4792 5.90417 16.2875 5.7125C16.0958 5.52083 16 5.28333 16 5C16 4.71667 16.0958 4.47917 16.2875 4.2875C16.4792 4.09583 16.7167 4 17 4H21C21.55 4 22.0208 4.19583 22.4125 4.5875C22.8042 4.97917 23 5.45 23 6V20C23 20.55 22.8042 21.0208 22.4125 21.4125C22.0208 21.8042 21.55 22 21 22H17ZM6.16123 19.3404C6.30454 19.7363 6.68048 20 7.10153 20H8.89847C9.31952 20 9.69546 19.7363 9.83877 19.3404L12.2691 12.6261C12.4322 12.1755 12.2527 11.6726 11.8412 11.427L9.45 10H6.55L4.15876 11.427C3.74728 11.6726 3.56783 12.1755 3.73092 12.6261L6.16123 19.3404Z"
|
||||
fill="#1C1B1F"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
36
components/Icons/Air.tsx
Normal file
36
components/Icons/Air.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { iconVariants } from "./variants"
|
||||
|
||||
import type { IconProps } from "@/types/components/icon"
|
||||
|
||||
export default function AirIcon({ className, color, ...props }: IconProps) {
|
||||
const classNames = iconVariants({ className, color })
|
||||
return (
|
||||
<svg
|
||||
className={classNames}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
{...props}
|
||||
>
|
||||
<mask
|
||||
id="mask0_69_3423"
|
||||
style={{ maskType: "alpha" }}
|
||||
maskUnits="userSpaceOnUse"
|
||||
x="0"
|
||||
y="0"
|
||||
width="24"
|
||||
height="24"
|
||||
>
|
||||
<rect width="24" height="24" fill="#D9D9D9" />
|
||||
</mask>
|
||||
<g mask="url(#mask0_69_3423)">
|
||||
<path
|
||||
d="M11.5125 19.8C10.9884 19.8 10.5011 19.6708 10.0507 19.4125C9.60022 19.1541 9.24167 18.8 8.975 18.35C8.8 18.0416 8.79453 17.7312 8.9586 17.4187C9.12267 17.1062 9.37657 16.95 9.7203 16.95C9.92343 16.95 10.1042 17.0104 10.2625 17.1312C10.4208 17.252 10.5583 17.3916 10.675 17.55C10.764 17.675 10.8842 17.7687 11.0355 17.8312C11.1868 17.8937 11.3458 17.925 11.5125 17.925C11.7958 17.925 12.0375 17.825 12.2375 17.625C12.4375 17.425 12.5375 17.1833 12.5375 16.9C12.5375 16.6166 12.4375 16.375 12.2375 16.175C12.0375 15.975 11.7976 15.875 11.5178 15.875H3.125C2.86667 15.875 2.64583 15.7833 2.4625 15.6C2.27917 15.4166 2.1875 15.1958 2.1875 14.9375C2.1875 14.6791 2.27917 14.4583 2.4625 14.275C2.64583 14.0916 2.86667 14 3.125 14H11.5125C12.318 14 13.0028 14.2822 13.5667 14.8467C14.1306 15.4111 14.4125 16.0965 14.4125 16.9029C14.4125 17.7093 14.1306 18.3937 13.5667 18.9562C13.0028 19.5187 12.318 19.8 11.5125 19.8ZM3.125 9.99995C2.86667 9.99995 2.64583 9.90828 2.4625 9.72495C2.27917 9.54162 2.1875 9.32078 2.1875 9.06245C2.1875 8.80412 2.27917 8.58328 2.4625 8.39995C2.64583 8.21662 2.86667 8.12495 3.125 8.12495H15.4375C15.8611 8.12495 16.2212 7.97654 16.5177 7.67973C16.8142 7.38291 16.9625 7.02249 16.9625 6.59848C16.9625 6.17446 16.8148 5.81453 16.5193 5.5187C16.2237 5.22287 15.8649 5.07495 15.4428 5.07495C15.1726 5.07495 14.9208 5.13593 14.6875 5.25788C14.4542 5.37983 14.2708 5.55463 14.1375 5.78228C14.0208 5.97739 13.8816 6.15412 13.7199 6.31245C13.5581 6.47078 13.364 6.54995 13.1375 6.54995C12.8208 6.54995 12.5688 6.42495 12.3813 6.17495C12.1938 5.92495 12.1542 5.65828 12.2625 5.37495C12.4958 4.70828 12.9038 4.17912 13.4863 3.78745C14.0689 3.39578 14.7193 3.19995 15.4375 3.19995C16.3765 3.19995 17.178 3.53213 17.8418 4.19648C18.5056 4.86083 18.8375 5.66291 18.8375 6.60273C18.8375 7.54254 18.5056 8.3437 17.8418 9.0062C17.178 9.6687 16.3765 9.99995 15.4375 9.99995H3.125ZM19.85 17.5625C19.525 17.7041 19.2125 17.6864 18.9125 17.5094C18.6125 17.3323 18.4625 17.0745 18.4625 16.7358C18.4625 16.5202 18.5375 16.3354 18.6875 16.1812C18.8375 16.027 19.0083 15.9 19.2 15.8C19.4417 15.6666 19.625 15.478 19.75 15.2341C19.875 14.9901 19.9375 14.7287 19.9375 14.45C19.9375 14.0263 19.7892 13.6663 19.4927 13.3698C19.1962 13.0732 18.8361 12.925 18.4125 12.925H3.125C2.86667 12.925 2.64583 12.8333 2.4625 12.65C2.27917 12.4666 2.1875 12.2458 2.1875 11.9875C2.1875 11.7291 2.27917 11.5083 2.4625 11.325C2.64583 11.1416 2.86667 11.05 3.125 11.05H18.4125C19.3516 11.05 20.153 11.3813 20.8168 12.044C21.4806 12.7067 21.8125 13.5067 21.8125 14.4442C21.8125 15.123 21.637 15.7433 21.286 16.3049C20.935 16.8665 20.4563 17.2857 19.85 17.5625Z"
|
||||
fill="#26201E"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -2,7 +2,11 @@ import { iconVariants } from "./variants"
|
||||
|
||||
import type { IconProps } from "@/types/components/icon"
|
||||
|
||||
export default function CoffeeIcon({ className, color, ...props }: IconProps) {
|
||||
export default function CoffeeAltIcon({
|
||||
className,
|
||||
color,
|
||||
...props
|
||||
}: IconProps) {
|
||||
const classNames = iconVariants({ className, color })
|
||||
return (
|
||||
<svg
|
||||
40
components/Icons/ConvenienceStore24h.tsx
Normal file
40
components/Icons/ConvenienceStore24h.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { iconVariants } from "./variants"
|
||||
|
||||
import type { IconProps } from "@/types/components/icon"
|
||||
|
||||
export default function ConvenienceStore24hIcon({
|
||||
className,
|
||||
color,
|
||||
...props
|
||||
}: IconProps) {
|
||||
const classNames = iconVariants({ className, color })
|
||||
return (
|
||||
<svg
|
||||
className={classNames}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
{...props}
|
||||
>
|
||||
<mask
|
||||
id="mask0_69_3405"
|
||||
style={{ maskType: "alpha" }}
|
||||
maskUnits="userSpaceOnUse"
|
||||
x="0"
|
||||
y="0"
|
||||
width="24"
|
||||
height="24"
|
||||
>
|
||||
<rect width="24" height="24" fill="#D9D9D9" />
|
||||
</mask>
|
||||
<g mask="url(#mask0_69_3405)">
|
||||
<path
|
||||
d="M20.825 11.075V18.925C20.825 19.4407 20.6414 19.8821 20.2742 20.2493C19.9071 20.6165 19.4657 20.8 18.95 20.8H5.07502C4.55938 20.8 4.11797 20.6165 3.75079 20.2493C3.38361 19.8821 3.20002 19.4407 3.20002 18.925V11.075C2.79168 10.7584 2.4896 10.3188 2.29377 9.7563C2.09793 9.1938 2.10002 8.59172 2.30002 7.95005L3.32502 4.62505C3.4566 4.20617 3.69097 3.85977 4.02814 3.58587C4.36531 3.31199 4.75593 3.17505 5.20002 3.17505H18.8258C19.267 3.17505 19.6542 3.3063 19.9875 3.5688C20.3209 3.8313 20.5603 4.18272 20.7059 4.62305L21.725 7.95005C21.925 8.59172 21.9271 9.18755 21.7313 9.73755C21.5354 10.2875 21.2334 10.7334 20.825 11.075ZM14.175 10.075C14.6417 10.075 14.9896 9.92088 15.2188 9.61255C15.4479 9.30422 15.5375 8.95838 15.4875 8.57505L14.95 5.05005H12.95V8.72505C12.95 9.08852 13.0691 9.40438 13.3073 9.67265C13.5455 9.94092 13.8347 10.075 14.175 10.075ZM9.74902 10.075C10.14 10.075 10.4588 9.94092 10.7053 9.67265C10.9518 9.40438 11.075 9.08852 11.075 8.72505V5.05005H9.07502L8.53752 8.57505C8.47918 8.96672 8.56877 9.31463 8.80627 9.6188C9.04377 9.92297 9.35802 10.075 9.74902 10.075ZM5.37502 10.075C5.69168 10.075 5.96252 9.96436 6.18752 9.74297C6.41252 9.52161 6.55002 9.24063 6.60002 8.90005L7.16252 5.05005H5.13752L4.12502 8.37505C4.00835 8.76672 4.0646 9.14797 4.29377 9.5188C4.52293 9.88963 4.88335 10.075 5.37502 10.075ZM18.65 10.075C19.1334 10.075 19.4958 9.8938 19.7375 9.5313C19.9792 9.1688 20.0333 8.78338 19.9 8.37505L18.8625 5.05005H16.8625L17.4235 8.90005C17.4745 9.23338 17.6125 9.51255 17.8375 9.73755C18.0625 9.96255 18.3333 10.075 18.65 10.075ZM5.07502 18.925H18.95V11.9125C18.875 11.9375 18.8167 11.95 18.775 11.95H18.65C18.2066 11.95 17.8166 11.875 17.48 11.725C17.1433 11.575 16.8149 11.3334 16.4947 11C16.2066 11.3 15.8728 11.5334 15.4933 11.7C15.1139 11.8667 14.7097 11.95 14.2807 11.95C13.8352 11.95 13.4229 11.8667 13.0438 11.7C12.6646 11.5334 12.325 11.3 12.025 11C11.7417 11.3 11.4125 11.5334 11.0375 11.7C10.6625 11.8667 10.2662 11.95 9.84864 11.95C9.38289 11.95 8.95418 11.8709 8.56252 11.7125C8.17085 11.5542 7.82502 11.3167 7.52502 11C7.15835 11.3667 6.81043 11.6167 6.48127 11.75C6.1521 11.8834 5.78335 11.95 5.37502 11.95H5.23037C5.1768 11.95 5.12502 11.9375 5.07502 11.9125V18.925ZM8.56344 17.875H10.5171C10.6474 17.875 10.7604 17.8263 10.8563 17.7288C10.9521 17.6313 11 17.5175 11 17.3875C11 17.2575 10.9513 17.1438 10.8538 17.0463C10.7563 16.9488 10.6425 16.9 10.5125 16.9H9.05002V15.925H10.5125C10.6425 15.925 10.7563 15.8763 10.8538 15.7788C10.9513 15.6813 11 15.5675 11 15.4375V13.4603C11 13.3285 10.9512 13.2146 10.8535 13.1188C10.7558 13.023 10.6418 12.975 10.5116 12.975H8.55789C8.42764 12.975 8.3146 13.0238 8.21877 13.1213C8.12293 13.2188 8.07502 13.3325 8.07502 13.4625C8.07502 13.5925 8.12377 13.7063 8.22127 13.8038C8.31877 13.9013 8.43252 13.95 8.56252 13.95H10.025V14.95H8.55812C8.42772 14.95 8.3146 14.9989 8.21877 15.0966C8.12293 15.1943 8.07502 15.3082 8.07502 15.4385V17.3922C8.07502 17.5224 8.12386 17.6355 8.22154 17.7313C8.31922 17.8271 8.43319 17.875 8.56344 17.875ZM14.9707 15.9294V17.3886C14.9707 17.5183 15.0193 17.6318 15.1166 17.7291C15.2139 17.8264 15.3292 17.875 15.4625 17.875C15.5958 17.875 15.7104 17.8263 15.8063 17.7288C15.9021 17.6313 15.95 17.5175 15.95 17.3875V13.459C15.95 13.328 15.9013 13.2146 15.8038 13.1188C15.7063 13.023 15.5925 12.975 15.4625 12.975C15.3325 12.975 15.2188 13.0238 15.1213 13.1213C15.0238 13.2188 14.975 13.3325 14.975 13.4625V14.95H14V13.4611C14 13.3287 13.9513 13.2146 13.8538 13.1188C13.7563 13.023 13.6425 12.975 13.5125 12.975C13.3825 12.975 13.2688 13.0238 13.1713 13.1213C13.0738 13.2188 13.025 13.3325 13.025 13.4625V15.4398C13.025 15.5716 13.0737 15.6862 13.1709 15.7835C13.2682 15.8808 13.3817 15.9294 13.5114 15.9294H14.9707Z"
|
||||
fill="#26201E"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
36
components/Icons/Cool.tsx
Normal file
36
components/Icons/Cool.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { iconVariants } from "./variants"
|
||||
|
||||
import type { IconProps } from "@/types/components/icon"
|
||||
|
||||
export default function CoolIcon({ className, color, ...props }: IconProps) {
|
||||
const classNames = iconVariants({ className, color })
|
||||
return (
|
||||
<svg
|
||||
className={classNames}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
{...props}
|
||||
>
|
||||
<mask
|
||||
id="mask0_69_3415"
|
||||
style={{ maskType: "alpha" }}
|
||||
maskUnits="userSpaceOnUse"
|
||||
x="0"
|
||||
y="0"
|
||||
width="24"
|
||||
height="24"
|
||||
>
|
||||
<rect width="24" height="24" fill="#D9D9D9" />
|
||||
</mask>
|
||||
<g mask="url(#mask0_69_3415)">
|
||||
<path
|
||||
d="M11.075 17.625L8.46245 20.2125C8.28745 20.3875 8.07078 20.475 7.81245 20.475C7.55412 20.475 7.33745 20.3792 7.16245 20.1875C6.97912 20.0042 6.88745 19.7875 6.88745 19.5375C6.88745 19.2875 6.98328 19.0667 7.17495 18.875L11.075 14.975V12.925H9.02495L5.09995 16.85C4.92495 17.025 4.71245 17.1125 4.46245 17.1125C4.21245 17.1125 3.99162 17.0209 3.79995 16.8375C3.61662 16.6625 3.52495 16.4459 3.52495 16.1875C3.52495 15.9292 3.61662 15.7084 3.79995 15.525L6.37495 12.925H3.11245C2.85412 12.925 2.63745 12.8355 2.46245 12.6563C2.28745 12.4771 2.19995 12.2542 2.19995 11.9875C2.19995 11.7292 2.28953 11.5084 2.4687 11.325C2.64787 11.1417 2.87078 11.05 3.13745 11.05H6.37495L3.78745 8.46255C3.61245 8.28755 3.52495 8.07088 3.52495 7.81255C3.52495 7.55422 3.62078 7.33338 3.81245 7.15005C3.99578 6.97505 4.21245 6.88755 4.46245 6.88755C4.71245 6.88755 4.93328 6.97922 5.12495 7.16255L9.02495 11.05H11.075V9.00005L7.14995 5.10005C6.97495 4.92505 6.88745 4.71255 6.88745 4.46255C6.88745 4.21255 6.97912 3.99172 7.16245 3.80005C7.33745 3.61672 7.55412 3.52505 7.81245 3.52505C8.07078 3.52505 8.29162 3.61672 8.47495 3.80005L11.075 6.35005V3.11255C11.075 2.85422 11.1645 2.63338 11.3437 2.45005C11.5229 2.26672 11.7458 2.17505 12.0125 2.17505C12.2708 2.17505 12.4916 2.26672 12.675 2.45005C12.8583 2.63338 12.95 2.85422 12.95 3.11255V6.35005L15.5375 3.78755C15.7125 3.61255 15.9291 3.52505 16.1875 3.52505C16.4458 3.52505 16.6666 3.61672 16.85 3.80005C17.025 3.99172 17.1125 4.21255 17.1125 4.46255C17.1125 4.71255 17.0208 4.92922 16.8375 5.11255L12.95 9.00005V11.05H15L18.9 7.15005C19.075 6.97505 19.2875 6.88755 19.5375 6.88755C19.7875 6.88755 20.0083 6.97922 20.2 7.16255C20.3833 7.33755 20.475 7.55422 20.475 7.81255C20.475 8.07088 20.3833 8.29172 20.2 8.47505L17.65 11.05H20.8875C21.1458 11.05 21.3666 11.1417 21.55 11.325C21.7333 11.5084 21.825 11.7292 21.825 11.9875C21.825 12.2542 21.7333 12.4771 21.55 12.6563C21.3666 12.8355 21.1458 12.925 20.8875 12.925H17.65L20.2125 15.5375C20.3875 15.7125 20.475 15.9292 20.475 16.1875C20.475 16.4459 20.3833 16.6625 20.2 16.8375C20.0083 17.0209 19.7875 17.1125 19.5375 17.1125C19.2875 17.1125 19.0708 17.0167 18.8875 16.825L15 12.925H12.95V14.975L16.85 18.9C17.025 19.075 17.1125 19.2875 17.1125 19.5375C17.1125 19.7875 17.0208 20.0084 16.8375 20.2C16.6625 20.3834 16.4458 20.475 16.1875 20.475C15.9291 20.475 15.7083 20.3834 15.525 20.2L12.95 17.625V20.8875C12.95 21.1459 12.8583 21.3626 12.675 21.5375C12.4916 21.7125 12.2708 21.8 12.0125 21.8C11.7458 21.8 11.5229 21.7105 11.3437 21.5313C11.1645 21.3521 11.075 21.1292 11.075 20.8625V17.625Z"
|
||||
fill="#26201E"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -2,7 +2,11 @@ import { iconVariants } from "./variants"
|
||||
|
||||
import type { IconProps } from "@/types/components/icon"
|
||||
|
||||
export default function CoffeeIcon({ className, color, ...props }: IconProps) {
|
||||
export default function DoorOpenIcon({
|
||||
className,
|
||||
color,
|
||||
...props
|
||||
}: IconProps) {
|
||||
const classNames = iconVariants({ className, color })
|
||||
return (
|
||||
<svg
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user