Merge branch 'develop' into feature/tracking

This commit is contained in:
Linus Flood
2024-10-24 12:39:34 +02:00
221 changed files with 5789 additions and 1491 deletions

View File

@@ -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)

View File

@@ -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;
}
}

View File

@@ -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 />

View File

@@ -0,0 +1,5 @@
.layout {
background-color: var(--Base-Surface-Primary-light-Normal);
min-height: 100dvh;
padding: 80px 0 160px;
}

View File

@@ -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>
}

View File

@@ -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}`)

View File

@@ -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>
)

View File

@@ -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"

View File

@@ -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"

View File

@@ -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}

View File

@@ -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%;
}

View File

@@ -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 />
}

View File

@@ -0,0 +1 @@
export { default } from "../../page"

View File

@@ -0,0 +1,3 @@
export default function ConfirmedBookingSlot() {
return null
}

View File

@@ -0,0 +1 @@
export { default } from "../page"

View File

@@ -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 />

View File

@@ -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

View File

@@ -0,0 +1 @@
export { default } from "../page"

View File

@@ -0,0 +1 @@
export { default } from "../../page"

View File

@@ -0,0 +1 @@
export { default } from "./page"

View File

@@ -0,0 +1 @@
export { default } from "../../page"

View 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>
)
}

View File

@@ -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}

View File

@@ -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")
}

View File

@@ -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} />
}

View File

@@ -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>
)
}

View File

@@ -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;
}

View File

@@ -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>

View File

@@ -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;

View File

@@ -2,7 +2,6 @@
display: flex;
flex-direction: column;
gap: var(--Spacing-x3);
padding: var(--Spacing-x3) var(--Spacing-x4);
}
.column {

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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);
}

View File

@@ -37,6 +37,7 @@
display: grid;
padding: var(--Spacing-x4) var(--Spacing-x2) 0;
gap: var(--Spacing-x4);
align-items: start;
}
.mainContent {

View File

@@ -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}

View File

@@ -20,6 +20,10 @@
gap: var(--Spacing-x1);
}
.icon {
flex-shrink: 0;
}
.showAllAmenities {
width: fit-content;
}

View File

@@ -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>
)

View File

@@ -88,7 +88,7 @@ export default function TabNavigation({
scroll={true}
onClick={pauseScrollSpy}
>
{intl.formatMessage({ id: link.text })}
{link.text}
</Link>
)
})}

View File

@@ -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
}

View File

@@ -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;
}
}

View File

@@ -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} />

View File

@@ -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}

View File

@@ -397,6 +397,7 @@ export const renderOptions: RenderOptions = {
height={365}
src={image.url}
width={width}
focalPoint={image.focalPoint}
{...props}
/>
<Caption>{image.meta.caption}</Caption>

View File

@@ -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 (
<>

View File

@@ -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) => {

View File

@@ -46,4 +46,8 @@
.nameInputs {
grid-template-columns: 1fr 1fr;
}
.signUpButton {
width: fit-content;
}
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -70,7 +70,7 @@ export default function ChildSelector({ roomIndex = 0 }: ChildSelectorProps) {
roomIndex={roomIndex}
index={index}
child={child}
key={index}
key={"child_" + index}
/>
))}
</>

View File

@@ -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}

View File

@@ -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;
}
}

View File

@@ -1,4 +1,7 @@
import type { FocalPoint } from "@/types/components/image"
export interface HeroProps {
alt: string
src: string
focalPoint?: FocalPoint
}

View File

@@ -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}
/>
)
}

View File

@@ -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>
)
}

View File

@@ -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;
}
}

View File

@@ -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>
</>
)
}

View File

@@ -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;
}
}

View File

@@ -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>
)
}

View File

@@ -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);
}

View File

@@ -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",
},
}

View File

@@ -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
}

View File

@@ -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>
)
}

View File

@@ -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
}

View 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>
)
}

View File

@@ -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;

View File

@@ -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",

View File

@@ -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"

View File

@@ -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;
}

View File

@@ -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>

View File

@@ -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);

View File

@@ -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>

View File

@@ -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

View File

@@ -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;
}
}

View 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>
)
}

View File

@@ -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;
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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,

View File

@@ -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" })}

View File

@@ -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>
)}

View File

@@ -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);
}

View File

@@ -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>

View File

@@ -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

View 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
View 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>
)
}

View File

@@ -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

View 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
View 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>
)
}

View File

@@ -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