From a0286603db1d9c2699430f34797ea9067cf6a709 Mon Sep 17 00:00:00 2001 From: Pontus Dreij Date: Fri, 21 Feb 2025 09:06:15 +0000 Subject: [PATCH] Merged in feat(SW-1275)-cancel-booking-my-stay (pull request #1376) Feat(SW-1275) cancel booking my stay * feat(SW-1276) UI implementation Desktop part 1 for MyStay * feat(SW-1276) UI implementation Desktop part 2 for MyStay * feat(SW-1276) UI implementation Mobile part 1 for MyStay * refactor: move files from MyStay/MyStay to MyStay * feat(SW-1276) Sidepeek implementation * feat(SW-1276): Refactoring * feat(SW-1276) UI implementation Mobile part 2 for MyStay * feat(SW-1276): translations * feat(SW-1276) fixed skeleton * feat(SW-1276): Added missing translations * feat(SW-1276) fixed translations * feat(SW-1275) cancel modal * feat(SW-1275): Mutate cancel booking * feat(SW-1275) added translations * feat(SW-1275) match current cancellationReason * feat(SW-1275) Added modal for manage stay * feat(SW-1275) Added missing icon * feat(SW-1275) New Dont cancel button * feat(SW-1275) Added preperation for Cancellation number * feat(SW-1275): added --modal-box-shadow * feat(SW-1718) Add to calendar * feat(SW-1718) general add to calendar Approved-by: Niclas Edenvin --- app/globals.css | 2 + .../Rewards/Redeem/redeem.module.css | 2 +- components/Dialog/dialog.module.css | 2 +- .../HotelReservation/AddToCalendar/index.tsx | 50 ++++++ .../Header/Actions/AddToCalendar.tsx | 61 ------- .../Header/Actions/AddToCalendarButton.tsx | 20 +++ .../BookingConfirmation/Header/index.tsx | 4 +- .../BookingConfirmation/index.tsx | 13 +- .../priceChangeDialog.module.css | 2 +- .../MyStay/CancelStay/Confirmation/index.tsx | 70 ++++++++ .../CancelStay/FinalConfirmation/index.tsx | 55 +++++++ .../MyStay/CancelStay/cancelStay.module.css | 24 +++ .../MyStay/CancelStay/hooks/useCancelStay.ts | 86 ++++++++++ .../MyStay/CancelStay/index.tsx | 87 ++++++++++ .../MyStay/CancelStay/utils.ts | 44 ++++++ .../Actions/AddToCalendarButton.tsx | 29 ++++ .../ActionPanel/actionPanel.module.css | 42 +++++ .../MyStay/ManageStay/ActionPanel/index.tsx | 149 ++++++++++++++++++ .../MyStay/ManageStay/ModalContent/index.tsx | 62 ++++++++ .../ModalContent/modalContent.module.css | 34 ++++ .../MyStay/ManageStay/index.tsx | 136 ++++++++++++++++ .../MyStay/ManageStay/modifyModal.module.css | 62 ++++++++ .../MyStay/ReferenceCard/index.tsx | 53 ++++++- .../HotelReservation/MyStay/Room/index.tsx | 2 +- components/HotelReservation/MyStay/index.tsx | 29 ++-- .../HotelReservation/MyStay/myStay.module.css | 7 + .../filterAndSortModal.module.css | 2 +- components/Icons/CrossCircleOutline.tsx | 27 ++++ components/Icons/get-icon-by-icon-name.ts | 3 + components/Icons/index.tsx | 1 + components/Modal/modal.module.css | 4 +- components/Modal/motionVariants.ts | 13 ++ .../MyPages/Surprises/surprises.module.css | 2 +- i18n/dictionaries/da.json | 16 ++ i18n/dictionaries/de.json | 17 ++ i18n/dictionaries/en.json | 16 ++ i18n/dictionaries/fi.json | 17 ++ i18n/dictionaries/no.json | 17 ++ i18n/dictionaries/sv.json | 17 ++ server/routers/booking/input.ts | 5 + server/routers/booking/mutation.ts | 105 +++++++++++- server/routers/booking/output.ts | 57 +++++-- server/routers/booking/query.ts | 6 + .../actions/addToCalendar.ts | 9 +- types/components/icon.ts | 1 + 45 files changed, 1358 insertions(+), 104 deletions(-) create mode 100644 components/HotelReservation/AddToCalendar/index.tsx delete mode 100644 components/HotelReservation/BookingConfirmation/Header/Actions/AddToCalendar.tsx create mode 100644 components/HotelReservation/BookingConfirmation/Header/Actions/AddToCalendarButton.tsx create mode 100644 components/HotelReservation/MyStay/CancelStay/Confirmation/index.tsx create mode 100644 components/HotelReservation/MyStay/CancelStay/FinalConfirmation/index.tsx create mode 100644 components/HotelReservation/MyStay/CancelStay/cancelStay.module.css create mode 100644 components/HotelReservation/MyStay/CancelStay/hooks/useCancelStay.ts create mode 100644 components/HotelReservation/MyStay/CancelStay/index.tsx create mode 100644 components/HotelReservation/MyStay/CancelStay/utils.ts create mode 100644 components/HotelReservation/MyStay/ManageStay/ActionPanel/Actions/AddToCalendarButton.tsx create mode 100644 components/HotelReservation/MyStay/ManageStay/ActionPanel/actionPanel.module.css create mode 100644 components/HotelReservation/MyStay/ManageStay/ActionPanel/index.tsx create mode 100644 components/HotelReservation/MyStay/ManageStay/ModalContent/index.tsx create mode 100644 components/HotelReservation/MyStay/ManageStay/ModalContent/modalContent.module.css create mode 100644 components/HotelReservation/MyStay/ManageStay/index.tsx create mode 100644 components/HotelReservation/MyStay/ManageStay/modifyModal.module.css create mode 100644 components/Icons/CrossCircleOutline.tsx diff --git a/app/globals.css b/app/globals.css index b602c3835..80d378314 100644 --- a/app/globals.css +++ b/app/globals.css @@ -130,6 +130,8 @@ --default-modal-overlay-z-index: 100; --default-modal-z-index: 101; + --modal-box-shadow: 0px 4px 24px 0px rgba(38, 32, 30, 0.08); + @supports (interpolate-size: allow-keywords) { interpolate-size: allow-keywords; } diff --git a/components/Blocks/DynamicContent/Rewards/Redeem/redeem.module.css b/components/Blocks/DynamicContent/Rewards/Redeem/redeem.module.css index 2999e34f9..57f1613f9 100644 --- a/components/Blocks/DynamicContent/Rewards/Redeem/redeem.module.css +++ b/components/Blocks/DynamicContent/Rewards/Redeem/redeem.module.css @@ -37,7 +37,7 @@ .modal { background-color: var(--Base-Surface-Primary-light-Normal); border-radius: var(--Corner-radius-Medium); - box-shadow: 0px 4px 24px 0px rgba(38, 32, 30, 0.08); + box-shadow: var(--modal-box-shadow); width: 100%; position: absolute; left: 0; diff --git a/components/Dialog/dialog.module.css b/components/Dialog/dialog.module.css index ab3abd26e..986bc9b66 100644 --- a/components/Dialog/dialog.module.css +++ b/components/Dialog/dialog.module.css @@ -32,7 +32,7 @@ .modal { background-color: var(--Base-Surface-Primary-light-Normal); border-radius: var(--Corner-radius-Medium); - box-shadow: 0px 4px 24px 0px rgba(38, 32, 30, 0.08); + box-shadow: var(--modal-box-shadow); display: flex; flex-direction: column; gap: var(--Spacing-x3); diff --git a/components/HotelReservation/AddToCalendar/index.tsx b/components/HotelReservation/AddToCalendar/index.tsx new file mode 100644 index 000000000..0e95a51fa --- /dev/null +++ b/components/HotelReservation/AddToCalendar/index.tsx @@ -0,0 +1,50 @@ +"use client" +import { createEvent } from "ics" +import { useIntl } from "react-intl" + +import { dt } from "@/lib/dt" + +import { toast } from "@/components/TempDesignSystem/Toasts" +import useLang from "@/hooks/useLang" + +import type { AddToCalendarProps } from "@/types/components/hotelReservation/bookingConfirmation/actions/addToCalendar" + +export default function AddToCalendar({ + checkInDate, + event, + hotelName, + renderButton, +}: AddToCalendarProps) { + const lang = useLang() + const intl = useIntl() + + async function downloadBooking() { + try { + const d = dt(checkInDate).locale(lang).format("YYYY-MM-DD") + const filename = `${hotelName.toLowerCase().split(" ").join("_")}-${d}.ics` + + createEvent(event, (error, value) => { + if (error) { + console.error("ICS Error:", error) + toast.error(intl.formatMessage({ id: "Failed to add to calendar" })) + return + } + + const file = new File([value], filename, { type: "text/calendar" }) + const url = URL.createObjectURL(file) + const anchor = document.createElement("a") + anchor.href = url + anchor.download = filename + document.body.appendChild(anchor) + anchor.click() + document.body.removeChild(anchor) + URL.revokeObjectURL(url) + }) + } catch (error) { + console.error("Download error:", error) + toast.error(intl.formatMessage({ id: "Failed to add to calendar" })) + } + } + + return renderButton(downloadBooking) +} diff --git a/components/HotelReservation/BookingConfirmation/Header/Actions/AddToCalendar.tsx b/components/HotelReservation/BookingConfirmation/Header/Actions/AddToCalendar.tsx deleted file mode 100644 index a21055bee..000000000 --- a/components/HotelReservation/BookingConfirmation/Header/Actions/AddToCalendar.tsx +++ /dev/null @@ -1,61 +0,0 @@ -"use client" -import { createEvent } from "ics" -import { useIntl } from "react-intl" - -import { dt } from "@/lib/dt" - -import { CalendarAddIcon } from "@/components/Icons" -import Button from "@/components/TempDesignSystem/Button" -import useLang from "@/hooks/useLang" - -import type { AddToCalendarProps } from "@/types/components/hotelReservation/bookingConfirmation/actions/addToCalendar" - -export default function AddToCalendar({ - checkInDate, - event, - hotelName, -}: AddToCalendarProps) { - const intl = useIntl() - const lang = useLang() - - async function downloadBooking() { - const d = dt(checkInDate).locale(lang).format("YYYY-MM-DD") - const filename = `${hotelName.toLowerCase().split(" ").join("_")}-${d}.ics` - - const file: Blob = await new Promise((resolve, reject) => { - createEvent(event, (error, value) => { - if (error) { - reject(error) - } - - resolve(new File([value], filename, { type: "text/calendar" })) - }) - }) - - const url = URL.createObjectURL(file) - - const anchor = document.createElement("a") - anchor.href = url - anchor.download = filename - - document.body.appendChild(anchor) - anchor.click() - document.body.removeChild(anchor) - - URL.revokeObjectURL(url) - } - - return ( - - ) -} diff --git a/components/HotelReservation/BookingConfirmation/Header/Actions/AddToCalendarButton.tsx b/components/HotelReservation/BookingConfirmation/Header/Actions/AddToCalendarButton.tsx new file mode 100644 index 000000000..a82e696cc --- /dev/null +++ b/components/HotelReservation/BookingConfirmation/Header/Actions/AddToCalendarButton.tsx @@ -0,0 +1,20 @@ +"use client" +import { useIntl } from "react-intl" + +import { CalendarAddIcon } from "@/components/Icons" +import Button from "@/components/TempDesignSystem/Button" + +export default function AddToCalendarButton({ + onPress, +}: { + onPress: () => void +}) { + const intl = useIntl() + + return ( + + ) +} diff --git a/components/HotelReservation/BookingConfirmation/Header/index.tsx b/components/HotelReservation/BookingConfirmation/Header/index.tsx index ba7ef1e36..fe2471b16 100644 --- a/components/HotelReservation/BookingConfirmation/Header/index.tsx +++ b/components/HotelReservation/BookingConfirmation/Header/index.tsx @@ -6,7 +6,8 @@ import Body from "@/components/TempDesignSystem/Text/Body" import Subtitle from "@/components/TempDesignSystem/Text/Subtitle" import Title from "@/components/TempDesignSystem/Text/Title" -import AddToCalendar from "./Actions/AddToCalendar" +import AddToCalendar from "../../AddToCalendar" +import AddToCalendarButton from "./Actions/AddToCalendarButton" import DownloadInvoice from "./Actions/DownloadInvoice" import { generateDateTime } from "./Actions/helpers" import ManageBooking from "./Actions/ManageBooking" @@ -80,6 +81,7 @@ export default function Header({ checkInDate={booking.checkInDate} event={event} hotelName={hotel.name} + renderButton={(onPress) => } /> Array(c.quantity).fill(invertedBedTypeMap[c.bedType])) .join("|"), noOfRooms: 1, // // TODO: Handle multiple rooms diff --git a/components/HotelReservation/EnterDetails/PriceChangeDialog/priceChangeDialog.module.css b/components/HotelReservation/EnterDetails/PriceChangeDialog/priceChangeDialog.module.css index b90f4c380..a695d973f 100644 --- a/components/HotelReservation/EnterDetails/PriceChangeDialog/priceChangeDialog.module.css +++ b/components/HotelReservation/EnterDetails/PriceChangeDialog/priceChangeDialog.module.css @@ -51,7 +51,7 @@ .dialog { background-color: var(--Scandic-Brand-Pale-Peach); border-radius: var(--Corner-radius-Medium); - box-shadow: 0px 4px 24px 0px rgba(38, 32, 30, 0.08); + box-shadow: var(--modal-box-shadow); display: flex; flex-direction: column; gap: var(--Spacing-x2); diff --git a/components/HotelReservation/MyStay/CancelStay/Confirmation/index.tsx b/components/HotelReservation/MyStay/CancelStay/Confirmation/index.tsx new file mode 100644 index 000000000..d30e032e1 --- /dev/null +++ b/components/HotelReservation/MyStay/CancelStay/Confirmation/index.tsx @@ -0,0 +1,70 @@ +import { useIntl } from "react-intl" + +import Body from "@/components/TempDesignSystem/Text/Body" +import Caption from "@/components/TempDesignSystem/Text/Caption" +import Subtitle from "@/components/TempDesignSystem/Text/Subtitle" + +import styles from "../cancelStay.module.css" + +import type { Hotel } from "@/types/hotel" +import type { BookingConfirmation } from "@/types/trpc/routers/booking/confirmation" + +interface CancelStayConfirmationProps { + hotel: Hotel + booking: BookingConfirmation["booking"] + stayDetails: { + checkInDate: string + checkOutDate: string + nightsText: string + adultsText: string + childrenText: string + } +} + +export function CancelStayConfirmation({ + hotel, + booking, + stayDetails, +}: CancelStayConfirmationProps) { + const intl = useIntl() + + return ( + <> +
+ + {intl.formatMessage( + { + id: "Are you sure you want to cancel your stay at {hotel} from {checkInDate} to {checkOutDate}? This can't be reversed.", + }, + { + hotel: hotel.name, + checkInDate: stayDetails.checkInDate, + checkOutDate: stayDetails.checkOutDate, + } + )} + + + {intl.formatMessage({ id: "No charges were made." })} + +
+
+
+ + {intl.formatMessage({ id: "Cancellation cost" })} + + + {stayDetails.nightsText}, {stayDetails.adultsText} + {booking.childrenAges?.length > 0 + ? `, ${stayDetails.childrenText}` + : ""} + +
+
+ + 0 {booking.currencyCode} + +
+
+ + ) +} diff --git a/components/HotelReservation/MyStay/CancelStay/FinalConfirmation/index.tsx b/components/HotelReservation/MyStay/CancelStay/FinalConfirmation/index.tsx new file mode 100644 index 000000000..ea97e67f5 --- /dev/null +++ b/components/HotelReservation/MyStay/CancelStay/FinalConfirmation/index.tsx @@ -0,0 +1,55 @@ +import { useIntl } from "react-intl" + +import Body from "@/components/TempDesignSystem/Text/Body" +import Caption from "@/components/TempDesignSystem/Text/Caption" +import Subtitle from "@/components/TempDesignSystem/Text/Subtitle" + +import styles from "../cancelStay.module.css" + +import type { BookingConfirmation } from "@/types/trpc/routers/booking/confirmation" + +interface FinalConfirmationProps { + booking: BookingConfirmation["booking"] + stayDetails: { + nightsText: string + adultsText: string + childrenText: string + } +} + +export function FinalConfirmation({ + booking, + stayDetails, +}: FinalConfirmationProps) { + const intl = useIntl() + + return ( + <> +
+ + {intl.formatMessage({ + id: "Are you sure you want to continue with the cancellation?", + })} + +
+
+
+ + {intl.formatMessage({ id: "Cancellation cost" })} + + + {stayDetails.nightsText}, {stayDetails.adultsText} + {booking.childrenAges?.length > 0 + ? `, ${stayDetails.childrenText}` + : ""} + +
+
+ + 0 {booking.currencyCode} + +
+
+ + ) +} diff --git a/components/HotelReservation/MyStay/CancelStay/cancelStay.module.css b/components/HotelReservation/MyStay/CancelStay/cancelStay.module.css new file mode 100644 index 000000000..0666e1f4e --- /dev/null +++ b/components/HotelReservation/MyStay/CancelStay/cancelStay.module.css @@ -0,0 +1,24 @@ +.modalText { + display: flex; + flex-direction: column; + gap: var(--Spacing-x2); +} + +.priceContainer { + display: flex; + padding: var(--Spacing-x2); + background-color: var(--Base-Background-Primary-Normal); + border-radius: var(--Corner-radius-Medium); + align-items: center; + justify-content: flex-end; +} + +.info { + border-right: 1px solid var(--Base-Border-Subtle); + padding-right: var(--Spacing-x2); + text-align: right; +} + +.price { + padding-left: var(--Spacing-x2); +} diff --git a/components/HotelReservation/MyStay/CancelStay/hooks/useCancelStay.ts b/components/HotelReservation/MyStay/CancelStay/hooks/useCancelStay.ts new file mode 100644 index 000000000..cca46e736 --- /dev/null +++ b/components/HotelReservation/MyStay/CancelStay/hooks/useCancelStay.ts @@ -0,0 +1,86 @@ +import { useState } from "react" +import { useIntl } from "react-intl" + +import { trpc } from "@/lib/trpc/client" + +import { toast } from "@/components/TempDesignSystem/Toasts" +import useLang from "@/hooks/useLang" + +import type { CancelStayProps } from ".." + +export default function useCancelStay({ + booking, + setBookingStatus, + handleCloseModal, + handleBackToManageStay, +}: Omit) { + const intl = useIntl() + const lang = useLang() + const [currentStep, setCurrentStep] = useState(1) + const [isLoading, setIsLoading] = useState(false) + + const cancelStay = trpc.booking.cancel.useMutation({ + onMutate: () => setIsLoading(true), + onSuccess: (result) => { + if (!result) { + toast.error( + intl.formatMessage({ + id: "Something went wrong. Please try again later.", + }) + ) + return + } + + setBookingStatus() + toast.success( + intl.formatMessage( + { + id: "Your stay was cancelled. Cancellation cost: 0 {currency}. We’re sorry to see that the plans didn’t work out", + }, + { currency: booking.currencyCode } + ) + ) + }, + onError: () => { + toast.error( + intl.formatMessage({ + id: "Something went wrong. Please try again later.", + }) + ) + }, + onSettled: () => { + handleCloseModal() + }, + }) + + function handleCancelStay() { + if (!booking.confirmationNumber) { + toast.error( + intl.formatMessage({ + id: "Something went wrong. Please try again later.", + }) + ) + return + } + + cancelStay.mutate({ + confirmationNumber: booking.confirmationNumber, + language: lang, + }) + } + + function handleCloseCancelStay() { + setCurrentStep(1) + setIsLoading(false) + handleBackToManageStay() + } + + return { + currentStep, + isLoading, + handleCancelStay, + handleCloseCancelStay, + handleBack: () => setCurrentStep(1), + handleForward: () => setCurrentStep(2), + } +} diff --git a/components/HotelReservation/MyStay/CancelStay/index.tsx b/components/HotelReservation/MyStay/CancelStay/index.tsx new file mode 100644 index 000000000..a45b9e14a --- /dev/null +++ b/components/HotelReservation/MyStay/CancelStay/index.tsx @@ -0,0 +1,87 @@ +"use client" + +import { useIntl } from "react-intl" + +import useLang from "@/hooks/useLang" + +import { ModalContent } from "../ManageStay/ModalContent" +import useCancelStay from "./hooks/useCancelStay" +import { CancelStayConfirmation } from "./Confirmation" +import { FinalConfirmation } from "./FinalConfirmation" +import { formatStayDetails } from "./utils" + +import type { Hotel } from "@/types/hotel" +import type { BookingConfirmation } from "@/types/trpc/routers/booking/confirmation" + +export interface CancelStayProps { + booking: BookingConfirmation["booking"] + hotel: Hotel + setBookingStatus: () => void + handleCloseModal: () => void + handleBackToManageStay: () => void +} + +export default function CancelStay({ + booking, + hotel, + setBookingStatus, + handleCloseModal, + handleBackToManageStay, +}: CancelStayProps) { + const intl = useIntl() + const lang = useLang() + const { + currentStep, + isLoading, + handleCancelStay, + handleCloseCancelStay, + handleForward, + } = useCancelStay({ + booking, + setBookingStatus, + handleCloseModal, + handleBackToManageStay, + }) + + const stayDetails = formatStayDetails({ booking, lang, intl }) + const isFirstStep = currentStep === 1 + + return ( + <> + + ) : ( + + ) + } + primaryAction={{ + label: isFirstStep + ? intl.formatMessage({ id: "Cancel stay" }) + : intl.formatMessage({ id: "Confirm cancellation" }), + onClick: isFirstStep ? handleForward : handleCancelStay, + intent: isFirstStep ? "secondary" : "primary", + isLoading: isLoading, + }} + secondaryAction={{ + label: isFirstStep + ? intl.formatMessage({ id: "Back" }) + : intl.formatMessage({ id: "Don't cancel" }), + onClick: isFirstStep ? handleCloseCancelStay : handleCloseModal, + intent: "text", + }} + /> + + ) +} diff --git a/components/HotelReservation/MyStay/CancelStay/utils.ts b/components/HotelReservation/MyStay/CancelStay/utils.ts new file mode 100644 index 000000000..05eb83012 --- /dev/null +++ b/components/HotelReservation/MyStay/CancelStay/utils.ts @@ -0,0 +1,44 @@ +import { dt } from "@/lib/dt" + +import type { IntlShape } from "react-intl" + +import type { BookingConfirmation } from "@/types/trpc/routers/booking/confirmation" + +export function formatStayDetails({ + booking, + lang, + intl, +}: { + booking: BookingConfirmation["booking"] + lang: string + intl: IntlShape +}) { + const checkInDate = dt(booking.checkInDate) + .locale(lang) + .format("dddd D MMM YYYY") + const checkOutDate = dt(booking.checkOutDate) + .locale(lang) + .format("dddd D MMM YYYY") + const diff = dt(checkOutDate).diff(checkInDate, "days") + + const nightsText = intl.formatMessage( + { id: "{totalNights, plural, one {# night} other {# nights}}" }, + { totalNights: diff } + ) + const adultsText = intl.formatMessage( + { id: "{totalAdults, plural, one {# adult} other {# adults}}" }, + { totalAdults: booking.adults } + ) + const childrenText = intl.formatMessage( + { id: "{totalChildren, plural, one {# child} other {# children}}" }, + { totalChildren: booking.childrenAges?.length } + ) + + return { + checkInDate, + checkOutDate, + nightsText, + adultsText, + childrenText, + } +} diff --git a/components/HotelReservation/MyStay/ManageStay/ActionPanel/Actions/AddToCalendarButton.tsx b/components/HotelReservation/MyStay/ManageStay/ActionPanel/Actions/AddToCalendarButton.tsx new file mode 100644 index 000000000..5bb3066b8 --- /dev/null +++ b/components/HotelReservation/MyStay/ManageStay/ActionPanel/Actions/AddToCalendarButton.tsx @@ -0,0 +1,29 @@ +"use client" + +import { useIntl } from "react-intl" + +import { CalendarAddIcon } from "@/components/Icons" +import Button from "@/components/TempDesignSystem/Button" + +import styles from "../actionPanel.module.css" + +export default function AddToCalendarButton({ + onPress, +}: { + onPress: () => void +}) { + const intl = useIntl() + + return ( + + ) +} diff --git a/components/HotelReservation/MyStay/ManageStay/ActionPanel/actionPanel.module.css b/components/HotelReservation/MyStay/ManageStay/ActionPanel/actionPanel.module.css new file mode 100644 index 000000000..c4df9f5f5 --- /dev/null +++ b/components/HotelReservation/MyStay/ManageStay/ActionPanel/actionPanel.module.css @@ -0,0 +1,42 @@ +.actionPanel { + display: flex; + gap: var(--Spacing-x3); + padding: var(--Spacing-x3); +} + +.menu { + width: 432px; + display: flex; + flex-direction: column; + gap: var(--Spacing-x2); +} + +.actionPanel .menu .button { + width: 100%; + color: var(--Scandic-Brand-Burgundy); + justify-content: space-between !important; + padding: var(--Spacing-x1) 0 !important; +} + +.info { + width: 256px; + background-color: var(--Base-Background-Primary-Normal); + padding: var(--Spacing-x3); + text-align: right; + display: flex; + flex-direction: column; + gap: var(--Spacing-x2); + align-items: flex-end; +} + +.tag { + text-transform: uppercase; + font-size: 12px; + font-weight: 600; + color: var(--Main-Red-60); + font-family: var(--typography-Caption-Labels-fontFamily); +} + +.link { + margin-top: auto; +} diff --git a/components/HotelReservation/MyStay/ManageStay/ActionPanel/index.tsx b/components/HotelReservation/MyStay/ManageStay/ActionPanel/index.tsx new file mode 100644 index 000000000..42ebf3b9b --- /dev/null +++ b/components/HotelReservation/MyStay/ManageStay/ActionPanel/index.tsx @@ -0,0 +1,149 @@ +import { useIntl } from "react-intl" + +import { customerService } from "@/constants/currentWebHrefs" + +import AddToCalendar from "@/components/HotelReservation/AddToCalendar" +import { generateDateTime } from "@/components/HotelReservation/BookingConfirmation/Header/Actions/helpers" +import { + CalendarIcon, + ChevronRightIcon, + CreditCard, + CrossCircleOutlineIcon, + DownloadIcon, +} from "@/components/Icons" +import Button from "@/components/TempDesignSystem/Button" +import Link from "@/components/TempDesignSystem/Link" +import Body from "@/components/TempDesignSystem/Text/Body" +import Caption from "@/components/TempDesignSystem/Text/Caption" +import Subtitle from "@/components/TempDesignSystem/Text/Subtitle" +import useLang from "@/hooks/useLang" + +import AddToCalendarButton from "./Actions/AddToCalendarButton" + +import styles from "./actionPanel.module.css" + +import type { EventAttributes } from "ics" + +import type { Hotel } from "@/types/hotel" +import type { BookingConfirmation } from "@/types/trpc/routers/booking/confirmation" + +export default function ActionPanel({ + booking, + hotel, + showCancelButton, + onCancelClick, +}: { + booking: BookingConfirmation["booking"] + hotel: Hotel + showCancelButton: boolean + onCancelClick: () => void +}) { + const intl = useIntl() + const lang = useLang() + + const event: EventAttributes = { + busyStatus: "FREE", + categories: ["booking", "hotel", "stay"], + created: generateDateTime(booking.createDateTime), + description: hotel.hotelContent.texts.descriptions.medium, + end: generateDateTime(booking.checkOutDate), + endInputType: "utc", + geo: { + lat: hotel.location.latitude, + lon: hotel.location.longitude, + }, + location: `${hotel.address.streetAddress}, ${hotel.address.zipCode} ${hotel.address.city} ${hotel.address.country}`, + start: generateDateTime(booking.checkInDate), + startInputType: "utc", + status: "CONFIRMED", + title: hotel.name, + url: hotel.contactInformation.websiteUrl, + } + + return ( +
+
+ + + } + /> + + {showCancelButton && ( + + )} +
+
+
+ + {intl.formatMessage({ id: "Reference number" })} + + + {booking.confirmationNumber} + +
+
+ + {hotel.name} + + + {hotel.address.streetAddress} + + + {hotel.address.city} + + + + {hotel.contactInformation.phoneNumber} + + +
+ + + {intl.formatMessage({ id: "Customer support" })} + + + +
+
+ ) +} diff --git a/components/HotelReservation/MyStay/ManageStay/ModalContent/index.tsx b/components/HotelReservation/MyStay/ManageStay/ModalContent/index.tsx new file mode 100644 index 000000000..e33cf4cac --- /dev/null +++ b/components/HotelReservation/MyStay/ManageStay/ModalContent/index.tsx @@ -0,0 +1,62 @@ +import { CloseLargeIcon } from "@/components/Icons" +import Button from "@/components/TempDesignSystem/Button" +import Subtitle from "@/components/TempDesignSystem/Text/Subtitle" + +import styles from "./modalContent.module.css" + +import type { ReactNode } from "react" + +interface ModalContentProps { + title: string + content: ReactNode + primaryAction: { + label: string + onClick: () => void + intent?: "primary" | "secondary" | "text" + isLoading?: boolean + } + secondaryAction: { + label: string + onClick: () => void + intent?: "primary" | "secondary" | "text" + } + onClose: () => void +} + +export function ModalContent({ + title, + content, + primaryAction, + secondaryAction, + onClose, +}: ModalContentProps) { + return ( + <> +
+ {title} + +
+
{content}
+
+ + +
+ + ) +} diff --git a/components/HotelReservation/MyStay/ManageStay/ModalContent/modalContent.module.css b/components/HotelReservation/MyStay/ManageStay/ModalContent/modalContent.module.css new file mode 100644 index 000000000..fe5dcffbd --- /dev/null +++ b/components/HotelReservation/MyStay/ManageStay/ModalContent/modalContent.module.css @@ -0,0 +1,34 @@ +.content { + width: 640px; + max-width: 100%; + display: flex; + flex-direction: column; + gap: var(--Spacing-x3); + padding: var(--Spacing-x1) var(--Spacing-x3) var(--Spacing-x4); +} + +.header { + position: relative; + padding: var(--Spacing-x3) var(--Spacing-x3) 0; +} + +.footer { + display: flex; + justify-content: space-between; + width: 100%; + border-top: 1px solid var(--Base-Border-Subtle); + padding: var(--Spacing-x3); +} + +.close { + background: none; + border: none; + cursor: pointer; + position: absolute; + display: flex; + align-items: center; + padding: 0; + justify-content: center; + top: 20px; + right: 20px; +} diff --git a/components/HotelReservation/MyStay/ManageStay/index.tsx b/components/HotelReservation/MyStay/ManageStay/index.tsx new file mode 100644 index 000000000..0c7cbf8d9 --- /dev/null +++ b/components/HotelReservation/MyStay/ManageStay/index.tsx @@ -0,0 +1,136 @@ +"use client" +import { motion } from "framer-motion" +import { useEffect, useState } from "react" +import { Dialog, Modal, ModalOverlay } from "react-aria-components" +import { useIntl } from "react-intl" + +import { BookingStatusEnum } from "@/constants/booking" + +import { ChevronDownIcon } from "@/components/Icons" +import { + type AnimationState, + AnimationStateEnum, +} from "@/components/Modal/modal" +import { slideFromTop } from "@/components/Modal/motionVariants" +import Button from "@/components/TempDesignSystem/Button" + +import CancelStay from "../CancelStay" +import ActionPanel from "./ActionPanel" + +import styles from "./modifyModal.module.css" + +import type { Hotel } from "@/types/hotel" +import type { BookingConfirmation } from "@/types/trpc/routers/booking/confirmation" + +type ActiveView = "actionPanel" | "cancelStay" + +export default function ManageStay({ + booking, + hotel, + setBookingStatus, + bookingStatus, +}: { + booking: BookingConfirmation["booking"] + hotel: Hotel + setBookingStatus: (status: BookingStatusEnum) => void + bookingStatus: string | null +}) { + const [isOpen, setIsOpen] = useState(false) + const [animation, setAnimation] = useState( + AnimationStateEnum.visible + ) + const [activeView, setActiveView] = useState("actionPanel") + + const intl = useIntl() + + const MotionOverlay = motion(ModalOverlay) + const MotionModal = motion(Modal) + + const showCancelButton = + bookingStatus !== BookingStatusEnum.Cancelled && booking.isCancelable + + useEffect(() => { + if (typeof isOpen === "boolean") { + setAnimation( + isOpen ? AnimationStateEnum.visible : AnimationStateEnum.hidden + ) + } + if (isOpen === undefined) { + setAnimation(AnimationStateEnum.unmounted) + } + }, [isOpen]) + + function modalStateHandler(newAnimationState: AnimationState) { + setAnimation((currentAnimationState) => + newAnimationState === AnimationStateEnum.hidden && + currentAnimationState === AnimationStateEnum.hidden + ? AnimationStateEnum.unmounted + : currentAnimationState + ) + } + + function handleClose() { + setIsOpen(false) + setActiveView("actionPanel") + } + function handleBack() { + setActiveView("actionPanel") + } + + function renderContent() { + switch (activeView) { + case "cancelStay": + return ( + + setBookingStatus(BookingStatusEnum.Cancelled) + } + handleCloseModal={handleClose} + handleBackToManageStay={handleBack} + /> + ) + default: + return ( + setActiveView("cancelStay")} + showCancelButton={showCancelButton} + /> + ) + } + } + + return ( + <> + + + + + {renderContent()} + + + + + ) +} diff --git a/components/HotelReservation/MyStay/ManageStay/modifyModal.module.css b/components/HotelReservation/MyStay/ManageStay/modifyModal.module.css new file mode 100644 index 000000000..ec41cbb99 --- /dev/null +++ b/components/HotelReservation/MyStay/ManageStay/modifyModal.module.css @@ -0,0 +1,62 @@ +.overlay { + background: rgba(0, 0, 0, 0.5); + height: var(--visual-viewport-height); + position: fixed; + top: 0; + left: 0; + width: 100vw; + z-index: var(--default-modal-overlay-z-index); +} + +.modal { + background-color: var(--Base-Surface-Primary-light-Normal); + border-radius: var(--Corner-radius-Medium) var(--Corner-radius-Medium) 0 0; + box-shadow: var(--modal-box-shadow); + width: 100%; + position: absolute; + left: 0; + bottom: 0; + z-index: var(--default-modal-z-index); +} + +.dialog { + display: flex; + flex-direction: column; + + /* For removing focus outline when modal opens first time */ + outline: 0 none; + + /* for supporting animations within content */ + position: relative; + overflow: hidden; +} + +.close { + background: none; + border: none; + cursor: pointer; + position: absolute; + right: var(--Spacing-x2); + width: var(--button-dimension); + height: var(--button-dimension); + display: flex; + align-items: center; + padding: 0; + justify-content: center; +} + +@media screen and (min-width: 768px) { + .overlay { + display: flex; + justify-content: center; + align-items: center; + } + + .modal { + left: auto; + bottom: auto; + width: auto; + border-radius: var(--Corner-radius-Medium); + max-width: var(--max-width-page); + } +} diff --git a/components/HotelReservation/MyStay/ReferenceCard/index.tsx b/components/HotelReservation/MyStay/ReferenceCard/index.tsx index ae4fb795b..3a6a9dcb1 100644 --- a/components/HotelReservation/MyStay/ReferenceCard/index.tsx +++ b/components/HotelReservation/MyStay/ReferenceCard/index.tsx @@ -1,32 +1,46 @@ +"use client" + +import { useState } from "react" +import { useIntl } from "react-intl" + +import { BookingStatusEnum } from "@/constants/booking" import { dt } from "@/lib/dt" +import CrossCircleIcon from "@/components/Icons/CrossCircle" import Button from "@/components/TempDesignSystem/Button" import Divider from "@/components/TempDesignSystem/Divider" +import IconChip from "@/components/TempDesignSystem/IconChip" import Link from "@/components/TempDesignSystem/Link" import Caption from "@/components/TempDesignSystem/Text/Caption" import Subtitle from "@/components/TempDesignSystem/Text/Subtitle" -import { getIntl } from "@/i18n" -import { getLang } from "@/i18n/serverContext" +import useLang from "@/hooks/useLang" import { formatPrice } from "@/utils/numberFormatting" +import ManageStay from "../ManageStay" + import styles from "./referenceCard.module.css" import type { Hotel } from "@/types/hotel" import type { BookingConfirmation } from "@/types/trpc/routers/booking/confirmation" -export async function ReferenceCard({ +export function ReferenceCard({ booking, hotel, }: { booking: BookingConfirmation["booking"] hotel: Hotel }) { - const intl = await getIntl() - const lang = getLang() + const [bookingStatus, setBookingStatus] = useState(booking.reservationStatus) + const intl = useIntl() + const lang = useLang() const fromDate = dt(booking.checkInDate).locale(lang) const toDate = dt(booking.checkOutDate).locale(lang) + const isCancelled = bookingStatus === BookingStatusEnum.Cancelled + + const showCancelButton = !isCancelled && booking.isCancelable + const directionsUrl = `https://www.google.com/maps/dir/?api=1&destination=${hotel.location.latitude},${hotel.location.longitude}` return ( @@ -36,10 +50,15 @@ export async function ReferenceCard({ {intl.formatMessage({ id: "Reference" })} - {intl.formatMessage({ id: "Reference number" })} + {isCancelled + ? intl.formatMessage({ id: "Cancellation number" }) + : intl.formatMessage({ id: "Reference number" })} - {booking.confirmationNumber} + {/* TODO: Implement this: https://scandichotels.atlassian.net/browse/API2-2883 to get correct cancellation number */} + {isCancelled + ? booking.linkedReservations[0]?.cancellationNumber + : booking.confirmationNumber} @@ -105,8 +124,26 @@ export async function ReferenceCard({ {formatPrice(intl, booking.totalPrice, booking.currencyCode)} + {!showCancelButton && ( +
+ } + > + + {intl.formatMessage({ id: "Status" })}:{" "} + {intl.formatMessage({ id: "Cancelled" })} + + +
+ )}
- +
diff --git a/components/HotelReservation/MyStay/index.tsx b/components/HotelReservation/MyStay/index.tsx index 426fd8ecd..c4f83d372 100644 --- a/components/HotelReservation/MyStay/index.tsx +++ b/components/HotelReservation/MyStay/index.tsx @@ -1,3 +1,5 @@ +import { notFound } from "next/navigation" + import { homeHrefs } from "@/constants/homeHrefs" import { env } from "@/env/server" import { dt } from "@/lib/dt" @@ -21,7 +23,13 @@ import { Room } from "./Room" import styles from "./myStay.module.css" export async function MyStay({ reservationId }: { reservationId: string }) { - const { booking, hotel, room } = await getBookingConfirmation(reservationId) + const bookingConfirmation = await getBookingConfirmation(reservationId) + if (!bookingConfirmation) { + return notFound() + } + + const { booking, hotel, room } = bookingConfirmation + const userResponse = await getProfileSafely() const user = userResponse && !("error" in userResponse) ? userResponse : null const intl = await getIntl() @@ -38,14 +46,17 @@ export async function MyStay({ reservationId }: { reservationId: string }) {
- {hotel.gallery?.heroImages[0].imageSizes.large && ( - {hotel.name} - )} + + {hotel.name}
diff --git a/components/HotelReservation/MyStay/myStay.module.css b/components/HotelReservation/MyStay/myStay.module.css index 9be3e07e7..6570f091e 100644 --- a/components/HotelReservation/MyStay/myStay.module.css +++ b/components/HotelReservation/MyStay/myStay.module.css @@ -52,6 +52,13 @@ } } +@media (min-width: 768px) { + .content { + width: var(--max-width-content); + padding-bottom: 160px; + } +} + .headerSkeleton { display: flex; flex-direction: column; diff --git a/components/HotelReservation/SelectHotel/FilterAndSortModal/filterAndSortModal.module.css b/components/HotelReservation/SelectHotel/FilterAndSortModal/filterAndSortModal.module.css index 5f38f2614..8dd144535 100644 --- a/components/HotelReservation/SelectHotel/FilterAndSortModal/filterAndSortModal.module.css +++ b/components/HotelReservation/SelectHotel/FilterAndSortModal/filterAndSortModal.module.css @@ -45,7 +45,7 @@ height: calc(100dvh - 20px); background-color: var(--Base-Surface-Primary-light-Normal); border-radius: var(--Corner-radius-Medium); - box-shadow: 0px 4px 24px 0px rgba(38, 32, 30, 0.08); + box-shadow: var(--modal-box-shadow); width: 100%; &[data-entering] { diff --git a/components/Icons/CrossCircleOutline.tsx b/components/Icons/CrossCircleOutline.tsx new file mode 100644 index 000000000..6bb29f83b --- /dev/null +++ b/components/Icons/CrossCircleOutline.tsx @@ -0,0 +1,27 @@ +import { iconVariants } from "./variants" + +import type { IconProps } from "@/types/components/icon" + +export default function CrossCircleOutlineIcon({ + className, + color, + ...props +}: IconProps) { + const classNames = iconVariants({ className, color }) + return ( + + + + ) +} diff --git a/components/Icons/get-icon-by-icon-name.ts b/components/Icons/get-icon-by-icon-name.ts index 9a05e6c51..40554766b 100644 --- a/components/Icons/get-icon-by-icon-name.ts +++ b/components/Icons/get-icon-by-icon-name.ts @@ -31,6 +31,7 @@ import { CoolIcon, CroissantCoffeeEggIcon, CrossCircle, + CrossCircleOutlineIcon, CulturalIcon, CutleryOneIcon, CutleryTwoIcon, @@ -154,6 +155,8 @@ export function getIconByIconName( return CheckIcon case IconName.CrossCircle: return CrossCircle + case IconName.CrossCircleOutline: + return CrossCircleOutlineIcon case IconName.CheckCircle: return CheckCircleIcon case IconName.ChevronDown: diff --git a/components/Icons/index.tsx b/components/Icons/index.tsx index af881a2b9..07bbd4da7 100644 --- a/components/Icons/index.tsx +++ b/components/Icons/index.tsx @@ -60,6 +60,7 @@ export { default as CreditCard } from "./CreditCard" export { default as CreditCardAddIcon } from "./CreditCardAdd" export { default as CroissantCoffeeEggIcon } from "./CroissantCoffeeEgg" export { default as CrossCircle } from "./CrossCircle" +export { default as CrossCircleOutlineIcon } from "./CrossCircleOutline" export { default as CulturalIcon } from "./Cultural" export { default as CutleryOneIcon } from "./CutleryOne" export { default as CutleryTwoIcon } from "./CutleryTwo" diff --git a/components/Modal/modal.module.css b/components/Modal/modal.module.css index 2ed8a30c5..40731e1b8 100644 --- a/components/Modal/modal.module.css +++ b/components/Modal/modal.module.css @@ -11,7 +11,7 @@ .modal { background-color: var(--Base-Surface-Primary-light-Normal); border-radius: var(--Corner-radius-Medium) var(--Corner-radius-Medium) 0 0; - box-shadow: 0px 4px 24px 0px rgba(38, 32, 30, 0.08); + box-shadow: var(--modal-box-shadow); width: 100%; position: absolute; left: 0; @@ -39,7 +39,7 @@ align-items: flex-start; min-height: var(--button-dimension); position: relative; - padding: var(--Spacing-x2) var(--Spacing-x3) 0; + padding: var(--Spacing-x3) var(--Spacing-x3) 0; } .content { diff --git a/components/Modal/motionVariants.ts b/components/Modal/motionVariants.ts index e24932152..511b14ddf 100644 --- a/components/Modal/motionVariants.ts +++ b/components/Modal/motionVariants.ts @@ -21,3 +21,16 @@ export const slideInOut = { transition: { duration: 0.4, ease: "easeInOut" }, }, } + +export const slideFromTop = { + hidden: { + opacity: 0, + y: -32, + transition: { duration: 0.4, ease: "easeInOut" }, + }, + visible: { + opacity: 1, + y: 0, + transition: { duration: 0.4, ease: "easeInOut" }, + }, +} diff --git a/components/MyPages/Surprises/surprises.module.css b/components/MyPages/Surprises/surprises.module.css index 9a80761f4..99f290ceb 100644 --- a/components/MyPages/Surprises/surprises.module.css +++ b/components/MyPages/Surprises/surprises.module.css @@ -49,7 +49,7 @@ .modal { background-color: var(--Base-Surface-Primary-light-Normal); border-radius: var(--Corner-radius-Medium); - box-shadow: 0px 4px 24px 0px rgba(38, 32, 30, 0.08); + box-shadow: var(--modal-box-shadow); width: 100%; position: absolute; left: 0; diff --git a/i18n/dictionaries/da.json b/i18n/dictionaries/da.json index 22dc64cff..5520e2c1d 100644 --- a/i18n/dictionaries/da.json +++ b/i18n/dictionaries/da.json @@ -3,6 +3,8 @@ "Included (based on availability)": "Inkluderet (baseret på tilgængelighed)", "Total price (incl VAT)": "Samlet pris (inkl. moms)", "{amount} 0 {currency}/night per adult": "{amount} 0 {currency}/nat per voksen", + "Status Paid": "Status Betalt", + "Status Unpaid": "Status Ikke betalt", "A destination or hotel name is needed to be able to search for a hotel room.": "Et destinations- eller hotelnavn er nødvendigt for at kunne søge efter et hotelværelse.", "A photo of the room": "Et foto af værelset", "ACCE": "Tilgængelighed", @@ -47,6 +49,8 @@ "Approx {value}.": "Ca. {value}", "Approx.": "Ca.", "Approx. {value}": "Approx. {value}", + "Are you sure you want to cancel your stay at {hotel} from {checkInDate} to {checkOutDate}? This can't be reversed.": "Er du sikker på, at du vil annullere dit ophold hos {hotel} fra {checkInDate} til {checkOutDate}? Dette kan ikke gendannes.", + "Are you sure you want to continue with the cancellation?": "Er du sikker på, at du vil fortsætte med annullereringen?", "Are you sure you want to remove the card ending with {lastFourDigits} from your member profile?": "Er du sikker på, at du vil fjerne kortet, der slutter me {lastFourDigits} fra din medlemsprofil?", "Arrival date": "Ankomstdato", "As our Close Friend": "Som vores nære ven", @@ -101,7 +105,11 @@ "Cabaret seating": "Cabaret seating", "Campaign": "Kampagne", "Cancel": "Afbestille", + "Cancel stay": "Annuller ophold", + "Cancellation cost": "Annulleret pris", + "Cancellation number": "Annulleringsnummer", "Cancellation policy": "Cancellation policy", + "Cancelled": "Annulleret", "Change room": "Skift værelse", "Changes can be made until {time} on {date}, subject to availability. Room rates may vary.": "Ændringer kan gøres indtil {time} på {date}, under forudsætning af tilgængelighed. Priserne for værelserne kan variere.", "Check for level upgrade": "Check for level upgrade", @@ -138,6 +146,7 @@ "Complete booking": "Fuldfør bookingen", "Complete booking & go to payment": "Udfyld booking & gå til betaling", "Complete the booking": "Fuldfør bookingen", + "Confirm cancellation": "Bekræft annullerering", "Contact information": "Kontaktoplysninger", "Contact our memberservice": "Contact our memberservice", "Contact us": "Kontakt os", @@ -155,6 +164,7 @@ "Current Points: {points, number}": "Current Points: {points, number}", "Current password": "Nuværende kodeord", "Customer service": "Kundeservice", + "Customer support": "Kundesupport", "Date of Birth": "Fødselsdato", "Date of birth not matching": "Date of birth not matching", "Day": "Dag", @@ -175,6 +185,7 @@ "Distance to city center": "Afstand til centrum", "Distance to hotel: {distanceInM} m": "Afstand til hotel: {distance} m", "Do you want to start the day with Scandics famous breakfast buffé?": "Vil du starte dagen med Scandics berømte morgenbuffet?", + "Don't cancel": "Annuller ikke", "Done": "Færdig", "Download invoice": "Download faktura", "Download the Scandic app": "Download Scandic-appen", @@ -200,6 +211,7 @@ "Extra bed will be provided additionally": "Der vil blive stillet en ekstra seng til rådighed", "Extras to your booking": "Tillæg til din booking", "FAQ": "Ofte stillede spørgsmål", + "Failed to add to calendar": "Fejl ved tilføjelse til kalender", "Failed to delete credit card, please try again later.": "Kunne ikke slette kreditkort. Prøv venligst igen senere.", "Failed to unlink account": "Failed to unlink account", "Failed to upgrade level": "Failed to upgrade level", @@ -244,6 +256,7 @@ "Go to My Benefits": "Gå til 'Mine fordele'", "Go to profile": "Go to profile", "Guarantee booking with credit card": "Garantere booking med kreditkort", + "Guarantee late arrival": "Garanter sen ankomst", "Guest information": "Gæsteinformation", "Guests": "Gæster", "Guests & Rooms": "Gæster & værelser", @@ -294,6 +307,7 @@ "Join now": "Tilmeld dig nu", "Join or log in while booking for member pricing.": "Tilmeld dig eller log ind under booking for medlemspris.", "Kayaking": "Kajakroning", + "Keep stay": "Behold ophold", "King bed": "Kingsize-seng", "Language": "Sprog", "Last name": "Efternavn", @@ -387,6 +401,7 @@ "No": "Nej", "No availability": "Ingen tilgængelighed", "No breakfast": "Ingen morgenmad", + "No charges were made.": "Ingen gebyrer blev opkrævet.", "No content published": "Intet indhold offentliggjort", "No hotels match your filters": "Ingen rum matchede dine filtre.", "No matching location found": "Der blev ikke fundet nogen matchende placering", @@ -688,6 +703,7 @@ "Your points to spend": "Dine brugbare point", "Your room": "Dit værelse", "Your selected bed type will be provided based on availability": "Din valgte sengtype vil blive stillet til rådighed baseret på tilgængelighed", + "Your stay was cancelled. Cancellation cost: 0 {currency}. We’re sorry to see that the plans didn’t work out": "Dit ophold blev annulleret. Annullereringspris: 0 {currency}. Vi beklager, at planerne ikke fungerede ud", "Zip code": "Postnummer", "Zoo": "Zoo", "Zoom in": "Zoom ind", diff --git a/i18n/dictionaries/de.json b/i18n/dictionaries/de.json index 9f075b3c2..79470e0e0 100644 --- a/i18n/dictionaries/de.json +++ b/i18n/dictionaries/de.json @@ -3,6 +3,8 @@ "Included (based on availability)": "Inbegriffen (je nach Verfügbarkeit)", "Total price (incl VAT)": "Gesamtpreis (inkl. MwSt.)", "{amount} 0 {currency}/night per adult": "{amount} 0 {currency}/Nacht pro Erwachsenem", + "Status Paid": "Status Bezahlt", + "Status Unpaid": "Status Unbezahlt", "A destination or hotel name is needed to be able to search for a hotel room.": "Ein Reiseziel oder Hotelname wird benötigt, um nach einem Hotelzimmer suchen zu können.", "A photo of the room": "Ein Foto des Zimmers", "ACCE": "Zugänglichkeit", @@ -47,6 +49,8 @@ "Approx {value}.": "Ca. {value}", "Approx.": "Ca.", "Approx. {value}": "Approx. {value}", + "Are you sure you want to cancel your stay at {hotel} from {checkInDate} to {checkOutDate}? This can't be reversed.": "Sind Sie sicher, dass Sie Ihren Aufenthalt bei {hotel} vom {checkInDate} bis {checkOutDate} stornieren möchten? Dies kann nicht rückgängig gemacht werden.", + "Are you sure you want to continue with the cancellation?": "Sind Sie sicher, dass Sie mit der Stornierung fortfahren möchten?", "Are you sure you want to remove the card ending with {lastFourDigits} from your member profile?": "Möchten Sie die Karte mit der Endung {lastFourDigits} wirklich aus Ihrem Mitgliedsprofil entfernen?", "Arrival date": "Ankunftsdatum", "As our Close Friend": "Als unser enger Freund", @@ -102,7 +106,11 @@ "Cabaret seating": "Cabaret seating", "Campaign": "Kampagne", "Cancel": "Stornieren", + "Cancel stay": "Stornieren", + "Cancellation cost": "Stornierungskosten", + "Cancellation number": "Stornierungsnummer", "Cancellation policy": "Cancellation policy", + "Cancelled": "Storniert", "Change room": "Zimmer ändern", "Changes can be made until {time} on {date}, subject to availability. Room rates may vary.": "Änderungen können bis {time} am {date} vorgenommen werden, vorausgesetzt, dass die Zimmer noch verfügbar sind. Die Zimmerpreise können variieren.", "Check for level upgrade": "Check for level upgrade", @@ -139,6 +147,7 @@ "Complete booking": "Buchung abschließen", "Complete booking & go to payment": "Buchung abschließen & zur Bezahlung gehen", "Complete the booking": "Buchung abschließen", + "Confirm cancellation": "Stornierung bestätigen", "Contact information": "Kontaktinformationen", "Contact our memberservice": "Contact our memberservice", "Contact us": "Kontaktieren Sie uns", @@ -156,6 +165,7 @@ "Current Points: {points, number}": "Current Points: {points, number}", "Current password": "Aktuelles Passwort", "Customer service": "Kundendienst", + "Customer support": "Kundesupport", "Date of Birth": "Geburtsdatum", "Date of birth not matching": "Date of birth not matching", "Day": "Tag", @@ -176,6 +186,7 @@ "Distance to city center": "Entfernung zum Stadtzentrum", "Distance to hotel: {distanceInM} m": "Entfernung zum Hotel: {distance} m", "Do you want to start the day with Scandics famous breakfast buffé?": "Möchten Sie den Tag mit Scandics berühmtem Frühstücksbuffet beginnen?", + "Don't cancel": "Stornieren", "Done": "Fertig", "Download invoice": "Rechnung herunterladen", "Download the Scandic app": "Laden Sie die Scandic-App herunter", @@ -201,6 +212,7 @@ "Extra bed will be provided additionally": "Ein zusätzliches Bett wird bereitgestellt", "Extras to your booking": "Extras zu Ihrer Buchung", "FAQ": "Häufig gestellte Fragen", + "Failed to add to calendar": "Fehler beim Hinzufügen zum Kalender", "Failed to delete credit card, please try again later.": "Kreditkarte konnte nicht gelöscht werden. Bitte versuchen Sie es später noch einmal.", "Failed to unlink account": "Failed to unlink account", "Failed to upgrade level": "Failed to upgrade level", @@ -245,6 +257,7 @@ "Go to My Benefits": "Gehen Sie zu „Meine Vorteile“", "Go to profile": "Go to profile", "Guarantee booking with credit card": "Buchung mit Kreditkarte garantieren", + "Guarantee late arrival": "Garantere sen ankomst", "Guest information": "Informationen für Gäste", "Guests": "Gäste", "Guests & Rooms": "Gäste & Zimmer", @@ -295,6 +308,7 @@ "Join now": "Mitglied werden", "Join or log in while booking for member pricing.": "Treten Sie Scandic Friends bei oder loggen Sie sich ein, um den Mitgliederpreis zu erhalten.", "Kayaking": "Kajakfahren", + "Keep stay": "Aufenthalt behalten", "King bed": "Kingsize-Bett", "Language": "Sprache", "Last name": "Nachname", @@ -356,6 +370,7 @@ "Menu": "Menü", "Menus": "Menüs", "Modify": "Ändern", + "Modify dates": "Ändra datum", "Modify guest details": "Gastdetails ändern", "Mon-Fri Always open": "Mo-Fr Immer geöffnet", "Mon-Fri {openingTime}-{closingTime}": "Mo-Fr {openingTime}-{closingTime}", @@ -388,6 +403,7 @@ "No": "Nein", "No availability": "Keine Verfügbarkeit", "No breakfast": "Kein Frühstück", + "No charges were made.": "Es wurden keine Gebühren erhoben.", "No content published": "Kein Inhalt veröffentlicht", "No hotels match your filters": "Kein Zimmer entspricht Ihren Filtern.", "No matching location found": "Kein passender Standort gefunden", @@ -688,6 +704,7 @@ "Your points to spend": "Meine Punkte", "Your room": "Ihr Zimmer", "Your selected bed type will be provided based on availability": "Ihre ausgewählte Bettart wird basierend auf der Verfügbarkeit bereitgestellt", + "Your stay was cancelled. Cancellation cost: 0 {currency}. We’re sorry to see that the plans didn’t work out": "Ihr Aufenthalt wurde storniert. Stornierungskosten: 0 {currency}. Es tut uns leid, dass die Pläne nicht funktionierten", "Zip code": "PLZ", "Zoo": "Zoo", "Zoom in": "Vergrößern", diff --git a/i18n/dictionaries/en.json b/i18n/dictionaries/en.json index b7b91daa1..4953c0d93 100644 --- a/i18n/dictionaries/en.json +++ b/i18n/dictionaries/en.json @@ -4,6 +4,8 @@ "Included (based on availability)": "Included (based on availability)", "Total price (incl VAT)": "Total price (incl VAT)", "{amount} 0 {currency}/night per adult": "{amount} 0 {currency}/night per adult", + "Status Paid": "Status Paid", + "Status Unpaid": "Status Unpaid", "A destination or hotel name is needed to be able to search for a hotel room.": "A destination or hotel name is needed to be able to search for a hotel room.", "A photo of the room": "A photo of the room", "ACCE": "Accessibility", @@ -47,6 +49,8 @@ "Apply": "Apply", "Approx.": "Approx.", "Approx. {value}": "Approx. {value}", + "Are you sure you want to cancel your stay at {hotel} from {checkInDate} to {checkOutDate}? This can't be reversed.": "Are you sure you want to cancel your stay at {hotel} from {checkInDate} to {checkOutDate}? This can't be reversed.", + "Are you sure you want to continue with the cancellation?": "Are you sure you want to continue with the cancellation?", "Are you sure you want to remove the card ending with {lastFourDigits} from your member profile?": "Are you sure you want to remove the card ending with {lastFourDigits} from your member profile?", "Arrival date": "Arrival date", "As our Close Friend": "As our Close Friend", @@ -103,7 +107,11 @@ "Campaign": "Campaign", "Cancel": "Cancel", "Cancel booking": "Cancel booking", + "Cancel stay": "Cancel stay", + "Cancellation cost": "Cancellation cost", + "Cancellation number": "Cancellation number", "Cancellation policy": "Cancellation policy", + "Cancelled": "Cancelled", "Change room": "Change room", "Changes can be made until {time} on {date}, subject to availability. Room rates may vary.": "Changes can be made until {time} on {date}, subject to availability. Room rates may vary.", "Check for level upgrade": "Check for level upgrade", @@ -140,6 +148,7 @@ "Complete booking": "Complete booking", "Complete booking & go to payment": "Complete booking & go to payment", "Complete the booking": "Complete the booking", + "Confirm cancellation": "Confirm cancellation", "Contact information": "Contact information", "Contact our memberservice": "Contact our memberservice", "Contact us": "Contact us", @@ -158,6 +167,7 @@ "Current Points: {points, number}": "Current Points: {points, number}", "Current password": "Current password", "Customer service": "Customer service", + "Customer support": "Customer support", "Date of Birth": "Date of Birth", "Date of birth not matching": "Date of birth not matching", "Day": "Day", @@ -178,6 +188,7 @@ "Distance to city center": "Distance to city center", "Distance to hotel: {distanceInM} m": "Distance to hotel: {distanceInM} m", "Do you want to start the day with Scandics famous breakfast buffé?": "Do you want to start the day with Scandics famous breakfast buffé?", + "Don't cancel": "Don't cancel", "Done": "Done", "Download invoice": "Download invoice", "Download the Scandic app": "Download the Scandic app", @@ -203,6 +214,7 @@ "Extra bed will be provided additionally": "Extra bed will be provided additionally", "Extras to your booking": "Extras to your booking", "FAQ": "FAQ", + "Failed to add to calendar": "Failed to add to calendar", "Failed to delete credit card, please try again later.": "Failed to delete credit card, please try again later.", "Failed to unlink account": "Failed to unlink account", "Failed to upgrade level": "Failed to upgrade level", @@ -247,6 +259,7 @@ "Go to My Benefits": "Go to My Benefits", "Go to profile": "Go to profile", "Guarantee booking with credit card": "Guarantee booking with credit card", + "Guarantee late arrival": "Guarantee late arrival", "Guest information": "Guest information", "Guests": "Guests", "Guests & Rooms": "Guests & Rooms", @@ -298,6 +311,7 @@ "Join now": "Join now", "Join or log in while booking for member pricing.": "Join or log in while booking for member pricing.", "Kayaking": "Kayaking", + "Keep stay": "Keep stay", "King bed": "King bed", "Language": "Language", "Last name": "Last name", @@ -392,6 +406,7 @@ "No": "No", "No availability": "No availability", "No breakfast": "No breakfast", + "No charges were made.": "No charges were made.", "No content published": "No content published", "No hotels match your filters": "No hotels match your filters", "No matching location found": "No matching location found", @@ -694,6 +709,7 @@ "Your points to spend": "Your points to spend", "Your room": "Your room", "Your selected bed type will be provided based on availability": "Your selected bed type will be provided based on availability", + "Your stay was cancelled. Cancellation cost: 0 {currency}. We’re sorry to see that the plans didn’t work out": "Your stay was cancelled. Cancellation cost: 0 {currency}. We’re sorry to see that the plans didn’t work out", "Zip code": "Zip code", "Zoo": "Zoo", "Zoom in": "Zoom in", diff --git a/i18n/dictionaries/fi.json b/i18n/dictionaries/fi.json index 4d3d748a2..410b9b6a6 100644 --- a/i18n/dictionaries/fi.json +++ b/i18n/dictionaries/fi.json @@ -3,6 +3,8 @@ "Included (based on availability)": "Sisältyy (saatavuuden mukaan)", "Total price (incl VAT)": "Kokonaishinta (sis. ALV)", "{amount} 0 {currency}/night per adult": "{amount} 0 {currency}/yötä aikuista kohti", + "Status Paid": "Status Maksettu", + "Status Unpaid": "Status Ei maksettu", "A destination or hotel name is needed to be able to search for a hotel room.": "Kohteen tai hotellin nimi tarvitaan, jotta hotellihuonetta voidaan hakea.", "A photo of the room": "Kuva huoneesta", "ACCE": "Saavutettavuus", @@ -46,6 +48,8 @@ "Apply": "Tallenna", "Approx.": "N.", "Approx. {value}": "N. {value}", + "Are you sure you want to cancel your stay at {hotel} from {checkInDate} to {checkOutDate}? This can't be reversed.": "Oletko varmasti haluamassa peruuttaa majoituksesi hoteleissa {hotel} alkaen {checkInDate} asti {checkOutDate}? Tätä ei voi kumota.", + "Are you sure you want to continue with the cancellation?": "Oletko varmasti haluamassa jatkaa peruuttamista?", "Are you sure you want to remove the card ending with {lastFourDigits} from your member profile?": "Haluatko varmasti poistaa kortin, joka päättyy numeroon {lastFourDigits} jäsenprofiilistasi?", "Arrival date": "Saapumispäivä", "As our Close Friend": "Läheisenä ystävänämme", @@ -100,7 +104,11 @@ "Cabaret seating": "Cabaret seating", "Campaign": "Kampanja", "Cancel": "Peruuttaa", + "Cancel stay": "Peruuta majoitus", + "Cancellation cost": "Peruutusmaksu", + "Cancellation number": "Peruutusnumero", "Cancellation policy": "Cancellation policy", + "Cancelled": "Peruttu", "Change room": "Vaihda huonetta", "Changes can be made until {time} on {date}, subject to availability. Room rates may vary.": "Muutoksia voi tehdä {time} päivänä {date}, olettaen saatavuuden olemassaolon. Huonehinnat voivat muuttua.", "Check for level upgrade": "Check for level upgrade", @@ -138,6 +146,7 @@ "Complete booking": "Complete booking", "Complete booking & go to payment": "Täydennä varaus & siirry maksamaan", "Complete the booking": "Täydennä varaus", + "Confirm cancellation": "Vahvista peruutus", "Contact information": "Yhteystiedot", "Contact our memberservice": "Contact our memberservice", "Contact us": "Ota meihin yhteyttä", @@ -155,6 +164,7 @@ "Current Points: {points, number}": "Current Points: {points, number}", "Current password": "Nykyinen salasana", "Customer service": "Asiakaspalvelu", + "Customer support": "Asiakaspalvelu", "Date of Birth": "Syntymäaika", "Date of birth not matching": "Date of birth not matching", "Day": "Päivä", @@ -175,6 +185,7 @@ "Distance to city center": "Etäisyys kaupungin keskustaan", "Distance to hotel: {distanceInM} m": "Etäisyys hotelliin: {distance} m", "Do you want to start the day with Scandics famous breakfast buffé?": "Haluatko aloittaa päiväsi Scandicsin kuuluisalla aamiaisbuffella?", + "Don't cancel": "Peruuta ei", "Done": "Valmis", "Download invoice": "Lataa lasku", "Download the Scandic app": "Lataa Scandic-sovellus", @@ -200,6 +211,7 @@ "Extra bed will be provided additionally": "Lisävuode toimitetaan erikseen", "Extras to your booking": "Varauksessa lisäpalveluita", "FAQ": "Usein kysytyt kysymykset", + "Failed to add to calendar": "Virhe kalenteriin lisäämisessä", "Failed to delete credit card, please try again later.": "Luottokortin poistaminen epäonnistui, yritä myöhemmin uudelleen.", "Failed to unlink account": "Failed to unlink account", "Failed to upgrade level": "Failed to upgrade level", @@ -244,6 +256,7 @@ "Go to My Benefits": "Siirry kohtaan 'Omat edut'", "Go to profile": "Go to profile", "Guarantee booking with credit card": "Varmista varaus luottokortilla", + "Guarantee late arrival": "Varmista myöhäisempi tulema", "Guest information": "Vieraan tiedot", "Guests": "Vierailijat", "Guests & Rooms": "Vieraat & Huoneet", @@ -294,6 +307,7 @@ "Join now": "Liity jäseneksi", "Join or log in while booking for member pricing.": "Liity tai kirjaudu sisään, kun varaat jäsenhinnan.", "Kayaking": "Melonta", + "Keep stay": "Jatka majoittumista", "King bed": "King-vuode", "Language": "Kieli", "Last name": "Sukunimi", @@ -355,6 +369,7 @@ "Menu": "Valikko", "Menus": "Valikot", "Modify": "Muokkaa", + "Modify dates": "Muuta päivämääriä", "Modify guest details": "Muuta vierailijoiden tietoja", "Mon-Fri Always open": "Ma-Pe Aina auki", "Mon-Fri {openingTime}-{closingTime}": "Ma-Pe {openingTime}-{closingTime}", @@ -387,6 +402,7 @@ "No": "Ei", "No availability": "Ei saatavuutta", "No breakfast": "Ei aamiaista", + "No charges were made.": "Ei maksuja tehty.", "No content published": "Ei julkaistua sisältöä", "No hotels match your filters": "Yksikään huone ei vastannut suodattimiasi", "No matching location found": "Vastaavaa sijaintia ei löytynyt", @@ -688,6 +704,7 @@ "Your points to spend": "Käytettävissä olevat pisteesi", "Your room": "Sinun huoneesi", "Your selected bed type will be provided based on availability": "Valitun vuodetyypin toimitetaan saatavuuden mukaan", + "Your stay was cancelled. Cancellation cost: 0 {currency}. We’re sorry to see that the plans didn’t work out": "Majoituksesi peruutettiin. Peruutusmaksu: 0 {currency}. Emme voi käyttää sitä, että suunnitellut majoitukset eivät toiminneet", "Zip code": "Postinumero", "Zoo": "Eläintarha", "Zoom in": "Lähennä", diff --git a/i18n/dictionaries/no.json b/i18n/dictionaries/no.json index 50da80774..40e22d04f 100644 --- a/i18n/dictionaries/no.json +++ b/i18n/dictionaries/no.json @@ -3,6 +3,8 @@ "Included (based on availability)": "Inkludert (basert på tilgjengelighet)", "Total price (incl VAT)": "Totalpris (inkl. mva)", "{amount} 0 {currency}/night per adult": "{amount} 0 {currency}/natt per voksen", + "Status Paid": "Status Betalt", + "Status Unpaid": "Status Ikke betalt", "A destination or hotel name is needed to be able to search for a hotel room.": "Et reisemål eller hotellnavn er nødvendig for å kunne søke etter et hotellrom.", "A photo of the room": "Et bilde av rommet", "ACCE": "Tilgjengelighet", @@ -46,6 +48,8 @@ "Apply": "Velg", "Approx.": "Ca.", "Approx. {value}": "Ca. {value}", + "Are you sure you want to cancel your stay at {hotel} from {checkInDate} to {checkOutDate}? This can't be reversed.": "Er du sikker på, at du vil annullere dit ophold hos {hotel} fra {checkInDate} til {checkOutDate}? Dette kan ikke gendannes.", + "Are you sure you want to continue with the cancellation?": "Er du sikker på, at du vil fortsætte med annullereringen?", "Are you sure you want to remove the card ending with {lastFourDigits} from your member profile?": "Er du sikker på at du vil fjerne kortet som slutter på {lastFourDigits} fra medlemsprofilen din?", "Arrival date": "Ankomstdato", "As our Close Friend": "Som vår nære venn", @@ -100,7 +104,11 @@ "Cabaret seating": "Cabaret seating", "Campaign": "Kampanje", "Cancel": "Avbryt", + "Cancel stay": "Avbryt opphold", + "Cancellation cost": "Annulleret pris", + "Cancellation number": "Annulleringsnummer", "Cancellation policy": "Cancellation policy", + "Cancelled": "Avbrutt", "Change room": "Endre rom", "Changes can be made until {time} on {date}, subject to availability. Room rates may vary.": "Endringer kan gjøres til {time} på {date}, under forutsetning av tilgjengelighet. Rompriser kan variere.", "Check for level upgrade": "Check for level upgrade", @@ -137,6 +145,7 @@ "Complete booking": "Fullfør reservasjonen", "Complete booking & go to payment": "Fullfør bestilling & gå til betaling", "Complete the booking": "Fullfør reservasjonen", + "Confirm cancellation": "Bekræft annullerering", "Contact information": "Kontaktinformasjon", "Contact our memberservice": "Contact our memberservice", "Contact us": "Kontakt oss", @@ -154,6 +163,7 @@ "Current Points: {points, number}": "Current Points: {points, number}", "Current password": "Nåværende passord", "Customer service": "Kundeservice", + "Customer support": "Kundesupport", "Date of Birth": "Fødselsdato", "Date of birth not matching": "Date of birth not matching", "Day": "Dag", @@ -174,6 +184,7 @@ "Distance to city center": "Avstand til sentrum", "Distance to hotel: {distanceInM} m": "Avstand til hotell: {distance} m", "Do you want to start the day with Scandics famous breakfast buffé?": "Vil du starte dagen med Scandics berømte frokostbuffé?", + "Don't cancel": "Annuller ikke", "Done": "Ferdig", "Download invoice": "Last ned faktura", "Download the Scandic app": "Last ned Scandic-appen", @@ -199,6 +210,7 @@ "Extra bed will be provided additionally": "Ekstra seng vil bli tilgjengelig", "Extras to your booking": "Tilvalg til bestillingen din", "FAQ": "Ofte stilte spørsmål", + "Failed to add to calendar": "Feil ved tilføyelse til kalender", "Failed to delete credit card, please try again later.": "Kunne ikke slette kredittkortet, prøv igjen senere.", "Failed to unlink account": "Failed to unlink account", "Failed to upgrade level": "Failed to upgrade level", @@ -243,6 +255,7 @@ "Go to My Benefits": "Gå til 'Mine fordeler'", "Go to profile": "Go to profile", "Guarantee booking with credit card": "Garantere booking med kredittkort", + "Guarantee late arrival": "Garantere sen ankomst", "Guest information": "Informasjon til gjester", "Guests": "Gjester", "Guests & Rooms": "Gjester & rom", @@ -293,6 +306,7 @@ "Join now": "Bli medlem nå", "Join or log in while booking for member pricing.": "Bli med eller logg inn under bestilling for medlemspris.", "Kayaking": "Kajakkpadling", + "Keep stay": "Behold ophold", "King bed": "King-size-seng", "Language": "Språk", "Last name": "Etternavn", @@ -354,6 +368,7 @@ "Menu": "Menu", "Menus": "Menyer", "Modify": "Endre", + "Modify dates": "Endre datoer", "Modify guest details": "Endre gjestdetaljer", "Mon-Fri Always open": "Man-Fre Alltid åpen", "Mon-Fri {openingTime}-{closingTime}": "Man-Fre {openingTime}-{closingTime}", @@ -386,6 +401,7 @@ "No": "Nei", "No availability": "Ingen tilgjengelighet", "No breakfast": "Ingen frokost", + "No charges were made.": "Ingen gebyrer blev opkrævet.", "No content published": "Ingen innhold publisert", "No hotels match your filters": "Ingen rom samsvarte med filtrene dine", "No matching location found": "Fant ingen samsvarende plassering", @@ -686,6 +702,7 @@ "Your points to spend": "Dine brukbare poeng", "Your room": "Rommet ditt", "Your selected bed type will be provided based on availability": "Din valgte sengtype vil blive stillet til rådighed baseret på tilgængelighed", + "Your stay was cancelled. Cancellation cost: 0 {currency}. We’re sorry to see that the plans didn’t work out": "Dit ophold blev annulleret. Annullereringspris: 0 {currency}. Vi beklager, at planerne ikke fungerede ud", "Zip code": "Post kode", "Zoo": "Dyrehage", "Zoom in": "Zoom inn", diff --git a/i18n/dictionaries/sv.json b/i18n/dictionaries/sv.json index 925d48adc..1eb051eba 100644 --- a/i18n/dictionaries/sv.json +++ b/i18n/dictionaries/sv.json @@ -3,6 +3,8 @@ "Included (based on availability)": "Ingår (baserat på tillgänglighet)", "Total price (incl VAT)": "Totalpris (inkl moms)", "{amount} 0 {currency}/night per adult": "{amount} 0 {currency}/natt per vuxen", + "Status Paid": "Status Betalat", + "Status Unpaid": "Status Ej betalat", "A destination or hotel name is needed to be able to search for a hotel room.": "Ett destinations- eller hotellnamn behövs för att kunna söka efter ett hotellrum.", "A photo of the room": "Ett foto av rummet", "ACCE": "Tillgänglighet", @@ -46,6 +48,8 @@ "Apply": "Tillämpa", "Approx.": "Ca.", "Approx. {value}": "Ca. {value}", + "Are you sure you want to cancel your stay at {hotel} from {checkInDate} to {checkOutDate}? This can't be reversed.": "Är du säker på att du vill avboka din vistelse hos {hotel} från {checkInDate} till {checkOutDate}? Detta kan inte ångras.", + "Are you sure you want to continue with the cancellation?": "Är du säker på att du vill fortsätta med avbokningen?", "Are you sure you want to remove the card ending with {lastFourDigits} from your member profile?": "Är du säker på att du vill ta bort kortet som slutar med {lastFourDigits} från din medlemsprofil?", "Arrival date": "Ankomstdatum", "As our Close Friend": "Som vår nära vän", @@ -100,7 +104,11 @@ "Cabaret seating": "Cabaret seating", "Campaign": "Kampanj", "Cancel": "Avbryt", + "Cancel stay": "Avboka vistelse", + "Cancellation cost": "Avbokningskostnad", + "Cancellation number": "Avbokningsnummer", "Cancellation policy": "Cancellation policy", + "Cancelled": "Avbokad", "Change room": "Ändra rum", "Changes can be made until {time} on {date}, subject to availability. Room rates may vary.": "Ändringar kan göras tills {time} den {date}, under förutsättning av tillgänglighet. Priserna för rummen kan variera.", "Check for level upgrade": "Check for level upgrade", @@ -137,6 +145,7 @@ "Complete booking": "Slutför bokning", "Complete booking & go to payment": "Fullför bokning & gå till betalning", "Complete the booking": "Slutför bokningen", + "Confirm cancellation": "Bekräfta avbokning", "Contact information": "Kontaktinformation", "Contact our memberservice": "Contact our memberservice", "Contact us": "Kontakta oss", @@ -154,6 +163,7 @@ "Current Points: {points, number}": "Current Points: {points, number}", "Current password": "Nuvarande lösenord", "Customer service": "Kundservice", + "Customer support": "Kundesupport", "Date of Birth": "Födelsedatum", "Date of birth not matching": "Date of birth not matching", "Day": "Dag", @@ -174,6 +184,7 @@ "Distance to city center": "Avstånd till centrum", "Distance to hotel: {distanceInM} m": "Avstånd till hotell: {distance} m", "Do you want to start the day with Scandics famous breakfast buffé?": "Vill du starta dagen med Scandics berömda frukostbuffé?", + "Don't cancel": "Avboka inte", "Done": "Klar", "Download invoice": "Ladda ner faktura", "Download the Scandic app": "Ladda ner Scandic-appen", @@ -199,6 +210,7 @@ "Extra bed will be provided additionally": "Extra säng kommer att tillhandahållas", "Extras to your booking": "Extra tillval till din bokning", "FAQ": "FAQ", + "Failed to add to calendar": "Misslyckades att lägga till i kalender", "Failed to delete credit card, please try again later.": "Det gick inte att ta bort kreditkortet, försök igen senare.", "Failed to unlink account": "Failed to unlink account", "Failed to upgrade level": "Failed to upgrade level", @@ -243,6 +255,7 @@ "Go to My Benefits": "Gå till 'Mina förmåner'", "Go to profile": "Go to profile", "Guarantee booking with credit card": "Garantera bokning med kreditkort", + "Guarantee late arrival": "Garantera sen ankomst", "Guest information": "Information till gästerna", "Guests": "Gäster", "Guests & Rooms": "Gäster & rum", @@ -293,6 +306,7 @@ "Join now": "Gå med nu", "Join or log in while booking for member pricing.": "Bli medlem eller logga in när du bokar för medlemspriser.", "Kayaking": "Kajakpaddling", + "Keep stay": "Behåll vistelse", "King bed": "King size-säng", "Language": "Språk", "Last name": "Efternamn", @@ -354,6 +368,7 @@ "Menu": "Meny", "Menus": "Menyer", "Modify": "Ändra", + "Modify dates": "Ändra datum", "Modify guest details": "Ändra gästinformation", "Mon-Fri Always open": "Mån-Fre Alltid öppet", "Mon-Fri {openingTime}-{closingTime}": "Mån-Fre {openingTime}-{closingTime}", @@ -386,6 +401,7 @@ "No": "Nej", "No availability": "Ingen tillgänglighet", "No breakfast": "Ingen frukost", + "No charges were made.": "Inga avgifter har debiterats.", "No content published": "Inget innehåll publicerat", "No hotels match your filters": "Inga rum matchade dina filter", "No matching location found": "Ingen matchande plats hittades", @@ -686,6 +702,7 @@ "Your points to spend": "Dina spenderbara poäng", "Your room": "Ditt rum", "Your selected bed type will be provided based on availability": "Din valda sängtyp kommer att tillhandahållas baserat på tillgänglighet", + "Your stay was cancelled. Cancellation cost: 0 {currency}. We’re sorry to see that the plans didn’t work out": "Din vistelse blev avbokad. Avbokningskostnad: 0 {currency}. Vi beklagar att planerna inte fungerade ut", "Zip code": "Postnummer", "Zoo": "Djurpark", "Zoom in": "Zooma in", diff --git a/server/routers/booking/input.ts b/server/routers/booking/input.ts index 43c0e670d..a6ac1b970 100644 --- a/server/routers/booking/input.ts +++ b/server/routers/booking/input.ts @@ -89,6 +89,11 @@ export const priceChangeInput = z.object({ confirmationNumber: z.string(), }) +export const cancelBookingInput = z.object({ + confirmationNumber: z.string(), + language: z.nativeEnum(Lang).transform((val) => langToApiLang[val]), +}) + // Query const confirmationNumberInput = z.object({ confirmationNumber: z.string(), diff --git a/server/routers/booking/mutation.ts b/server/routers/booking/mutation.ts index e8028bf79..c4eaac963 100644 --- a/server/routers/booking/mutation.ts +++ b/server/routers/booking/mutation.ts @@ -6,7 +6,11 @@ import { router, safeProtectedServiceProcedure } from "@/server/trpc" import { getMembership } from "@/utils/user" -import { createBookingInput, priceChangeInput } from "./input" +import { + cancelBookingInput, + createBookingInput, + priceChangeInput, +} from "./input" import { createBookingSchema } from "./output" import type { Session } from "next-auth" @@ -27,6 +31,13 @@ const priceChangeSuccessCounter = meter.createCounter( const priceChangeFailCounter = meter.createCounter( "trpc.bookings.price-change-fail" ) +const cancelBookingCounter = meter.createCounter("trpc.bookings.cancel") +const cancelBookingSuccessCounter = meter.createCounter( + "trpc.bookings.cancel-success" +) +const cancelBookingFailCounter = meter.createCounter( + "trpc.bookings.cancel-fail" +) async function getMembershipNumber( session: Session | null @@ -201,6 +212,98 @@ export const bookingMutationRouter = router({ priceChangeSuccessCounter.add(1, { confirmationNumber }) + return verifiedData.data + }), + cancel: safeProtectedServiceProcedure + .input(cancelBookingInput) + .mutation(async function ({ ctx, input }) { + const accessToken = ctx.session?.token.access_token ?? ctx.serviceToken + const { confirmationNumber, language } = input + + const headers = { + Authorization: `Bearer ${accessToken}`, + } + + const cancellationReason = { + reasonCode: "WEB-CANCEL", + reason: "WEB-CANCEL", + } + + const loggingAttributes = { + confirmationNumber, + language, + } + + cancelBookingCounter.add(1, loggingAttributes) + + console.info( + "api.booking.cancel start", + JSON.stringify({ + request: loggingAttributes, + headers, + }) + ) + + const apiResponse = await api.remove( + api.endpoints.v1.Booking.cancel(confirmationNumber), + { + headers, + body: JSON.stringify(cancellationReason), + } as RequestInit, + { language } + ) + + if (!apiResponse.ok) { + const text = await apiResponse.text() + cancelBookingFailCounter.add(1, { + confirmationNumber, + error_type: "http_error", + error: JSON.stringify({ + status: apiResponse.status, + }), + }) + console.error( + "api.booking.cancel error", + JSON.stringify({ + error: { + status: apiResponse.status, + statusText: apiResponse.statusText, + text, + }, + query: loggingAttributes, + }) + ) + return false + } + + const apiJson = await apiResponse.json() + const verifiedData = createBookingSchema.safeParse(apiJson) + + if (!verifiedData.success) { + cancelBookingFailCounter.add(1, { + confirmationNumber, + error_type: "validation_error", + }) + + console.error( + "api.booking.cancel validation error", + JSON.stringify({ + query: loggingAttributes, + error: verifiedData.error, + }) + ) + return null + } + + cancelBookingSuccessCounter.add(1, loggingAttributes) + + console.info( + "api.booking.cancel success", + JSON.stringify({ + query: loggingAttributes, + }) + ) + return verifiedData.data }), }) diff --git a/server/routers/booking/output.ts b/server/routers/booking/output.ts index 9223ed7c6..d54c90242 100644 --- a/server/routers/booking/output.ts +++ b/server/routers/booking/output.ts @@ -136,6 +136,49 @@ export const linkedReservationsSchema = z.object({ profileId: z.string().default(""), }) +const linksSchema = z.object({ + addAncillary: z + .object({ + href: z.string(), + meta: z.object({ + method: z.string(), + }), + }) + .nullable(), + cancel: z + .object({ + href: z.string(), + meta: z.object({ + method: z.string(), + }), + }) + .nullable(), + guarantee: z + .object({ + href: z.string(), + meta: z.object({ + method: z.string(), + }), + }) + .nullable(), + modify: z + .object({ + href: z.string(), + meta: z.object({ + method: z.string(), + }), + }) + .nullable(), + self: z + .object({ + href: z.string(), + meta: z.object({ + method: z.string(), + }), + }) + .nullable(), +}) + export const bookingConfirmationSchema = z .object({ data: z.object({ @@ -167,20 +210,12 @@ export const bookingConfirmationSchema = z }), id: z.string(), type: z.literal("booking"), - links: z.object({ - addAncillary: z - .object({ - href: z.string(), - meta: z.object({ - method: z.string(), - }), - }) - .nullable(), - }), + links: linksSchema, }), }) .transform(({ data }) => ({ ...data.attributes, - extraBedTypes: data.attributes.childBedPreferences, showAncillaries: !!data.links.addAncillary, + isCancelable: !!data.links.cancel, + isModifiable: !!data.links.modify, })) diff --git a/server/routers/booking/query.ts b/server/routers/booking/query.ts index 46d6dbf3d..5861b0da6 100644 --- a/server/routers/booking/query.ts +++ b/server/routers/booking/query.ts @@ -69,6 +69,12 @@ export const bookingQueryRouter = router({ }) ) + // If the booking is not found, return null. + // This scenario is expected to happen when a logged in user trying to access a booking that doesn't belong to them. + if (apiResponse.status === 400) { + return null + } + throw serverErrorByStatus(apiResponse.status, apiResponse) } diff --git a/types/components/hotelReservation/bookingConfirmation/actions/addToCalendar.ts b/types/components/hotelReservation/bookingConfirmation/actions/addToCalendar.ts index e24c1ffc7..3f84a29fe 100644 --- a/types/components/hotelReservation/bookingConfirmation/actions/addToCalendar.ts +++ b/types/components/hotelReservation/bookingConfirmation/actions/addToCalendar.ts @@ -3,7 +3,12 @@ import type { EventAttributes } from "ics" import type { RouterOutput } from "@/lib/trpc/client" export interface AddToCalendarProps { - checkInDate: RouterOutput["booking"]["confirmation"]["booking"]["checkInDate"] + checkInDate: NonNullable< + RouterOutput["booking"]["confirmation"] + >["booking"]["checkInDate"] event: EventAttributes - hotelName: RouterOutput["booking"]["confirmation"]["hotel"]["name"] + hotelName: NonNullable< + RouterOutput["booking"]["confirmation"] + >["hotel"]["name"] + renderButton: (onPress: () => Promise) => React.ReactNode } diff --git a/types/components/icon.ts b/types/components/icon.ts index 9071bb13e..55721f234 100644 --- a/types/components/icon.ts +++ b/types/components/icon.ts @@ -39,6 +39,7 @@ export enum IconName { Cool = "Cool", CroissantCoffeeEgg = "CroissantCoffeeEgg", CrossCircle = "CrossCircle", + CrossCircleOutline = "CrossCircleOutline", Cultural = "Cultural", CutleryOne = "CutleryOne", CutleryTwo = "CutleryTwo",