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:
Pontus Dreij
2025-02-18 14:20:54 +00:00
parent 90fee1b0c4
commit 8616e4ab76
56 changed files with 4163 additions and 227 deletions

View File

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

View File

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

View File

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

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

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

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

View File

@@ -1,9 +0,0 @@
.container {
display: flex;
justify-content: space-between;
align-items: center;
}
.actions {
display: flex;
}

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

View File

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

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

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

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

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

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

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