Merged in feat/SW-1276-implement-design (pull request #1348)
Feat/SW-1276 implement design * feat(SW-1276) UI implementation Desktop part 1 for MyStay * feat(SW-1276) UI implementation Desktop part 2 for MyStay * feat(SW-1276) UI implementation Mobile part 1 for MyStay * refactor: move files from MyStay/MyStay to MyStay * feat(SW-1276) Sidepeek implementation * feat(SW-1276): Refactoring * feat(SW-1276) UI implementation Mobile part 2 for MyStay * feat(SW-1276): translations * feat(SW-1276) fixed skeleton * feat(SW-1276): Added missing translations * feat(SW-1276): Removed console log * feat(SW-1276) fixed translations * feat(SW-1276): Added translations * feat(SW-1276) fix dynamic ID:s * feat(SW-1276) removed createElement * feat(SW-1276): Fixed build errors * feat(SW-1276): Updated label * feat(SW-1276): Rewrite SummaryCard Approved-by: Niclas Edenvin
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
import Image from "@/components/Image"
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
|
||||
import styles from "./summaryCard.module.css"
|
||||
|
||||
interface SummaryCardProps {
|
||||
title: string
|
||||
image: {
|
||||
src: string
|
||||
alt: string
|
||||
}
|
||||
texts: string[]
|
||||
supportingText?: string
|
||||
links?: {
|
||||
href: string
|
||||
text: string
|
||||
icon: React.ReactNode
|
||||
}[]
|
||||
chip?: React.ReactNode
|
||||
}
|
||||
|
||||
export default function SummaryCard({
|
||||
title,
|
||||
texts,
|
||||
image,
|
||||
supportingText,
|
||||
links,
|
||||
chip,
|
||||
}: SummaryCardProps) {
|
||||
return (
|
||||
<div className={styles.card}>
|
||||
<div className={styles.image}>
|
||||
<Image src={image.src} alt={image.alt} width={152} height={152} />
|
||||
</div>
|
||||
<div className={styles.content}>
|
||||
<div className={styles.topContent}>
|
||||
<Body textTransform="bold" color="uiTextHighContrast">
|
||||
{title}
|
||||
</Body>
|
||||
{texts.map((text) => (
|
||||
<Body color="uiTextHighContrast" key={text}>
|
||||
{text}
|
||||
</Body>
|
||||
))}
|
||||
</div>
|
||||
{supportingText && (
|
||||
<Caption color="uiTextPlaceholder">{supportingText}</Caption>
|
||||
)}
|
||||
<div className={styles.bottomContent}>
|
||||
{chip}
|
||||
{links && (
|
||||
<div className={styles.links}>
|
||||
{links.map((link) => (
|
||||
<Caption asChild type="bold" color="burgundy" key={link.href}>
|
||||
<Link
|
||||
href={link.href}
|
||||
target="_blank"
|
||||
color="burgundy"
|
||||
className={styles.link}
|
||||
>
|
||||
{link.icon}
|
||||
{link.text}
|
||||
</Link>
|
||||
</Caption>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.card {
|
||||
align-items: flex-start;
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
|
||||
.image {
|
||||
width: 152px;
|
||||
height: 152px;
|
||||
|
||||
border-radius: var(--Corner-radius-Medium);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.image {
|
||||
background-color: var(--Base-Surface-Secondary-light-Normal);
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.topContent {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.bottomContent {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.links {
|
||||
display: flex;
|
||||
gap: var(--Spacing-x2);
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--Spacing-x-half);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
.bookingSummary {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x5);
|
||||
}
|
||||
|
||||
.bookingSummaryContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 80px;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.bookingSummaryContent {
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
|
||||
.toast {
|
||||
width: var(--max-width-content);
|
||||
margin: 0 auto;
|
||||
}
|
||||
130
components/HotelReservation/MyStay/BookingSummary/index.tsx
Normal file
130
components/HotelReservation/MyStay/BookingSummary/index.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import { dt } from "@/lib/dt"
|
||||
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
DirectionsIcon,
|
||||
EmailIcon,
|
||||
LinkIcon,
|
||||
} from "@/components/Icons"
|
||||
import CrossCircleIcon from "@/components/Icons/CrossCircle"
|
||||
import IconChip from "@/components/TempDesignSystem/IconChip"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
import { Toast } from "@/components/TempDesignSystem/Toasts"
|
||||
import { getIntl } from "@/i18n"
|
||||
import { getLang } from "@/i18n/serverContext"
|
||||
import { formatPrice } from "@/utils/numberFormatting"
|
||||
|
||||
import SummaryCard from "./SummaryCard"
|
||||
|
||||
import styles from "./bookingSummary.module.css"
|
||||
|
||||
import type { Hotel } from "@/types/hotel"
|
||||
import type { BookingConfirmation } from "@/types/trpc/routers/booking/confirmation"
|
||||
|
||||
interface BookingSummaryProps {
|
||||
booking: BookingConfirmation["booking"]
|
||||
hotel: Hotel
|
||||
}
|
||||
|
||||
export default async function BookingSummary({
|
||||
booking,
|
||||
hotel,
|
||||
}: BookingSummaryProps) {
|
||||
const intl = await getIntl()
|
||||
const lang = getLang()
|
||||
|
||||
const directionsUrl = `https://www.google.com/maps/dir/?api=1&destination=${hotel.location.latitude},${hotel.location.longitude}`
|
||||
const isPaid =
|
||||
booking.rateDefinition.cancellationRule !== "CancellableBefore6PM"
|
||||
const bookingDate = dt(booking.createDateTime)
|
||||
.locale(lang)
|
||||
.format("D MMMM YYYY")
|
||||
|
||||
return (
|
||||
<div className={styles.bookingSummary}>
|
||||
<Subtitle textTransform="uppercase" color="burgundy">
|
||||
{intl.formatMessage({ id: "Booking summary" })}
|
||||
</Subtitle>
|
||||
<div className={styles.bookingSummaryContent}>
|
||||
<SummaryCard
|
||||
title={formatPrice(intl, booking.totalPrice, booking.currencyCode)}
|
||||
image={{
|
||||
src: "/_static/img/scandic-coin.svg",
|
||||
alt: "Scandic coin",
|
||||
}}
|
||||
texts={[`${intl.formatMessage({ id: "Payment" })}: N/A`]}
|
||||
supportingText={bookingDate}
|
||||
chip={
|
||||
<IconChip
|
||||
color={isPaid ? "green" : "red"}
|
||||
icon={
|
||||
isPaid ? (
|
||||
<CheckCircleIcon width={20} height={20} color="green" />
|
||||
) : (
|
||||
<CrossCircleIcon width={20} height={20} color="red" />
|
||||
)
|
||||
}
|
||||
>
|
||||
<Caption color={isPaid ? "green" : "red"}>
|
||||
<strong>{intl.formatMessage({ id: "Status" })}:</strong>{" "}
|
||||
{isPaid
|
||||
? intl.formatMessage({ id: "Paid" })
|
||||
: intl.formatMessage({ id: "Unpaid" })}
|
||||
</Caption>
|
||||
</IconChip>
|
||||
}
|
||||
/>
|
||||
<SummaryCard
|
||||
title={hotel.name}
|
||||
image={{
|
||||
src: "/_static/img/scandic-service.svg",
|
||||
alt: "Scandic service",
|
||||
}}
|
||||
texts={[
|
||||
hotel.address.streetAddress,
|
||||
`${hotel.address.zipCode} ${hotel.address.city}`,
|
||||
]}
|
||||
supportingText={intl.formatMessage(
|
||||
{ id: "Long {long} ∙ Lat {lat}" },
|
||||
{
|
||||
lat: hotel.location.latitude,
|
||||
long: hotel.location.longitude,
|
||||
}
|
||||
)}
|
||||
links={[
|
||||
{
|
||||
href: directionsUrl,
|
||||
text: intl.formatMessage({ id: "Directions" }),
|
||||
icon: <DirectionsIcon width={20} height={20} color="burgundy" />,
|
||||
},
|
||||
{
|
||||
href: `mailto:${hotel.contactInformation.email}`,
|
||||
text: intl.formatMessage({ id: "Email" }),
|
||||
icon: <EmailIcon width={20} height={20} color="burgundy" />,
|
||||
},
|
||||
{
|
||||
href: hotel.contactInformation.websiteUrl,
|
||||
text: intl.formatMessage({ id: "Homepage" }),
|
||||
icon: <LinkIcon width={20} height={20} color="burgundy" />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
{hotel.specialAlerts.length > 0 && (
|
||||
<div className={styles.toast}>
|
||||
<Toast variant="info">
|
||||
<ul className={styles.list}>
|
||||
{hotel.specialAlerts.map((alert) => (
|
||||
<li key={alert.id}>
|
||||
<Body color="uiTextHighContrast">{alert.text}</Body>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Toast>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
28
components/HotelReservation/MyStay/Header/header.module.css
Normal file
28
components/HotelReservation/MyStay/Header/header.module.css
Normal file
@@ -0,0 +1,28 @@
|
||||
header .title {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin: 0 auto;
|
||||
padding-top: var(--Spacing-x6);
|
||||
margin-top: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.title .hotelName {
|
||||
font-family: var(--typography-Title-3-fontFamily);
|
||||
font-size: var(--typography-Title-3-fontSize);
|
||||
font-weight: var(--typography-Title-3-fontWeight);
|
||||
letter-spacing: var(--typography-Title-3-letterSpacing);
|
||||
line-height: var(--typography-Title-3-lineHeight);
|
||||
display: block;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.title .hotelName {
|
||||
font-family: var(--typography-Title-1-fontFamily);
|
||||
font-size: var(--typography-Title-1-fontSize);
|
||||
font-weight: var(--typography-Title-1-fontWeight);
|
||||
letter-spacing: var(--typography-Title-1-letterSpacing);
|
||||
line-height: var(--typography-Title-1-lineHeight);
|
||||
}
|
||||
}
|
||||
22
components/HotelReservation/MyStay/Header/index.tsx
Normal file
22
components/HotelReservation/MyStay/Header/index.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import BiroScript from "@/components/TempDesignSystem/Text/BiroScript"
|
||||
import Title from "@/components/TempDesignSystem/Text/Title"
|
||||
import { getIntl } from "@/i18n"
|
||||
|
||||
import styles from "./header.module.css"
|
||||
|
||||
import type { BookingConfirmation } from "@/types/trpc/routers/booking/confirmation"
|
||||
|
||||
export async function Header({ hotel }: Pick<BookingConfirmation, "hotel">) {
|
||||
const intl = await getIntl()
|
||||
return (
|
||||
<header>
|
||||
<Title as="h2" color="white" className={styles.title} textAlign="center">
|
||||
<BiroScript type="two" tilted="medium">
|
||||
{intl.formatMessage({ id: "My stay at" })}{" "}
|
||||
</BiroScript>
|
||||
<span className={styles.hotelName}>{hotel.name}</span>
|
||||
{hotel.cityName}
|
||||
</Title>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
.container {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import { getIntl } from "@/i18n"
|
||||
|
||||
import styles from "./bookingActions.module.css"
|
||||
|
||||
export async function BookingActions() {
|
||||
const intl = await getIntl()
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div>
|
||||
{intl.formatMessage(
|
||||
{
|
||||
id: "Changes can be made until {time}, {date} pending availability.",
|
||||
},
|
||||
{ time: "15:00", date: "Mon 15 Aug" }
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.actions}>
|
||||
<Button>{intl.formatMessage({ id: "Modify dates" })}</Button>
|
||||
<Button>{intl.formatMessage({ id: "Cancel booking" })}</Button>
|
||||
<Button>{intl.formatMessage({ id: "Customer service" })}</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import Title from "@/components/TempDesignSystem/Text/Title"
|
||||
import { getIntl } from "@/i18n"
|
||||
|
||||
import type { BookingConfirmation } from "@/types/trpc/routers/booking/confirmation"
|
||||
|
||||
export async function Header({
|
||||
booking,
|
||||
hotel,
|
||||
}: Pick<BookingConfirmation, "booking" | "hotel">) {
|
||||
const intl = await getIntl()
|
||||
return (
|
||||
<header>
|
||||
<Title as="h2" color="red">
|
||||
{intl.formatMessage(
|
||||
{ id: "My stay at {hotelName}" },
|
||||
{
|
||||
hotelName: hotel.name,
|
||||
}
|
||||
)}
|
||||
</Title>
|
||||
<Title as="h3" level="h2" textTransform="regular">
|
||||
{intl.formatMessage(
|
||||
{ id: "Reservation No. {reservationNumber}" },
|
||||
{
|
||||
reservationNumber: booking.confirmationNumber,
|
||||
}
|
||||
)}
|
||||
</Title>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import { dt } from "@/lib/dt"
|
||||
import {
|
||||
getAncillaryPackages,
|
||||
getBookingConfirmation,
|
||||
} from "@/lib/trpc/memoizedRequests"
|
||||
|
||||
import Divider from "@/components/TempDesignSystem/Divider"
|
||||
|
||||
import HotelDetails from "../../BookingConfirmation/HotelDetails"
|
||||
import PaymentDetails from "../../BookingConfirmation/PaymentDetails"
|
||||
import Promos from "../../BookingConfirmation/Promos"
|
||||
import Rooms from "../../BookingConfirmation/Rooms"
|
||||
import { Ancillaries } from "./Ancillaries"
|
||||
import { BookingActions } from "./BookingActions"
|
||||
import { Header } from "./Header"
|
||||
|
||||
import styles from "./myStay.module.css"
|
||||
|
||||
export async function MyStay({ reservationId }: { reservationId: string }) {
|
||||
const { booking, hotel, room } = await getBookingConfirmation(reservationId)
|
||||
const fromDate = dt(booking.checkInDate).format("YYYY-MM-DD")
|
||||
const toDate = dt(booking.checkOutDate).format("YYYY-MM-DD")
|
||||
const hotelId = hotel.operaId
|
||||
const ancillaryInput = { fromDate, hotelId, toDate }
|
||||
void getAncillaryPackages(ancillaryInput)
|
||||
const ancillaryPackages = await getAncillaryPackages(ancillaryInput)
|
||||
|
||||
return (
|
||||
<main className={styles.main}>
|
||||
<Header booking={booking} hotel={hotel} />
|
||||
<BookingActions />
|
||||
{room && <Rooms booking={booking} mainRoom={room} />}
|
||||
{booking.showAncillaries && (
|
||||
<Ancillaries ancillaries={ancillaryPackages} />
|
||||
)}
|
||||
<Divider color="primaryLightSubtle" />
|
||||
<PaymentDetails booking={booking} />
|
||||
<Divider color="primaryLightSubtle" />
|
||||
<HotelDetails hotel={hotel} />
|
||||
<Promos
|
||||
hotelId={hotel.operaId}
|
||||
lastName={booking.guest.lastName}
|
||||
confirmationNumber={booking.confirmationNumber}
|
||||
/>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
.main {
|
||||
background-color: var(--Base-Surface-Primary-light-Normal);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x5);
|
||||
margin: 0 auto;
|
||||
min-height: 100dvh;
|
||||
padding-top: var(--Spacing-x5);
|
||||
width: var(--max-width-page);
|
||||
}
|
||||
|
||||
.headerSkeleton {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.bookingActionsSkeleton {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: var(--Spacing-x2);
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.bookingActionsSkeletonButtons {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.ancillariesSkeleton {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.paymentDetailsSkeleton {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.hotelDetailsSkeleton {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x2);
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import SkeletonShimmer from "@/components/SkeletonShimmer"
|
||||
import Divider from "@/components/TempDesignSystem/Divider"
|
||||
|
||||
import styles from "./myStay.module.css"
|
||||
|
||||
export async function MyStaySkeleton() {
|
||||
return (
|
||||
<div className={styles.main}>
|
||||
<div className={styles.headerSkeleton}>
|
||||
<SkeletonShimmer width={"100%"} height="40px" />
|
||||
<SkeletonShimmer width={"300px"} height="30px" />
|
||||
</div>
|
||||
<div className={styles.bookingActionsSkeleton}>
|
||||
<SkeletonShimmer width={"300px"} height="20px" />
|
||||
<div className={styles.bookingActionsSkeletonButtons}>
|
||||
<SkeletonShimmer width={"125px"} height="50px" />
|
||||
<SkeletonShimmer width={"125px"} height="50px" />
|
||||
<SkeletonShimmer width={"125px"} height="50px" />
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.roomSkeleton}>
|
||||
<SkeletonShimmer width={"100%"} height="290px" />
|
||||
</div>
|
||||
<div className={styles.ancillariesSkeleton}>
|
||||
<SkeletonShimmer width={"280px"} height="200px" />
|
||||
<SkeletonShimmer width={"280px"} height="200px" />
|
||||
<SkeletonShimmer width={"280px"} height="200px" />
|
||||
<SkeletonShimmer width={"280px"} height="200px" />
|
||||
<SkeletonShimmer width={"280px"} height="200px" />
|
||||
</div>
|
||||
<Divider color="primaryLightSubtle" />
|
||||
<div className={styles.paymentDetailsSkeleton}>
|
||||
<SkeletonShimmer width={"200px"} height="30px" />
|
||||
<SkeletonShimmer width={"180px"} height="20px" />
|
||||
<SkeletonShimmer width={"190px"} height="20px" />
|
||||
<SkeletonShimmer width={"170px"} height="20px" />
|
||||
</div>
|
||||
<Divider color="primaryLightSubtle" />
|
||||
<div className={styles.hotelDetailsSkeleton}>
|
||||
<SkeletonShimmer width={"200px"} height="30px" />
|
||||
<SkeletonShimmer width={"180px"} height="20px" />
|
||||
<SkeletonShimmer width={"190px"} height="20px" />
|
||||
<SkeletonShimmer width={"170px"} height="20px" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
26
components/HotelReservation/MyStay/Promo/index.tsx
Normal file
26
components/HotelReservation/MyStay/Promo/index.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Title from "@/components/TempDesignSystem/Text/Title"
|
||||
|
||||
import styles from "./promo.module.css"
|
||||
|
||||
import type { PromoProps } from "@/types/components/hotelReservation/bookingConfirmation/promo"
|
||||
|
||||
export default function Promo({ buttonText, href, text, title }: PromoProps) {
|
||||
return (
|
||||
<Link className={styles.link} color="none" href={href}>
|
||||
<article className={styles.promo}>
|
||||
<Title color="white" level="h4">
|
||||
{title}
|
||||
</Title>
|
||||
<Body className={styles.text} color="white" textAlign="center">
|
||||
{text}
|
||||
</Body>
|
||||
<Button asChild intent="primary" size="small" theme="primaryStrong">
|
||||
<div>{buttonText}</div>
|
||||
</Button>
|
||||
</article>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
33
components/HotelReservation/MyStay/Promo/promo.module.css
Normal file
33
components/HotelReservation/MyStay/Promo/promo.module.css
Normal file
@@ -0,0 +1,33 @@
|
||||
.promo {
|
||||
align-items: center;
|
||||
background-position: 50%;
|
||||
background-repeat: no-repeat;
|
||||
background-size: cover;
|
||||
display: flex;
|
||||
flex: 1 0 480px;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x2);
|
||||
height: 480px;
|
||||
justify-content: center;
|
||||
padding: var(--Spacing-x4) var(--Spacing-x3);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.promo {
|
||||
border-radius: var(--Medium, 8px);
|
||||
}
|
||||
}
|
||||
|
||||
.link .promo {
|
||||
background-image: linear-gradient(
|
||||
180deg,
|
||||
rgba(0, 0, 0, 0) 0%,
|
||||
rgba(0, 0, 0, 0.36) 37.88%,
|
||||
rgba(0, 0, 0, 0.75) 100%
|
||||
),
|
||||
url("/_static/img/Scandic_Family_Breakfast.jpg");
|
||||
}
|
||||
|
||||
.text {
|
||||
max-width: 400px;
|
||||
}
|
||||
131
components/HotelReservation/MyStay/ReferenceCard/index.tsx
Normal file
131
components/HotelReservation/MyStay/ReferenceCard/index.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import { dt } from "@/lib/dt"
|
||||
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Divider from "@/components/TempDesignSystem/Divider"
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
import { getIntl } from "@/i18n"
|
||||
import { getLang } from "@/i18n/serverContext"
|
||||
import { formatPrice } from "@/utils/numberFormatting"
|
||||
|
||||
import styles from "./referenceCard.module.css"
|
||||
|
||||
import type { Hotel } from "@/types/hotel"
|
||||
import type { BookingConfirmation } from "@/types/trpc/routers/booking/confirmation"
|
||||
|
||||
export async function ReferenceCard({
|
||||
booking,
|
||||
hotel,
|
||||
}: {
|
||||
booking: BookingConfirmation["booking"]
|
||||
hotel: Hotel
|
||||
}) {
|
||||
const intl = await getIntl()
|
||||
const lang = getLang()
|
||||
|
||||
const fromDate = dt(booking.checkInDate).locale(lang)
|
||||
const toDate = dt(booking.checkOutDate).locale(lang)
|
||||
|
||||
const directionsUrl = `https://www.google.com/maps/dir/?api=1&destination=${hotel.location.latitude},${hotel.location.longitude}`
|
||||
|
||||
return (
|
||||
<div className={styles.referenceCard}>
|
||||
<div className={styles.referenceRow}>
|
||||
<Subtitle color="uiTextHighContrast" className={styles.titleMobile}>
|
||||
{intl.formatMessage({ id: "Reference" })}
|
||||
</Subtitle>
|
||||
<Subtitle color="uiTextHighContrast" className={styles.titleDesktop}>
|
||||
{intl.formatMessage({ id: "Reference number" })}
|
||||
</Subtitle>
|
||||
<Subtitle color="uiTextHighContrast">
|
||||
{booking.confirmationNumber}
|
||||
</Subtitle>
|
||||
</div>
|
||||
<Divider color="primaryLightSubtle" className={styles.divider} />
|
||||
<div className={styles.referenceRow}>
|
||||
<Caption
|
||||
textTransform="uppercase"
|
||||
type="bold"
|
||||
color="uiTextHighContrast"
|
||||
>
|
||||
{intl.formatMessage({ id: "Guests" })}
|
||||
</Caption>
|
||||
<Caption type="bold" color="uiTextHighContrast">
|
||||
{booking.childrenAges.length > 0
|
||||
? intl.formatMessage(
|
||||
{ id: "{adults} adults, {children} children" },
|
||||
{
|
||||
adults: booking.adults,
|
||||
children: booking.childrenAges.length,
|
||||
}
|
||||
)
|
||||
: intl.formatMessage(
|
||||
{ id: "{adults} adults" },
|
||||
{
|
||||
adults: booking.adults,
|
||||
}
|
||||
)}
|
||||
</Caption>
|
||||
</div>
|
||||
<div className={styles.referenceRow}>
|
||||
<Caption
|
||||
textTransform="uppercase"
|
||||
type="bold"
|
||||
color="uiTextHighContrast"
|
||||
>
|
||||
{intl.formatMessage({ id: "Check-in" })}
|
||||
</Caption>
|
||||
<Caption type="bold" color="uiTextHighContrast">
|
||||
{`${fromDate.format("dddd, D MMMM")} ${intl.formatMessage({ id: "from" })} ${fromDate.format("HH:mm")}`}
|
||||
</Caption>
|
||||
</div>
|
||||
<div className={styles.referenceRow}>
|
||||
<Caption
|
||||
textTransform="uppercase"
|
||||
type="bold"
|
||||
color="uiTextHighContrast"
|
||||
>
|
||||
{intl.formatMessage({ id: "Check-out" })}
|
||||
</Caption>
|
||||
<Caption type="bold" color="uiTextHighContrast">
|
||||
{`${toDate.format("dddd, D MMMM")} ${intl.formatMessage({ id: "from" })} ${toDate.format("HH:mm")}`}
|
||||
</Caption>
|
||||
</div>
|
||||
<Divider color="primaryLightSubtle" className={styles.divider} />
|
||||
<div className={styles.referenceRow}>
|
||||
<Caption
|
||||
textTransform="uppercase"
|
||||
type="bold"
|
||||
color="uiTextHighContrast"
|
||||
>
|
||||
{intl.formatMessage({ id: "Total paid" })}
|
||||
</Caption>
|
||||
<Caption type="bold" color="uiTextHighContrast">
|
||||
{formatPrice(intl, booking.totalPrice, booking.currencyCode)}
|
||||
</Caption>
|
||||
</div>
|
||||
<div className={styles.actionArea}>
|
||||
<Button fullWidth>{intl.formatMessage({ id: "Manage stay" })}</Button>
|
||||
<Button fullWidth intent="secondary" asChild>
|
||||
<Link href={directionsUrl} target="_blank">
|
||||
{intl.formatMessage({ id: "Get directions" })}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
{booking.rateDefinition.cancellationRule !== "NotCancellable" && (
|
||||
<Caption className={styles.note} color="uiTextHighContrast">
|
||||
{intl.formatMessage(
|
||||
{
|
||||
id: "Changes can be made until {time} on {date}, subject to availability. Room rates may vary.",
|
||||
},
|
||||
{
|
||||
date: fromDate.format("D MMMM"),
|
||||
time: "18:00",
|
||||
}
|
||||
)}
|
||||
</Caption>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
.referenceCard {
|
||||
width: var(--max-width-content);
|
||||
max-width: 588px;
|
||||
margin: 0 auto;
|
||||
padding: var(--Spacing-x3);
|
||||
border-radius: var(--Corner-radius-Large);
|
||||
background-color: var(--Base-Surface-Primary-light-Normal);
|
||||
box-shadow: 0px 0px 14px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.referenceRow {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding-bottom: var(--Spacing-x-one-and-half);
|
||||
}
|
||||
|
||||
.divider {
|
||||
margin-bottom: var(--Spacing-x-one-and-half);
|
||||
}
|
||||
|
||||
.actionArea {
|
||||
display: flex;
|
||||
gap: var(--Spacing-x3);
|
||||
margin: var(--Spacing-x4) 0 var(--Spacing-x3);
|
||||
}
|
||||
|
||||
.referenceCard .note {
|
||||
text-align: center;
|
||||
width: 80%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.titleDesktop {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.titleMobile {
|
||||
display: none;
|
||||
}
|
||||
.titleDesktop {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
97
components/HotelReservation/MyStay/Room/GuestDetails.tsx
Normal file
97
components/HotelReservation/MyStay/Room/GuestDetails.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { DiamondIcon, EditIcon } from "@/components/Icons"
|
||||
import MembershipLevelIcon from "@/components/Levels/Icon"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
|
||||
import styles from "./room.module.css"
|
||||
|
||||
import type { BookingConfirmation } from "@/types/trpc/routers/booking/confirmation"
|
||||
import type { User } from "@/types/user"
|
||||
|
||||
export default function GuestDetails({
|
||||
user,
|
||||
booking,
|
||||
isMobile = false,
|
||||
}: {
|
||||
user: User | null
|
||||
booking: BookingConfirmation["booking"]
|
||||
isMobile?: boolean
|
||||
}) {
|
||||
const intl = useIntl()
|
||||
const containerClass = isMobile
|
||||
? styles.guestDetailsMobile
|
||||
: styles.guestDetailsDesktop
|
||||
|
||||
return (
|
||||
<div className={containerClass}>
|
||||
{user?.membership && (
|
||||
<div className={styles.userDetails}>
|
||||
<div className={styles.row}>
|
||||
<div className={styles.rowTitle}>
|
||||
<Caption
|
||||
type="bold"
|
||||
color="burgundy"
|
||||
textTransform="uppercase"
|
||||
textAlign="center"
|
||||
>
|
||||
{intl.formatMessage({ id: "Your member tier" })}
|
||||
</Caption>
|
||||
</div>
|
||||
<MembershipLevelIcon
|
||||
level={user.membership.membershipLevel}
|
||||
color="red"
|
||||
height={isMobile ? "40" : "20"}
|
||||
width={isMobile ? "80" : "40"}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.totalPoints}>
|
||||
{isMobile && (
|
||||
<div className={styles.totalPointsIcon}>
|
||||
<DiamondIcon color="uiTextHighContrast" />
|
||||
</div>
|
||||
)}
|
||||
<Caption
|
||||
type="bold"
|
||||
color="uiTextHighContrast"
|
||||
textTransform="uppercase"
|
||||
>
|
||||
{intl.formatMessage({ id: "Total points" })}
|
||||
</Caption>
|
||||
|
||||
<Body color="uiTextHighContrast" className={styles.totalPointsText}>
|
||||
{user.membership.currentPoints}
|
||||
</Body>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.guest}>
|
||||
<Body textTransform="bold" color="uiTextHighContrast">
|
||||
{booking.guest.firstName} {booking.guest.lastName}
|
||||
</Body>
|
||||
{user?.membership && (
|
||||
<Body color="uiTextHighContrast">
|
||||
{intl.formatMessage({ id: "Member no." })}{" "}
|
||||
{user.membership.membershipNumber}
|
||||
</Body>
|
||||
)}
|
||||
<Caption color="uiTextHighContrast">{booking.guest.email}</Caption>
|
||||
<Caption color="uiTextHighContrast">
|
||||
{booking.guest.phoneNumber}
|
||||
</Caption>
|
||||
</div>
|
||||
<Button
|
||||
variant="icon"
|
||||
color="burgundy"
|
||||
intent={isMobile ? "secondary" : "text"}
|
||||
>
|
||||
<EditIcon color="burgundy" width={20} height={20} />
|
||||
<Caption color="burgundy">
|
||||
{intl.formatMessage({ id: "Modify guest details" })}
|
||||
</Caption>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
298
components/HotelReservation/MyStay/Room/index.tsx
Normal file
298
components/HotelReservation/MyStay/Room/index.tsx
Normal file
@@ -0,0 +1,298 @@
|
||||
"use client"
|
||||
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { dt } from "@/lib/dt"
|
||||
|
||||
import { getIconForFeatureCode } from "@/components/HotelReservation/utils"
|
||||
import {
|
||||
BedDoubleIcon,
|
||||
CoffeeIcon,
|
||||
ContractIcon,
|
||||
DoorOpenIcon,
|
||||
PersonIcon,
|
||||
} from "@/components/Icons"
|
||||
import RocketLaunch from "@/components/Icons/Refresh"
|
||||
import Image from "@/components/Image"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
import useLang from "@/hooks/useLang"
|
||||
import { formatPrice } from "@/utils/numberFormatting"
|
||||
|
||||
import ToggleSidePeek from "../../EnterDetails/SelectedRoom/ToggleSidePeek"
|
||||
import PriceDetailsModal from "../../PriceDetailsModal"
|
||||
import GuestDetails from "./GuestDetails"
|
||||
|
||||
import styles from "./room.module.css"
|
||||
|
||||
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
|
||||
import { BreakfastPackageEnum } from "@/types/enums/breakfast"
|
||||
import type { Hotel, Room } from "@/types/hotel"
|
||||
import type { BookingConfirmation } from "@/types/trpc/routers/booking/confirmation"
|
||||
import type { User } from "@/types/user"
|
||||
|
||||
interface RoomProps {
|
||||
booking: BookingConfirmation["booking"]
|
||||
room:
|
||||
| (Room & {
|
||||
bedType: Room["roomTypes"][number]
|
||||
})
|
||||
| null
|
||||
hotel: Hotel
|
||||
user: User | null
|
||||
}
|
||||
|
||||
function hasBreakfastPackage(
|
||||
packages: BookingConfirmation["booking"]["packages"]
|
||||
) {
|
||||
return packages.some(
|
||||
(p) =>
|
||||
p.code === BreakfastPackageEnum.REGULAR_BREAKFAST ||
|
||||
p.code === BreakfastPackageEnum.FREE_MEMBER_BREAKFAST ||
|
||||
p.code === BreakfastPackageEnum.SPECIAL_PACKAGE_BREAKFAST
|
||||
)
|
||||
}
|
||||
|
||||
function RoomHeader({
|
||||
room,
|
||||
hotel,
|
||||
}: {
|
||||
room: RoomProps["room"]
|
||||
hotel: Hotel
|
||||
}) {
|
||||
if (!room) return null
|
||||
|
||||
return (
|
||||
<div className={styles.roomHeader}>
|
||||
<Subtitle
|
||||
textTransform="uppercase"
|
||||
color="burgundy"
|
||||
className={styles.roomName}
|
||||
>
|
||||
{room.name}
|
||||
</Subtitle>
|
||||
<ToggleSidePeek
|
||||
hotelId={hotel.operaId}
|
||||
roomTypeCode={room.roomTypes[0].code}
|
||||
intent="text"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Room({ booking, room, hotel, user }: RoomProps) {
|
||||
const intl = useIntl()
|
||||
const lang = useLang()
|
||||
|
||||
if (!room) return null
|
||||
|
||||
const fromDate = dt(booking.checkInDate).locale(lang)
|
||||
|
||||
return (
|
||||
<div className={styles.roomContainer}>
|
||||
<article className={styles.room}>
|
||||
<RoomHeader room={room} hotel={hotel} />
|
||||
<div className={styles.booking}>
|
||||
<div className={styles.chipContainer}>
|
||||
{booking.packages
|
||||
.filter((item) =>
|
||||
Object.values(RoomPackageCodeEnum).includes(
|
||||
item.code as RoomPackageCodeEnum
|
||||
)
|
||||
)
|
||||
.map((item) => {
|
||||
const Icon = getIconForFeatureCode(
|
||||
item.code as RoomPackageCodeEnum
|
||||
)
|
||||
return (
|
||||
<span className={styles.chip} key={item.code}>
|
||||
<Icon width={16} height={16} color="burgundy" />
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className={styles.images}>
|
||||
{room.images.slice(0, 2).map((image) => (
|
||||
<Image
|
||||
key={image.imageSizes.large}
|
||||
src={image.imageSizes.large}
|
||||
className={styles.image}
|
||||
alt={room?.name ?? hotel.name}
|
||||
width={700}
|
||||
height={450}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className={styles.roomDetails}>
|
||||
<div className={styles.bookingDetails}>
|
||||
<div className={styles.row}>
|
||||
<span className={styles.rowTitle}>
|
||||
<ContractIcon color="grey80" width={20} height={20} />
|
||||
<Body textTransform="bold" color="uiTextHighContrast">
|
||||
{intl.formatMessage({ id: "Booking policy" })}
|
||||
</Body>
|
||||
</span>
|
||||
<div className={styles.rowContent}>
|
||||
<Body color="uiTextHighContrast">
|
||||
{booking.rateDefinition.title}
|
||||
</Body>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.row}>
|
||||
<span className={styles.rowTitle}>
|
||||
<RocketLaunch color="grey80" width={20} height={20} />
|
||||
<Body textTransform="bold" color="uiTextHighContrast">
|
||||
{intl.formatMessage({ id: "Rebooking" })}
|
||||
</Body>
|
||||
</span>
|
||||
<div className={styles.rowContent}>
|
||||
<Body color="uiTextHighContrast">
|
||||
{intl.formatMessage(
|
||||
{ id: "Until {time}, {date}" },
|
||||
{ time: "18:00", date: fromDate.format("dddd D MMM") }
|
||||
)}
|
||||
</Body>
|
||||
</div>
|
||||
</div>
|
||||
{booking.packages.some((item) =>
|
||||
Object.values(RoomPackageCodeEnum).includes(
|
||||
item.code as RoomPackageCodeEnum
|
||||
)
|
||||
) && (
|
||||
<div className={styles.row}>
|
||||
<span className={styles.rowTitle}>
|
||||
<DoorOpenIcon color="grey80" width={20} height={20} />
|
||||
<Body textTransform="bold" color="uiTextHighContrast">
|
||||
{intl.formatMessage({ id: "Room type" })}
|
||||
</Body>
|
||||
</span>
|
||||
<div className={styles.rowContent}>
|
||||
<Body color="uiTextHighContrast">
|
||||
{booking.packages
|
||||
.filter((item) =>
|
||||
Object.values(RoomPackageCodeEnum).includes(
|
||||
item.code as RoomPackageCodeEnum
|
||||
)
|
||||
)
|
||||
.map((item) => item.description)
|
||||
.join(", ")}
|
||||
</Body>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.row}>
|
||||
<span className={styles.rowTitle}>
|
||||
<PersonIcon color="grey80" width={20} height={20} />
|
||||
<Body textTransform="bold" color="uiTextHighContrast">
|
||||
{intl.formatMessage({ id: "Guests" })}
|
||||
</Body>
|
||||
</span>
|
||||
<div className={styles.rowContent}>
|
||||
<Body color="uiTextHighContrast">
|
||||
{booking.childrenAges.length > 0
|
||||
? intl.formatMessage(
|
||||
{ id: "{adults} adults, {children} children" },
|
||||
{
|
||||
adults: booking.adults,
|
||||
children: booking.childrenAges.length,
|
||||
}
|
||||
)
|
||||
: intl.formatMessage(
|
||||
{ id: "{adults} adults" },
|
||||
{
|
||||
adults: booking.adults,
|
||||
}
|
||||
)}
|
||||
</Body>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.row}>
|
||||
<span className={styles.rowTitle}>
|
||||
<CoffeeIcon color="grey80" width={20} height={20} />
|
||||
<Body textTransform="bold" color="uiTextHighContrast">
|
||||
{intl.formatMessage({ id: "Breakfast" })}
|
||||
</Body>
|
||||
</span>
|
||||
<div className={styles.rowContent}>
|
||||
<Body color="uiTextHighContrast">
|
||||
{hasBreakfastPackage(booking.packages)
|
||||
? intl.formatMessage({ id: "Included" })
|
||||
: intl.formatMessage({ id: "Not included" })}
|
||||
</Body>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.row}>
|
||||
<span className={styles.rowTitle}>
|
||||
<BedDoubleIcon color="grey80" width={20} height={20} />
|
||||
<Body textTransform="bold" color="uiTextHighContrast">
|
||||
{intl.formatMessage({ id: "Bed preference" })}
|
||||
</Body>
|
||||
</span>
|
||||
<div className={styles.rowContent}>
|
||||
<Body color="uiTextHighContrast">
|
||||
{room.bedType.mainBed.description}
|
||||
{room.bedType.mainBed.widthRange.min ===
|
||||
room.bedType.mainBed.widthRange.max
|
||||
? ` (${room.bedType.mainBed.widthRange.min} ${intl.formatMessage({ id: "cm" })})`
|
||||
: ` (${room.bedType.mainBed.widthRange.min} - ${room.bedType.mainBed.widthRange.max} ${intl.formatMessage({ id: "cm" })})`}
|
||||
</Body>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<GuestDetails user={user} booking={booking} isMobile={false} />
|
||||
</div>
|
||||
<div className={styles.bookingInformation}>
|
||||
<div className={styles.bookingCode}></div>
|
||||
<div className={styles.priceDetails}>
|
||||
<div className={styles.price}>
|
||||
<Body color="uiTextHighContrast">
|
||||
{intl.formatMessage({ id: "Room total" })}
|
||||
</Body>
|
||||
<Body color="uiTextHighContrast" textTransform="bold">
|
||||
{formatPrice(intl, booking.totalPrice, booking.currencyCode)}
|
||||
</Body>
|
||||
</div>
|
||||
|
||||
<PriceDetailsModal
|
||||
fromDate={dt(booking.checkInDate).format("YYYY-MM-DD")}
|
||||
toDate={dt(booking.checkOutDate).format("YYYY-MM-DD")}
|
||||
rooms={[
|
||||
{
|
||||
adults: booking.adults,
|
||||
childrenInRoom: undefined,
|
||||
roomPrice: {
|
||||
perNight: {
|
||||
requested: undefined,
|
||||
local: {
|
||||
currency: booking.currencyCode,
|
||||
price: booking.totalPrice,
|
||||
},
|
||||
},
|
||||
perStay: {
|
||||
requested: undefined,
|
||||
local: {
|
||||
currency: booking.currencyCode,
|
||||
price: booking.totalPrice,
|
||||
},
|
||||
},
|
||||
},
|
||||
roomType: room.name,
|
||||
},
|
||||
]}
|
||||
totalPrice={{
|
||||
requested: undefined,
|
||||
local: {
|
||||
currency: booking.currencyCode,
|
||||
price: booking.totalPrice,
|
||||
},
|
||||
}}
|
||||
vat={booking.vatPercentage}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
<GuestDetails user={user} booking={booking} isMobile={true} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
284
components/HotelReservation/MyStay/Room/room.module.css
Normal file
284
components/HotelReservation/MyStay/Room/room.module.css
Normal file
@@ -0,0 +1,284 @@
|
||||
.room {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x2);
|
||||
background-color: var(--Base-Background-Primary-Normal);
|
||||
padding: var(--Spacing-x3) 0;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.room {
|
||||
background-color: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.roomHeader {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: var(--max-width-content);
|
||||
margin: 0 auto;
|
||||
align-items: flex-start;
|
||||
gap: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.roomHeader {
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
|
||||
.booking {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x2);
|
||||
position: relative;
|
||||
width: var(--max-width-content);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.booking {
|
||||
border-radius: var(--Corner-radius-Large);
|
||||
background-color: var(--Base-Background-Primary-Normal);
|
||||
padding: var(--Spacing-x2);
|
||||
}
|
||||
}
|
||||
|
||||
.chipContainer {
|
||||
position: absolute;
|
||||
top: 300px;
|
||||
left: 25px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.chip {
|
||||
background-color: var(--Main-Grey-White);
|
||||
padding: var(--Spacing-x-half) var(--Spacing-x1);
|
||||
border-radius: var(--Corner-radius-Small);
|
||||
}
|
||||
|
||||
.images {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x-one-and-half);
|
||||
grid-template-columns: 1fr;
|
||||
height: 210px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.images {
|
||||
height: 320px;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.image {
|
||||
border-radius: var(--Corner-radius-Medium);
|
||||
width: 100%;
|
||||
height: 210px;
|
||||
aspect-ratio: 16/9;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.image:last-child {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.image {
|
||||
height: 100%;
|
||||
}
|
||||
.image:last-child {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.roomDetails {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x5);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.roomDetails {
|
||||
grid-template-columns: minmax(0, 700px) 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.bookingDetails {
|
||||
max-width: 100%;
|
||||
padding: 0 var(--Spacing-x2);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.bookingDetails {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: var(--Spacing-x-one-and-half) 0;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.row {
|
||||
border-bottom: 1px solid var(--Base-Border-Subtle);
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
|
||||
.row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.rowTitle {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.rowTitle svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.rowTitle svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.rowContent {
|
||||
padding-left: var(--Spacing-x4);
|
||||
}
|
||||
|
||||
.guestDetailsDesktop {
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.guestDetailsDesktop {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
.guestDetailsMobile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-bottom: var(--Spacing-x2);
|
||||
background-color: var(--Main-Brand-PalePeach);
|
||||
padding: var(--Spacing-x3) 0;
|
||||
}
|
||||
|
||||
.guestDetailsMobile .row {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.guestDetailsMobile .rowTitle {
|
||||
margin-bottom: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.guestDetailsMobile .userDetails {
|
||||
width: calc(100% - var(--Spacing-x4) - var(--Spacing-x4));
|
||||
border-bottom: 1px solid var(--Primary-Light-On-Surface-Divider);
|
||||
padding-bottom: var(--Spacing-x3);
|
||||
margin-bottom: var(--Spacing-x3);
|
||||
}
|
||||
|
||||
.guestDetailsMobile .totalPoints {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--Spacing-x1);
|
||||
padding-top: var(--Spacing-x3);
|
||||
}
|
||||
|
||||
.guestDetailsMobile .totalPointsText {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.guestDetailsMobile .guest {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.guestDetailsMobile {
|
||||
display: none;
|
||||
}
|
||||
.totalPoints {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--Spacing-x-one-and-half) 0;
|
||||
}
|
||||
}
|
||||
|
||||
.guest {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
margin-bottom: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.bookingInformation {
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
align-items: center;
|
||||
gap: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.bookingInformation {
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
|
||||
.priceDetails {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--Spacing-x1);
|
||||
border-top: 1px solid var(--Base-Border-Subtle);
|
||||
border-bottom: 1px solid var(--Base-Border-Subtle);
|
||||
padding: var(--Spacing-x-one-and-half) 0;
|
||||
width: calc(100% - var(--Spacing-x4));
|
||||
justify-content: center;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.priceDetails {
|
||||
border: none;
|
||||
margin: 0;
|
||||
width: auto;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
}
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.price {
|
||||
display: flex;
|
||||
gap: var(--Spacing-x1);
|
||||
}
|
||||
}
|
||||
|
||||
.userDetails {
|
||||
width: 100%;
|
||||
border-bottom: 1px solid var(--Base-Border-Subtle);
|
||||
margin-bottom: var(--Spacing-x1);
|
||||
}
|
||||
71
components/HotelReservation/MyStay/index.tsx
Normal file
71
components/HotelReservation/MyStay/index.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { homeHrefs } from "@/constants/homeHrefs"
|
||||
import { env } from "@/env/server"
|
||||
import { dt } from "@/lib/dt"
|
||||
import {
|
||||
getAncillaryPackages,
|
||||
getBookingConfirmation,
|
||||
getProfileSafely,
|
||||
} from "@/lib/trpc/memoizedRequests"
|
||||
|
||||
import Image from "@/components/Image"
|
||||
import { getIntl } from "@/i18n"
|
||||
import { getLang } from "@/i18n/serverContext"
|
||||
|
||||
import { Ancillaries } from "./Ancillaries"
|
||||
import BookingSummary from "./BookingSummary"
|
||||
import { Header } from "./Header"
|
||||
import Promo from "./Promo"
|
||||
import { ReferenceCard } from "./ReferenceCard"
|
||||
import { Room } from "./Room"
|
||||
|
||||
import styles from "./myStay.module.css"
|
||||
|
||||
export async function MyStay({ reservationId }: { reservationId: string }) {
|
||||
const { booking, hotel, room } = await getBookingConfirmation(reservationId)
|
||||
const userResponse = await getProfileSafely()
|
||||
const user = userResponse && !("error" in userResponse) ? userResponse : null
|
||||
const intl = await getIntl()
|
||||
const lang = getLang()
|
||||
const homeUrl = homeHrefs[env.NODE_ENV][lang]
|
||||
const fromDate = dt(booking.checkInDate).format("YYYY-MM-DD")
|
||||
const toDate = dt(booking.checkOutDate).format("YYYY-MM-DD")
|
||||
const hotelId = hotel.operaId
|
||||
const ancillaryInput = { fromDate, hotelId, toDate }
|
||||
void getAncillaryPackages(ancillaryInput)
|
||||
const ancillaryPackages = await getAncillaryPackages(ancillaryInput)
|
||||
|
||||
return (
|
||||
<main className={styles.main}>
|
||||
<div className={styles.imageContainer}>
|
||||
<div className={styles.blurOverlay} />
|
||||
{hotel.gallery?.heroImages[0].imageSizes.large && (
|
||||
<Image
|
||||
className={styles.image}
|
||||
src={hotel.gallery.heroImages[0].imageSizes.large}
|
||||
alt={hotel.name}
|
||||
fill
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.content}>
|
||||
<div className={styles.headerContainer}>
|
||||
<Header hotel={hotel} />
|
||||
<ReferenceCard booking={booking} hotel={hotel} />
|
||||
</div>
|
||||
{booking.showAncillaries && (
|
||||
<Ancillaries ancillaries={ancillaryPackages} />
|
||||
)}
|
||||
<Room booking={booking} room={room} hotel={hotel} user={user} />
|
||||
<BookingSummary booking={booking} hotel={hotel} />
|
||||
<Promo
|
||||
buttonText={intl.formatMessage({ id: "Book another stay" })}
|
||||
href={`${homeUrl}?hotel=${hotel.operaId}`}
|
||||
text={intl.formatMessage({
|
||||
id: "Get inspired and start dreaming beyond your next trip. Explore more Scandic destinations.",
|
||||
})}
|
||||
title={intl.formatMessage({ id: "Book your next stay" })}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
98
components/HotelReservation/MyStay/myStay.module.css
Normal file
98
components/HotelReservation/MyStay/myStay.module.css
Normal file
@@ -0,0 +1,98 @@
|
||||
.main {
|
||||
background-color: var(--Base-Surface-Primary-light-Normal);
|
||||
min-height: 100dvh;
|
||||
}
|
||||
|
||||
.imageContainer {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 480px;
|
||||
}
|
||||
|
||||
.blurOverlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
backdrop-filter: blur(12px);
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
mask-image: linear-gradient(to bottom, rgba(0, 0, 0, 1) 0%, transparent 100%);
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
rgba(0, 0, 0, 0.5) 0%,
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
|
||||
.image {
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
}
|
||||
|
||||
.headerContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x4);
|
||||
}
|
||||
|
||||
.content {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 80px;
|
||||
margin: 0 auto;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
padding-bottom: var(--Spacing-x3);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.content {
|
||||
width: var(--max-width-content);
|
||||
padding-bottom: 160px;
|
||||
}
|
||||
}
|
||||
|
||||
.headerSkeleton {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x2);
|
||||
align-items: center;
|
||||
padding: var(--Spacing-x6) var(--Spacing-x2) 0;
|
||||
}
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x2);
|
||||
padding: 0 var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.cardSkeleton {
|
||||
max-width: 100%;
|
||||
margin: -30px auto 0;
|
||||
padding: 0 var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.ancillariesSkeleton {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.ancillariesSkeleton {
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
|
||||
.paymentDetailsSkeleton {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.hotelDetailsSkeleton {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x2);
|
||||
}
|
||||
34
components/HotelReservation/MyStay/myStaySkeleton.tsx
Normal file
34
components/HotelReservation/MyStay/myStaySkeleton.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import SkeletonShimmer from "@/components/SkeletonShimmer"
|
||||
|
||||
import styles from "./myStay.module.css"
|
||||
|
||||
export async function MyStaySkeleton() {
|
||||
return (
|
||||
<div className={styles.content}>
|
||||
<div className={styles.headerSkeleton}>
|
||||
<SkeletonShimmer width={"100px"} height="20px" />
|
||||
<SkeletonShimmer width={"450px"} height="50px" />
|
||||
<SkeletonShimmer width={"200px"} height="30px" />
|
||||
</div>
|
||||
<div className={styles.cardSkeleton}>
|
||||
<SkeletonShimmer width="590px" height="380px" />
|
||||
</div>
|
||||
<div className={styles.section}>
|
||||
<SkeletonShimmer width={"200px"} height="30px" />
|
||||
<div className={styles.ancillariesSkeleton}>
|
||||
<SkeletonShimmer width="280px" height="200px" />
|
||||
<SkeletonShimmer width="280px" height="200px" />
|
||||
<SkeletonShimmer width="280px" height="200px" />
|
||||
<SkeletonShimmer width="280px" height="200px" />
|
||||
<SkeletonShimmer width="280px" height="200px" />
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.section}>
|
||||
<SkeletonShimmer width={"200px"} height="30px" />
|
||||
<div className={styles.roomSkeleton}>
|
||||
<SkeletonShimmer width="100%" height="700px" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user