Merged in monorepo-step-1 (pull request #1080)

Migrate to a monorepo setup - step 1

* Move web to subfolder /apps/scandic-web

* Yarn + transitive deps

- Move to yarn
- design-system package removed for now since yarn doesn't
support the parameter for token (ie project currently broken)
- Add missing transitive dependencies as Yarn otherwise
prevents these imports
- VS Code doesn't pick up TS path aliases unless you open
/apps/scandic-web instead of root (will be fixed with monorepo)

* Pin framer-motion to temporarily fix typing issue

https://github.com/adobe/react-spectrum/issues/7494

* Pin zod to avoid typ error

There seems to have been a breaking change in the types
returned by zod where error is now returned as undefined
instead of missing in the type. We should just handle this
but to avoid merge conflicts just pin the dependency for
now.

* Pin react-intl version

Pin version of react-intl to avoid tiny type issue where formatMessage
does not accept a generic any more. This will be fixed in a future
commit, but to avoid merge conflicts just pin for now.

* Pin typescript version

Temporarily pin version as newer versions as stricter and results in
a type error. Will be fixed in future commit after merge.

* Setup workspaces

* Add design-system as a monorepo package

* Remove unused env var DESIGN_SYSTEM_ACCESS_TOKEN

* Fix husky for monorepo setup

* Update netlify.toml

* Add lint script to root package.json

* Add stub readme

* Fix react-intl formatMessage types

* Test netlify.toml in root

* Remove root toml

* Update netlify.toml publish path

* Remove package-lock.json

* Update build for branch/preview builds


Approved-by: Linus Flood
This commit is contained in:
Anton Gunnarsson
2025-02-26 10:36:17 +00:00
committed by Linus Flood
parent 667cab6fb6
commit 80100e7631
2731 changed files with 30986 additions and 23708 deletions

View File

@@ -0,0 +1,71 @@
.modalWrapper {
display: flex;
flex-direction: column;
max-height: 80dvh;
width: 100%;
}
.form {
display: flex;
flex-direction: column;
flex-grow: 1;
overflow-y: auto;
}
.form::-webkit-scrollbar {
display: none;
}
.modalScrollable {
display: flex;
flex-direction: column;
margin-bottom: var(--Spacing-x2);
}
.imageContainer {
position: relative;
width: 100%;
aspect-ratio: 16 / 9;
border-radius: var(--Corner-radius-Medium);
overflow: hidden;
margin-top: var(--Spacing-x1);
flex-shrink: 0;
margin-bottom: var(--Spacing-x3);
}
.image {
object-fit: cover;
}
.price {
display: flex;
gap: var(--Spacing-x1);
align-items: center;
}
.contentContainer {
display: flex;
flex-direction: column;
}
.actionButtons {
display: flex;
gap: var(--Spacing-x4);
justify-content: flex-end;
position: sticky;
bottom: 0;
z-index: 10;
background: var(--UI-Opacity-White-100);
padding-top: var(--Spacing-x2);
border-top: 1px solid var(--Base-Border-Normal);
}
@media screen and (min-width: 768px) {
.modalWrapper {
width: 492px;
}
.imageContainer {
height: 240px;
}
}

View File

@@ -0,0 +1,296 @@
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { FormProvider, useForm } from "react-hook-form"
import { useIntl } from "react-intl"
import { useMediaQuery } from "usehooks-ts"
import { trpc } from "@/lib/trpc/client"
import { useAddAncillaryStore } from "@/stores/my-stay/add-ancillary-flow"
import Image from "@/components/Image"
import Modal from "@/components/Modal"
import Button from "@/components/TempDesignSystem/Button"
import Divider from "@/components/TempDesignSystem/Divider"
import Body from "@/components/TempDesignSystem/Text/Body"
import { toast } from "@/components/TempDesignSystem/Toasts"
import useLang from "@/hooks/useLang"
import { formatPrice } from "@/utils/numberFormatting"
import { generateDeliveryOptions } from "../../utils"
import ConfirmationStep from "../ConfirmationStep"
import DeliveryMethodStep from "../DeliveryDetailsStep"
import { type AncillaryFormData, ancillaryFormSchema } from "../schema"
import SelectQuantityStep from "../SelectQuantityStep"
import styles from "./addAncillaryFlowModal.module.css"
import type { AddAncillaryFlowModalProps } from "@/types/components/myPages/myStay/ancillaries"
type FieldName = keyof AncillaryFormData
const STEP_FIELD_MAP: Record<number, FieldName[]> = {
1: ["quantityWithPoints", "quantityWithCard"],
2: ["deliveryTime"],
3: ["termsAndConditions"],
}
export default function AddAncillaryFlowModal({
isOpen,
onClose,
booking,
user,
}: AddAncillaryFlowModalProps) {
const {
step,
nextStep,
prevStep,
resetStore,
selectedAncillary,
confirmationNumber,
openedFrom,
setGridIsOpen,
} = useAddAncillaryStore()
const intl = useIntl()
const lang = useLang()
const isMobile = useMediaQuery("(max-width: 767px)")
const deliveryTimeOptions = generateDeliveryOptions(booking.checkInDate)
const defaultDeliveryTime = deliveryTimeOptions[0]?.value
const formMethods = useForm<AncillaryFormData>({
defaultValues: {
quantityWithPoints: null,
quantityWithCard: user ? null : 1,
deliveryTime: defaultDeliveryTime,
optionalText: "",
termsAndConditions: false,
},
mode: "onSubmit",
reValidateMode: "onChange",
resolver: zodResolver(ancillaryFormSchema),
})
const { reset, trigger, handleSubmit, formState } = formMethods
const addAncillary = trpc.booking.packages.useMutation({
onSuccess: (data, variables) => {
if (!data) {
toast.error(
intl.formatMessage(
{
id: "Something went wrong. {ancillary} could not be added to your booking!",
},
{ ancillary: selectedAncillary?.title }
)
)
return
}
const description = variables.ancillaryDeliveryTime
? intl.formatMessage(
{
id: "Delivery between {deliveryTime}. Payment will be made on check-in.",
},
{ deliveryTime: variables.ancillaryDeliveryTime }
)
: undefined
toast.success(
intl.formatMessage(
{ id: "{ancillary} added to your booking!" },
{ ancillary: selectedAncillary?.title }
),
{ description }
)
handleClose()
},
onError: () => {
toast.error(
intl.formatMessage(
{
id: "Something went wrong. {ancillary} could not be added to your booking!",
},
{ ancillary: selectedAncillary?.title }
)
)
},
})
const onSubmit = (data: AncillaryFormData) => {
const packages = []
if (data.quantityWithCard) {
packages.push({
code: selectedAncillary!.id,
quantity: data.quantityWithCard,
comment: data.optionalText || undefined,
})
}
if (selectedAncillary?.loyaltyCode && data.quantityWithPoints) {
packages.push({
code: selectedAncillary.loyaltyCode,
quantity: data.quantityWithPoints,
comment: data.optionalText || undefined,
})
}
addAncillary.mutate({
confirmationNumber,
ancillaryComment: data.optionalText ?? "",
ancillaryDeliveryTime: data.deliveryTime ?? undefined,
packages,
language: lang,
})
}
const handleNextStep = async () => {
let fieldsToValidate = []
if (isMobile && step === 1) {
fieldsToValidate = [...STEP_FIELD_MAP[1]]
if (selectedAncillary?.requiresDeliveryTime) {
fieldsToValidate = [...fieldsToValidate, ...STEP_FIELD_MAP[2]]
}
} else if (step === 2) {
fieldsToValidate = selectedAncillary?.requiresDeliveryTime
? STEP_FIELD_MAP[2] || []
: []
} else {
fieldsToValidate = STEP_FIELD_MAP[step] || []
}
if (await trigger(fieldsToValidate)) {
nextStep()
}
}
const handleBack = () => {
if (step > 1) {
prevStep()
} else {
handleClose()
if (openedFrom === "grid") setGridIsOpen(true)
}
}
const handleClose = () => {
reset()
resetStore()
onClose()
}
if (!selectedAncillary) return null
const confirmLabel = intl.formatMessage({ id: "Confirm" })
const continueLabel = intl.formatMessage({ id: "Continue" })
const confirmStep =
isMobile || (!isMobile && !selectedAncillary.requiresDeliveryTime) ? 2 : 3
return (
<Modal
isOpen={isOpen}
onToggle={handleClose}
title={selectedAncillary.title}
>
<div className={styles.modalWrapper}>
<FormProvider {...formMethods}>
<form onSubmit={handleSubmit(onSubmit)} className={styles.form}>
<div className={styles.modalScrollable}>
<div className={styles.imageContainer}>
<Image
className={styles.image}
src={selectedAncillary.imageUrl}
alt={selectedAncillary.title}
fill
/>
</div>
<div className={styles.contentContainer}>
<div className={styles.price}>
<Body textTransform="bold" color="uiTextHighContrast">
{formatPrice(
intl,
selectedAncillary.price.total,
selectedAncillary.price.currency
)}
</Body>
{selectedAncillary.points && (
<>
<Divider variant="vertical" color="subtle" />
<Body textTransform="bold" color="uiTextHighContrast">
{selectedAncillary.points}{" "}
{intl.formatMessage({ id: "points" })}
</Body>
</>
)}
</div>
{selectedAncillary.description && (
<Body asChild color="uiTextHighContrast">
<div
dangerouslySetInnerHTML={{
__html: selectedAncillary.description,
}}
/>
</Body>
)}
</div>
{isMobile ? (
<>
{step === 1 && (
<>
<SelectQuantityStep user={user} />
{selectedAncillary.requiresDeliveryTime && (
<DeliveryMethodStep
deliveryTimeOptions={deliveryTimeOptions}
/>
)}
</>
)}
{step === 2 && <ConfirmationStep />}
</>
) : (
<>
{step === 1 && <SelectQuantityStep user={user} />}
{step === 2 && selectedAncillary.requiresDeliveryTime && (
<DeliveryMethodStep
deliveryTimeOptions={deliveryTimeOptions}
/>
)}
{(step === 3 ||
(step === 2 &&
!selectedAncillary.requiresDeliveryTime)) && (
<ConfirmationStep />
)}
</>
)}
</div>
<div className={styles.actionButtons}>
<Button
type="button"
theme="base"
intent="text"
size="small"
onClick={handleBack}
>
{intl.formatMessage({ id: "Back" })}
</Button>
<Button
type="button"
intent={step === confirmStep ? "primary" : "secondary"}
size="small"
disabled={formState.isSubmitting}
onClick={
step === confirmStep
? () => handleSubmit(onSubmit)()
: handleNextStep
}
>
{step === confirmStep ? confirmLabel : continueLabel}
</Button>
</div>
</form>
</FormProvider>
</div>
</Modal>
)
}

View File

@@ -0,0 +1,25 @@
.modalContent {
display: flex;
flex-direction: column;
gap: var(--Spacing-x2);
}
.price {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--Spacing-x1);
padding: var(--Spacing-x-one-and-half);
background-color: var(--Base-Surface-Secondary-light-Normal);
border-radius: var(--Corner-radius-Medium);
margin-bottom: var(--Spacing-x2);
}
.card {
display: flex;
align-items: center;
gap: var(--Spacing-x1);
padding: var(--Spacing-x2) var(--Spacing-x-one-and-half);
border-radius: var(--Corner-radius-Medium);
background-color: var(--Base-Surface-Subtle-Normal);
}

View File

@@ -0,0 +1,120 @@
import { useFormContext } from "react-hook-form"
import { useIntl } from "react-intl"
import {
bookingTermsAndConditions,
privacyPolicy,
} from "@/constants/currentWebHrefs"
import { useAddAncillaryStore } from "@/stores/my-stay/add-ancillary-flow"
import { CreditCard } from "@/components/Icons"
import Divider from "@/components/TempDesignSystem/Divider"
import Checkbox from "@/components/TempDesignSystem/Form/Checkbox"
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 useLang from "@/hooks/useLang"
import { formatPrice } from "@/utils/numberFormatting"
import styles from "./confirmationStep.module.css"
export default function ConfirmationStep() {
const { watch } = useFormContext()
const { selectedAncillary } = useAddAncillaryStore()
const intl = useIntl()
const lang = useLang()
const quantityWithPoints = watch("quantityWithPoints")
const quantityWithCard = watch("quantityWithCard")
if (!selectedAncillary) {
return null
}
const totalPrice = quantityWithCard
? selectedAncillary.price.total * quantityWithCard
: null
const totalPoints =
quantityWithPoints && selectedAncillary.points
? selectedAncillary.points * quantityWithPoints
: null
return (
<div className={styles.modalContent}>
<header>
<Subtitle type="two">
{intl.formatMessage({
id: "Reserve with Card",
})}
</Subtitle>
</header>
<Body>
{intl.formatMessage({
id: "Payment will be made on check-in. The card will be only used to guarantee the ancillary in case of no-show.",
})}
</Body>
<div className={styles.card}>
<CreditCard color="black" />
<Body textTransform="bold">{"MasterCard"}</Body>
<Body color="uiTextMediumContrast">{"**** 1234"}</Body>
</div>
<Checkbox name="termsAndConditions" registerOptions={{ required: true }}>
<Caption>
{intl.formatMessage(
{
id: "Yes, I accept the general <termsAndConditionsLink>Terms & Conditions</termsAndConditionsLink>, and understand that Scandic will process my personal data in accordance with <privacyPolicyLink>Scandic's Privacy policy</privacyPolicyLink>. There you can learn more about what data we process, your rights and where to turn if you have questions.",
},
{
termsAndConditionsLink: (str) => (
<Link
className={styles.link}
variant="underscored"
href={bookingTermsAndConditions[lang]}
target="_blank"
>
{str}
</Link>
),
privacyPolicyLink: (str) => (
<Link
className={styles.link}
variant="underscored"
href={privacyPolicy[lang]}
target="_blank"
>
{str}
</Link>
),
}
)}
</Caption>
</Checkbox>
<div className={styles.price}>
<Caption>
{intl.formatMessage(
{ id: "<b>Total price</b> (incl VAT)" },
{ b: (str) => <b>{str}</b> }
)}
</Caption>
{totalPrice !== null && (
<Body textTransform="bold" color="uiTextHighContrast">
{formatPrice(intl, totalPrice, selectedAncillary.price.currency)}
</Body>
)}
{totalPoints !== null && (
<div>
<div>
<Divider variant="vertical" color="subtle" />
</div>
<Body textTransform="bold" color="uiTextHighContrast">
{totalPoints} {intl.formatMessage({ id: "points" })}
</Body>
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,15 @@
.selectContainer {
display: flex;
flex-direction: column;
gap: var(--Spacing-x2);
margin-bottom: var(--Spacing-x2);
}
.select {
display: flex;
flex-direction: column;
gap: var(--Spacing-x2);
padding: var(--Spacing-x2) var(--Spacing-x3);
background-color: var(--Base-Background-Primary-Normal);
border-radius: var(--Corner-radius-Medium);
}

View File

@@ -0,0 +1,50 @@
import { useIntl } from "react-intl"
import Input from "@/components/TempDesignSystem/Form/Input"
import Select from "@/components/TempDesignSystem/Form/Select"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import styles from "./deliveryDetailsStep.module.css"
import type { DeliveryMethodStepProps } from "@/types/components/myPages/myStay/ancillaries"
export default function DeliveryMethodStep({
deliveryTimeOptions,
}: DeliveryMethodStepProps) {
const intl = useIntl()
return (
<div className={styles.selectContainer}>
<div className={styles.select}>
<Subtitle type="two">
{intl.formatMessage({ id: "Delivered at:" })}
</Subtitle>
<Select
name="deliveryTime"
label={""}
items={deliveryTimeOptions}
registerOptions={{ required: true }}
isNestedInModal
/>
<Body>
{intl.formatMessage({
id: "All add-ons are delivered at the same time. Changes to delivery times will affect earlier add-ons.",
})}
</Body>
</div>
<div className={styles.select}>
<Input
label={intl.formatMessage({ id: "Other Requests" })}
name="optionalText"
/>
<Caption>
{intl.formatMessage({
id: "Optional",
})}
</Caption>
</div>
</div>
)
}

View File

@@ -0,0 +1,85 @@
import { useFormContext } from "react-hook-form"
import { useIntl } from "react-intl"
import { useAddAncillaryStore } from "@/stores/my-stay/add-ancillary-flow"
import { DiamondIcon } from "@/components/Icons"
import ErrorMessage from "@/components/TempDesignSystem/Form/ErrorMessage"
import Select from "@/components/TempDesignSystem/Form/Select"
import Body from "@/components/TempDesignSystem/Text/Body"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import styles from "./selectQuantityStep.module.css"
import type { SelectQuantityStepProps } from "@/types/components/myPages/myStay/ancillaries"
export default function SelectQuantityStep({ user }: SelectQuantityStepProps) {
const intl = useIntl()
const { selectedAncillary } = useAddAncillaryStore()
const { formState } = useFormContext()
const cardQuantityOptions = Array.from({ length: 7 }, (_, i) => ({
label: `${i}`,
value: i,
}))
const pointsCost = selectedAncillary?.points ?? 0
const currentPoints = user?.membership?.currentPoints ?? 0
const maxAffordable =
pointsCost > 0 ? Math.min(Math.floor(currentPoints / pointsCost), 7) : 0
const pointsQuantityOptions = Array.from(
{ length: maxAffordable + 1 },
(_, i) => ({
label: `${i}`,
value: i,
})
)
const insufficientPoints = currentPoints < pointsCost || currentPoints === 0
const pointsLabel =
insufficientPoints && user
? intl.formatMessage({ id: "Insufficient points" })
: intl.formatMessage({ id: "Select quantity" })
return (
<div className={styles.selectContainer}>
{selectedAncillary?.points && user && (
<div className={styles.select}>
<Subtitle type="two">
{intl.formatMessage({ id: "Pay with points" })}
</Subtitle>
<div className={styles.totalPointsContainer}>
<div className={styles.totalPoints}>
<DiamondIcon />
<Subtitle textTransform="uppercase" type="two">
{intl.formatMessage({ id: "Total points" })}
</Subtitle>
</div>
<Body>{currentPoints}</Body>
</div>
<Select
name="quantityWithPoints"
label={pointsLabel}
items={pointsQuantityOptions}
disabled={!user || insufficientPoints}
isNestedInModal
/>
</div>
)}
<div className={styles.select}>
<Subtitle type="two">
{intl.formatMessage({ id: "Pay with Card" })}
</Subtitle>
<Select
name="quantityWithCard"
label={intl.formatMessage({ id: "Select quantity" })}
items={cardQuantityOptions}
isNestedInModal
/>
</div>
<ErrorMessage errors={formState.errors} name="quantityWithCard" />
</div>
)
}

View File

@@ -0,0 +1,29 @@
.selectContainer {
display: flex;
flex-direction: column;
gap: var(--Spacing-x-quarter);
margin-bottom: var(--Spacing-x2);
}
.select {
display: flex;
flex-direction: column;
gap: var(--Spacing-x1);
padding: var(--Spacing-x2) var(--Spacing-x3);
background-color: var(--Base-Background-Primary-Normal);
border-radius: var(--Corner-radius-Medium);
}
.totalPointsContainer {
display: flex;
justify-content: space-between;
background-color: var(--Scandic-Peach-10);
padding: var(--Spacing-x1) var(--Spacing-x-one-and-half);
border-radius: var(--Corner-radius-Medium);
}
.totalPoints {
display: flex;
gap: var(--Spacing-x1);
align-items: center;
}

View File

@@ -0,0 +1,22 @@
import { z } from "zod"
export const ancillaryFormSchema = z
.object({
quantityWithPoints: z.number().nullable(),
quantityWithCard: z.number().nullable(),
deliveryTime: z.string().nullable().optional(),
optionalText: z.string().optional(),
termsAndConditions: z
.boolean()
.refine((val) => val, "You must accept the terms"),
})
.refine(
(data) =>
(data.quantityWithPoints ?? 0) > 0 || (data.quantityWithCard ?? 0) > 0,
{
message: "You must select at least one quantity",
path: ["quantityWithCard"],
}
)
export type AncillaryFormData = z.infer<typeof ancillaryFormSchema>

View File

@@ -0,0 +1,52 @@
.modalTrigger {
display: none;
}
.modalContent {
width: 100%;
}
.tabs {
display: flex;
gap: var(--Spacing-x1);
padding: var(--Spacing-x3) 0;
flex-wrap: wrap;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(251px, 1fr));
gap: var(--Spacing-x2);
height: 470px;
overflow-y: auto;
padding-right: var(--Spacing-x-one-and-half);
margin-top: var(--Spacing-x2);
}
.chip {
border-radius: 28px;
padding: var(--Spacing-x-one-and-half) var(--Spacing-x2);
border: none;
cursor: pointer;
background: var(--Base-Surface-Subtle-Normal);
}
.chip.selected {
background: var(--Base-Text-High-contrast);
}
@media screen and (min-width: 768px) {
.modalContent {
width: 600px;
}
}
@media screen and (min-width: 1052px) {
.modalContent {
width: 833px;
}
.modalTrigger {
display: block;
}
}

View File

@@ -0,0 +1,91 @@
import { useIntl } from "react-intl"
import { useAddAncillaryStore } from "@/stores/my-stay/add-ancillary-flow"
import { ChevronRightSmallIcon } from "@/components/Icons"
import Modal from "@/components/Modal"
import { AncillaryCard } from "@/components/TempDesignSystem/AncillaryCard"
import Button from "@/components/TempDesignSystem/Button"
import Body from "@/components/TempDesignSystem/Text/Body"
import styles from "./ancillaryGridModal.module.css"
import type {
Ancillary,
AncillaryGridModalProps,
} from "@/types/components/myPages/myStay/ancillaries"
export default function AncillaryGridModal({
ancillaries,
selectedCategory,
setSelectedCategory,
handleCardClick,
}: AncillaryGridModalProps) {
const intl = useIntl()
const { isGridOpen, setGridIsOpen, setOpenedFrom } = useAddAncillaryStore()
const handleClick = (ancillary: Ancillary["ancillaryContent"][number]) => {
handleCardClick(ancillary)
setOpenedFrom("grid")
setGridIsOpen(false)
}
return (
<div className={styles.modalTrigger}>
<Button
theme="base"
variant="icon"
intent="text"
size="small"
onClick={() => setGridIsOpen(true)}
>
{intl.formatMessage({ id: "View all" })}
<ChevronRightSmallIcon
width={20}
height={20}
color="baseButtonTextOnFillNormal"
/>
</Button>
<Modal
isOpen={isGridOpen}
onToggle={() => setGridIsOpen(!isGridOpen)}
title={intl.formatMessage({ id: "Upgrade your stay" })}
>
<div className={styles.modalContent}>
<div className={styles.tabs}>
{ancillaries.map((category) => (
<button
key={category.categoryName}
className={`${styles.chip} ${category.categoryName === selectedCategory ? styles.selected : ""}`}
onClick={() => setSelectedCategory(category.categoryName)}
>
<Body
color={
category.categoryName === selectedCategory
? "pale"
: "baseTextHighContrast"
}
>
{category.categoryName}
</Body>
</button>
))}
</div>
<div className={styles.grid}>
{ancillaries
.find((category) => category.categoryName === selectedCategory)
?.ancillaryContent.map(({ description, ...ancillary }) => (
<div
key={ancillary.id}
onClick={() => handleClick({ description, ...ancillary })}
>
<AncillaryCard key={ancillary.id} ancillary={ancillary} />
</div>
))}
</div>
</div>
</Modal>
</div>
)
}

View File

@@ -0,0 +1,27 @@
.container {
display: flex;
flex-direction: column;
gap: var(--Spacing-x2);
margin: 0 auto;
width: var(--max-width-content);
}
.title {
display: flex;
justify-content: space-between;
}
.ancillaries {
display: none;
}
@media screen and (min-width: 1052px) {
.mobileAncillaries {
display: none;
}
.ancillaries {
display: grid;
grid-template-columns: repeat(4, minmax(251px, 1fr));
gap: var(--Spacing-x2);
}
}

View File

@@ -0,0 +1,126 @@
"use client"
import { useState } from "react"
import { useIntl } from "react-intl"
import { useAddAncillaryStore } from "@/stores/my-stay/add-ancillary-flow"
import { Carousel } from "@/components/Carousel"
import { AncillaryCard } from "@/components/TempDesignSystem/AncillaryCard"
import Title from "@/components/TempDesignSystem/Text/Title"
import AddAncillaryFlowModal from "./AddAncillaryFlow/AddAncillaryFlowModal"
import AncillaryGridModal from "./AncillaryGridModal"
import styles from "./ancillaries.module.css"
import type {
Ancillaries,
AncillariesProps,
Ancillary,
} from "@/types/components/myPages/myStay/ancillaries"
export function Ancillaries({ ancillaries, booking, user }: AncillariesProps) {
const intl = useIntl()
const [selectedCategory, setSelectedCategory] = useState<string | null>(
() => {
return ancillaries?.[0]?.categoryName ?? null
}
)
const { setSelectedAncillary, setConfirmationNumber, setOpenedFrom } =
useAddAncillaryStore()
const [isModalOpen, setModalOpen] = useState(false)
if (!ancillaries?.length) {
return null
}
function mergeAncillaries(
ancillaries: Ancillaries
): Ancillary["ancillaryContent"] {
const uniqueAncillaries = new Map(
ancillaries
.flatMap((category) => category.ancillaryContent)
.map((ancillary) => [ancillary.id, ancillary])
)
return [...uniqueAncillaries.values()]
}
const allAncillaries = mergeAncillaries(ancillaries)
const handleCardClick = (
ancillary: Ancillary["ancillaryContent"][number]
) => {
if (booking?.confirmationNumber) {
setConfirmationNumber(booking.confirmationNumber)
}
setSelectedAncillary(ancillary)
setOpenedFrom("list")
setModalOpen(true)
}
return (
<div className={styles.container}>
<div className={styles.title}>
<Title as="h5">{intl.formatMessage({ id: "Upgrade your stay" })}</Title>
<AncillaryGridModal
ancillaries={ancillaries}
selectedCategory={selectedCategory}
setSelectedCategory={setSelectedCategory}
handleCardClick={handleCardClick}
/>
</div>
<div className={styles.ancillaries}>
{allAncillaries
.slice(0, 4)
.map(({ description, points, ...ancillary }) => {
const ancillaryData = !!user ? { points, ...ancillary } : ancillary
return (
<div
key={ancillary.id}
onClick={() =>
handleCardClick({ description, points, ...ancillary })
}
>
<AncillaryCard key={ancillary.id} ancillary={ancillaryData} />
</div>
)
})}
</div>
<div className={styles.mobileAncillaries}>
<Carousel>
<Carousel.Content className={styles.carouselContainer}>
{allAncillaries.map(({ description, points, ...ancillary }) => {
const ancillaryData = !!user
? { points, ...ancillary }
: ancillary
return (
<Carousel.Item
key={ancillary.id}
onClick={() =>
handleCardClick({ description, points, ...ancillary })
}
>
<AncillaryCard key={ancillary.id} ancillary={ancillaryData} />
</Carousel.Item>
)
})}
</Carousel.Content>
<Carousel.Previous className={styles.navigationButton} />
<Carousel.Next className={styles.navigationButton} />
<Carousel.Dots />
</Carousel>
</div>
<AddAncillaryFlowModal
user={user}
isOpen={isModalOpen}
onClose={() => setModalOpen(false)}
booking={booking}
/>
</div>
)
}

View File

@@ -0,0 +1,11 @@
import { dt } from "@/lib/dt"
export const generateDeliveryOptions = (checkInDate: Date) => {
const start = dt(checkInDate).startOf("day")
const timeSlots = ["16:00-17:00", "17:00-18:00", "18:00-19:00", "19:00-20:00"]
return timeSlots.map((slot) => ({
label: `${start.format("YYYY-MM-DD")} ${slot}`,
value: `${start.format("YYYY-MM-DD")} ${slot}`,
}))
}

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,70 @@
import { useIntl } from "react-intl"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import styles from "../cancelStay.module.css"
import type { Hotel } from "@/types/hotel"
import type { BookingConfirmation } from "@/types/trpc/routers/booking/confirmation"
interface CancelStayConfirmationProps {
hotel: Hotel
booking: BookingConfirmation["booking"]
stayDetails: {
checkInDate: string
checkOutDate: string
nightsText: string
adultsText: string
childrenText: string
}
}
export function CancelStayConfirmation({
hotel,
booking,
stayDetails,
}: CancelStayConfirmationProps) {
const intl = useIntl()
return (
<>
<div className={styles.modalText}>
<Body color="uiTextHighContrast">
{intl.formatMessage(
{
id: "Are you sure you want to cancel your stay at {hotel} from {checkInDate} to {checkOutDate}? This can't be reversed.",
},
{
hotel: hotel.name,
checkInDate: stayDetails.checkInDate,
checkOutDate: stayDetails.checkOutDate,
}
)}
</Body>
<Caption color="uiTextHighContrast">
{intl.formatMessage({ id: "No charges were made." })}
</Caption>
</div>
<div className={styles.priceContainer}>
<div className={styles.info}>
<Caption color="uiTextHighContrast" type="bold">
{intl.formatMessage({ id: "Cancellation cost" })}
</Caption>
<Caption color="uiTextHighContrast">
{stayDetails.nightsText}, {stayDetails.adultsText}
{booking.childrenAges?.length > 0
? `, ${stayDetails.childrenText}`
: ""}
</Caption>
</div>
<div className={styles.price}>
<Subtitle color="burgundy" type="one">
0 {booking.currencyCode}
</Subtitle>
</div>
</div>
</>
)
}

View File

@@ -0,0 +1,55 @@
import { useIntl } from "react-intl"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import styles from "../cancelStay.module.css"
import type { BookingConfirmation } from "@/types/trpc/routers/booking/confirmation"
interface FinalConfirmationProps {
booking: BookingConfirmation["booking"]
stayDetails: {
nightsText: string
adultsText: string
childrenText: string
}
}
export function FinalConfirmation({
booking,
stayDetails,
}: FinalConfirmationProps) {
const intl = useIntl()
return (
<>
<div className={styles.modalText}>
<Body color="uiTextHighContrast">
{intl.formatMessage({
id: "Are you sure you want to continue with the cancellation?",
})}
</Body>
</div>
<div className={styles.priceContainer}>
<div className={styles.info}>
<Caption color="uiTextHighContrast" type="bold">
{intl.formatMessage({ id: "Cancellation cost" })}
</Caption>
<Caption color="uiTextHighContrast">
{stayDetails.nightsText}, {stayDetails.adultsText}
{booking.childrenAges?.length > 0
? `, ${stayDetails.childrenText}`
: ""}
</Caption>
</div>
<div className={styles.price}>
<Subtitle color="burgundy" type="one">
0 {booking.currencyCode}
</Subtitle>
</div>
</div>
</>
)
}

View File

@@ -0,0 +1,24 @@
.modalText {
display: flex;
flex-direction: column;
gap: var(--Spacing-x2);
}
.priceContainer {
display: flex;
padding: var(--Spacing-x2);
background-color: var(--Base-Background-Primary-Normal);
border-radius: var(--Corner-radius-Medium);
align-items: center;
justify-content: flex-end;
}
.info {
border-right: 1px solid var(--Base-Border-Subtle);
padding-right: var(--Spacing-x2);
text-align: right;
}
.price {
padding-left: var(--Spacing-x2);
}

View File

@@ -0,0 +1,86 @@
import { useState } from "react"
import { useIntl } from "react-intl"
import { trpc } from "@/lib/trpc/client"
import { toast } from "@/components/TempDesignSystem/Toasts"
import useLang from "@/hooks/useLang"
import type { CancelStayProps } from ".."
export default function useCancelStay({
booking,
setBookingStatus,
handleCloseModal,
handleBackToManageStay,
}: Omit<CancelStayProps, "hotel">) {
const intl = useIntl()
const lang = useLang()
const [currentStep, setCurrentStep] = useState(1)
const [isLoading, setIsLoading] = useState(false)
const cancelStay = trpc.booking.cancel.useMutation({
onMutate: () => setIsLoading(true),
onSuccess: (result) => {
if (!result) {
toast.error(
intl.formatMessage({
id: "Something went wrong. Please try again later.",
})
)
return
}
setBookingStatus()
toast.success(
intl.formatMessage(
{
id: "Your stay was cancelled. Cancellation cost: 0 {currency}. Were sorry to see that the plans didnt work out",
},
{ currency: booking.currencyCode }
)
)
},
onError: () => {
toast.error(
intl.formatMessage({
id: "Something went wrong. Please try again later.",
})
)
},
onSettled: () => {
handleCloseModal()
},
})
function handleCancelStay() {
if (!booking.confirmationNumber) {
toast.error(
intl.formatMessage({
id: "Something went wrong. Please try again later.",
})
)
return
}
cancelStay.mutate({
confirmationNumber: booking.confirmationNumber,
language: lang,
})
}
function handleCloseCancelStay() {
setCurrentStep(1)
setIsLoading(false)
handleBackToManageStay()
}
return {
currentStep,
isLoading,
handleCancelStay,
handleCloseCancelStay,
handleBack: () => setCurrentStep(1),
handleForward: () => setCurrentStep(2),
}
}

View File

@@ -0,0 +1,87 @@
"use client"
import { useIntl } from "react-intl"
import useLang from "@/hooks/useLang"
import { ModalContent } from "../ManageStay/ModalContent"
import useCancelStay from "./hooks/useCancelStay"
import { CancelStayConfirmation } from "./Confirmation"
import { FinalConfirmation } from "./FinalConfirmation"
import { formatStayDetails } from "./utils"
import type { Hotel } from "@/types/hotel"
import type { BookingConfirmation } from "@/types/trpc/routers/booking/confirmation"
export interface CancelStayProps {
booking: BookingConfirmation["booking"]
hotel: Hotel
setBookingStatus: () => void
handleCloseModal: () => void
handleBackToManageStay: () => void
}
export default function CancelStay({
booking,
hotel,
setBookingStatus,
handleCloseModal,
handleBackToManageStay,
}: CancelStayProps) {
const intl = useIntl()
const lang = useLang()
const {
currentStep,
isLoading,
handleCancelStay,
handleCloseCancelStay,
handleForward,
} = useCancelStay({
booking,
setBookingStatus,
handleCloseModal,
handleBackToManageStay,
})
const stayDetails = formatStayDetails({ booking, lang, intl })
const isFirstStep = currentStep === 1
return (
<>
<ModalContent
title={
isFirstStep
? intl.formatMessage({ id: "Cancel stay" })
: intl.formatMessage({ id: "Confirm cancellation" })
}
onClose={handleCloseModal}
content={
isFirstStep ? (
<CancelStayConfirmation
hotel={hotel}
booking={booking}
stayDetails={stayDetails}
/>
) : (
<FinalConfirmation booking={booking} stayDetails={stayDetails} />
)
}
primaryAction={{
label: isFirstStep
? intl.formatMessage({ id: "Cancel stay" })
: intl.formatMessage({ id: "Confirm cancellation" }),
onClick: isFirstStep ? handleForward : handleCancelStay,
intent: isFirstStep ? "secondary" : "primary",
isLoading: isLoading,
}}
secondaryAction={{
label: isFirstStep
? intl.formatMessage({ id: "Back" })
: intl.formatMessage({ id: "Don't cancel" }),
onClick: isFirstStep ? handleCloseCancelStay : handleCloseModal,
intent: "text",
}}
/>
</>
)
}

View File

@@ -0,0 +1,44 @@
import { dt } from "@/lib/dt"
import type { IntlShape } from "react-intl"
import type { BookingConfirmation } from "@/types/trpc/routers/booking/confirmation"
export function formatStayDetails({
booking,
lang,
intl,
}: {
booking: BookingConfirmation["booking"]
lang: string
intl: IntlShape
}) {
const checkInDate = dt(booking.checkInDate)
.locale(lang)
.format("dddd D MMM YYYY")
const checkOutDate = dt(booking.checkOutDate)
.locale(lang)
.format("dddd D MMM YYYY")
const diff = dt(checkOutDate).diff(checkInDate, "days")
const nightsText = intl.formatMessage(
{ id: "{totalNights, plural, one {# night} other {# nights}}" },
{ totalNights: diff }
)
const adultsText = intl.formatMessage(
{ id: "{totalAdults, plural, one {# adult} other {# adults}}" },
{ totalAdults: booking.adults }
)
const childrenText = intl.formatMessage(
{ id: "{totalChildren, plural, one {# child} other {# children}}" },
{ totalChildren: booking.childrenAges?.length }
)
return {
checkInDate,
checkOutDate,
nightsText,
adultsText,
childrenText,
}
}

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

@@ -0,0 +1,29 @@
"use client"
import { useIntl } from "react-intl"
import { CalendarAddIcon } from "@/components/Icons"
import Button from "@/components/TempDesignSystem/Button"
import styles from "../actionPanel.module.css"
export default function AddToCalendarButton({
onPress,
}: {
onPress: () => void
}) {
const intl = useIntl()
return (
<Button
variant="icon"
intent="text"
theme="base"
className={styles.button}
onPress={onPress}
>
{intl.formatMessage({ id: "Add to calendar" })}
<CalendarAddIcon width={24} height={24} color="burgundy" />
</Button>
)
}

View File

@@ -0,0 +1,42 @@
.actionPanel {
display: flex;
gap: var(--Spacing-x3);
padding: var(--Spacing-x3);
}
.menu {
width: 432px;
display: flex;
flex-direction: column;
gap: var(--Spacing-x2);
}
.actionPanel .menu .button {
width: 100%;
color: var(--Scandic-Brand-Burgundy);
justify-content: space-between !important;
padding: var(--Spacing-x1) 0 !important;
}
.info {
width: 256px;
background-color: var(--Base-Background-Primary-Normal);
padding: var(--Spacing-x3);
text-align: right;
display: flex;
flex-direction: column;
gap: var(--Spacing-x2);
align-items: flex-end;
}
.tag {
text-transform: uppercase;
font-size: 12px;
font-weight: 600;
color: var(--Main-Red-60);
font-family: var(--typography-Caption-Labels-fontFamily);
}
.link {
margin-top: auto;
}

View File

@@ -0,0 +1,149 @@
import { useIntl } from "react-intl"
import { customerService } from "@/constants/currentWebHrefs"
import AddToCalendar from "@/components/HotelReservation/AddToCalendar"
import { generateDateTime } from "@/components/HotelReservation/BookingConfirmation/Header/Actions/helpers"
import {
CalendarIcon,
ChevronRightIcon,
CreditCard,
CrossCircleOutlineIcon,
DownloadIcon,
} from "@/components/Icons"
import Button from "@/components/TempDesignSystem/Button"
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 useLang from "@/hooks/useLang"
import AddToCalendarButton from "./Actions/AddToCalendarButton"
import styles from "./actionPanel.module.css"
import type { EventAttributes } from "ics"
import type { Hotel } from "@/types/hotel"
import type { BookingConfirmation } from "@/types/trpc/routers/booking/confirmation"
export default function ActionPanel({
booking,
hotel,
showCancelButton,
onCancelClick,
}: {
booking: BookingConfirmation["booking"]
hotel: Hotel
showCancelButton: boolean
onCancelClick: () => void
}) {
const intl = useIntl()
const lang = useLang()
const event: EventAttributes = {
busyStatus: "FREE",
categories: ["booking", "hotel", "stay"],
created: generateDateTime(booking.createDateTime),
description: hotel.hotelContent.texts.descriptions?.medium,
end: generateDateTime(booking.checkOutDate),
endInputType: "utc",
geo: {
lat: hotel.location.latitude,
lon: hotel.location.longitude,
},
location: `${hotel.address.streetAddress}, ${hotel.address.zipCode} ${hotel.address.city} ${hotel.address.country}`,
start: generateDateTime(booking.checkInDate),
startInputType: "utc",
status: "CONFIRMED",
title: hotel.name,
url: hotel.contactInformation.websiteUrl,
}
return (
<div className={styles.actionPanel}>
<div className={styles.menu}>
<Button
variant="icon"
onClick={onCancelClick}
intent="text"
className={styles.button}
>
{intl.formatMessage({ id: "Modify dates" })}
<CalendarIcon width={24} height={24} color="burgundy" />
</Button>
<Button
variant="icon"
onClick={onCancelClick}
intent="text"
className={styles.button}
>
{intl.formatMessage({ id: "Guarantee late arrival" })}
<CreditCard width={24} height={24} color="burgundy" />
</Button>
<AddToCalendar
checkInDate={booking.checkInDate}
event={event}
hotelName={hotel.name}
renderButton={(onPress) => <AddToCalendarButton onPress={onPress} />}
/>
<Button
variant="icon"
onClick={onCancelClick}
intent="text"
className={styles.button}
>
{intl.formatMessage({ id: "Download invoice" })}
<DownloadIcon width={24} height={24} color="burgundy" />
</Button>
{showCancelButton && (
<Button
variant="icon"
onClick={onCancelClick}
intent="text"
className={styles.button}
>
{intl.formatMessage({ id: "Cancel stay" })}
<CrossCircleOutlineIcon width={24} height={24} color="burgundy" />
</Button>
)}
</div>
<div className={styles.info}>
<div>
<span className={styles.tag}>
{intl.formatMessage({ id: "Reference number" })}
</span>
<Subtitle color="burgundy" textAlign="right">
{booking.confirmationNumber}
</Subtitle>
</div>
<div className={styles.hotel}>
<Body color="uiTextHighContrast" textAlign="right">
{hotel.name}
</Body>
<Body color="uiTextHighContrast" textAlign="right">
{hotel.address.streetAddress}
</Body>
<Body color="uiTextHighContrast" textAlign="right">
{hotel.address.city}
</Body>
<Body color="uiTextHighContrast" asChild>
<Link href={`tel:${hotel.contactInformation.phoneNumber}`}>
{hotel.contactInformation.phoneNumber}
</Link>
</Body>
</div>
<Link
href={customerService[lang]}
variant="icon"
className={styles.link}
>
<Caption color="burgundy">
{intl.formatMessage({ id: "Customer support" })}
</Caption>
<ChevronRightIcon width={20} height={20} color="burgundy" />
</Link>
</div>
</div>
)
}

View File

@@ -0,0 +1,62 @@
import { CloseLargeIcon } from "@/components/Icons"
import Button from "@/components/TempDesignSystem/Button"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import styles from "./modalContent.module.css"
import type { ReactNode } from "react"
interface ModalContentProps {
title: string
content: ReactNode
primaryAction: {
label: string
onClick: () => void
intent?: "primary" | "secondary" | "text"
isLoading?: boolean
}
secondaryAction: {
label: string
onClick: () => void
intent?: "primary" | "secondary" | "text"
}
onClose: () => void
}
export function ModalContent({
title,
content,
primaryAction,
secondaryAction,
onClose,
}: ModalContentProps) {
return (
<>
<header className={styles.header}>
<Subtitle color="uiTextHighContrast">{title}</Subtitle>
<button onClick={onClose} type="button" className={styles.close}>
<CloseLargeIcon color="uiTextMediumContrast" />
</button>
</header>
<div className={styles.content}>{content}</div>
<footer className={styles.footer}>
<Button
theme="base"
intent={secondaryAction.intent ?? "text"}
color="burgundy"
onClick={secondaryAction.onClick}
>
{secondaryAction.label}
</Button>
<Button
theme="base"
intent={primaryAction.intent ?? "secondary"}
onClick={primaryAction.onClick}
disabled={primaryAction.isLoading}
>
{primaryAction.label}
</Button>
</footer>
</>
)
}

View File

@@ -0,0 +1,34 @@
.content {
width: 640px;
max-width: 100%;
display: flex;
flex-direction: column;
gap: var(--Spacing-x3);
padding: var(--Spacing-x1) var(--Spacing-x3) var(--Spacing-x4);
}
.header {
position: relative;
padding: var(--Spacing-x3) var(--Spacing-x3) 0;
}
.footer {
display: flex;
justify-content: space-between;
width: 100%;
border-top: 1px solid var(--Base-Border-Subtle);
padding: var(--Spacing-x3);
}
.close {
background: none;
border: none;
cursor: pointer;
position: absolute;
display: flex;
align-items: center;
padding: 0;
justify-content: center;
top: 20px;
right: 20px;
}

View File

@@ -0,0 +1,136 @@
"use client"
import { motion } from "framer-motion"
import { useEffect, useState } from "react"
import { Dialog, Modal, ModalOverlay } from "react-aria-components"
import { useIntl } from "react-intl"
import { BookingStatusEnum } from "@/constants/booking"
import { ChevronDownIcon } from "@/components/Icons"
import {
type AnimationState,
AnimationStateEnum,
} from "@/components/Modal/modal"
import { slideFromTop } from "@/components/Modal/motionVariants"
import Button from "@/components/TempDesignSystem/Button"
import CancelStay from "../CancelStay"
import ActionPanel from "./ActionPanel"
import styles from "./modifyModal.module.css"
import type { Hotel } from "@/types/hotel"
import type { BookingConfirmation } from "@/types/trpc/routers/booking/confirmation"
type ActiveView = "actionPanel" | "cancelStay"
export default function ManageStay({
booking,
hotel,
setBookingStatus,
bookingStatus,
}: {
booking: BookingConfirmation["booking"]
hotel: Hotel
setBookingStatus: (status: BookingStatusEnum) => void
bookingStatus: string | null
}) {
const [isOpen, setIsOpen] = useState(false)
const [animation, setAnimation] = useState<AnimationState>(
AnimationStateEnum.visible
)
const [activeView, setActiveView] = useState<ActiveView>("actionPanel")
const intl = useIntl()
const MotionOverlay = motion(ModalOverlay)
const MotionModal = motion(Modal)
const showCancelButton =
bookingStatus !== BookingStatusEnum.Cancelled && booking.isCancelable
useEffect(() => {
if (typeof isOpen === "boolean") {
setAnimation(
isOpen ? AnimationStateEnum.visible : AnimationStateEnum.hidden
)
}
if (isOpen === undefined) {
setAnimation(AnimationStateEnum.unmounted)
}
}, [isOpen])
function modalStateHandler(newAnimationState: AnimationState) {
setAnimation((currentAnimationState) =>
newAnimationState === AnimationStateEnum.hidden &&
currentAnimationState === AnimationStateEnum.hidden
? AnimationStateEnum.unmounted
: currentAnimationState
)
}
function handleClose() {
setIsOpen(false)
setActiveView("actionPanel")
}
function handleBack() {
setActiveView("actionPanel")
}
function renderContent() {
switch (activeView) {
case "cancelStay":
return (
<CancelStay
booking={booking}
hotel={hotel}
setBookingStatus={() =>
setBookingStatus(BookingStatusEnum.Cancelled)
}
handleCloseModal={handleClose}
handleBackToManageStay={handleBack}
/>
)
default:
return (
<ActionPanel
booking={booking}
hotel={hotel}
onCancelClick={() => setActiveView("cancelStay")}
showCancelButton={showCancelButton}
/>
)
}
}
return (
<>
<Button variant="icon" fullWidth onClick={() => setIsOpen(true)}>
{intl.formatMessage({ id: "Manage stay" })}
<ChevronDownIcon width={24} height={24} color="burgundy" />
</Button>
<MotionOverlay
isOpen={isOpen}
className={styles.overlay}
initial={"hidden"}
onAnimationComplete={modalStateHandler}
onOpenChange={handleClose}
isDismissable
>
<MotionModal
className={styles.modal}
initial={"hidden"}
animate={animation}
variants={slideFromTop}
>
<Dialog
className={styles.dialog}
aria-label={intl.formatMessage({ id: "Dialog" })}
>
{renderContent()}
</Dialog>
</MotionModal>
</MotionOverlay>
</>
)
}

View File

@@ -0,0 +1,62 @@
.overlay {
background: rgba(0, 0, 0, 0.5);
height: var(--visual-viewport-height);
position: fixed;
top: 0;
left: 0;
width: 100vw;
z-index: var(--default-modal-overlay-z-index);
}
.modal {
background-color: var(--Base-Surface-Primary-light-Normal);
border-radius: var(--Corner-radius-Medium) var(--Corner-radius-Medium) 0 0;
box-shadow: var(--modal-box-shadow);
width: 100%;
position: absolute;
left: 0;
bottom: 0;
z-index: var(--default-modal-z-index);
}
.dialog {
display: flex;
flex-direction: column;
/* For removing focus outline when modal opens first time */
outline: 0 none;
/* for supporting animations within content */
position: relative;
overflow: hidden;
}
.close {
background: none;
border: none;
cursor: pointer;
position: absolute;
right: var(--Spacing-x2);
width: var(--button-dimension);
height: var(--button-dimension);
display: flex;
align-items: center;
padding: 0;
justify-content: center;
}
@media screen and (min-width: 768px) {
.overlay {
display: flex;
justify-content: center;
align-items: center;
}
.modal {
left: auto;
bottom: auto;
width: auto;
border-radius: var(--Corner-radius-Medium);
max-width: var(--max-width-page);
}
}

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,168 @@
"use client"
import { useState } from "react"
import { useIntl } from "react-intl"
import { BookingStatusEnum } from "@/constants/booking"
import { dt } from "@/lib/dt"
import CrossCircleIcon from "@/components/Icons/CrossCircle"
import Button from "@/components/TempDesignSystem/Button"
import Divider from "@/components/TempDesignSystem/Divider"
import IconChip from "@/components/TempDesignSystem/IconChip"
import Link from "@/components/TempDesignSystem/Link"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import useLang from "@/hooks/useLang"
import { formatPrice } from "@/utils/numberFormatting"
import ManageStay from "../ManageStay"
import styles from "./referenceCard.module.css"
import type { Hotel } from "@/types/hotel"
import type { BookingConfirmation } from "@/types/trpc/routers/booking/confirmation"
export function ReferenceCard({
booking,
hotel,
}: {
booking: BookingConfirmation["booking"]
hotel: Hotel
}) {
const [bookingStatus, setBookingStatus] = useState(booking.reservationStatus)
const intl = useIntl()
const lang = useLang()
const fromDate = dt(booking.checkInDate).locale(lang)
const toDate = dt(booking.checkOutDate).locale(lang)
const isCancelled = bookingStatus === BookingStatusEnum.Cancelled
const showCancelButton = !isCancelled && booking.isCancelable
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}>
{isCancelled
? intl.formatMessage({ id: "Cancellation number" })
: intl.formatMessage({ id: "Reference number" })}
</Subtitle>
<Subtitle color="uiTextHighContrast">
{/* TODO: Implement this: https://scandichotels.atlassian.net/browse/API2-2883 to get correct cancellation number */}
{isCancelled
? booking.linkedReservations[0]?.cancellationNumber
: 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>
{!showCancelButton && (
<div className={styles.referenceRow}>
<IconChip
color={"red"}
icon={<CrossCircleIcon width={20} height={20} color="red" />}
>
<Caption color={"red"}>
<strong>{intl.formatMessage({ id: "Status" })}:</strong>{" "}
{intl.formatMessage({ id: "Cancelled" })}
</Caption>
</IconChip>
</div>
)}
<div className={styles.actionArea}>
<ManageStay
booking={booking}
hotel={hotel}
setBookingStatus={setBookingStatus}
bookingStatus={bookingStatus}
/>
<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,122 @@
"use client"
import { useRouter } from "next/navigation"
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 useLang from "@/hooks/useLang"
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 lang = useLang()
const router = useRouter()
const containerClass = isMobile
? styles.guestDetailsMobile
: styles.guestDetailsDesktop
const isMemberBooking =
booking.guest.membershipNumber === user?.membership?.membershipNumber
function handleModifyGuestDetails() {
if (isMemberBooking) {
const expirationTime = Date.now() + 10 * 60 * 1000
localStorage.setItem(
"myStayReturnRoute",
JSON.stringify({
path: window.location.pathname,
expiry: expirationTime,
})
)
router.push(`/${lang}/scandic-friends/my-pages/profile/edit`)
} else {
console.log("not a member booking") // TODO: Implement non-member booking
}
}
return (
<div className={containerClass}>
{isMemberBooking && (
<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>
{isMemberBooking && (
<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"}
onClick={handleModifyGuestDetails}
>
<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.cancellationText}
</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,86 @@
import { notFound } from "next/navigation"
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 bookingConfirmation = await getBookingConfirmation(reservationId)
if (!bookingConfirmation) {
return notFound()
}
const { booking, hotel, room } = bookingConfirmation
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} />
<Image
className={styles.image}
src={
hotel.gallery?.heroImages[0]?.imageSizes.large ??
room?.images[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}
booking={booking}
user={user}
/>
)}
<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,105 @@
.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;
}
}
@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>
)
}