Merged in feat/SW-1281-ancillaries-add-flow (pull request #1399)

Feat/SW-1281 ancillaries add flow

* feat(SW-1546): update design

* feat(SW-1546): show points only if logged in

* feat(SW-1546): always show points

* feat(SW-1281): ancillary add flow initial

* feat(SW-1546): add api call

* feat(SW-1281): refactor naming and break out components

* feat(SW-1281): handle back button

* feat(SW-1281): make mobile cards clickable

* feat(SW-1281): refactor spread ancillaries

* feat(SW-1281): add deliverytimes

* feat(SW-1281): rebase master

* feat(SW-1281): add design for logged in or not

* feat(SW-1281): add design

* feat(SW-1281): add mobile design

* feat(SW-1281): fix carousel

* feat(SW-1281): show deliverytime only if ancillary has not been added

* feat(SW-1281): add design

* feat(SW-1281): add translations

* feat(SW-1281): add translations

* feat(SW-1281): add translations

* feat(SW-1281): base dates on check in date only

* feat(SW-1281): fix show correct toast when no valid data

* feat(SW-1281): hande logic if deliverytime is not required

* feat(SW-1281): fix max width for mobile

* feat(SW-1281): refactor after pr comment


Approved-by: Niclas Edenvin
Approved-by: Linus Flood
This commit is contained in:
Bianca Widstam
2025-02-26 07:20:45 +00:00
committed by Linus Flood
parent 341f0c54ed
commit 541b91e34c
32 changed files with 1208 additions and 129 deletions

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,120 @@
import { useFormContext } from "react-hook-form"
import { useIntl } from "react-intl"
import {
bookingTermsAndConditions,
privacyPolicy,
} from "@/constants/currentWebHrefs"
import { useAddAncillaryStore } from "@/stores/my-stay/add-ancillary-flow"
import { CreditCard } from "@/components/Icons"
import Divider from "@/components/TempDesignSystem/Divider"
import Checkbox from "@/components/TempDesignSystem/Form/Checkbox"
import Link from "@/components/TempDesignSystem/Link"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import useLang from "@/hooks/useLang"
import { formatPrice } from "@/utils/numberFormatting"
import styles from "./confirmationStep.module.css"
export default function ConfirmationStep() {
const { watch } = useFormContext()
const { selectedAncillary } = useAddAncillaryStore()
const intl = useIntl()
const lang = useLang()
const quantityWithPoints = watch("quantityWithPoints")
const quantityWithCard = watch("quantityWithCard")
if (!selectedAncillary) {
return null
}
const totalPrice = quantityWithCard
? selectedAncillary.price.total * quantityWithCard
: null
const totalPoints =
quantityWithPoints && selectedAncillary.points
? selectedAncillary.points * quantityWithPoints
: null
return (
<div className={styles.modalContent}>
<header>
<Subtitle type="two">
{intl.formatMessage({
id: "Reserve with Card",
})}
</Subtitle>
</header>
<Body>
{intl.formatMessage({
id: "Payment will be made on check-in. The card will be only used to guarantee the ancillary in case of no-show.",
})}
</Body>
<div className={styles.card}>
<CreditCard color="black" />
<Body textTransform="bold">{"MasterCard"}</Body>
<Body color="uiTextMediumContrast">{"**** 1234"}</Body>
</div>
<Checkbox name="termsAndConditions" registerOptions={{ required: true }}>
<Caption>
{intl.formatMessage<React.ReactNode>(
{
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<React.ReactNode>(
{ id: "<b>Total price</b> (incl VAT)" },
{ b: (str) => <b>{str}</b> }
)}
</Caption>
{totalPrice !== null && (
<Body textTransform="bold" color="uiTextHighContrast">
{formatPrice(intl, totalPrice, selectedAncillary.price.currency)}
</Body>
)}
{totalPoints !== null && (
<div>
<div>
<Divider variant="vertical" color="subtle" />
</div>
<Body textTransform="bold" color="uiTextHighContrast">
{totalPoints} {intl.formatMessage({ id: "points" })}
</Body>
</div>
)}
</div>
</div>
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,86 +2,26 @@
display: flex;
flex-direction: column;
gap: var(--Spacing-x2);
margin: 0 auto;
width: var(--max-width-content);
}
.title {
display: flex;
justify-content: space-between;
}
.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);
max-height: 417px;
overflow-y: auto;
padding-right: var(--Spacing-x1);
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);
}
.ancillaries {
display: none;
}
.modal {
display: none;
}
.carouselContainer {
display: grid;
grid-auto-flow: column;
grid-auto-columns: 80%;
gap: var(--Spacing-x2);
}
@media screen and (min-width: 768px) {
.modalContent {
width: 600px;
}
.carouselContainer {
grid-auto-columns: calc((100% - var(--Spacing-x3)) / 2);
}
}
@media screen and (min-width: 1052px) {
.mobileAncillaries {
display: none;
}
.modalContent {
width: 833px;
}
.ancillaries {
display: grid;
grid-template-columns: repeat(4, minmax(251px, 1fr));
gap: var(--Spacing-x2);
}
.modal {
display: block;
}
}

View File

@@ -2,14 +2,15 @@
import { useState } from "react"
import { useIntl } from "react-intl"
import { useAddAncillaryStore } from "@/stores/my-stay/add-ancillary-flow"
import { Carousel } from "@/components/Carousel"
import { ChevronRightSmallIcon } from "@/components/Icons"
import Modal from "@/components/Modal"
import { AncillaryCard } from "@/components/TempDesignSystem/AncillaryCard"
import Button from "@/components/TempDesignSystem/Button"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Title from "@/components/TempDesignSystem/Text/Title"
import AddAncillaryFlowModal from "./AddAncillaryFlow/AddAncillaryFlowModal"
import AncillaryGridModal from "./AncillaryGridModal"
import styles from "./ancillaries.module.css"
import type {
@@ -18,7 +19,7 @@ import type {
Ancillary,
} from "@/types/components/myPages/myStay/ancillaries"
export function Ancillaries({ ancillaries }: AncillariesProps) {
export function Ancillaries({ ancillaries, booking, user }: AncillariesProps) {
const intl = useIntl()
const [selectedCategory, setSelectedCategory] = useState<string | null>(
() => {
@@ -26,6 +27,10 @@ export function Ancillaries({ ancillaries }: AncillariesProps) {
}
)
const { setSelectedAncillary, setConfirmationNumber, setOpenedFrom } =
useAddAncillaryStore()
const [isModalOpen, setModalOpen] = useState(false)
if (!ancillaries?.length) {
return null
}
@@ -43,78 +48,79 @@ export function Ancillaries({ ancillaries }: AncillariesProps) {
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>
<div className={styles.modal}>
<Modal
trigger={
<Button theme="base" variant="icon" intent="text" size="small">
{intl.formatMessage({ id: "View all" })}
<ChevronRightSmallIcon
width={20}
height={20}
color="baseButtonTextOnFillNormal"
/>
</Button>
}
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)}
>
<Caption
color={
category.categoryName === selectedCategory
? "pale"
: "baseTextHighContrast"
}
>
{category.categoryName}
</Caption>
</button>
))}
</div>
<div className={styles.grid}>
{ancillaries
.find(
(category) => category.categoryName === selectedCategory
)
?.ancillaryContent.map(({ description, ...ancillary }) => (
<AncillaryCard key={ancillary.id} ancillary={ancillary} />
))}
</div>
</div>
</Modal>
</div>
<AncillaryGridModal
ancillaries={ancillaries}
selectedCategory={selectedCategory}
setSelectedCategory={setSelectedCategory}
handleCardClick={handleCardClick}
/>
</div>
<div className={styles.ancillaries}>
{allAncillaries.slice(0, 4).map(({ description, ...ancillary }) => (
<AncillaryCard key={ancillary.id} ancillary={ancillary} />
))}
{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, ...ancillary }) => (
<Carousel.Item key={ancillary.id}>
<AncillaryCard key={ancillary.id} ancillary={ancillary} />
</Carousel.Item>
))}
{allAncillaries.map(({ description, points, ...ancillary }) => {
const ancillaryData = !!user
? { points, ...ancillary }
: ancillary
return (
<Carousel.Item
key={ancillary.id}
onClick={() =>
handleCardClick({ description, points, ...ancillary })
}
>
<AncillaryCard key={ancillary.id} ancillary={ancillaryData} />
</Carousel.Item>
)
})}
</Carousel.Content>
<Carousel.Previous className={styles.navigationButton} />
<Carousel.Next className={styles.navigationButton} />
<Carousel.Dots />
</Carousel>
</div>
<AddAncillaryFlowModal
user={user}
isOpen={isModalOpen}
onClose={() => setModalOpen(false)}
booking={booking}
/>
</div>
)
}

View File

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

View File

@@ -64,7 +64,11 @@ export async function MyStay({ reservationId }: { reservationId: string }) {
<ReferenceCard booking={booking} hotel={hotel} />
</div>
{booking.showAncillaries && (
<Ancillaries ancillaries={ancillaryPackages} />
<Ancillaries
ancillaries={ancillaryPackages}
booking={booking}
user={user}
/>
)}
<Room booking={booking} room={room} hotel={hotel} user={user} />
<BookingSummary booking={booking} hotel={hotel} />