Merged in feat/sw-1688-list-breakfast (pull request #1615)

Feat/sw-1688 list breakfast

Approved-by: Pontus Dreij
This commit is contained in:
Niclas Edenvin
2025-03-25 09:56:05 +00:00
parent a3950c5072
commit fef3a785d0
21 changed files with 196 additions and 43 deletions

View File

@@ -80,7 +80,7 @@ export default function Breakfast() {
ancillary={{
title: intl.formatMessage({ id: "Breakfast buffet" }),
price: {
total: pkg.localPrice.price,
totalPrice: pkg.localPrice.price,
currency: pkg.localPrice.currency,
included:
pkg.code === BreakfastPackageEnum.FREE_MEMBER_BREAKFAST,
@@ -100,7 +100,7 @@ export default function Breakfast() {
ancillary={{
title: intl.formatMessage({ id: "No breakfast" }),
price: {
total: 0,
totalPrice: 0,
currency: packages?.[0].localPrice.currency ?? "",
},
description: intl.formatMessage({

View File

@@ -44,7 +44,7 @@ export default function PriceSummary({
label={intl.formatMessage({ id: "Price including VAT" })}
value={formatPrice(
intl,
selectedAncillary.price.total,
selectedAncillary.price.totalPrice,
selectedAncillary.price.currency
)}
/>

View File

@@ -36,7 +36,7 @@ export default function PriceDetails({
}
const totalPrice =
quantityWithCard && selectedAncillary
? selectedAncillary.price.total * quantityWithCard
? selectedAncillary.price.totalPrice * quantityWithCard
: null
const totalPoints =

View File

@@ -12,6 +12,7 @@ import Select from "@/components/TempDesignSystem/Form/Select"
import styles from "./selectQuantityStep.module.css"
import type { SelectQuantityStepProps } from "@/types/components/myPages/myStay/ancillaries"
import { BreakfastPackageEnum } from "@/types/enums/breakfast"
export default function SelectQuantityStep({ user }: SelectQuantityStepProps) {
const intl = useIntl()
@@ -46,6 +47,13 @@ export default function SelectQuantityStep({ user }: SelectQuantityStepProps) {
? intl.formatMessage({ id: "Insufficient points" })
: intl.formatMessage({ id: "Select quantity" })
// TODO: Remove this when add breakfast is implemented
if (
selectedAncillary?.id === BreakfastPackageEnum.ANCILLARY_REGULAR_BREAKFAST
) {
return "Breakfast TBI"
}
return (
<div className={styles.selectContainer}>
{selectedAncillary?.points && user && (

View File

@@ -40,6 +40,7 @@ import Steps from "./Steps"
import styles from "./addAncillaryFlowModal.module.css"
import type { AddAncillaryFlowModalProps } from "@/types/components/myPages/myStay/ancillaries"
import { BreakfastPackageEnum } from "@/types/enums/breakfast"
export default function AddAncillaryFlowModal({
booking,
@@ -218,6 +219,7 @@ export default function AddAncillaryFlowModal({
</div>
)
}
const modalTitle =
currentStep === AncillaryStepEnum.selectAncillary
? intl.formatMessage({ id: "Upgrade your stay" })
@@ -251,7 +253,7 @@ export default function AddAncillaryFlowModal({
<p>
{formatPrice(
intl,
selectedAncillary.price.total,
selectedAncillary.price.totalPrice,
selectedAncillary.price.currency
)}
</p>
@@ -290,7 +292,10 @@ export default function AddAncillaryFlowModal({
<Steps user={user} savedCreditCards={savedCreditCards} />
</div>
</form>
{currentStep === AncillaryStepEnum.selectAncillary ? null : (
{/* TODO: Remove the berakfast check when add breakfast is implemented */}
{currentStep === AncillaryStepEnum.selectAncillary ||
selectedAncillary?.id ===
BreakfastPackageEnum.ANCILLARY_REGULAR_BREAKFAST ? null : (
<div
className={
currentStep === AncillaryStepEnum.confirmation

View File

@@ -1,4 +1,5 @@
"use client"
import { useMemo } from "react"
import { useIntl } from "react-intl"
import { Carousel } from "@/components/Carousel"
@@ -17,46 +18,145 @@ import type {
Ancillaries,
AncillariesProps,
Ancillary,
SelectedAncillary,
} from "@/types/components/myPages/myStay/ancillaries"
import { BreakfastPackageEnum } from "@/types/enums/breakfast"
import type { User } from "@/types/user"
function filterPoints(ancillaries: Ancillaries, user: User | null) {
return ancillaries.map((ancillary) => {
return {
...ancillary,
ancillaryContent: ancillary.ancillaryContent.map(
({ points, ...ancillary }) => ({
...ancillary,
points: user ? points : undefined,
})
),
}
})
}
function generateUniqueAncillaries(
ancillaries: Ancillaries
): Ancillary["ancillaryContent"] {
const uniqueAncillaries = new Map(
ancillaries.flatMap((a) => {
return a.ancillaryContent.map((ancillary) => [ancillary.id, ancillary])
})
)
return [...uniqueAncillaries.values()]
}
/**
* Adds the breakfast package to the ancillaries
*
* Returns the ancillaries array with the breakfast package added to the
* specified category. If the category doesn't exist it's created.
*/
function addBreakfastPackage(
ancillaries: Ancillaries,
breakfast: SelectedAncillary | undefined,
categoryName: string
): Ancillaries {
if (!breakfast) return ancillaries
const category = ancillaries.find((a) => a.categoryName === categoryName)
if (category) {
const newCategory = {
...category,
ancillaryContent: [breakfast, ...category.ancillaryContent],
}
return ancillaries.map((ancillary) =>
ancillary.categoryName === categoryName ? newCategory : ancillary
)
}
return [{ categoryName, ancillaryContent: [breakfast] }, ...ancillaries]
}
export function Ancillaries({
ancillaries,
booking,
packages,
user,
savedCreditCards,
refId,
}: AncillariesProps) {
const intl = useIntl()
/**
* A constructed ancillary for breakfast
*
* This is a "fake" ancillary for breakfast, since breakfast isn't really an
* ancillary in the system. This makes it play nicely with the add ancillary
* flow. If the user shouldn't be able to add breakfast this will be `undefined`.
*/
const breakfastAncillary = useMemo(() => {
// This is the logic deciding if breakfast should be addable or not
if (
booking.rateDefinition.breakfastIncluded ||
booking.packages.some((p) =>
Object.values(BreakfastPackageEnum).includes(
p.code as unknown as BreakfastPackageEnum
)
)
) {
return undefined
}
const breakfastPackage = packages?.find(
(p) => p.code === BreakfastPackageEnum.ANCILLARY_REGULAR_BREAKFAST
)
const breakfastAncillary: SelectedAncillary | undefined = breakfastPackage
? {
description: intl.formatMessage({ id: "Buffet" }),
id: breakfastPackage.code,
title: intl.formatMessage({ id: "Breakfast" }),
price: breakfastPackage.localPrice,
// TODO: Change this to the correct URL, whatever that is
imageUrl:
"https://images-test.scandichotels.com/publishedmedia/hcf9hchiad7zrvlkc2pt/Breakfast_-_Scandic_Sweden_-_Free_to_use.jpg",
requiresDeliveryTime: false,
loyaltyCode: undefined,
points: undefined,
}
: undefined
return breakfastAncillary
}, [
booking.packages,
booking.rateDefinition.breakfastIncluded,
intl,
packages,
])
const allAncillaries = useMemo(() => {
if (!ancillaries?.length) {
return []
}
const withBreakfastPopular = addBreakfastPackage(
ancillaries,
breakfastAncillary,
"Popular"
)
const withBreakfastFood = addBreakfastPackage(
withBreakfastPopular,
breakfastAncillary,
"Food"
)
const filtered = filterPoints(withBreakfastFood, user)
return filtered
}, [ancillaries, breakfastAncillary, user])
if (!ancillaries?.length) {
return null
}
function filterPoints(ancillaries: Ancillaries) {
return ancillaries.map((ancillary) => {
return {
...ancillary,
ancillaryContent: ancillary.ancillaryContent.map(
({ points, ...ancillary }) => ({
...ancillary,
points: user ? points : undefined,
})
),
}
})
}
function generateUniqueAncillaries(
ancillaries: Ancillaries
): Ancillary["ancillaryContent"] {
const uniqueAncillaries = new Map(
ancillaries.flatMap((a) =>
a.ancillaryContent.map((ancillary) => [ancillary.id, ancillary])
)
)
return [...uniqueAncillaries.values()]
}
const allAncillaries = filterPoints(ancillaries)
const uniqueAncillaries = generateUniqueAncillaries(allAncillaries)
return (
@@ -77,7 +177,7 @@ export function Ancillaries({
<div className={styles.mobileAncillaries}>
<Carousel>
<Carousel.Content className={styles.carouselContainer}>
<Carousel.Content>
{uniqueAncillaries.map((ancillary) => {
return (
<Carousel.Item key={ancillary.id}>
@@ -86,8 +186,8 @@ export function Ancillaries({
)
})}
</Carousel.Content>
<Carousel.Previous className={styles.navigationButton} />
<Carousel.Next className={styles.navigationButton} />
<Carousel.Previous />
<Carousel.Next />
<Carousel.Dots />
</Carousel>
</div>

View File

@@ -109,7 +109,9 @@ export function SingleRoom({ bedType, image, hotel, user }: RoomProps) {
const adultsAndChildrenMsg = [adultsMsg, childrenMsg].join(", ")
const hasPackages = packages?.some((item) =>
Object.values(RoomPackageCodeEnum).includes(item.code)
Object.values(RoomPackageCodeEnum).includes(
item.code as RoomPackageCodeEnum
)
)
return (
@@ -270,7 +272,7 @@ export function SingleRoom({ bedType, image, hotel, user }: RoomProps) {
{packages!
.filter((item) =>
Object.values(RoomPackageCodeEnum).includes(
item.code
item.code as RoomPackageCodeEnum
)
)
.map((item) => item.description)

View File

@@ -9,6 +9,7 @@ import { dt } from "@/lib/dt"
import {
getAncillaryPackages,
getBookingConfirmation,
getPackages,
getProfileSafely,
getSavedPaymentCardsSafely,
} from "@/lib/trpc/memoizedRequests"
@@ -33,6 +34,8 @@ import Rooms from "./Rooms"
import styles from "./myStay.module.css"
import { BreakfastPackageEnum } from "@/types/enums/breakfast"
export async function MyStay({ refId }: { refId: string }) {
const value = decrypt(refId)
if (!value) {
@@ -58,6 +61,21 @@ export async function MyStay({ refId }: { refId: string }) {
hotelId: hotel.operaId,
toDate: dt(booking.checkOutDate).format("YYYY-MM-DD"),
})
const packages = await getPackages({
startDate: dt(booking.checkInDate).format("YYYY-MM-DD"),
hotelId: hotel.operaId,
endDate: dt(booking.checkOutDate).format("YYYY-MM-DD"),
adults: booking.adults,
children: booking.childrenAges.length,
packageCodes: [
BreakfastPackageEnum.ANCILLARY_REGULAR_BREAKFAST,
BreakfastPackageEnum.ANCILLARY_CHILD_PAYING_BREAKFAST,
BreakfastPackageEnum.FREE_CHILD_BREAKFAST,
],
lang,
})
const supportedCards = hotel.merchantInformationData.cards
const savedCreditCards = await getSavedPaymentCardsSafely({
supportedCards,
@@ -97,6 +115,7 @@ export async function MyStay({ refId }: { refId: string }) {
<Ancillaries
ancillaries={ancillaryPackages}
booking={booking}
packages={packages}
user={user}
savedCreditCards={savedCreditCards}
refId={refId}

View File

@@ -230,7 +230,9 @@ export default function BookedRoomSidePeek({
</div>
</div>
{packages?.some((item) =>
Object.values(RoomPackageCodeEnum).includes(item.code)
Object.values(RoomPackageCodeEnum).includes(
item.code as RoomPackageCodeEnum
)
) && (
<div className={styles.row}>
<span className={styles.rowTitle}>
@@ -244,7 +246,9 @@ export default function BookedRoomSidePeek({
<p color="uiTextHighContrast">
{packages
?.filter((item) =>
Object.values(RoomPackageCodeEnum).includes(item.code)
Object.values(RoomPackageCodeEnum).includes(
item.code as RoomPackageCodeEnum
)
)
.map((item) => item.description)
.join(", ")}

View File

@@ -13,7 +13,7 @@ import type { AncillaryCardProps } from "@/types/components/ancillaryCard"
export function AncillaryCard({ ancillary }: AncillaryCardProps) {
const intl = useIntl()
const priceMsg = `${formatPrice(intl, ancillary.price.total, ancillary.price.currency)} ${ancillary.price.text ?? ""}`
const priceMsg = `${formatPrice(intl, ancillary.price.totalPrice, ancillary.price.currency)} ${ancillary.price.text ?? ""}`
return (
<article className={styles.ancillaryCard}>

View File

@@ -131,6 +131,7 @@
"Breakfast included": "Morgenmad inkluderet",
"Breakfast is included.": "Morgenmad er inkluderet.",
"Breakfast selection in next step.": "Valg af morgenmad i næste trin.",
"Buffet": "Buffet",
"Bus terminal": "Busstation",
"Business": "Forretning",
"By accepting the <termsAndConditionsLink>Terms and Conditions for Scandic Friends</termsAndConditionsLink> I understand that my personal data will be processed in accordance with <privacyPolicy>Scandic's Privacy Policy</privacyPolicy>.": "Ved at acceptere <termsAndConditionsLink>vilkårene og betingelserne for Scandic Friends</termsAndConditionsLink>, forstår jeg, at mine personlige oplysninger vil blive behandlet i overensstemmelse med <privacyPolicy>Scandics privatlivspolitik</privacyPolicy>.",

View File

@@ -132,6 +132,7 @@
"Breakfast included": "Frühstück inbegriffen",
"Breakfast is included.": "Frühstück ist inbegriffen.",
"Breakfast selection in next step.": "Frühstücksauswahl in nächsten Schritt.",
"Buffet": "Büfett",
"Bus terminal": "Bus terminal",
"Business": "Geschäft",
"By accepting the <termsAndConditionsLink>Terms and Conditions for Scandic Friends</termsAndConditionsLink> I understand that my personal data will be processed in accordance with <privacyPolicy>Scandic's Privacy Policy</privacyPolicy>.": "Mit der Annahme der <termsAndConditionsLink>Allgemeinen Geschäftsbedingungen für Scandic Friends</termsAndConditionsLink> erkläre ich mich damit einverstanden, dass meine persönlichen Daten in Übereinstimmung mit der <privacyPolicy>Datenschutzrichtlinie von Scandic verarbeitet werden</privacyPolicy>.",

View File

@@ -130,6 +130,7 @@
"Breakfast included": "Breakfast included",
"Breakfast is included.": "Breakfast is included.",
"Breakfast selection in next step.": "Breakfast selection in next step.",
"Buffet": "Buffet",
"Bus terminal": "Bus terminal",
"Business": "Business",
"By accepting the <termsAndConditionsLink>Terms and Conditions for Scandic Friends</termsAndConditionsLink> I understand that my personal data will be processed in accordance with <privacyPolicy>Scandic's Privacy Policy</privacyPolicy>.": "By accepting the <termsAndConditionsLink>Terms and Conditions for Scandic Friends</termsAndConditionsLink> I understand that my personal data will be processed in accordance with <privacyPolicy>Scandic's Privacy Policy</privacyPolicy>.",

View File

@@ -130,6 +130,7 @@
"Breakfast included": "Aamiainen sisältyy",
"Breakfast is included.": "Aamiainen sisältyy.",
"Breakfast selection in next step.": "Aamiaisvalinta seuraavassa vaiheessa.",
"Buffet": "Buffet",
"Bus terminal": "Bussiasema",
"Business": "Business",
"By accepting the <termsAndConditionsLink>Terms and Conditions for Scandic Friends</termsAndConditionsLink> I understand that my personal data will be processed in accordance with <privacyPolicy>Scandic's Privacy Policy</privacyPolicy>.": "Kyllä, <termsAndConditionsLink>hyväksyn Scandic Friends -jäsenyyttä</termsAndConditionsLink> koskevat ehdot ja ymmärrän, että Scandic käsittelee henkilötietojani <privacyPolicy>Scandicin Tietosuojaselosteen mukaisesti</privacyPolicy>.",

View File

@@ -130,6 +130,7 @@
"Breakfast included": "Frokost inkludert",
"Breakfast is included.": "Frokost er inkludert.",
"Breakfast selection in next step.": "Frokostvalg i neste steg.",
"Buffet": "Buffet",
"Bus terminal": "Bussterminal",
"Business": "Forretnings",
"By accepting the <termsAndConditionsLink>Terms and Conditions for Scandic Friends</termsAndConditionsLink> I understand that my personal data will be processed in accordance with <privacyPolicy>Scandic's Privacy Policy</privacyPolicy>.": "Ved å akseptere <termsAndConditionsLink>vilkårene og betingelsene for Scandic Friends</termsAndConditionsLink>, er jeg inneforstått med at mine personopplysninger vil bli behandlet i samsvar med <privacyPolicy>Scandics personvernpolicy</privacyPolicy>.",

View File

@@ -130,6 +130,7 @@
"Breakfast included": "Frukost ingår",
"Breakfast is included.": "Frukost ingår.",
"Breakfast selection in next step.": "Frukostval i nästa steg.",
"Buffet": "Buffé",
"Bus terminal": "Bussterminal",
"Business": "Business",
"By accepting the <termsAndConditionsLink>Terms and Conditions for Scandic Friends</termsAndConditionsLink> I understand that my personal data will be processed in accordance with <privacyPolicy>Scandic's Privacy Policy</privacyPolicy>.": "Genom att acceptera <termsAndConditionsLink>villkoren för Scandic Friends</termsAndConditionsLink> förstår jag att mina personuppgifter kommer att behandlas i enlighet med <privacyPolicy>Scandics Integritetspolicy</privacyPolicy>.",

View File

@@ -507,7 +507,7 @@ export const ancillaryPackagesSchema = z
description: item.descriptions.html,
imageUrl: item.images[0]?.imageSizes.small,
price: {
total: item.variants.ancillary.price.totalPrice,
totalPrice: item.variants.ancillary.price.totalPrice,
currency: item.variants.ancillary.price.currency,
},
points: item.variants.ancillaryLoyalty?.points,

View File

@@ -3,6 +3,7 @@ import { z } from "zod"
import { imageSizesSchema } from "./image"
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
import { BreakfastPackageEnum } from "@/types/enums/breakfast"
import { PackageTypeEnum } from "@/types/enums/packages"
// TODO: Remove optional and default when the API change has been deployed
@@ -41,7 +42,7 @@ export const ancillaryContentSchema = z.object({
})
export const packageSchema = z.object({
code: z.nativeEnum(RoomPackageCodeEnum),
code: z.nativeEnum({ ...RoomPackageCodeEnum, ...BreakfastPackageEnum }),
description: z.string(),
inventories: z.array(inventorySchema),
itemCode: z.string().default(""),

View File

@@ -4,7 +4,7 @@ export interface AncillaryCardProps {
imageUrl: string
imageOpacity?: number
price: {
total: number
totalPrice: number
currency: string
text?: string
included?: boolean

View File

@@ -2,14 +2,19 @@ import type { z } from "zod"
import type { BookingConfirmation } from "@/types/trpc/routers/booking/confirmation"
import type { CreditCard, User } from "@/types/user"
import type { ancillaryPackagesSchema } from "@/server/routers/hotels/output"
import type {
ancillaryPackagesSchema,
packagesSchema,
} from "@/server/routers/hotels/output"
export type Ancillaries = z.output<typeof ancillaryPackagesSchema>
export type Ancillary = Ancillaries[number]
export type SelectedAncillary = Ancillary["ancillaryContent"][number]
export type Packages = z.output<typeof packagesSchema>
export interface AncillariesProps extends Pick<BookingConfirmation, "booking"> {
ancillaries: Ancillaries | null
packages: Packages | null
user: User | null
savedCreditCards: CreditCard[] | null
refId: string

View File

@@ -1,5 +1,8 @@
export enum BreakfastPackageEnum {
FREE_MEMBER_BREAKFAST = "BRF0",
FREE_CHILD_BREAKFAST = "BRFINF",
REGULAR_BREAKFAST = "BRF1",
SPECIAL_PACKAGE_BREAKFAST = "F01S",
ANCILLARY_REGULAR_BREAKFAST = "BRF2",
ANCILLARY_CHILD_PAYING_BREAKFAST = "BRF2C",
}