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} />

View File

@@ -11,7 +11,9 @@ export default function Select({
label,
disabled,
name,
isNestedInModal = false,
registerOptions = {},
defaultSelectedKey,
}: SelectProps) {
const { control } = useFormContext()
const { field } = useController({
@@ -23,7 +25,7 @@ export default function Select({
return (
<ReactAriaSelect
className={className}
defaultSelectedKey={field.value}
defaultSelectedKey={defaultSelectedKey || field.value}
disabled={disabled || field.disabled}
items={items}
label={label}
@@ -33,6 +35,7 @@ export default function Select({
onSelect={field.onChange}
value={field.value}
data-testid={name}
isNestedInModal={isNestedInModal}
/>
)
}

View File

@@ -33,6 +33,7 @@
"Adults": "voksne",
"Age": "Alder",
"Airport": "Lufthavn",
"All add-ons are delivered at the same time. Changes to delivery times will affect earlier add-ons.": "Alle tillæg leveres på samme tid. Ændringer i leveringstider vil påvirke tidligere tillæg.",
"All our breakfast buffets offer gluten free, vegan, and allergy-friendly options.": "Alle vores morgenmadsbuffeter tilbyder glutenfrie, veganske og allergivenlige muligheder.",
"Allergy-friendly room": "Allergirum",
"Already a friend?": "Allerede en ven?",
@@ -60,6 +61,7 @@
"Attractions": "Attraktioner",
"Average price per night": "gennemsnitspris pr. nat",
"Away from elevator": "Væk fra elevator",
"Back": "Tilbage",
"Back to scandichotels.com": "Tilbage til scandichotels.com",
"Back to top": "Tilbage til top",
"Bar": "Bar",
@@ -146,6 +148,7 @@
"Complete booking": "Fuldfør bookingen",
"Complete booking & go to payment": "Udfyld booking & gå til betaling",
"Complete the booking": "Fuldfør bookingen",
"Confirm": "Bekræft",
"Confirm cancellation": "Bekræft annullerering",
"Contact information": "Kontaktoplysninger",
"Contact our memberservice": "Contact our memberservice",
@@ -168,6 +171,8 @@
"Date of Birth": "Fødselsdato",
"Date of birth not matching": "Date of birth not matching",
"Day": "Dag",
"Delivered at:": "Leveret til:",
"Delivery between {deliveryTime}. Payment will be made on check-in": "Levering mellem {deliveryTime}. Betaling vil ske ved check-in.",
"Description": "Beskrivelse",
"Destination": "Destination",
"Destinations in {country}": "Destinationer i {country}",
@@ -299,6 +304,7 @@
"Indoor pool": "Indendørs pool",
"Indoor windows and excellent lighting": "Indoor windows and excellent lighting",
"Indoor windows facing the hotel": "Indoor windows facing the hotel",
"Insufficient points": "Utilstrækkelige point",
"Invalid booking code": "Ugyldig reservationskode",
"Is there anything else you would like us to know before your arrival?": "Er der andet, du gerne vil have os til at vide, før din ankomst?",
"It is not posible to manage your communication preferences right now, please try again later or contact support if the problem persists.": "Det er ikke muligt at administrere dine kommunikationspræferencer lige nu, prøv venligst igen senere eller kontakt support, hvis problemet fortsætter.",
@@ -433,6 +439,8 @@
"Open my pages menu": "Åbn mine sider menuen",
"Open {amount, plural, one {gift} other {gifts}}": "Åbne {amount, plural, one {gave} other {gaver}}",
"Opening hours": "Åbningstider",
"Optional": "Valgfri",
"Other requests": "Andre ønsker",
"Outdoor": "Udendørs",
"Outdoor pool": "Udendørs pool",
"Overview": "Oversigt",
@@ -444,6 +452,8 @@
"Password": "Adgangskode",
"Pay later": "Betal senere",
"Pay now": "Betal nu",
"Pay with card": "Betal med kort",
"Pay with points": "Betal med point",
"Payment": "Betaling",
"Payment Guarantee": "Garanti betaling",
"Payment details": "Payment details",
@@ -451,6 +461,7 @@
"Payment method": "Payment method",
"Payment received": "Payment received",
"Payment status": "Payment status",
"Payment will be made on check-in. The card will be only used to guarantee the ancillary in case of no-show.": "Betaling vil ske ved check-in. Kortet vil kun blive brugt til at garantere tillægget i tilfælde af en no-show.",
"Per night from": "Per nat fra",
"Pet room": "Kæledyrsrum",
"Pet-friendly rooms have an additional fee of 20 EUR per stay": "Kæledyrsrum har en ekstra gebyr på 20 EUR per ophold",
@@ -510,6 +521,7 @@
"Reservation No. {reservationNumber}": "Reservation No. {reservationNumber}",
"Reservation number {value}": "Reservation number {value}",
"Reservation policy": "Reservation policy",
"Reserve with Card": "Reserver med kort",
"Restaurant & Bar": "Restaurant & Bar",
"Restaurants & Bars": "Restaurants & Bars",
"Retype new password": "Gentag den nye adgangskode",
@@ -555,6 +567,7 @@
"Select hotel": "Vælg hotel",
"Select language": "Vælg sprog",
"Select payment method": "Vælg betalingsmetode",
"Select quantity": "Vælg antal",
"Select room": "Zimmer auswählen",
"Select your language": "Vælg dit sprog",
"Shopping": "Shopping",
@@ -572,6 +585,7 @@
"Something went wrong and we couldn't add your card. Please try again later.": "Noget gik galt, og vi kunne ikke tilføje dit kort. Prøv venligst igen senere.",
"Something went wrong and we couldn't remove your card. Please try again later.": "Noget gik galt, og vi kunne ikke fjerne dit kort. Prøv venligst igen senere.",
"Something went wrong!": "Noget gik galt!",
"Something went wrong. {ancillary} could not be added to your booking!": "Noget gik galt. {ancillary} kunne ikke tilføjes til din booking!",
"Sort by": "Sorter efter",
"Special requests": "Specielle ønsker",
"Spice things up": "Krydre tingene",
@@ -740,6 +754,7 @@
"{amount} out of {total}": "{amount} ud af {total}",
"{amount} {amount, plural, one {hotel} other {hotels}}": "{amount} {amount, plural, one {hotel} other {hoteller}}",
"{amount}/night per adult": "{amount}/nat per voksen",
"{ancillary} added to your booking!": "{ancillary} tilføjet til din booking!",
"{card} ending with {cardno}": "{card} slutter med {cardno}",
"{checkInDate} from {checkInTime}": "{checkInDate} fra {checkInTime}",
"{checkOutDate} from {checkOutTime}": "{checkOutDate} fra {checkOutTime}",

View File

@@ -33,6 +33,7 @@
"Adults": "Erwachsene",
"Age": "Alter",
"Airport": "Flughafen",
"All add-ons are delivered at the same time. Changes to delivery times will affect earlier add-ons.": "Alle Add-ons werden gleichzeitig geliefert. Änderungen der Lieferzeiten wirken sich auf frühere Add-ons aus.",
"All our breakfast buffets offer gluten free, vegan, and allergy-friendly options.": "Alle unsere Frühstücksbuffets bieten glutenfreie, vegane und allergikerfreundliche Speisen.",
"Allergy-friendly room": "Allergikerzimmer",
"Already a friend?": "Sind wir schon Freunde?",
@@ -61,6 +62,7 @@
"Attractions": "Attractions",
"Average price per night": "Durchschnittspreis pro Nacht",
"Away from elevator": "Weg vom Aufzug",
"Back": "Zurück",
"Back to scandichotels.com": "Zurück zu scandichotels.com",
"Back to top": "Zurück zur Spitze",
"Bar": "Bar",
@@ -147,6 +149,7 @@
"Complete booking": "Buchung abschließen",
"Complete booking & go to payment": "Buchung abschließen & zur Bezahlung gehen",
"Complete the booking": "Buchung abschließen",
"Confirm": "Bestätigen",
"Confirm cancellation": "Stornierung bestätigen",
"Contact information": "Kontaktinformationen",
"Contact our memberservice": "Contact our memberservice",
@@ -169,6 +172,8 @@
"Date of Birth": "Geburtsdatum",
"Date of birth not matching": "Date of birth not matching",
"Day": "Tag",
"Delivered at": "Geliefert bei:",
"Delivery between {deliveryTime}. Payment will be made on check-in": "Lieferung zwischen {deliveryTime}. Die Zahlung erfolgt beim Check-in.",
"Description": "Beschreibung",
"Destination": "Bestimmungsort",
"Destinations in {country}": "Ziele in {country}",
@@ -300,6 +305,7 @@
"Indoor pool": "Innenpool",
"Indoor windows and excellent lighting": "Indoor windows and excellent lighting",
"Indoor windows facing the hotel": "Indoor windows facing the hotel",
"Insufficient points": "Zu wenig Punkte",
"Invalid booking code": "Ungültiger Buchungscode",
"Is there anything else you would like us to know before your arrival?": "Gibt es noch etwas, das Sie uns vor Ihrer Ankunft mitteilen möchten?",
"It is not posible to manage your communication preferences right now, please try again later or contact support if the problem persists.": "Es ist derzeit nicht möglich, Ihre Kommunikationseinstellungen zu verwalten. Bitte versuchen Sie es später erneut oder wenden Sie sich an den Support, wenn das Problem weiterhin besteht.",
@@ -435,6 +441,8 @@
"Open my pages menu": "Meine Seiten Menü öffnen",
"Open {amount, plural, one {gift} other {gifts}}": "{amount, plural, one {Geschenk} other {Geschenke}} öffnen",
"Opening hours": "Öffnungszeiten",
"Optional": "Optional",
"Other Requests": "Andere Anfragen",
"Outdoor": "Im Freien",
"Outdoor pool": "Außenpool",
"Overview": "Übersicht",
@@ -446,6 +454,8 @@
"Password": "Passwort",
"Pay later": "Später bezahlen",
"Pay now": "Jetzt bezahlen",
"Pay with Card": "Mit Karte bezahlen",
"Pay with points": "Mit Punkten bezahlen",
"Payment": "Zahlung",
"Payment Guarantee": "Zahlungsgarantie",
"Payment details": "Payment details",
@@ -453,6 +463,7 @@
"Payment method": "Payment method",
"Payment received": "Payment received",
"Payment status": "Payment status",
"Payment will be made on check-in. The card will be only used to guarantee the ancillary in case of no-show.": "Die Zahlung erfolgt beim Check-in. Die Karte wird nur zur Garantie der Nebenkosten im Falle eines No-Shows verwendet.",
"Per night from": "Pro Nacht ab",
"Pet room": "Haustierzimmer",
"Pet-friendly rooms have an additional fee of 20 EUR per stay": "Haustierzimmer haben einen zusätzlichen Preis von 20 EUR pro Aufenthalt",
@@ -512,6 +523,7 @@
"Reservation No. {reservationNumber}": "Reservation No. {reservationNumber}",
"Reservation number {value}": "Reservation number {value}",
"Reservation policy": "Reservation policy",
"Reserve with Card": "Mit Karte reservieren",
"Restaurant & Bar": "Restaurant & Bar",
"Restaurants & Bars": "Restaurants & Bars",
"Retype new password": "Neues Passwort erneut eingeben",
@@ -557,6 +569,7 @@
"Select hotel": "Hotel auswählen",
"Select language": "Sprache auswählen",
"Select payment method": "Zahlungsart auswählen",
"Select quantity": "Menge auswählen",
"Select room": "Vælg værelse",
"Select your language": "Wählen Sie Ihre Sprache",
"Shopping": "Einkaufen",
@@ -574,6 +587,7 @@
"Something went wrong and we couldn't add your card. Please try again later.": "Ein Fehler ist aufgetreten und wir konnten Ihre Karte nicht hinzufügen. Bitte versuchen Sie es später erneut.",
"Something went wrong and we couldn't remove your card. Please try again later.": "Ein Fehler ist aufgetreten und wir konnten Ihre Karte nicht entfernen. Bitte versuchen Sie es später noch einmal.",
"Something went wrong!": "Etwas ist schief gelaufen!",
"Something went wrong. {ancillary} could not be added to your booking!": "Etwas ist schief gelaufen. {ancillary} konnte nicht zu Ihrer Buchung hinzugefügt werden!",
"Sort by": "Sortieren nach",
"Special requests": "Spezielle Anfragen",
"Spice things up": "Würzen Sie die Dinge auf",
@@ -741,6 +755,7 @@
"{amount} out of {total}": "{amount} von {total}",
"{amount} {amount, plural, one {hotel} other {hotels}}": "{amount} {amount, plural, one {hotel} other {hotels}}",
"{amount}/night per adult": "{amount}/Nacht pro Erwachsenem",
"{ancillary} added to your booking!": "{ancillary} zu Ihrer Buchung hinzugefügt!",
"{card} ending with {cardno}": "{card} endet mit {cardno}",
"{checkInDate} from {checkInTime}": "{checkInDate} aus {checkInTime}",
"{checkOutDate} from {checkOutTime}": "{checkOutDate} aus {checkOutTime}",

View File

@@ -34,6 +34,7 @@
"Adults": "Adults",
"Age": "Age",
"Airport": "Airport",
"All add-ons are delivered at the same time. Changes to delivery times will affect earlier add-ons.": "All add-ons are delivered at the same time. Changes to delivery times will affect earlier add-ons.",
"All our breakfast buffets offer gluten free, vegan, and allergy-friendly options.": "All our breakfast buffets offer gluten free, vegan, and allergy-friendly options.",
"Allergy-friendly room": "Allergy room",
"Already a friend?": "Already a friend?",
@@ -148,6 +149,7 @@
"Complete booking": "Complete booking",
"Complete booking & go to payment": "Complete booking & go to payment",
"Complete the booking": "Complete the booking",
"Confirm": "Confirm",
"Confirm cancellation": "Confirm cancellation",
"Contact information": "Contact information",
"Contact our memberservice": "Contact our memberservice",
@@ -171,6 +173,8 @@
"Date of Birth": "Date of Birth",
"Date of birth not matching": "Date of birth not matching",
"Day": "Day",
"Delivered at:": "Delivered at:",
"Delivery between {deliveryTime}. Payment will be made on check-in": "Delivery between {deliveryTime}. Payment will be made on check-in.",
"Description": "Description",
"Destination": "Destination",
"Destinations in {country}": "Destinations in {country}",
@@ -303,6 +307,7 @@
"Indoor pool": "Indoor pool",
"Indoor windows and excellent lighting": "Indoor windows and excellent lighting",
"Indoor windows facing the hotel": "Indoor windows facing the hotel",
"Insufficient points": "Insufficient points",
"Invalid booking code": "Invalid booking code",
"Is there anything else you would like us to know before your arrival?": "Is there anything else you would like us to know before your arrival?",
"It is not posible to manage your communication preferences right now, please try again later or contact support if the problem persists.": "It is not posible to manage your communication preferences right now, please try again later or contact support if the problem persists.",
@@ -438,6 +443,8 @@
"Open my pages menu": "Open my pages menu",
"Open {amount, plural, one {gift} other {gifts}}": "Open {amount, plural, one {gift} other {gifts}}",
"Opening hours": "Opening hours",
"Optional": "Optional",
"Other Requests": "Other Requests",
"Outdoor": "Outdoor",
"Outdoor pool": "Outdoor pool",
"Overview": "Overview",
@@ -449,6 +456,8 @@
"Password": "Password",
"Pay later": "Pay later",
"Pay now": "Pay now",
"Pay with Card": "Pay with Card",
"Pay with points": "Pay with points",
"Payment": "Payment",
"Payment Guarantee": "Payment Guarantee",
"Payment details": "Payment details",
@@ -456,6 +465,7 @@
"Payment method": "Payment method",
"Payment received": "Payment received",
"Payment status": "Payment status",
"Payment will be made on check-in. The card will be only used to guarantee the ancillary in case of no-show.": "Payment will be made on check-in. The card will be only used to guarantee the ancillary in case of no-show.",
"Per night from": "Per night from",
"Pet room": "Pet room",
"Pet-friendly rooms have an additional fee of 20 EUR per stay": "Pet-friendly rooms have an additional fee of 20 EUR per stay",
@@ -517,6 +527,7 @@
"Reservation No. {reservationNumber}": "Reservation No. {reservationNumber}",
"Reservation number {value}": "Reservation number {value}",
"Reservation policy": "Reservation policy",
"Reserve with Card": "Reserve with Card",
"Restaurant & Bar": "Restaurant & Bar",
"Restaurants & Bars": "Restaurants & Bars",
"Retype new password": "Retype new password",
@@ -563,6 +574,7 @@
"Select hotel": "Select hotel",
"Select language": "Select language",
"Select payment method": "Select payment method",
"Select quantity": "Select quantity",
"Select room": "Select room",
"Select your language": "Select your language",
"Shopping": "Shopping",
@@ -580,6 +592,7 @@
"Something went wrong and we couldn't add your card. Please try again later.": "Something went wrong and we couldn't add your card. Please try again later.",
"Something went wrong and we couldn't remove your card. Please try again later.": "Something went wrong and we couldn't remove your card. Please try again later.",
"Something went wrong!": "Something went wrong!",
"Something went wrong. {ancillary} could not be added to your booking!": "Something went wrong. {ancillary} could not be added to your booking!",
"Sort by": "Sort by",
"Special requests": "Special requests",
"Spice things up": "Spice things up",
@@ -684,6 +697,7 @@
"Windows with natural daylight": "Windows with natural daylight",
"Year": "Year",
"Yes": "Yes",
"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.": "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.",
"Yes, close and remove benefit": "Yes, close and remove benefit",
"Yes, discard changes": "Yes, discard changes",
"Yes, redeem": "Yes, redeem",
@@ -746,6 +760,7 @@
"{amount} out of {total}": "{amount} out of {total}",
"{amount} {amount, plural, one {hotel} other {hotels}}": "{amount} {amount, plural, one {hotel} other {hotels}}",
"{amount}/night per adult": "{amount}/night per adult",
"{ancillary} added to your booking!": "{ancillary} added to your booking!",
"{card} ending with {cardno}": "{card} ending with {cardno}",
"{checkInDate} from {checkInTime}": "{checkInDate} from {checkInTime}",
"{checkOutDate} from {checkOutTime}": "{checkOutDate} from {checkOutTime}",

View File

@@ -33,6 +33,7 @@
"Adults": "Aikuista",
"Age": "Ikä",
"Airport": "Lentokenttä",
"All add-ons are delivered at the same time. Changes to delivery times will affect earlier add-ons.": "Kaikki lisäosat toimitetaan samanaikaisesti. Toimitusaikojen muutokset vaikuttavat aiempiin lisäosiin.",
"All our breakfast buffets offer gluten free, vegan, and allergy-friendly options.": "Kaikki aamiaisbuffettimme tarjoavat gluteenittomia, vegaanisia ja allergiaystävällisiä vaihtoehtoja.",
"Allergy-friendly room": "Allergiahuone",
"Already a friend?": "Oletko jo ystävä?",
@@ -59,6 +60,7 @@
"Attractions": "Nähtävyydet",
"Average price per night": "keskihinta per yö",
"Away from elevator": "Kaukana hissistä",
"Back": "Takaisin",
"Back to scandichotels.com": "Takaisin scandichotels.com",
"Back to top": "Takaisin ylös",
"Bar": "Bar",
@@ -146,6 +148,7 @@
"Complete booking": "Complete booking",
"Complete booking & go to payment": "Täydennä varaus & siirry maksamaan",
"Complete the booking": "Täydennä varaus",
"Confirm": "Vahvista",
"Confirm cancellation": "Vahvista peruutus",
"Contact information": "Yhteystiedot",
"Contact our memberservice": "Contact our memberservice",
@@ -168,6 +171,8 @@
"Date of Birth": "Syntymäaika",
"Date of birth not matching": "Date of birth not matching",
"Day": "Päivä",
"Delivered at:": "Toimitettu:",
"Delivery between {deliveryTime}. Payment will be made on check-in": "Toimitus välillä {deliveryTime}. Maksu suoritetaan sisäänkirjautumisen yhteydessä.",
"Description": "Kuvaus",
"Destination": "Kohde",
"Destinations in {country}": "Kohteet maassa {country}",
@@ -299,6 +304,7 @@
"Indoor pool": "Sisäuima-allas",
"Indoor windows and excellent lighting": "Indoor windows and excellent lighting",
"Indoor windows facing the hotel": "Indoor windows facing the hotel",
"Insufficient points": "Riittämättä pisteitä",
"Invalid booking code": "Virheellinen varauskoodi",
"Is there anything else you would like us to know before your arrival?": "Onko jotain muuta, mitä haluaisit meidän tietävän ennen saapumistasi?",
"It is not posible to manage your communication preferences right now, please try again later or contact support if the problem persists.": "Viestintäasetuksiasi ei voi hallita juuri nyt. Yritä myöhemmin uudelleen tai ota yhteyttä tukeen, jos ongelma jatkuu.",
@@ -434,6 +440,8 @@
"Open my pages menu": "Avaa omat sivut -valikko",
"Open {amount, plural, one {gift} other {gifts}}": "{amount, plural, one {Avoin lahja} other {Avoimet lahjat}}",
"Opening hours": "Aukioloajat",
"Optional": "Valinnainen",
"Other Requests": "Muut pyynnöt",
"Outdoor": "Ulkona",
"Outdoor pool": "Ulkouima-allas",
"Overview": "Yleiskatsaus",
@@ -445,6 +453,8 @@
"Password": "Salasana",
"Pay later": "Maksa myöhemmin",
"Pay now": "Maksa nyt",
"Pay with Card": "Maksa kortilla",
"Pay with points": "Maksa pisteillä",
"Payment": "Maksu",
"Payment Guarantee": "Varmistusmaksu",
"Payment details": "Payment details",
@@ -452,6 +462,7 @@
"Payment method": "Payment method",
"Payment received": "Payment received",
"Payment status": "Payment status",
"Payment will be made on check-in. The card will be only used to guarantee the ancillary in case of no-show.": "Maksu suoritetaan sisäänkirjautumisen yhteydessä. Korttia käytetään vain lisäpalvelun varmistamiseen, jos varausmyyntiä ei tapahdu.",
"Per night from": "Per yö alkaen",
"Pet room": "Lemmikkihuone",
"Pet-friendly rooms have an additional fee of 20 EUR per stay": "Lemmikkihuoneen lisäkustannus on 20 EUR per majoitus",
@@ -511,6 +522,7 @@
"Reservation No. {reservationNumber}": "Reservation No. {reservationNumber}",
"Reservation number {value}": "Reservation number {value}",
"Reservation policy": "Reservation policy",
"Reserve with Card": "Varaa kortilla",
"Restaurant & Bar": "Ravintola & Baari",
"Restaurants & Bars": "Restaurants & Bars",
"Retype new password": "Kirjoita uusi salasana uudelleen",
@@ -557,6 +569,7 @@
"Select hotel": "Valitse hotelli",
"Select language": "Valitse kieli",
"Select payment method": "Valitse maksutapa",
"Select quantity": "Valitse määrä",
"Select room": "Valitse huone",
"Select your language": "Valitse kieli",
"Shopping": "Ostokset",
@@ -574,6 +587,7 @@
"Something went wrong and we couldn't add your card. Please try again later.": "Jotain meni pieleen, emmekä voineet lisätä korttiasi. Yritä myöhemmin uudelleen.",
"Something went wrong and we couldn't remove your card. Please try again later.": "Jotain meni pieleen, emmekä voineet poistaa korttiasi. Yritä myöhemmin uudelleen.",
"Something went wrong!": "Jotain meni pieleen!",
"Something went wrong. {ancillary} could not be added to your booking!": "Jotain meni pieleen. {ancillary} ei voitu lisätä varaukseesi!",
"Sort by": "Lajitteluperuste",
"Special requests": "Erityistoiveet",
"Spice things up": "Mausta asioita",
@@ -741,6 +755,7 @@
"{amount} out of {total}": "{amount}/{total}",
"{amount} {amount, plural, one {hotel} other {hotels}}": "{amount} {amount, plural, one {hotelli} other {hotellit}}",
"{amount}/night per adult": "{amount}/yötä aikuista kohti",
"{ancillary} added to your booking!": "{ancillary} lisätty varaukseesi!",
"{card} ending with {cardno}": "{card} päättyen {cardno}",
"{checkInDate} from {checkInTime}": "{checkInDate} alkaen {checkInTime}",
"{checkOutDate} from {checkOutTime}": "{checkOutDate} alkaen {checkOutTime}",

View File

@@ -33,6 +33,7 @@
"Adults": "Voksne",
"Age": "Alder",
"Airport": "Flyplass",
"All add-ons are delivered at the same time. Changes to delivery times will affect earlier add-ons.": "Alle tilvalg leveres samtidig. Endringer i leveringstidspunktene vil påvirke tidligere tilvalg.",
"All our breakfast buffets offer gluten free, vegan, and allergy-friendly options.": "Alle våre frokostbufféer tilbyr glutenfrie, veganske og allergivennlige alternativer.",
"Allergy-friendly room": "Allergirom",
"Already a friend?": "Allerede Friend?",
@@ -59,6 +60,7 @@
"Attractions": "Attraksjoner",
"Average price per night": "gjennomsnittlig pris per natt",
"Away from elevator": "Bort fra heisen",
"Back": "Tilbake",
"Back to scandichotels.com": "Tilbake til scandichotels.com",
"Back to top": "Tilbake til toppen",
"Bar": "Bar",
@@ -145,6 +147,7 @@
"Complete booking": "Fullfør reservasjonen",
"Complete booking & go to payment": "Fullfør bestilling & gå til betaling",
"Complete the booking": "Fullfør reservasjonen",
"Confirm": "Bekreft",
"Confirm cancellation": "Bekræft annullerering",
"Contact information": "Kontaktinformasjon",
"Contact our memberservice": "Contact our memberservice",
@@ -167,6 +170,8 @@
"Date of Birth": "Fødselsdato",
"Date of birth not matching": "Date of birth not matching",
"Day": "Dag",
"Delivered at:": "Delivered at:",
"Delivery between {deliveryTime}. Payment will be made on check-in": "Levering mellom {deliveryTime}. Betaling vil skje ved innsjekking.",
"Description": "Beskrivelse",
"Destination": "Destinasjon",
"Destinations in {country}": "Destinasjoner i {country}",
@@ -298,6 +303,7 @@
"Indoor pool": "Innendørs basseng",
"Indoor windows and excellent lighting": "Indoor windows and excellent lighting",
"Indoor windows facing the hotel": "Indoor windows facing the hotel",
"Insufficient points": "Utilstrekklig poeng",
"Invalid booking code": "Ugyldig bookingkode",
"Is there anything else you would like us to know before your arrival?": "Er det noe annet du vil at vi skal vite før ankomsten din?",
"It is not posible to manage your communication preferences right now, please try again later or contact support if the problem persists.": "Det er ikke mulig å administrere kommunikasjonspreferansene dine akkurat nå, prøv igjen senere eller kontakt support hvis problemet vedvarer.",
@@ -433,6 +439,8 @@
"Open my pages menu": "Åpne mine sider menyen",
"Open {amount, plural, one {gift} other {gifts}}": "{amount, plural, one {Åpen gave} other {Åpnen gaver}}",
"Opening hours": "Åpningstider",
"Optional": "Valgfritt",
"Other Requests": "Andre ønsker",
"Outdoor": "Utendørs",
"Outdoor pool": "Utendørs basseng",
"Overview": "Oversikt",
@@ -451,6 +459,7 @@
"Payment method": "Payment method",
"Payment received": "Payment received",
"Payment status": "Payment status",
"Payment will be made on check-in. The card will be only used to guarantee the ancillary in case of no-show.": "Betaling vil skje ved innsjekking. Kortet vil kun bli brukt til å garantere tillegget i tilfelle av en no-show.",
"Per night from": "Per nat fra",
"Pet room": "Kjæledyrsrom",
"Pet-friendly rooms have an additional fee of 20 EUR per stay": "Kjæledyrsrom har en tilleggsavgift på 20 EUR per opphold",
@@ -510,6 +519,7 @@
"Reservation No. {reservationNumber}": "Reservation No. {reservationNumber}",
"Reservation number {value}": "Reservation number {value}",
"Reservation policy": "Reservation policy",
"Reserve with Card": "Reserver med kort",
"Restaurant & Bar": "Restaurant & Bar",
"Restaurants & Bars": "Restaurants & Bars",
"Retype new password": "Skriv inn nytt passord på nytt",
@@ -555,6 +565,7 @@
"Select hotel": "Velg hotell",
"Select language": "Velg språk",
"Select payment method": "Velg betalingsmetode",
"Select quantity": "Velg antall",
"Select room": "Velg rom",
"Select your language": "Velg språk",
"Shopping": "Shopping",
@@ -572,6 +583,7 @@
"Something went wrong and we couldn't add your card. Please try again later.": "Noe gikk galt, og vi kunne ikke legge til kortet ditt. Prøv igjen senere.",
"Something went wrong and we couldn't remove your card. Please try again later.": "Noe gikk galt, og vi kunne ikke fjerne kortet ditt. Vennligst prøv igjen senere.",
"Something went wrong!": "Noe gikk galt!",
"Something went wrong. {ancillary} could not be added to your booking!": "Noe gikk galt. {ancillary} kunne ikke legges til bestillingen din!",
"Sort by": "Sorter etter",
"Special requests": "Spesielle ønsker",
"Spice things up": "Krydre tingene",
@@ -739,6 +751,7 @@
"{amount} out of {total}": "{amount} av {total}",
"{amount} {amount, plural, one {hotel} other {hotels}}": "{amount} {amount, plural, one {hotell} other {hoteller}}",
"{amount}/night per adult": "{amount}/natt per voksen",
"{ancillary} added to your booking!": "{ancillary} lagt til bestillingen din!",
"{card} ending with {cardno}": "{card} slutter med {cardno}",
"{checkInDate} from {checkInTime}": "{checkInDate} fra {checkInTime}",
"{checkOutDate} from {checkOutTime}": "{checkOutDate} fra {checkOutTime}",

View File

@@ -33,6 +33,7 @@
"Adults": "Vuxna",
"Age": "Ålder",
"Airport": "Flygplats",
"All add-ons are delivered at the same time. Changes to delivery times will affect earlier add-ons.": "Alla tillägg levereras samtidigt. Ändringar av leveranstider kommer att påverka tidigare tillägg.",
"All our breakfast buffets offer gluten free, vegan, and allergy-friendly options.": "Alla våra frukostbufféer erbjuder glutenfria, veganska och allergivänliga alternativ.",
"Allergy-friendly room": "Allergirum",
"Already a friend?": "Är du redan en vän?",
@@ -59,6 +60,7 @@
"Attractions": "Sevärdheter",
"Average price per night": "Snittpris per natt",
"Away from elevator": "Bort från hissen",
"Back": "Tillbaka",
"Back to scandichotels.com": "Tillbaka till scandichotels.com",
"Back to top": "Tillbaka till toppen",
"Bar": "Bar",
@@ -145,6 +147,7 @@
"Complete booking": "Slutför bokning",
"Complete booking & go to payment": "Fullför bokning & gå till betalning",
"Complete the booking": "Slutför bokningen",
"Confirm": "Bekräfta",
"Confirm cancellation": "Bekräfta avbokning",
"Contact information": "Kontaktinformation",
"Contact our memberservice": "Contact our memberservice",
@@ -167,6 +170,8 @@
"Date of Birth": "Födelsedatum",
"Date of birth not matching": "Date of birth not matching",
"Day": "Dag",
"Delivered at:": "Levereras vid:",
"Delivery between {deliveryTime}. Payment will be made on check-in": "Leverans mellan {deliveryTime}. Betalning kommer att göras vid incheckning.",
"Description": "Beskrivning",
"Destination": "Destination",
"Destinations in {country}": "Destinationer i {country}",
@@ -298,6 +303,7 @@
"Indoor pool": "Inomhuspool",
"Indoor windows and excellent lighting": "Fönster inomhus och utmärkt belysning",
"Indoor windows facing the hotel": "Inomhusfönster mot hotellet",
"Insufficient points": "Otillräckliga poäng",
"Invalid booking code": "Ogiltig bokningskod",
"Is there anything else you would like us to know before your arrival?": "Är det något mer du vill att vi ska veta innan din ankomst?",
"It is not posible to manage your communication preferences right now, please try again later or contact support if the problem persists.": "Det gick inte att hantera dina kommunikationsinställningar just nu, försök igen senare eller kontakta supporten om problemet kvarstår.",
@@ -433,6 +439,8 @@
"Open my pages menu": "Öppna mina sidor menyn",
"Open {amount, plural, one {gift} other {gifts}}": "Öppna {amount, plural, one {gåva} other {gåvor}}",
"Opening hours": "Öppettider",
"Optional": "Valfritt",
"Other Requests": "Övriga önskemål",
"Outdoor": "Utomhus",
"Outdoor pool": "Utomhuspool",
"Overview": "Översikt",
@@ -451,6 +459,7 @@
"Payment method": "Payment method",
"Payment received": "Payment received",
"Payment status": "Payment status",
"Payment will be made on check-in. The card will be only used to guarantee the ancillary in case of no-show.": "Betalning kommer att ske vid incheckning. Kortet kommer endast att användas för att garantera tillägget i händelse av no-show.",
"Per night from": "Per natt från",
"Pet room": "Husdjursrum",
"Pet-friendly rooms have an additional fee of 20 EUR per stay": "Husdjursrum har en extra avgift på 20 EUR per vistelse",
@@ -510,6 +519,7 @@
"Reservation No. {reservationNumber}": "Reservation No. {reservationNumber}",
"Reservation number {value}": "Reservation number {value}",
"Reservation policy": "Reservation policy",
"Reserve with Card": "Reservera med kort",
"Restaurant & Bar": "Restaurang & Bar",
"Restaurants & Bars": "Restaurants & Bars",
"Retype new password": "Upprepa nytt lösenord",
@@ -555,6 +565,7 @@
"Select hotel": "Välj hotell",
"Select language": "Välj språk",
"Select payment method": "Välj betalningsmetod",
"Select quantity": "Välj antal",
"Select room": "Välj rum",
"Select your language": "Välj ditt språk",
"Shopping": "Shopping",
@@ -572,6 +583,7 @@
"Something went wrong and we couldn't add your card. Please try again later.": "Något gick fel och vi kunde inte lägga till ditt kort. Försök igen senare.",
"Something went wrong and we couldn't remove your card. Please try again later.": "Något gick fel och vi kunde inte ta bort ditt kort. Försök igen senare.",
"Something went wrong!": "Något gick fel!",
"Something went wrong. {ancillary} could not be added to your booking!": "Något gick fel. {ancillary} kunde inte läggas till i din bokning!",
"Sort by": "Sortera efter",
"Special requests": "Särskilda önskemål",
"Spice things up": "Krydda upp saker och ting",
@@ -741,6 +753,7 @@
"{amount} out of {total}": "{amount} av {total}",
"{amount} {amount, plural, one {hotel} other {hotels}}": "{amount} {amount, plural, one {hotell} other {hotell}}",
"{amount}/night per adult": "{amount}/natt per vuxen",
"{ancillary} added to your booking!": "{ancillary} har lagts till i din bokning!",
"{card} ending with {cardno}": "{card} som slutar på {cardno}",
"{checkInDate} from {checkInTime}": "{checkInDate} från {checkInTime}",
"{checkOutDate} from {checkOutTime}": "{checkOutDate} från {checkOutTime}",

View File

@@ -65,6 +65,9 @@ export namespace endpoints {
export function priceChange(confirmationNumber: string) {
return `${bookings}/${confirmationNumber}/priceChange`
}
export function packages(confirmationNumber: string) {
return `${bookings}/${confirmationNumber}/packages`
}
export const enum Stays {
future = `${base.path.booking}/${version}/${base.enitity.Stays}/future`,

View File

@@ -85,6 +85,20 @@ export const createBookingInput = z.object({
language: z.nativeEnum(Lang).transform((val) => langToApiLang[val]),
})
export const addPackageInput = z.object({
confirmationNumber: z.string(),
ancillaryComment: z.string(),
ancillaryDeliveryTime: z.string().optional(),
packages: z.array(
z.object({
code: z.string(),
quantity: z.number(),
comment: z.string().optional(),
})
),
language: z.nativeEnum(Lang).transform((val) => langToApiLang[val]),
})
export const priceChangeInput = z.object({
confirmationNumber: z.string(),
})

View File

@@ -8,6 +8,7 @@ import { getMembership } from "@/utils/user"
import {
cancelBookingInput,
addPackageInput,
createBookingInput,
priceChangeInput,
} from "./input"
@@ -39,6 +40,14 @@ const cancelBookingFailCounter = meter.createCounter(
"trpc.bookings.cancel-fail"
)
const addPackageCounter = meter.createCounter("trpc.bookings.add-package")
const addPackageSuccessCounter = meter.createCounter(
"trpc.bookings.add-package-success"
)
const addPackageFailCounter = meter.createCounter(
"trpc.bookings.add-package-fail"
)
async function getMembershipNumber(
session: Session | null
): Promise<string | undefined> {
@@ -304,6 +313,70 @@ export const bookingMutationRouter = router({
})
)
return verifiedData.data
}),
packages: safeProtectedServiceProcedure
.input(addPackageInput)
.mutation(async function ({ ctx, input }) {
const accessToken = ctx.session?.token.access_token ?? ctx.serviceToken
const { confirmationNumber, ...body } = input
addPackageCounter.add(1, { confirmationNumber })
const headers = {
Authorization: `Bearer ${accessToken}`,
}
const apiResponse = await api.post(
api.endpoints.v1.Booking.packages(confirmationNumber),
{
headers,
body: body,
}
)
if (!apiResponse.ok) {
const text = await apiResponse.text()
addPackageFailCounter.add(1, {
confirmationNumber,
error_type: "http_error",
error: JSON.stringify({
status: apiResponse.status,
}),
})
console.error(
"api.booking.addPackage error",
JSON.stringify({
query: { confirmationNumber },
error: {
status: apiResponse.status,
statusText: apiResponse.statusText,
error: text,
},
})
)
return null
}
const apiJson = await apiResponse.json()
const verifiedData = createBookingSchema.safeParse(apiJson)
if (!verifiedData.success) {
addPackageFailCounter.add(1, {
confirmationNumber,
error_type: "validation_error",
})
console.error(
"api.booking.addPackage validation error",
JSON.stringify({
query: { confirmationNumber },
error: verifiedData.error,
})
)
return null
}
addPackageSuccessCounter.add(1, { confirmationNumber })
return verifiedData.data
}),
})

View File

@@ -11,7 +11,7 @@ export const createBookingSchema = z
data: z.object({
attributes: z.object({
reservationStatus: z.string(),
paymentUrl: z.string().nullable(),
paymentUrl: z.string().nullable().optional(),
rooms: z
.array(
z.object({
@@ -81,6 +81,7 @@ const guestSchema = z.object({
const packageSchema = z
.object({
description: z.string().nullable().default(""),
type: z.string().nullable().default(""),
code: z.string().nullable().default(""),
price: z.object({
unit: z.number().int().nullable(),
@@ -94,6 +95,7 @@ const packageSchema = z
.transform((packageData) => ({
description: packageData.description,
code: packageData.code,
type: packageData.type,
currency: packageData.price.currency,
points: packageData.price.points,
totalPrice: packageData.price.totalPrice ?? 0,
@@ -195,7 +197,7 @@ export const bookingConfirmationSchema = z
createDateTime: z.date({ coerce: true }),
currencyCode: z.string(),
guest: guestSchema,
isGuaranteedForLateArrival: z.boolean(),
isGuaranteedForLateArrival: z.boolean().optional(),
linkedReservations: z.array(linkedReservationsSchema).default([]),
hotelId: z.string(),
packages: z.array(packageSchema).default([]),

View File

@@ -371,6 +371,8 @@ export const ancillaryPackagesSchema = z
currency: item.variants.ancillary.price.currency,
},
points: item.variants.ancillaryLoyalty?.points,
loyaltyCode: item.variants.ancillaryLoyalty?.code,
requiresDeliveryTime: item.requiresDeliveryTime,
})),
}))
.filter((ancillary) => ancillary.ancillaryContent.length > 0)

View File

@@ -29,12 +29,15 @@ export const ancillaryContentSchema = z.object({
status: z.string(),
id: z.string(),
variants: z.object({
ancillary: z.object({ price: packagePriceSchema }),
ancillaryLoyalty: z.object({ points: z.number() }).optional(),
ancillary: z.object({ id: z.string(), price: packagePriceSchema }),
ancillaryLoyalty: z
.object({ points: z.number(), code: z.string() })
.optional(),
}),
title: z.string(),
descriptions: z.object({ html: z.string() }),
images: z.array(z.object({ imageSizes: imageSizesSchema })),
requiresDeliveryTime: z.boolean(),
})
export const packageSchema = z.object({

View File

@@ -0,0 +1,42 @@
import { create } from "zustand"
import type { Ancillary } from "@/types/components/myPages/myStay/ancillaries"
interface AddAncillaryState {
step: number
totalSteps: number
nextStep: () => void
prevStep: () => void
resetStore: () => void
selectedAncillary: Ancillary["ancillaryContent"][number] | null
setSelectedAncillary: (
ancillary: Ancillary["ancillaryContent"][number]
) => void
confirmationNumber: string
setConfirmationNumber: (confirmationNumber: string) => void
openedFrom: "list" | "grid" | null
setOpenedFrom: (source: "list" | "grid") => void
isGridOpen: boolean
setGridIsOpen: (isOpen: boolean) => void
}
export const useAddAncillaryStore = create<AddAncillaryState>((set) => ({
step: 1,
totalSteps: 3,
nextStep: () =>
set((state) =>
state.step < state.totalSteps ? { step: state.step + 1 } : {}
),
prevStep: () =>
set((state) => (state.step > 1 ? { step: state.step - 1 } : {})),
resetStore: () => set({ step: 1 }),
selectedAncillary: null,
setSelectedAncillary: (ancillary) => set({ selectedAncillary: ancillary }),
confirmationNumber: "",
setConfirmationNumber: (confirmationNumber) =>
set({ confirmationNumber: confirmationNumber }),
openedFrom: null,
setOpenedFrom: (source) => set({ openedFrom: source }),
isGridOpen: false,
setGridIsOpen: (isOpen) => set({ isGridOpen: isOpen }),
}))

View File

@@ -19,3 +19,9 @@ export interface BreakfastChoiceCardProps extends AncillaryCardProps {
id?: string
value: string
}
export interface AncillaryChoiceCardProps extends AncillaryCardProps {
name: string
id?: string
value: string
}

View File

@@ -1,13 +1,15 @@
import type { z } from "zod"
import type { BookingConfirmation } from "@/types/trpc/routers/booking/confirmation"
import type { User } from "@/types/user"
import type { ancillaryPackagesSchema } from "@/server/routers/hotels/output"
export type Ancillaries = z.output<typeof ancillaryPackagesSchema>
export type Ancillary = Ancillaries[number]
export interface AncillariesProps {
export interface AncillariesProps extends Pick<BookingConfirmation, "booking"> {
ancillaries: Ancillaries | null
user: User | null
}
export interface AncillaryProps {
@@ -17,3 +19,28 @@ export interface AncillaryProps {
export interface MyStayProps extends BookingConfirmation {
ancillaries: Ancillaries | null
}
export interface AncillaryGridModalProps {
ancillaries: Ancillaries
selectedCategory: string | null
setSelectedCategory: (category: string) => void
handleCardClick: (ancillary: Ancillary["ancillaryContent"][number]) => void
}
export interface AddAncillaryFlowModalProps
extends Pick<BookingConfirmation, "booking"> {
isOpen: boolean
onClose: () => void
user: User | null
}
export interface DeliveryMethodStepProps {
deliveryTimeOptions: {
label: string
value: string
}[]
}
export interface SelectQuantityStepProps {
user: User | null
}

View File

@@ -188,3 +188,4 @@ export type TrackingPosition =
| "hamburger menu"
| "join scandic friends sidebar"
| "enter details"
| "my stay"