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:
committed by
Linus Flood
parent
667cab6fb6
commit
80100e7631
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}`,
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import Image from "@/components/Image"
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
|
||||
import styles from "./summaryCard.module.css"
|
||||
|
||||
interface SummaryCardProps {
|
||||
title: string
|
||||
image: {
|
||||
src: string
|
||||
alt: string
|
||||
}
|
||||
texts: string[]
|
||||
supportingText?: string
|
||||
links?: {
|
||||
href: string
|
||||
text: string
|
||||
icon: React.ReactNode
|
||||
}[]
|
||||
chip?: React.ReactNode
|
||||
}
|
||||
|
||||
export default function SummaryCard({
|
||||
title,
|
||||
texts,
|
||||
image,
|
||||
supportingText,
|
||||
links,
|
||||
chip,
|
||||
}: SummaryCardProps) {
|
||||
return (
|
||||
<div className={styles.card}>
|
||||
<div className={styles.image}>
|
||||
<Image src={image.src} alt={image.alt} width={152} height={152} />
|
||||
</div>
|
||||
<div className={styles.content}>
|
||||
<div className={styles.topContent}>
|
||||
<Body textTransform="bold" color="uiTextHighContrast">
|
||||
{title}
|
||||
</Body>
|
||||
{texts.map((text) => (
|
||||
<Body color="uiTextHighContrast" key={text}>
|
||||
{text}
|
||||
</Body>
|
||||
))}
|
||||
</div>
|
||||
{supportingText && (
|
||||
<Caption color="uiTextPlaceholder">{supportingText}</Caption>
|
||||
)}
|
||||
<div className={styles.bottomContent}>
|
||||
{chip}
|
||||
{links && (
|
||||
<div className={styles.links}>
|
||||
{links.map((link) => (
|
||||
<Caption asChild type="bold" color="burgundy" key={link.href}>
|
||||
<Link
|
||||
href={link.href}
|
||||
target="_blank"
|
||||
color="burgundy"
|
||||
className={styles.link}
|
||||
>
|
||||
{link.icon}
|
||||
{link.text}
|
||||
</Link>
|
||||
</Caption>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.card {
|
||||
align-items: flex-start;
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
|
||||
.image {
|
||||
width: 152px;
|
||||
height: 152px;
|
||||
|
||||
border-radius: var(--Corner-radius-Medium);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.image {
|
||||
background-color: var(--Base-Surface-Secondary-light-Normal);
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.topContent {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.bottomContent {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.links {
|
||||
display: flex;
|
||||
gap: var(--Spacing-x2);
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--Spacing-x-half);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
.bookingSummary {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x5);
|
||||
}
|
||||
|
||||
.bookingSummaryContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 80px;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.bookingSummaryContent {
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
|
||||
.toast {
|
||||
width: var(--max-width-content);
|
||||
margin: 0 auto;
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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}. We’re sorry to see that the plans didn’t 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),
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import SkeletonShimmer from "@/components/SkeletonShimmer"
|
||||
|
||||
import styles from "./myStay.module.css"
|
||||
|
||||
export async function MyStaySkeleton() {
|
||||
return (
|
||||
<div className={styles.content}>
|
||||
<div className={styles.headerSkeleton}>
|
||||
<SkeletonShimmer width={"100px"} height="20px" />
|
||||
<SkeletonShimmer width={"450px"} height="50px" />
|
||||
<SkeletonShimmer width={"200px"} height="30px" />
|
||||
</div>
|
||||
<div className={styles.cardSkeleton}>
|
||||
<SkeletonShimmer width="590px" height="380px" />
|
||||
</div>
|
||||
<div className={styles.section}>
|
||||
<SkeletonShimmer width={"200px"} height="30px" />
|
||||
<div className={styles.ancillariesSkeleton}>
|
||||
<SkeletonShimmer width="280px" height="200px" />
|
||||
<SkeletonShimmer width="280px" height="200px" />
|
||||
<SkeletonShimmer width="280px" height="200px" />
|
||||
<SkeletonShimmer width="280px" height="200px" />
|
||||
<SkeletonShimmer width="280px" height="200px" />
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.section}>
|
||||
<SkeletonShimmer width={"200px"} height="30px" />
|
||||
<div className={styles.roomSkeleton}>
|
||||
<SkeletonShimmer width="100%" height="700px" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user