Merged in feat/SW-1370/Guarantee-my-stay-ancillaries (pull request #1545)

Feat/SW-1370/Guarantee my stay ancillaries

* feat(SW-1370): guarantee for ancillaries

* feat(SW-1370): remove console log

* feat(SW-1370): add translations

* feat(SW-1370): small fix

* feat(SW-1370): fix must be guaranteed

* feat(SW-1370): fix logic and comments pr

* feat(SW-1370): fix comments pr

* feat(SW-1370): fix comments pr

* feat(SW-1370): add translation

* feat(SW-1370): add translation and fix pr comment

* feat(SW-1370): fix pr comment

* feat(SW-1370): fix encoding path refId issue

* feat(SW-1370): refactor AddAncillaryStore usage and introduce context provider

* feat(SW-1370): refactor

* feat(SW-1370): refactor ancillaries

* feat(SW-1370): fix merge


Approved-by: Simon.Emanuelsson
This commit is contained in:
Bianca Widstam
2025-03-21 07:29:04 +00:00
parent 2bc14a6eeb
commit 3c1eee88b1
62 changed files with 1838 additions and 912 deletions

View File

@@ -0,0 +1,18 @@
.buttons {
display: flex;
gap: var(--Spacing-x4);
justify-content: flex-end;
padding: var(--Space-x15) var(--Space-x15) 0;
}
.confirmButtons {
display: flex;
padding: 0 var(--Space-x15);
justify-content: space-between;
align-items: center;
}
.priceButton {
display: flex;
gap: var(--Space-x05);
}

View File

@@ -0,0 +1,136 @@
import { useFormContext, useWatch } from "react-hook-form"
import { useIntl } from "react-intl"
import { useMediaQuery } from "usehooks-ts"
import { Button } from "@scandic-hotels/design-system/Button"
import {
AncillaryStepEnum,
useAddAncillaryStore,
} from "@/stores/my-stay/add-ancillary-flow"
import { ChevronDownSmallIcon, ChevronUpSmallIcon } from "@/components/Icons"
import { type AncillaryFormData,quantitySchema } from "../../schema"
import styles from "./actionButtons.module.css"
import type { ActionButtonsProps } from "@/types/components/myPages/myStay/ancillaries"
export default function ActionButtons({
togglePriceDetails,
isPriceDetailsOpen,
isSubmitting,
}: ActionButtonsProps) {
const {
currentStep,
prevStep,
selectQuantity,
selectDeliveryTime,
selectQuantityAndDeliveryTime,
} = useAddAncillaryStore((state) => ({
currentStep: state.currentStep,
prevStep: state.prevStep,
selectQuantity: state.selectQuantity,
selectDeliveryTime: state.selectDeliveryTime,
selectQuantityAndDeliveryTime: state.selectQuantityAndDeliveryTime,
}))
const isMobile = useMediaQuery("(max-width: 767px)")
const { setError } = useFormContext()
const intl = useIntl()
const isConfirmStep = currentStep === AncillaryStepEnum.confirmation
const confirmLabel = intl.formatMessage({ id: "Confirm" })
const continueLabel = intl.formatMessage({ id: "Continue" })
const quantityWithCard = useWatch<AncillaryFormData>({
name: "quantityWithCard",
})
const quantityWithPoints = useWatch<AncillaryFormData>({
name: "quantityWithPoints",
})
function handleNextStep() {
if (currentStep === AncillaryStepEnum.selectQuantity) {
const validatedQuantity = quantitySchema.safeParse({
quantityWithCard,
quantityWithPoints,
})
if (validatedQuantity.success) {
if (isMobile) {
selectQuantityAndDeliveryTime()
} else {
selectQuantity()
}
} else {
setError("quantityWithCard", validatedQuantity.error.issues[0])
}
} else if (currentStep === AncillaryStepEnum.selectDelivery) {
selectDeliveryTime()
}
}
return (
<div className={isConfirmStep ? styles.confirmButtons : ""}>
{isConfirmStep && (
<Button
type="button"
typography="Body/Supporting text (caption)/smBold"
size="Small"
variant="Text"
onPress={togglePriceDetails}
className={styles.priceButton}
>
{intl.formatMessage({ id: "Price details" })}
{isPriceDetailsOpen ? (
<ChevronUpSmallIcon
width={20}
height={20}
color="baseButtonTextOnFillNormal"
/>
) : (
<ChevronDownSmallIcon
width={20}
height={20}
color="baseButtonTextOnFillNormal"
/>
)}
</Button>
)}
<div className={styles.buttons}>
<Button
typography="Body/Supporting text (caption)/smBold"
type="button"
variant="Text"
size="Small"
color="Primary"
onPress={prevStep}
>
{intl.formatMessage({ id: "Back" })}
</Button>
{isConfirmStep && (
<Button
type="submit"
typography="Body/Supporting text (caption)/smBold"
variant="Primary"
size="Small"
isDisabled={isSubmitting}
form="add-ancillary-form-id"
>
{confirmLabel}
</Button>
)}
{!isConfirmStep && (
<Button
type="button"
typography="Body/Supporting text (caption)/smBold"
variant="Secondary"
size="Small"
isDisabled={isSubmitting}
onPress={handleNextStep}
>
{continueLabel}
</Button>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,98 @@
import { useIntl } from "react-intl"
import { Typography } from "@scandic-hotels/design-system/Typography"
import Divider from "@/components/TempDesignSystem/Divider"
import { formatPrice } from "@/utils/numberFormatting"
import styles from "./priceSummary.module.css"
import type { Ancillary } from "@/types/components/myPages/myStay/ancillaries"
interface PriceSummaryProps {
totalPrice: number | null
totalPoints: number | null
totalUnits: number
selectedAncillary: NonNullable<Ancillary["ancillaryContent"][number]>
}
export default function PriceSummary({
totalPrice,
totalPoints,
totalUnits,
selectedAncillary,
}: PriceSummaryProps) {
const intl = useIntl()
const hasTotalPoints = typeof totalPoints === "number"
const hasTotalPrice = typeof totalPrice === "number"
return (
<div className={styles.container}>
<Typography variant="Body/Paragraph/mdBold">
<h2>{intl.formatMessage({ id: "Summary" })}</h2>
</Typography>
<Divider color="subtle" />
<div className={styles.column}>
<Typography variant="Body/Paragraph/mdBold">
<h2>{selectedAncillary.title}</h2>
</Typography>
<Typography variant="Body/Paragraph/mdBold">
<p>{`X${totalUnits}`}</p>
</Typography>
</div>
<div className={styles.column}>
<Typography variant="Body/Paragraph/mdRegular">
<h2>{intl.formatMessage({ id: "Price including VAT" })}</h2>
</Typography>
<Typography variant="Body/Paragraph/mdRegular">
<h2>
{formatPrice(
intl,
selectedAncillary.price.total,
selectedAncillary.price.currency
)}
</h2>
</Typography>
</div>
<Divider color="subtle" />
<div className={styles.column}>
<Typography variant="Body/Paragraph/mdRegular">
<p>
{intl.formatMessage(
{ id: "<b>Total price</b> (incl VAT)" },
{ b: (str) => <b>{str}</b> }
)}
</p>
</Typography>
<div className={styles.totalPrice}>
{hasTotalPoints && (
<div>
<div>
<Divider variant="vertical" color="subtle" />
</div>
<Typography variant="Body/Paragraph/mdBold">
<p>
{totalPoints} {intl.formatMessage({ id: "points" })}
{hasTotalPrice && "+"}
</p>
</Typography>
</div>
)}
{hasTotalPrice && (
<Typography variant="Body/Paragraph/mdBold">
<p>
{formatPrice(
intl,
totalPrice,
selectedAncillary.price.currency
)}
</p>
</Typography>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,21 @@
.container {
display: flex;
padding: var(--Space-x3);
flex-direction: column;
gap: var(--Space-x2);
align-self: stretch;
border-radius: var(--Corner-radius-lg);
border: 1px solid var(--Border-Divider-Default);
background: var(--Surface-Primary-Default);
margin: var(--Space-x1);
}
.column {
display: flex;
justify-content: space-between;
}
.totalPrice {
display: flex;
align-items: flex-start;
}

View File

@@ -0,0 +1,89 @@
import { useWatch } from "react-hook-form"
import { useIntl } from "react-intl"
import { Typography } from "@scandic-hotels/design-system/Typography"
import {
AncillaryStepEnum,
useAddAncillaryStore,
} from "@/stores/my-stay/add-ancillary-flow"
import Divider from "@/components/TempDesignSystem/Divider"
import { formatPrice } from "@/utils/numberFormatting"
import PriceSummary from "./PriceSummary"
import styles from "./priceDetails.module.css"
interface PriceDetailsProps {
isPriceDetailsOpen: boolean
}
export default function PriceDetails({
isPriceDetailsOpen,
}: PriceDetailsProps) {
const intl = useIntl()
const { currentStep, selectedAncillary } = useAddAncillaryStore((state) => ({
currentStep: state.currentStep,
selectedAncillary: state.selectedAncillary,
}))
const quantityWithPoints = useWatch({ name: "quantityWithPoints" })
const quantityWithCard = useWatch({ name: "quantityWithCard" })
if (!selectedAncillary || currentStep !== AncillaryStepEnum.confirmation) {
return null
}
const totalPrice =
quantityWithCard && selectedAncillary
? selectedAncillary.price.total * quantityWithCard
: null
const totalPoints =
quantityWithPoints && selectedAncillary?.points
? selectedAncillary.points * quantityWithPoints
: null
const totalUnits = (quantityWithCard ?? 0) + (quantityWithPoints ?? 0)
return (
<>
<div className={styles.totalPrice}>
<Typography variant="Body/Paragraph/mdRegular">
<p>
{intl.formatMessage(
{ id: "<b>Total price</b> (incl VAT)" },
{ b: (str) => <b>{str}</b> }
)}
</p>
</Typography>
{totalPrice !== null && (
<Typography variant="Body/Paragraph/mdBold">
<p>
{formatPrice(intl, totalPrice, selectedAncillary.price.currency)}
</p>
</Typography>
)}
{totalPoints !== null && (
<div>
<div>
<Divider variant="vertical" color="subtle" />
</div>
<Typography variant="Body/Paragraph/mdBold">
<p>
{totalPoints} {intl.formatMessage({ id: "points" })}
</p>
</Typography>
</div>
)}
</div>
<Divider color="subtle" />
{isPriceDetailsOpen && (
<PriceSummary
totalPrice={totalPrice}
totalPoints={totalPoints}
totalUnits={totalUnits}
selectedAncillary={selectedAncillary}
/>
)}
</>
)
}

View File

@@ -0,0 +1,9 @@
.totalPrice {
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);
}

View File

@@ -0,0 +1,10 @@
.modalContent {
display: flex;
flex-direction: column;
gap: var(--Spacing-x2);
}
.termsAndConditions {
display: flex;
gap: var(--Space-x1);
}

View File

@@ -0,0 +1,142 @@
import { useIntl } from "react-intl"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { PaymentMethodEnum } from "@/constants/booking"
import {
bookingTermsAndConditions,
privacyPolicy,
} from "@/constants/currentWebHrefs"
import { dt } from "@/lib/dt"
import { useAddAncillaryStore } from "@/stores/my-stay/add-ancillary-flow"
import MySavedCards from "@/components/HotelReservation/EnterDetails/Payment/MySavedCards"
import PaymentOption from "@/components/HotelReservation/EnterDetails/Payment/PaymentOption"
import Alert from "@/components/TempDesignSystem/Alert"
import Checkbox from "@/components/TempDesignSystem/Form/Checkbox"
import Link from "@/components/TempDesignSystem/Link"
import useLang from "@/hooks/useLang"
import styles from "./confirmationStep.module.css"
import type { ConfirmationStepProps } from "@/types/components/myPages/myStay/ancillaries"
import { AlertTypeEnum } from "@/types/enums/alert"
export default function ConfirmationStep({
savedCreditCards,
}: ConfirmationStepProps) {
const intl = useIntl()
const lang = useLang()
const { checkInDate, guaranteeInfo } = useAddAncillaryStore((state) => ({
checkInDate: state.booking.checkInDate,
guaranteeInfo: state.booking.guaranteeInfo,
}))
const refundableDate = dt(checkInDate)
.subtract(1, "day")
.locale(lang)
.format("23:59, dddd, D MMMM YYYY")
return (
<div className={styles.modalContent}>
<Typography variant="Body/Paragraph/mdRegular">
<p>
{intl.formatMessage(
{
id: "All ancillaries are fully refundable until {date}. Time selection and special requests are also modifiable.",
},
{ date: refundableDate }
)}
</p>
</Typography>
<header>
<Typography variant="Title/Subtitle/md">
<h2>
{intl.formatMessage({
id: "Reserve with Card",
})}
</h2>
</Typography>
</header>
<Typography variant="Body/Supporting text (caption)/smRegular">
<p>
{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.",
})}
</p>
</Typography>
{guaranteeInfo ? (
<PaymentOption
name="paymentMethod"
value={PaymentMethodEnum.card}
cardNumber={guaranteeInfo.maskedCard.slice(-4)}
label={intl.formatMessage({ id: "Credit card" })}
/>
) : (
<>
<Alert
type={AlertTypeEnum.Info}
text={intl.formatMessage({
id: "By adding a card you also guarantee your room booking for late arrival.",
})}
/>
{savedCreditCards?.length && (
<MySavedCards savedCreditCards={savedCreditCards} />
)}
<>
{savedCreditCards?.length && (
<Typography variant="Title/Overline/sm">
<h4>{intl.formatMessage({ id: "OTHER" })}</h4>
</Typography>
)}
<PaymentOption
name="paymentMethod"
value={PaymentMethodEnum.card}
label={intl.formatMessage({ id: "Credit card" })}
/>
</>
</>
)}
<div className={styles.termsAndConditions}>
<Checkbox
name="termsAndConditions"
registerOptions={{ required: true }}
topAlign
>
<Typography variant="Body/Supporting text (caption)/smRegular">
<p>
{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) => (
<Typography variant="Link/sm">
<Link
variant="underscored"
href={bookingTermsAndConditions[lang]}
target="_blank"
>
{str}
</Link>
</Typography>
),
privacyPolicyLink: (str) => (
<Typography variant="Link/sm">
<Link
variant="underscored"
href={privacyPolicy[lang]}
target="_blank"
>
{str}
</Link>
</Typography>
),
}
)}
</p>
</Typography>
</Checkbox>
</div>
</div>
)
}

View File

@@ -1,5 +1,6 @@
import { useIntl } from "react-intl"
import { generateDeliveryOptions } from "@/components/HotelReservation/MyStay/Ancillaries/utils"
import Input from "@/components/TempDesignSystem/Form/Input"
import Select from "@/components/TempDesignSystem/Form/Select"
import Body from "@/components/TempDesignSystem/Text/Body"
@@ -8,12 +9,9 @@ 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) {
export default function DeliveryMethodStep() {
const intl = useIntl()
const deliveryTimeOptions = generateDeliveryOptions()
return (
<div className={styles.selectContainer}>

View File

@@ -0,0 +1,26 @@
import {
AncillaryStepEnum,
useAddAncillaryStore,
} from "@/stores/my-stay/add-ancillary-flow"
import ConfirmationStep from "../ConfirmationStep"
import DeliveryMethodStep from "../DeliveryDetailsStep"
import SelectAncillaryStep from "../SelectAncillaryStep"
import SelectQuantityStep from "../SelectQuantityStep"
import type { StepsProps } from "@/types/components/myPages/myStay/ancillaries"
export default function Desktop({ user, savedCreditCards }: StepsProps) {
const currentStep = useAddAncillaryStore((state) => state.currentStep)
if (currentStep === AncillaryStepEnum.selectAncillary) {
return <SelectAncillaryStep />
}
if (currentStep === AncillaryStepEnum.selectQuantity) {
return <SelectQuantityStep user={user} />
}
if (currentStep === AncillaryStepEnum.selectDelivery) {
return <DeliveryMethodStep />
}
return <ConfirmationStep savedCreditCards={savedCreditCards} />
}

View File

@@ -0,0 +1,27 @@
import {
AncillaryStepEnum,
useAddAncillaryStore,
} from "@/stores/my-stay/add-ancillary-flow"
import ConfirmationStep from "../ConfirmationStep"
import DeliveryMethodStep from "../DeliveryDetailsStep"
import SelectQuantityStep from "../SelectQuantityStep"
import type { StepsProps } from "@/types/components/myPages/myStay/ancillaries"
export default function Mobile({ user, savedCreditCards }: StepsProps) {
const { currentStep, selectedAncillary } = useAddAncillaryStore((state) => ({
currentStep: state.currentStep,
selectedAncillary: state.selectedAncillary,
}))
if (currentStep === AncillaryStepEnum.selectQuantity) {
return (
<>
<SelectQuantityStep user={user} />
{selectedAncillary?.requiresDeliveryTime && <DeliveryMethodStep />}
</>
)
}
return <ConfirmationStep savedCreditCards={savedCreditCards} />
}

View File

@@ -0,0 +1,51 @@
import { useIntl } from "react-intl"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { useAddAncillaryStore } from "@/stores/my-stay/add-ancillary-flow"
import WrappedAncillaryCard from "../../../WrappedAncillaryCard"
import styles from "./selectAncillaryStep.module.css"
export default function SelectAncillaryStep() {
const {
ancillariesBySelectedCategory,
selectedCategory,
categories,
selectCategory,
} = useAddAncillaryStore((state) => ({
categories: state.categories,
selectedCategory: state.selectedCategory,
ancillariesBySelectedCategory: state.ancillariesBySelectedCategory,
selectCategory: state.selectCategory,
}))
const intl = useIntl()
return (
<div className={styles.container}>
<div className={styles.tabs}>
{categories.map((categoryName) => (
<button
key={categoryName}
className={`${styles.chip} ${categoryName === selectedCategory ? styles.selected : ""}`}
onClick={() => selectCategory(categoryName)}
>
<Typography variant="Body/Supporting text (caption)/smRegular">
<p>
{categoryName
? categoryName
: intl.formatMessage({ id: "Other" })}
</p>
</Typography>
</button>
))}
</div>
<div className={styles.grid}>
{ancillariesBySelectedCategory.map((ancillary) => (
<WrappedAncillaryCard key={ancillary.id} ancillary={ancillary} />
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,34 @@
.container {
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: var(--Corner-radius-Rounded);
padding: calc(var(--Space-x1) + var(--Space-x025)) var(--Space-x2);
cursor: pointer;
border: 1px solid var(--Border-Interactive-Default);
color: var(--Text-Default);
background-color: var(--Background-Secondary);
}
.chip.selected {
background: var(--Surface-Brand-Primary-3-Default);
color: var(--Text-Inverted);
}

View File

@@ -1,13 +1,13 @@
import { useFormContext } from "react-hook-form"
import { useIntl } from "react-intl"
import { Typography } from "@scandic-hotels/design-system/Typography"
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"
@@ -15,8 +15,12 @@ import type { SelectQuantityStepProps } from "@/types/components/myPages/myStay/
export default function SelectQuantityStep({ user }: SelectQuantityStepProps) {
const intl = useIntl()
const { selectedAncillary } = useAddAncillaryStore()
const { formState } = useFormContext()
const selectedAncillary = useAddAncillaryStore(
(state) => state.selectedAncillary
)
const {
formState: { errors },
} = useFormContext()
const cardQuantityOptions = Array.from({ length: 7 }, (_, i) => ({
label: `${i}`,
@@ -47,39 +51,41 @@ export default function SelectQuantityStep({ user }: SelectQuantityStepProps) {
<div className={styles.selectContainer}>
{selectedAncillary?.points && user && (
<div className={styles.select}>
<Subtitle type="two">
{intl.formatMessage({ id: "Pay with points" })}
</Subtitle>
<Typography variant="Title/Subtitle/md">
<h2>{intl.formatMessage({ id: "Pay with points" })}</h2>
</Typography>
<div className={styles.totalPointsContainer}>
<div className={styles.totalPoints}>
<DiamondIcon />
<Subtitle textTransform="uppercase" type="two">
{intl.formatMessage({ id: "Total points" })}
</Subtitle>
<Typography variant="Title/Overline/sm">
<h2>{intl.formatMessage({ id: "Total points" })}</h2>
</Typography>
</div>
<Body>{currentPoints}</Body>
<Typography variant="Body/Paragraph/mdRegular">
<p>{currentPoints}</p>
</Typography>
</div>
<Select
name="quantityWithPoints"
label={pointsLabel}
items={pointsQuantityOptions}
disabled={!user || insufficientPoints}
disabled={insufficientPoints}
isNestedInModal
/>
</div>
)}
<div className={styles.select}>
<Subtitle type="two">
{intl.formatMessage({ id: "Pay with Card" })}
</Subtitle>
<Typography variant="Title/Subtitle/md">
<h2> {intl.formatMessage({ id: "Pay with Card" })}</h2>
</Typography>
<Select
name="quantityWithCard"
label={intl.formatMessage({ id: "Select quantity" })}
items={cardQuantityOptions}
isNestedInModal
/>
<ErrorMessage errors={errors} name="quantityWithCard" />
</div>
<ErrorMessage errors={formState.errors} name="quantityWithCard" />
</div>
)
}

View File

@@ -0,0 +1,30 @@
.selectContainer {
display: flex;
flex-direction: column;
gap: var(--Space-x025);
margin-bottom: var(--Space-x2);
}
.select {
display: flex;
flex-direction: column;
gap: var(--Space-x1);
padding: var(--Space-x2) var(--Space-x3);
background-color: var(--Surface-Primary-OnSurface-Default);
border-radius: var(--Corner-radius-Medium);
margin-bottom: var(--Spacing-x1);
}
.totalPointsContainer {
display: flex;
justify-content: space-between;
background-color: var(--Surface-Brand-Primary-2-OnSurface-Accent);
padding: var(--Space-x1) var(--Space-x15);
border-radius: var(--Corner-radius-Medium);
}
.totalPoints {
display: flex;
gap: var(--Space-x15);
align-items: center;
}

View File

@@ -0,0 +1,12 @@
import { useMediaQuery } from "usehooks-ts"
import Desktop from "./Desktop"
import Mobile from "./Mobile"
import type { StepsProps } from "@/types/components/myPages/myStay/ancillaries"
export default function Steps(props: StepsProps) {
const isMobile = useMediaQuery("(max-width: 767px)")
return isMobile ? <Mobile {...props} /> : <Desktop {...props} />
}

View File

@@ -1,7 +1,7 @@
.modalWrapper {
display: flex;
flex-direction: column;
max-height: 80dvh;
max-height: 70dvh;
width: 100%;
}
@@ -39,7 +39,7 @@
.price {
display: flex;
gap: var(--Spacing-x1);
gap: var(--Spacing-x2);
align-items: center;
}
@@ -48,24 +48,53 @@
flex-direction: column;
}
.actionButtons {
.confirmStep {
display: flex;
gap: var(--Spacing-x4);
justify-content: flex-end;
flex-direction: column;
justify-content: space-between;
position: sticky;
border-radius: var(--Corner-radius-Medium);
bottom: 0;
z-index: 10;
background: var(--Surface-Primary-OnSurface-Default);
border-top: 1px solid var(--Base-Border-Normal);
padding-bottom: var(--Space-x15);
}
.description {
display: flex;
margin: var(--Spacing-x2) 0;
}
.actionButtons {
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);
padding-bottom: var(--Space-x025);
}
.divider {
display: flex;
gap: var(--Space-x2);
height: 24px;
}
@media screen and (min-width: 768px) {
.modalWrapper {
width: 492px;
}
.selectAncillarycontainer {
width: 600px;
}
.imageContainer {
height: 240px;
}
}
@media screen and (min-width: 1052px) {
.selectAncillarycontainer {
width: 833px;
}
}

View File

@@ -1,63 +1,70 @@
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { usePathname, useRouter, useSearchParams } from "next/navigation"
import { useEffect, useState } from "react"
import { FormProvider, useForm } from "react-hook-form"
import { useIntl } from "react-intl"
import { useMediaQuery } from "usehooks-ts"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { PaymentMethodEnum } from "@/constants/booking"
import { guaranteeCallback } from "@/constants/routes/hotelReservation"
import { env } from "@/env/client"
import { trpc } from "@/lib/trpc/client"
import { useAddAncillaryStore } from "@/stores/my-stay/add-ancillary-flow"
import {
AncillaryStepEnum,
useAddAncillaryStore,
} from "@/stores/my-stay/add-ancillary-flow"
import Image from "@/components/Image"
import LoadingSpinner from "@/components/LoadingSpinner"
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 { useGuaranteeBooking } from "@/hooks/booking/useGuaranteeBooking"
import useLang from "@/hooks/useLang"
import { formatPrice } from "@/utils/numberFormatting"
import { generateDeliveryOptions } from "../../utils"
import ConfirmationStep from "../ConfirmationStep"
import DeliveryMethodStep from "../DeliveryDetailsStep"
import {
clearAncillarySessionData,
generateDeliveryOptions,
getAncillarySessionData,
setAncillarySessionData,
} from "../../utils"
import { type AncillaryFormData, ancillaryFormSchema } from "../schema"
import SelectQuantityStep from "../SelectQuantityStep"
import ActionButtons from "./ActionButtons"
import PriceDetails from "./PriceDetails"
import Steps from "./Steps"
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,
savedCreditCards,
refId,
}: AddAncillaryFlowModalProps) {
const {
step,
nextStep,
prevStep,
resetStore,
selectedAncillary,
confirmationNumber,
openedFrom,
setGridIsOpen,
} = useAddAncillaryStore()
const { currentStep, selectedAncillary, closeModal } = useAddAncillaryStore(
(state) => ({
currentStep: state.currentStep,
selectedAncillary: state.selectedAncillary,
closeModal: state.closeModal,
})
)
const intl = useIntl()
const lang = useLang()
const isMobile = useMediaQuery("(max-width: 767px)")
const router = useRouter()
const searchParams = useSearchParams()
const pathname = usePathname()
const deliveryTimeOptions = generateDeliveryOptions(booking.checkInDate)
const [isPriceDetailsOpen, setIsPriceDetailsOpen] = useState(false)
const guaranteeRedirectUrl = `${env.NEXT_PUBLIC_NODE_ENV === "development" ? `http://localhost:${env.NEXT_PUBLIC_PORT}` : ""}${guaranteeCallback(lang)}`
const deliveryTimeOptions = generateDeliveryOptions()
const defaultDeliveryTime = deliveryTimeOptions[0]?.value
const defaultDeliveryTime = deliveryTimeOptions[0].value
const formMethods = useForm<AncillaryFormData>({
defaultValues: {
@@ -66,233 +73,238 @@ export default function AddAncillaryFlowModal({
deliveryTime: defaultDeliveryTime,
optionalText: "",
termsAndConditions: false,
paymentMethod: booking.guaranteeInfo
? PaymentMethodEnum.card
: savedCreditCards?.length
? savedCreditCards[0].id
: PaymentMethodEnum.card,
},
mode: "onSubmit",
mode: "onChange",
reValidateMode: "onChange",
resolver: zodResolver(ancillaryFormSchema),
})
const { reset, trigger, handleSubmit, formState } = formMethods
const ancillaryErrorMessage = intl.formatMessage(
{
id: "Something went wrong. {ancillary} could not be added to your booking!",
},
{ ancillary: selectedAncillary?.title }
)
function togglePriceDetails() {
setIsPriceDetailsOpen((isOpen) => !isOpen)
}
const utils = trpc.useUtils()
const addAncillary = trpc.booking.packages.useMutation({
onSuccess: (data, variables) => {
if (!data) {
toast.error(
if (data) {
clearAncillarySessionData()
closeModal()
utils.booking.confirmation.invalidate({
confirmationNumber: variables.confirmationNumber,
})
toast.success(
intl.formatMessage(
{
id: "Something went wrong. {ancillary} could not be added to your booking!",
},
{ id: "{ancillary} added to your booking!" },
{ ancillary: selectedAncillary?.title }
)
)
return
} else {
toast.error(ancillaryErrorMessage)
}
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 }
)
)
toast.error(ancillaryErrorMessage)
},
})
const { guaranteeBooking, isLoading } = useGuaranteeBooking({
confirmationNumber: booking.confirmationNumber,
})
const onSubmit = (data: AncillaryFormData) => {
const packages = []
if (data.quantityWithCard) {
packages.push({
code: selectedAncillary!.id,
quantity: data.quantityWithCard,
comment: data.optionalText || undefined,
if (!data.termsAndConditions) {
formMethods.setError("termsAndConditions", {
message: "You must accept the terms",
})
return
}
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,
setAncillarySessionData({
formData: data,
selectedAncillary,
})
}
const handleNextStep = async () => {
let fieldsToValidate = []
if (isMobile && step === 1) {
fieldsToValidate = [...STEP_FIELD_MAP[1]]
if (selectedAncillary?.requiresDeliveryTime) {
fieldsToValidate = [...fieldsToValidate, ...STEP_FIELD_MAP[2]]
if (booking.guaranteeInfo) {
const packages = []
if (selectedAncillary?.id && data.quantityWithCard) {
packages.push({
code: selectedAncillary.id,
quantity: data.quantityWithCard,
comment: data.optionalText || undefined,
})
}
} else if (step === 2) {
fieldsToValidate = selectedAncillary?.requiresDeliveryTime
? STEP_FIELD_MAP[2] || []
: []
if (selectedAncillary?.loyaltyCode && data.quantityWithPoints) {
packages.push({
code: selectedAncillary.loyaltyCode,
quantity: data.quantityWithPoints,
comment: data.optionalText || undefined,
})
}
addAncillary.mutate({
confirmationNumber: booking.confirmationNumber,
ancillaryComment: data.optionalText,
ancillaryDeliveryTime: selectedAncillary?.requiresDeliveryTime
? data.deliveryTime
: undefined,
packages,
language: lang,
})
} else {
fieldsToValidate = STEP_FIELD_MAP[step] || []
}
if (await trigger(fieldsToValidate)) {
nextStep()
const savedCreditCard = savedCreditCards?.find(
(card) => card.id === data.paymentMethod
)
if (booking.confirmationNumber) {
const card = savedCreditCard
? {
alias: savedCreditCard.alias,
expiryDate: savedCreditCard.expirationDate,
cardType: savedCreditCard.cardType,
}
: undefined
guaranteeBooking.mutate({
confirmationNumber: booking.confirmationNumber,
language: lang,
...(card && { card }),
success: `${guaranteeRedirectUrl}?status=success&RefId=${encodeURIComponent(refId)}&ancillary=1`,
error: `${guaranteeRedirectUrl}?status=error&RefId=${encodeURIComponent(refId)}&ancillary=1`,
cancel: `${guaranteeRedirectUrl}?status=cancel&RefId=${encodeURIComponent(refId)}&ancillary=1`,
})
} else {
toast.error(
intl.formatMessage({
id: "Something went wrong!",
})
)
}
}
}
const handleBack = () => {
if (step > 1) {
prevStep()
} else {
handleClose()
if (openedFrom === "grid") setGridIsOpen(true)
useEffect(() => {
const errorCode = searchParams.get("errorCode")
const ancillary = searchParams.get("ancillary")
if ((errorCode && ancillary) || errorCode === "AncillaryFailed") {
const queryParams = new URLSearchParams(searchParams.toString())
if (ancillary) {
queryParams.delete("ancillary")
}
queryParams.delete("errorCode")
const savedData = getAncillarySessionData()
if (savedData?.formData) {
formMethods.reset(savedData.formData)
}
router.replace(`${pathname}?${queryParams.toString()}`)
}
}, [searchParams, pathname, formMethods, router])
if (isLoading) {
return (
<div className={styles.loading}>
<LoadingSpinner />
</div>
)
}
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
const modalTitle =
currentStep === AncillaryStepEnum.selectAncillary
? intl.formatMessage({ id: "Upgrade your stay" })
: selectedAncillary?.title
return (
<Modal
isOpen={isOpen}
onToggle={handleClose}
title={selectedAncillary.title}
>
<div className={styles.modalWrapper}>
<Modal isOpen={true} onToggle={closeModal} title={modalTitle}>
<div
className={`${styles.modalWrapper} ${currentStep === AncillaryStepEnum.selectAncillary ? styles.selectAncillarycontainer : ""}`}
>
<FormProvider {...formMethods}>
<form onSubmit={handleSubmit(onSubmit)} className={styles.form}>
<form
onSubmit={formMethods.handleSubmit(onSubmit)}
className={styles.form}
id="add-ancillary-form-id"
>
<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">
{intl.formatMessage(
{ id: "{value} points" },
{
value: selectedAncillary.points,
}
{selectedAncillary && (
<>
<div className={styles.imageContainer}>
<Image
className={styles.image}
src={selectedAncillary.imageUrl}
alt={selectedAncillary.title}
fill
/>
</div>
{currentStep !== AncillaryStepEnum.confirmation && (
<div className={styles.contentContainer}>
<div className={styles.price}>
<Typography variant="Body/Paragraph/mdBold">
<p>
{formatPrice(
intl,
selectedAncillary.price.total,
selectedAncillary.price.currency
)}
</p>
</Typography>
{selectedAncillary.points && (
<div className={styles.divider}>
<Divider variant="vertical" color="subtle" />
<Typography variant="Body/Paragraph/mdBold">
<p>
{intl.formatMessage(
{ id: "{value} points" },
{
value: selectedAncillary.points,
}
)}
</p>
</Typography>
</div>
)}
</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.description}>
{selectedAncillary.description && (
<Typography variant="Body/Paragraph/mdRegular">
<p
dangerouslySetInnerHTML={{
__html: selectedAncillary.description,
}}
></p>
</Typography>
)}
</div>
</div>
)}
</>
)}
</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>
<Steps user={user} savedCreditCards={savedCreditCards} />
</div>
</form>
{currentStep === AncillaryStepEnum.selectAncillary ? null : (
<div
className={
currentStep === AncillaryStepEnum.confirmation
? styles.confirmStep
: styles.actionButtons
}
>
<PriceDetails isPriceDetailsOpen={isPriceDetailsOpen} />
<ActionButtons
isPriceDetailsOpen={isPriceDetailsOpen}
togglePriceDetails={togglePriceDetails}
isSubmitting={addAncillary.isPending || isLoading}
/>
</div>
)}
</FormProvider>
</div>
</Modal>

View File

@@ -0,0 +1,8 @@
import { useAddAncillaryStore } from "@/stores/my-stay/add-ancillary-flow"
export default function AncillaryFlowModalWrapper({
children,
}: React.PropsWithChildren) {
const isOpen = useAddAncillaryStore((state) => state.isOpen)
return isOpen ? <>{children}</> : null
}

View File

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

View File

@@ -1,127 +0,0 @@
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 }}
topAlign
>
<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">
{intl.formatMessage(
{ id: "{value} points" },
{ value: totalPoints }
)}
</Body>
</div>
)}
</div>
</div>
)
}

View File

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

View File

@@ -0,0 +1,21 @@
import { useAddAncillaryStore } from "@/stores/my-stay/add-ancillary-flow"
import { AncillaryCard } from "@/components/TempDesignSystem/AncillaryCard"
import type { SelectedAncillary } from "@/types/components/myPages/myStay/ancillaries"
interface WrappedAncillaryProps {
ancillary: SelectedAncillary
}
export default function WrappedAncillaryCard({
ancillary,
}: WrappedAncillaryProps) {
const { description, ...ancillaryWithoutDescription } = ancillary
const selectAncillary = useAddAncillaryStore((state) => state.selectAncillary)
return (
<div role="button" onClick={() => selectAncillary(ancillary)}>
<AncillaryCard ancillary={ancillaryWithoutDescription} />
</div>
)
}

View File

@@ -1,15 +1,14 @@
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"),
})
import { nullableStringValidator } from "@/utils/zod/stringValidator"
const quantitySchemaWithoutRefine = z.object({
quantityWithPoints: z.number().nullable(),
quantityWithCard: z.number().nullable(),
})
export const quantitySchema = z
.object({})
.merge(quantitySchemaWithoutRefine)
.refine(
(data) =>
(data.quantityWithPoints ?? 0) > 0 || (data.quantityWithCard ?? 0) > 0,
@@ -19,4 +18,21 @@ export const ancillaryFormSchema = z
}
)
export type AncillaryFormData = z.infer<typeof ancillaryFormSchema>
export const ancillaryFormSchema = z
.object({
deliveryTime: z.string(),
optionalText: z.string(),
termsAndConditions: z.boolean(),
paymentMethod: nullableStringValidator,
})
.merge(quantitySchemaWithoutRefine)
.refine(
(data) =>
(data.quantityWithPoints ?? 0) > 0 || (data.quantityWithCard ?? 0) > 0,
{
message: "You must select at least one quantity",
path: ["quantityWithCard"],
}
)
export type AncillaryFormData = z.output<typeof ancillaryFormSchema>