From 07fc8357c1a0f7457042c7b53d3696d44ddcc777 Mon Sep 17 00:00:00 2001 From: Chuma McPhoy Date: Tue, 7 Jan 2025 15:56:15 +0100 Subject: [PATCH 01/33] fix: update breadcrumbs max width for loyalty pages --- .../TempDesignSystem/Breadcrumbs/breadcrumbs.module.css | 4 ++++ components/TempDesignSystem/Breadcrumbs/variants.ts | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/components/TempDesignSystem/Breadcrumbs/breadcrumbs.module.css b/components/TempDesignSystem/Breadcrumbs/breadcrumbs.module.css index 77585eebb..3d9d3b654 100644 --- a/components/TempDesignSystem/Breadcrumbs/breadcrumbs.module.css +++ b/components/TempDesignSystem/Breadcrumbs/breadcrumbs.module.css @@ -22,6 +22,10 @@ max-width: var(--max-width-content); } +.pageWidth .list { + max-width: var(--max-width-page); +} + .list { align-items: center; display: flex; diff --git a/components/TempDesignSystem/Breadcrumbs/variants.ts b/components/TempDesignSystem/Breadcrumbs/variants.ts index 826a1d3b7..f839f37c2 100644 --- a/components/TempDesignSystem/Breadcrumbs/variants.ts +++ b/components/TempDesignSystem/Breadcrumbs/variants.ts @@ -11,7 +11,7 @@ export const breadcrumbsVariants = cva(styles.breadcrumbs, { [PageContentTypeEnum.contentPage]: styles.contentWidth, [PageContentTypeEnum.collectionPage]: styles.contentWidth, [PageContentTypeEnum.hotelPage]: styles.hotelHeaderWidth, - [PageContentTypeEnum.loyaltyPage]: styles.fullWidth, + [PageContentTypeEnum.loyaltyPage]: styles.pageWidth, default: styles.fullWidth, }, }, From cc7461bf3cfc54ddb6e4aafd4c63d9acd0c15d28 Mon Sep 17 00:00:00 2001 From: Chuma McPhoy Date: Tue, 7 Jan 2025 16:33:11 +0100 Subject: [PATCH 02/33] fix: update breadcrumbs styling for full width variants --- .../TempDesignSystem/Breadcrumbs/breadcrumbs.module.css | 6 +----- components/TempDesignSystem/Breadcrumbs/variants.ts | 2 +- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/components/TempDesignSystem/Breadcrumbs/breadcrumbs.module.css b/components/TempDesignSystem/Breadcrumbs/breadcrumbs.module.css index 3d9d3b654..a0dfee42d 100644 --- a/components/TempDesignSystem/Breadcrumbs/breadcrumbs.module.css +++ b/components/TempDesignSystem/Breadcrumbs/breadcrumbs.module.css @@ -15,17 +15,13 @@ } .fullWidth .list { - max-width: var(--max-width-navigation); + max-width: var(--max-width-page); } .contentWidth .list { max-width: var(--max-width-content); } -.pageWidth .list { - max-width: var(--max-width-page); -} - .list { align-items: center; display: flex; diff --git a/components/TempDesignSystem/Breadcrumbs/variants.ts b/components/TempDesignSystem/Breadcrumbs/variants.ts index f839f37c2..826a1d3b7 100644 --- a/components/TempDesignSystem/Breadcrumbs/variants.ts +++ b/components/TempDesignSystem/Breadcrumbs/variants.ts @@ -11,7 +11,7 @@ export const breadcrumbsVariants = cva(styles.breadcrumbs, { [PageContentTypeEnum.contentPage]: styles.contentWidth, [PageContentTypeEnum.collectionPage]: styles.contentWidth, [PageContentTypeEnum.hotelPage]: styles.hotelHeaderWidth, - [PageContentTypeEnum.loyaltyPage]: styles.pageWidth, + [PageContentTypeEnum.loyaltyPage]: styles.fullWidth, default: styles.fullWidth, }, }, From 93e1c740a1917ddbcf1f9ebb31f047b6886a2e1d Mon Sep 17 00:00:00 2001 From: Bianca Widstam Date: Wed, 8 Jan 2025 07:47:06 +0000 Subject: [PATCH 03/33] Merged in fix/SW-1334-filter-option-navigation (pull request #1144) fix(SW-1334): filter option saved when navigation back * fix(SW-1334): filter option saved when navigation back Approved-by: Niclas Edenvin --- .../SelectRate/RoomSelection/index.tsx | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/components/HotelReservation/SelectRate/RoomSelection/index.tsx b/components/HotelReservation/SelectRate/RoomSelection/index.tsx index ddab2f3f3..ffd712563 100644 --- a/components/HotelReservation/SelectRate/RoomSelection/index.tsx +++ b/components/HotelReservation/SelectRate/RoomSelection/index.tsx @@ -1,5 +1,5 @@ "use client" -import { useRouter, useSearchParams } from "next/navigation" +import { usePathname, useRouter, useSearchParams } from "next/navigation" import { useCallback, useEffect, useMemo, useRef } from "react" import { debounce } from "@/utils/debounce" @@ -28,6 +28,7 @@ export default function RoomSelection({ hotelType, }: RoomSelectionProps) { const router = useRouter() + const pathname = usePathname() const searchParams = useSearchParams() const isUserLoggedIn = !!user const roomRefs = useRef([]) @@ -109,9 +110,9 @@ export default function RoomSelection({ rateSummary.member.rateCode ) } - if (selectedPackages.length > 0) { - params.set(`room[${index}].packages`, selectedPackages.join(",")) - } + selectedPackages.length > 0 + ? params.set(`room[${index}].packages`, selectedPackages.join(",")) + : params.delete(`room[${index}].packages`) }) return params @@ -119,6 +120,12 @@ export default function RoomSelection({ function handleSubmit(e: React.FormEvent) { e.preventDefault() + + window.history.replaceState( + null, + "", + `${pathname}?${queryParams.toString()}` + ) router.push(`select-bed?${queryParams}`) } From c7e38af5c9454bd1b9e01f77d2c4198217e3ce06 Mon Sep 17 00:00:00 2001 From: Tobias Johansson Date: Wed, 8 Jan 2025 07:47:51 +0000 Subject: [PATCH 04/33] Merged in feat/enter-details-store-tests (pull request #1134) Feat/enter details store tests * test: add unit tests for useEnterDetailsStore and update Jest environment * test: added more useEnterDetailsStore tests Approved-by: Christel Westerberg --- jest.config.ts | 2 +- .../useEnterDetailsStore.test.tsx | 209 ++++++++++++++++++ 2 files changed, 210 insertions(+), 1 deletion(-) create mode 100644 stores/enter-details/useEnterDetailsStore.test.tsx diff --git a/jest.config.ts b/jest.config.ts index 28055b8c6..bf51303a6 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -154,7 +154,7 @@ const config: Config = { // snapshotSerializers: [], // The test environment that will be used for testing - // testEnvironment: "jest-environment-node", + testEnvironment: "jest-environment-jsdom", // Options that will be passed to the testEnvironment // testEnvironmentOptions: {}, diff --git a/stores/enter-details/useEnterDetailsStore.test.tsx b/stores/enter-details/useEnterDetailsStore.test.tsx new file mode 100644 index 000000000..dc6e3b442 --- /dev/null +++ b/stores/enter-details/useEnterDetailsStore.test.tsx @@ -0,0 +1,209 @@ +import { describe, expect, test } from "@jest/globals" +import { act, renderHook } from "@testing-library/react" +import { type PropsWithChildren } from "react" + +import { Lang } from "@/constants/languages" + +import EnterDetailsProvider from "@/providers/EnterDetailsProvider" + +import { detailsStorageName, useEnterDetailsStore } from "." + +import { BreakfastPackageEnum } from "@/types/enums/breakfast" +import { CurrencyEnum } from "@/types/enums/currency" +import { PackageTypeEnum } from "@/types/enums/packages" +import { StepEnum } from "@/types/enums/step" +import type { PersistedState } from "@/types/stores/enter-details" + +jest.mock("react", () => ({ + ...jest.requireActual("react"), + cache: jest.fn(), +})) + +jest.mock("@/server/utils", () => ({ + toLang: () => Lang.en, +})) + +jest.mock("@/lib/api", () => ({ + fetchRetry: jest.fn((fn) => fn), +})) + +const booking = { + hotel: "123", + fromDate: "2100-01-01", + toDate: "2100-01-02", + rooms: [ + { + adults: 1, + roomTypeCode: "SKS", + rateCode: "SAVEEU", + counterRateCode: "PLSA2BEU", + }, + ], +} + +const bedTypes = [ + { + description: "King-size bed", + value: "SKS", + size: { + min: 180, + max: 200, + }, + roomTypeCode: "SKS", + }, + { + description: "Queen-size bed", + value: "QZ", + size: { + min: 160, + max: 200, + }, + roomTypeCode: "QZ", + }, +] + +const guest = { + countryCode: "SE", + dateOfBirth: "", + email: "test@test.com", + firstName: "Tester", + lastName: "Testersson", + join: false, + membershipNo: "12345678901234", + phoneNumber: "+46700000000", + zipCode: "", +} + +const breakfastPackages = [ + { + code: BreakfastPackageEnum.REGULAR_BREAKFAST, + description: "Breakfast with reservation", + localPrice: { + currency: CurrencyEnum.SEK, + price: "99", + totalPrice: "99", + }, + requestedPrice: { + currency: CurrencyEnum.EUR, + price: "9", + totalPrice: "9", + }, + packageType: PackageTypeEnum.BreakfastAdult as const, + }, +] + +function Wrapper({ children }: PropsWithChildren) { + return ( + + {children} + + ) +} + +describe("Enter Details Store", () => { + beforeEach(() => { + window.sessionStorage.clear() + }) + + test("initialize with correct default values", () => { + const { result } = renderHook( + () => useEnterDetailsStore((state) => state), + { + wrapper: Wrapper, + } + ) + const state = result.current + + expect(state.currentStep).toBe(StepEnum.selectBed) + expect(state.booking).toEqual(booking) + expect(state.bedType).toEqual(undefined) + expect(state.breakfast).toEqual(undefined) + expect(Object.values(state.guest).every((value) => value === "")) + }) + + test("initialize with correct values from sessionStorage", async () => { + const storage: PersistedState = { + bedType: bedTypes[1], + breakfast: breakfastPackages[0], + booking, + guest, + } + + window.sessionStorage.setItem(detailsStorageName, JSON.stringify(storage)) + + const { result } = renderHook( + () => useEnterDetailsStore((state) => state), + { + wrapper: Wrapper, + } + ) + const state = result.current + + expect(state.bedType).toEqual(storage.bedType) + expect(state.guest).toEqual(storage.guest) + expect(state.booking).toEqual(storage.booking) + expect(state.breakfast).toEqual(storage.breakfast) + }) + + test("complete step and navigate to next step", async () => { + const { result } = renderHook( + () => useEnterDetailsStore((state) => state), + { + wrapper: Wrapper, + } + ) + + expect(result.current.currentStep).toEqual(StepEnum.selectBed) + + await act(async () => { + result.current.actions.updateBedType(bedTypes[0]) + }) + + expect(result.current.isValid[StepEnum.selectBed]).toEqual(true) + expect(result.current.currentStep).toEqual(StepEnum.breakfast) + expect(window.location.pathname.slice(1)).toBe(StepEnum.breakfast) + + await act(async () => { + result.current.actions.updateBreakfast(breakfastPackages[0]) + }) + + expect(result.current.isValid[StepEnum.breakfast]).toEqual(true) + expect(result.current.currentStep).toEqual(StepEnum.details) + expect(window.location.pathname.slice(1)).toBe(StepEnum.details) + + await act(async () => { + result.current.actions.updateDetails(guest) + }) + + expect(result.current.isValid[StepEnum.details]).toEqual(true) + expect(result.current.currentStep).toEqual(StepEnum.payment) + expect(window.location.pathname.slice(1)).toBe(StepEnum.payment) + }) +}) From a056b36443e42bf97d64947f0fab017c7929dd69 Mon Sep 17 00:00:00 2001 From: Bianca Widstam Date: Wed, 8 Jan 2025 12:05:15 +0000 Subject: [PATCH 05/33] Merged in fix/SW-1330-filter-tooltip-mobile (pull request #1146) fix(SW-1330): add space and fix touchable tooltip for ipad * fix(SW-1330): add space and fix touchable tooltip for ipad Approved-by: Pontus Dreij Approved-by: Niclas Edenvin --- .../SelectRate/RoomFilter/index.tsx | 49 +++++-------------- .../RoomFilter/roomFilter.module.css | 7 +++ 2 files changed, 20 insertions(+), 36 deletions(-) diff --git a/components/HotelReservation/SelectRate/RoomFilter/index.tsx b/components/HotelReservation/SelectRate/RoomFilter/index.tsx index ac952d518..70752b69b 100644 --- a/components/HotelReservation/SelectRate/RoomFilter/index.tsx +++ b/components/HotelReservation/SelectRate/RoomFilter/index.tsx @@ -65,9 +65,6 @@ export default function RoomFilter({ const tooltipText = intl.formatMessage({ id: "Pet-friendly rooms have an additional fee of 20 EUR per stay", }) - - const showTooltip = isAboveMobile && petFriendly - const submitFilter = useCallback(() => { const data = getValues() onFilter(data) @@ -94,19 +91,14 @@ export default function RoomFilter({
- {!isAboveMobile ? ( - - - + + +
{intl.formatMessage({ id: "Filter" })} - + {Object.entries(selectedFilters) .filter(([_, value]) => value) .map(([key]) => intl.formatMessage({ id: key })) .join(", ")} - - ) : ( - <> - - {intl.formatMessage({ id: "Filter" })} - - - {Object.entries(selectedFilters) - .filter(([_, value]) => value) - .map(([key]) => intl.formatMessage({ id: key })) - .join(", ")} - - - )} +
+
{intl.formatMessage( @@ -170,12 +146,13 @@ export default function RoomFilter({ /> ) - return showTooltip ? ( + return isPetRoom && isAboveMobile ? ( {checkboxChip} diff --git a/components/HotelReservation/SelectRate/RoomFilter/roomFilter.module.css b/components/HotelReservation/SelectRate/RoomFilter/roomFilter.module.css index f94858248..188bca200 100644 --- a/components/HotelReservation/SelectRate/RoomFilter/roomFilter.module.css +++ b/components/HotelReservation/SelectRate/RoomFilter/roomFilter.module.css @@ -12,6 +12,13 @@ align-items: center; } +.filter { + display: flex; + gap: var(--Spacing-x-half); + margin-left: var(--Spacing-x-half); + align-items: baseline; +} + .filterInfo { display: flex; flex-direction: row; From 18dd08f10e9d32a73cc9f1222b4bd91fc0613f8e Mon Sep 17 00:00:00 2001 From: Tobias Johansson Date: Wed, 8 Jan 2025 12:31:30 +0000 Subject: [PATCH 06/33] Merged in feat/SW-619-signup-non-happy (pull request #1083) Feat/SW-619 signup non happy * feat(SW-619): Added tests for Date input * feat(SW-619): Updated date input to not allow date below 18 years old, also added form validation and tests to cover this change * fix * feat(SW-619): add info banner if membership verification fails * fix(SW-619): update test description Approved-by: Christel Westerberg Approved-by: Arvid Norlin --- .../Confirmation/index.tsx | 19 +++ .../EnterDetails/Details/schema.ts | 15 +- .../TempDesignSystem/Form/Date/date.test.tsx | 138 ++++++++++++++++++ .../TempDesignSystem/Form/Date/index.tsx | 19 ++- i18n/dictionaries/da.json | 2 + i18n/dictionaries/de.json | 2 + i18n/dictionaries/en.json | 2 + i18n/dictionaries/fi.json | 2 + i18n/dictionaries/no.json | 2 + i18n/dictionaries/sv.json | 2 + jest.setup.ts | 12 ++ 11 files changed, 208 insertions(+), 7 deletions(-) create mode 100644 components/TempDesignSystem/Form/Date/date.test.tsx diff --git a/components/HotelReservation/BookingConfirmation/Confirmation/index.tsx b/components/HotelReservation/BookingConfirmation/Confirmation/index.tsx index 71efd978d..3512d66e1 100644 --- a/components/HotelReservation/BookingConfirmation/Confirmation/index.tsx +++ b/components/HotelReservation/BookingConfirmation/Confirmation/index.tsx @@ -1,5 +1,6 @@ "use client" import { useRef } from "react" +import { useIntl } from "react-intl" import Header from "@/components/HotelReservation/BookingConfirmation/Header" import HotelDetails from "@/components/HotelReservation/BookingConfirmation/HotelDetails" @@ -8,22 +9,40 @@ import Promos from "@/components/HotelReservation/BookingConfirmation/Promos" import Receipt from "@/components/HotelReservation/BookingConfirmation/Receipt" import Rooms from "@/components/HotelReservation/BookingConfirmation/Rooms" import SidePanel from "@/components/HotelReservation/SidePanel" +import Alert from "@/components/TempDesignSystem/Alert" import Divider from "@/components/TempDesignSystem/Divider" import styles from "./confirmation.module.css" import type { ConfirmationProps } from "@/types/components/hotelReservation/bookingConfirmation/bookingConfirmation" +import { AlertTypeEnum } from "@/types/enums/alert" export default function Confirmation({ booking, hotel, room, }: ConfirmationProps) { + const intl = useIntl() const mainRef = useRef(null) + + const failedToVerifyMembership = + booking.rateDefinition.isMemberRate && !booking.guest.membershipNumber + return (
+ {failedToVerifyMembership && ( + + )} diff --git a/components/HotelReservation/EnterDetails/Details/schema.ts b/components/HotelReservation/EnterDetails/Details/schema.ts index cbe8e2eae..8371fe31f 100644 --- a/components/HotelReservation/EnterDetails/Details/schema.ts +++ b/components/HotelReservation/EnterDetails/Details/schema.ts @@ -1,5 +1,7 @@ import { z } from "zod" +import { dt } from "@/lib/dt" + import { phoneValidator } from "@/utils/phoneValidator" // stringMatcher regex is copied from current web as specified by requirements. @@ -78,7 +80,18 @@ export const joinDetailsSchema = baseDetailsSchema.merge( z.object({ join: z.literal(true), zipCode: z.string().min(1, { message: "Zip code is required" }), - dateOfBirth: z.string().min(1, { message: "Date of birth is required" }), + dateOfBirth: z + .string() + .min(1, { message: "Date of birth is required" }) + .refine( + (date) => { + const today = dt() + const dob = dt(date) + const age = today.diff(dob, "year") + return age >= 18 + }, + { message: "Must be at least 18 years of age to continue" } + ), membershipNo: z.string().default(""), }) ) diff --git a/components/TempDesignSystem/Form/Date/date.test.tsx b/components/TempDesignSystem/Form/Date/date.test.tsx new file mode 100644 index 000000000..0b2fd4183 --- /dev/null +++ b/components/TempDesignSystem/Form/Date/date.test.tsx @@ -0,0 +1,138 @@ +import { describe, expect, test } from "@jest/globals" // importing because of type conflict with globals from Cypress +import { render, screen } from "@testing-library/react" +import { type UserEvent, userEvent } from "@testing-library/user-event" +import { FormProvider, useForm } from "react-hook-form" + +import { Lang } from "@/constants/languages" +import { dt } from "@/lib/dt" + +import { getLocalizedMonthName } from "@/utils/dateFormatting" + +import Date from "./index" + +interface FormWrapperProps { + defaultValues: Record + children: React.ReactNode + onSubmit: (data: unknown) => void +} + +function FormWrapper({ defaultValues, children, onSubmit }: FormWrapperProps) { + const methods = useForm({ + defaultValues, + }) + return ( + +
onSubmit(data))}> + {children} + +
+
+ ) +} + +async function selectOption(user: UserEvent, name: RegExp, value: string) { + // since its not a proper Select element selectOptions from userEvent doesn't work + const select = screen.queryByRole("button", { name }) + if (select) { + await user.click(select) + + const option = screen.queryByRole("option", { name: value }) + if (option) { + await user.click(option) + } else { + await user.click(select) // click select again to close it + } + } +} + +const testCases = [ + { + description: "date is set and submitted successfully", + defaultValue: "", + dateOfBirth: "1987-12-05", + expectedOutput: { + dateOfBirth: "1987-12-05", + year: 1987, + month: 12, + day: 5, + }, + }, + { + description: "sets default value and submits successfully", + defaultValue: "2000-01-01", + dateOfBirth: "", + expectedOutput: { + dateOfBirth: "2000-01-01", + year: 2000, + month: 1, + day: 1, + }, + }, + { + description: "accepts date exactly 18 years old", + defaultValue: "", + dateOfBirth: dt().subtract(18, "year").format("YYYY-MM-DD"), + expectedOutput: { + dateOfBirth: dt().subtract(18, "year").format("YYYY-MM-DD"), + }, + }, + { + description: "rejects date below 18 years old - by year", + defaultValue: "", + dateOfBirth: dt().subtract(17, "year").format("YYYY-MM-DD"), + expectedOutput: { + dateOfBirth: "", + }, + }, + { + description: "rejects date below 18 years old - by month", + defaultValue: "", + dateOfBirth: dt().subtract(18, "year").add(1, "month").format("YYYY-MM-DD"), + expectedOutput: { + dateOfBirth: "", + }, + }, + { + description: "rejects date below 18 years old - by day", + defaultValue: "", + dateOfBirth: dt().subtract(18, "year").add(1, "day").format("YYYY-MM-DD"), + expectedOutput: { + dateOfBirth: "", + }, + }, +] + +describe("Date input", () => { + test.each(testCases)( + "$description", + async ({ defaultValue, dateOfBirth, expectedOutput }) => { + const user = userEvent.setup() + const handleSubmit = jest.fn() + + render( + + + + ) + + const date = dt(dateOfBirth).toDate() + const year = date.getFullYear() + const month = date.getMonth() + 1 + const day = date.getDate() + + await selectOption(user, /year/i, year.toString()) + await selectOption(user, /month/i, getLocalizedMonthName(month, Lang.en)) + await selectOption(user, /day/i, day.toString()) + + const submitButton = screen.getByRole("button", { name: /submit/i }) + await user.click(submitButton) + + expect(handleSubmit).toHaveBeenCalledWith( + expect.objectContaining(expectedOutput) + ) + } + ) +}) diff --git a/components/TempDesignSystem/Form/Date/index.tsx b/components/TempDesignSystem/Form/Date/index.tsx index ae9d98818..008213b01 100644 --- a/components/TempDesignSystem/Form/Date/index.tsx +++ b/components/TempDesignSystem/Form/Date/index.tsx @@ -19,6 +19,8 @@ import styles from "./date.module.css" export default function DateSelect({ name, registerOptions = {} }: DateProps) { const intl = useIntl() + const lang = useLang() + const { control, setValue, formState, watch } = useFormContext() const { field, fieldState } = useController({ control, @@ -31,14 +33,20 @@ export default function DateSelect({ name, registerOptions = {} }: DateProps) { const month = watch(DateName.month) const day = watch(DateName.day) - const lang = useLang() - const months = rangeArray(1, 12).map((month) => ({ + const minAgeDate = dt().subtract(18, "year").toDate() // age 18 + const minAgeYear = minAgeDate.getFullYear() + const minAgeMonth = year === minAgeYear ? minAgeDate.getMonth() + 1 : null + const minAgeDay = + Number(year) === minAgeYear && Number(month) === minAgeMonth + ? minAgeDate.getDate() + : null + + const months = rangeArray(1, minAgeMonth ?? 12).map((month) => ({ value: month, label: getLocalizedMonthName(month, lang), })) - const currentYear = new Date().getFullYear() - const years = rangeArray(1900, currentYear - 18) + const years = rangeArray(1900, minAgeYear) .reverse() .map((year) => ({ value: year, label: year.toString() })) @@ -48,7 +56,7 @@ export default function DateSelect({ name, registerOptions = {} }: DateProps) { month ? Number(month) - 1 : null ) - const days = rangeArray(1, daysInMonth).map((day) => ({ + const days = rangeArray(1, minAgeDay ?? daysInMonth).map((day) => ({ value: day, label: `${day}`, })) @@ -119,7 +127,6 @@ export default function DateSelect({ name, registerOptions = {} }: DateProps) { ref={field.ref} value={dateValue} data-testid={name} - className={styles.datePicker} > diff --git a/i18n/dictionaries/da.json b/i18n/dictionaries/da.json index 7051fac39..2c988c4f0 100644 --- a/i18n/dictionaries/da.json +++ b/i18n/dictionaries/da.json @@ -501,6 +501,8 @@ "booking.bedOptions": "Sengemuligheder", "booking.children": "{totalChildren, plural, one {# barn} other {# børn}}", "booking.children.breakfasts": "{totalChildren, plural, one {# barn} other {# børn}}, {totalBreakfasts, plural, one {# morgenmad} other {# morgenmad}}", + "booking.confirmation.membershipInfo.heading": "Medlemskab ikke verificeret", + "booking.confirmation.membershipInfo.text": "Din booking er bekræftet, men vi kunne ikke verificere dit medlemskab. Hvis du har booket med et medlemstilbud, skal du enten vise dit eksisterende medlemskab ved check-in, blive medlem eller betale prisdifferencen ved check-in. Tilmelding er foretrukket online før opholdet.", "booking.confirmation.text": "Tak fordi du bookede hos os! Vi glæder os til at byde dig velkommen og håber du får et behageligt ophold. Hvis du har spørgsmål eller har brug for at foretage ændringer i din reservation, bedes du kontakte os.", "booking.confirmation.title": "Booking bekræftelse", "booking.guests": "Maks {nrOfGuests, plural, one {# gæst} other {# gæster}}", diff --git a/i18n/dictionaries/de.json b/i18n/dictionaries/de.json index 7954874e0..881059090 100644 --- a/i18n/dictionaries/de.json +++ b/i18n/dictionaries/de.json @@ -500,6 +500,8 @@ "booking.bedOptions": "Bettoptionen", "booking.children": "{totalChildren, plural, one {# kind} other {# kinder}}", "booking.children.breakfasts": "{totalChildren, plural, one {# kind} other {# kinder}}, {totalBreakfasts, plural, one {# frühstück} other {# frühstücke}}", + "booking.confirmation.membershipInfo.heading": "Medlemskab nicht verifiziert", + "booking.confirmation.membershipInfo.text": "Ihre Buchung ist bestätigt, aber wir konnten Ihr Mitglied nicht verifizieren. Wenn Sie mit einem Mitgliederrabatt gebucht haben, müssen Sie entweder Ihr vorhandenes Mitgliedschaftsnummer bei der Anreise präsentieren, ein Mitglied werden oder die Preisdifferenz bei der Anreise bezahlen. Die Anmeldung ist vorzugsweise online vor der Aufenthaltsdauer erfolgreich.", "booking.confirmation.text": "Vielen Dank, dass Sie bei uns gebucht haben! Wir freuen uns, Sie bei uns begrüßen zu dürfen und wünschen Ihnen einen angenehmen Aufenthalt. Wenn Sie Fragen haben oder Änderungen an Ihrer Buchung vornehmen müssen, kontaktieren Sie uns bitte..", "booking.confirmation.title": "Buchungsbestätigung", "booking.guests": "Max {nrOfGuests, plural, one {# gast} other {# gäste}}", diff --git a/i18n/dictionaries/en.json b/i18n/dictionaries/en.json index 771290e0a..d58076530 100644 --- a/i18n/dictionaries/en.json +++ b/i18n/dictionaries/en.json @@ -544,6 +544,8 @@ "booking.bedOptions": "Bed options", "booking.children": "{totalChildren, plural, one {# child} other {# children}}", "booking.children.breakfasts": "{totalChildren, plural, one {# child} other {# children}}, {totalBreakfasts, plural, one {# breakfast} other {# breakfasts}}", + "booking.confirmation.membershipInfo.heading": "Failed to verify membership", + "booking.confirmation.membershipInfo.text": "Your booking(s) is confirmed but we could not verify your membership. If you have booked with a member discount, you'll either need to present your existing membership number upon check-in, become a member or pay the price difference at the hotel. Signing up is preferably done online before the stay.", "booking.confirmation.text": "Thank you for booking with us! We look forward to welcoming you and hope you have a pleasant stay. If you have any questions or need to make changes to your reservation, please contact us.", "booking.confirmation.title": "Booking confirmation", "booking.guests": "Max {nrOfGuests, plural, one {# guest} other {# guests}}", diff --git a/i18n/dictionaries/fi.json b/i18n/dictionaries/fi.json index 4b242c84e..707506af9 100644 --- a/i18n/dictionaries/fi.json +++ b/i18n/dictionaries/fi.json @@ -499,6 +499,8 @@ "booking.bedOptions": "Vuodevaihtoehdot", "booking.children": "{totalChildren, plural, one {# lapsi} other {# lasta}}", "booking.children.breakfasts": "{totalChildren, plural, one {# lapsi} other {# lasta}}, {totalBreakfasts, plural, one {# aamiainen} other {# aamiaista}}", + "booking.confirmation.membershipInfo.heading": "Jäsenyys ei verifioitu", + "booking.confirmation.membershipInfo.text": "Varauksesi on vahvistettu, mutta jäsenyytesi ei voitu vahvistaa. Jos olet bookeutunut jäsenyysalennoilla, sinun on joko esitettävä olemassa olevan jäsenyysnumero tarkistukseen, tulla jäseneksi tai maksamaan hinnan eron hotellissa. Jäsenyyden tilittäminen on suositeltavampaa tehdä verkkoon ennen majoittumista.", "booking.confirmation.text": "Kiitos, että teit varauksen meiltä! Toivotamme sinut tervetulleeksi ja toivomme sinulle miellyttävää oleskelua. Jos sinulla on kysyttävää tai haluat tehdä muutoksia varaukseesi, ota meihin yhteyttä.", "booking.confirmation.title": "Varausvahvistus", "booking.guests": "Max {nrOfGuests, plural, one {# vieras} other {# vieraita}}", diff --git a/i18n/dictionaries/no.json b/i18n/dictionaries/no.json index 3fa2f494f..5e4948dc6 100644 --- a/i18n/dictionaries/no.json +++ b/i18n/dictionaries/no.json @@ -499,6 +499,8 @@ "booking.bedOptions": "Sengemuligheter", "booking.children": "{totalChildren, plural, one {# barn} other {# barn}}", "booking.children.breakfasts": "{totalChildren, plural, one {# barn} other {# barn}}, {totalBreakfasts, plural, one {# frokost} other {# frokoster}}", + "booking.confirmation.membershipInfo.heading": "Medlemskap ikke verifisert", + "booking.confirmation.membershipInfo.text": "Din bestilling er bekreftet, men vi kunne ikke verifisere medlemskapet ditt. Hvis du har booke ut med et medlemsrabatt, må du enten presentere eksisterende medlemsnummer ved check-in, bli medlem eller betale prisdifferansen ved hotellet. Registrering er foretrukket gjort online før oppholdet.", "booking.confirmation.text": "Takk for at du booket hos oss! Vi ser frem til å ønske deg velkommen og håper du får et hyggelig opphold. Hvis du har spørsmål eller trenger å gjøre endringer i bestillingen din, vennligst kontakt oss.", "booking.confirmation.title": "Bestillingsbekreftelse", "booking.guests": "Maks {nrOfGuests, plural, one {# gjest} other {# gjester}}", diff --git a/i18n/dictionaries/sv.json b/i18n/dictionaries/sv.json index 1fae7caf6..ecdd2e364 100644 --- a/i18n/dictionaries/sv.json +++ b/i18n/dictionaries/sv.json @@ -499,6 +499,8 @@ "booking.bedOptions": "Sängalternativ", "booking.children": "{totalChildren, plural, one {# barn} other {# barn}}", "booking.children.breakfasts": "{totalChildren, plural, one {# barn} other {# barn}}, {totalBreakfasts, plural, one {# frukost} other {# frukostar}}", + "booking.confirmation.membershipInfo.heading": "Medlemskap inte verifierat", + "booking.confirmation.membershipInfo.text": "Din bokning är bekräftad, men vi kunde inte verifiera ditt medlemskap. Om du har bokat med ett medlemsrabatt måste du antingen presentera ditt befintliga medlemsnummer vid check-in, bli medlem eller betala prisdifferensen vid hotell. Registrering är föredragen gjord online före vistelsen.", "booking.confirmation.text": "Tack för att du bokar hos oss! Vi ser fram emot att välkomna dig och hoppas att du får en trevlig vistelse. Om du har några frågor eller behöver göra ändringar i din bokning, vänligen kontakta oss.", "booking.confirmation.title": "Bokningsbekräftelse", "booking.guests": "Max {nrOfGuests, plural, one {# gäst} other {# gäster}}", diff --git a/jest.setup.ts b/jest.setup.ts index df6631eeb..6d866f13c 100644 --- a/jest.setup.ts +++ b/jest.setup.ts @@ -1 +1,13 @@ import "@testing-library/jest-dom" + +jest.mock("react-intl", () => ({ + useIntl: () => ({ + formatMessage: (message: { id: string }) => message.id, + }), +})) + +jest.mock("next/navigation", () => ({ + useRouter: jest.fn(), + usePathname: jest.fn().mockReturnValue("/"), + useParams: jest.fn().mockReturnValue({ lang: "en" }), +})) From 6ca56f3138ebd3dc343a0f6439635102e70749ac Mon Sep 17 00:00:00 2001 From: Tobias Johansson Date: Wed, 8 Jan 2025 12:34:20 +0000 Subject: [PATCH 07/33] Merged in feat/SW-822-handle-breakfast-included (pull request #1138) Feat/SW-822 handle breakfast included * feat(SW-822): Added flag for breakfast included and hide breakfast step if included * fix: check if window is defined to avoid error during SSR * fix: remove return if rate definition is not found because its expected if input is undefined Approved-by: Christel Westerberg Approved-by: Arvid Norlin --- .../hotelreservation/(standard)/step/page.tsx | 13 ++++-- .../EnterDetails/Summary/UI/index.tsx | 9 ++++- providers/EnterDetailsProvider.tsx | 4 +- server/routers/hotels/query.ts | 40 ++++++++++++------- types/components/hotelReservation/summary.ts | 1 + types/providers/enter-details.ts | 2 +- utils/tracking.ts | 7 +++- 7 files changed, 52 insertions(+), 24 deletions(-) diff --git a/app/[lang]/(live)/(public)/hotelreservation/(standard)/step/page.tsx b/app/[lang]/(live)/(public)/hotelreservation/(standard)/step/page.tsx index 27d570042..075e79766 100644 --- a/app/[lang]/(live)/(public)/hotelreservation/(standard)/step/page.tsx +++ b/app/[lang]/(live)/(public)/hotelreservation/(standard)/step/page.tsx @@ -126,7 +126,7 @@ export default async function StepPage({ return notFound() } - const mustBeGuaranteed = roomAvailability?.mustBeGuaranteed ?? false + const { mustBeGuaranteed, breakfastIncluded } = roomAvailability const paymentGuarantee = intl.formatMessage({ id: "Payment Guarantee", @@ -191,13 +191,18 @@ export default async function StepPage({ isMember: !!user, rateDetails: roomAvailability.rateDetails, roomType: roomAvailability.selectedRoom.roomType, + breakfastIncluded, } + const showBreakfastStep = Boolean( + breakfastPackages?.length && !breakfastIncluded + ) + return ( ) : null} - {breakfastPackages?.length ? ( + {showBreakfastStep ? ( - + ) : null} diff --git a/components/HotelReservation/EnterDetails/Summary/UI/index.tsx b/components/HotelReservation/EnterDetails/Summary/UI/index.tsx index d61563fdd..d828f6c55 100644 --- a/components/HotelReservation/EnterDetails/Summary/UI/index.tsx +++ b/components/HotelReservation/EnterDetails/Summary/UI/index.tsx @@ -51,6 +51,7 @@ export default function SummaryUI({ isMember, rateDetails, roomType, + breakfastIncluded, }: SummaryProps) { const intl = useIntl() const lang = useLang() @@ -242,7 +243,13 @@ export default function SummaryUI({
) : null} - {breakfast === false ? ( + {breakfastIncluded ? ( +
+ + {intl.formatMessage({ id: "Breakfast included" })} + +
+ ) : breakfast === false ? (
{intl.formatMessage({ id: "No breakfast" })} diff --git a/providers/EnterDetailsProvider.tsx b/providers/EnterDetailsProvider.tsx index 547130e7e..826116eb3 100644 --- a/providers/EnterDetailsProvider.tsx +++ b/providers/EnterDetailsProvider.tsx @@ -25,7 +25,7 @@ import type { DetailsState, InitialState } from "@/types/stores/enter-details" export default function EnterDetailsProvider({ bedTypes, booking, - breakfastPackages, + showBreakfastStep, children, packages, roomRate, @@ -44,7 +44,7 @@ export default function EnterDetailsProvider({ roomTypeCode: bedTypes[0].value, } } - if (!breakfastPackages?.length) { + if (!showBreakfastStep) { initialData.breakfast = false } diff --git a/server/routers/hotels/query.ts b/server/routers/hotels/query.ts index 86d615ebd..e17f1a51f 100644 --- a/server/routers/hotels/query.ts +++ b/server/routers/hotels/query.ts @@ -554,14 +554,20 @@ export const hotelQueryRouter = router({ (room) => room.roomType === selectedRoom?.roomType ) if (!selectedRoom) { + selectedRoomAvailabilityFailCounter.add(1, { + hotelId, + roomStayStartDate, + roomStayEndDate, + adults, + children, + bookingCode, + error_type: "not_found", + error: `Couldn't find selected room with input: ${roomTypeCode}`, + }) console.error("No matching room found") return null } - const rateDetails = validateAvailabilityData.data.rateDefinitions.find( - (rateDef) => rateDef.rateCode === rateCode - )?.generalTerms - const rateTypes = selectedRoom.products.find( (rate) => rate.productType.public?.rateCode === rateCode || @@ -569,20 +575,25 @@ export const hotelQueryRouter = router({ ) if (!rateTypes) { + selectedRoomAvailabilityFailCounter.add(1, { + hotelId, + roomStayStartDate, + roomStayEndDate, + adults, + children, + bookingCode, + error_type: "not_found", + error: `Couldn't find rateTypes for selected room: ${JSON.stringify(selectedRoom)}`, + }) console.error("No matching rate found") return null } const rates = rateTypes.productType - const mustBeGuaranteed = - validateAvailabilityData.data.rateDefinitions.filter( - (rate) => rate.rateCode === rateCode - )[0].mustBeGuaranteed - - const cancellationText = + const rateDefinition = validateAvailabilityData.data.rateDefinitions.find( (rate) => rate.rateCode === rateCode - )?.cancellationText ?? "" + ) const bedTypes = availableRoomsInCategory .map((availRoom) => { @@ -623,9 +634,10 @@ export const hotelQueryRouter = router({ return { selectedRoom, - rateDetails, - mustBeGuaranteed, - cancellationText, + rateDetails: rateDefinition?.generalTerms, + cancellationText: rateDefinition?.cancellationText ?? "", + mustBeGuaranteed: !!rateDefinition?.mustBeGuaranteed, + breakfastIncluded: !!rateDefinition?.breakfastIncluded, memberRate: rates?.member, publicRate: rates.public, bedTypes, diff --git a/types/components/hotelReservation/summary.ts b/types/components/hotelReservation/summary.ts index e705f3751..50fd2ed66 100644 --- a/types/components/hotelReservation/summary.ts +++ b/types/components/hotelReservation/summary.ts @@ -15,4 +15,5 @@ export interface SummaryProps extends Pick, Pick { isMember: boolean + breakfastIncluded: boolean } diff --git a/types/providers/enter-details.ts b/types/providers/enter-details.ts index 0862abb4d..0659d77ed 100644 --- a/types/providers/enter-details.ts +++ b/types/providers/enter-details.ts @@ -9,7 +9,7 @@ import type { Packages } from "../requests/packages" export interface DetailsProviderProps extends React.PropsWithChildren { booking: BookingData bedTypes: BedTypeSelection[] - breakfastPackages: BreakfastPackage[] | null + showBreakfastStep: boolean packages: Packages | null roomRate: Pick searchParamsStr: string diff --git a/utils/tracking.ts b/utils/tracking.ts index 53da7d744..59b7dd965 100644 --- a/utils/tracking.ts +++ b/utils/tracking.ts @@ -1,4 +1,7 @@ -import type { TrackingPosition, TrackingSDKData } from "@/types/components/tracking" +import type { + TrackingPosition, + TrackingSDKData, +} from "@/types/components/tracking" export function trackClick(name: string) { pushToDataLayer({ @@ -60,7 +63,7 @@ export function createSDKPageObject( return { ...trackingData, - domain: window.location.host, + domain: typeof window !== "undefined" ? window.location.host : "", pageName: pageName, siteSections: siteSections, } From 6fabfe236708a48059804df1349ce341df70d611 Mon Sep 17 00:00:00 2001 From: Tobias Johansson Date: Wed, 8 Jan 2025 14:14:38 +0100 Subject: [PATCH 08/33] fix: lint error in test --- stores/enter-details/useEnterDetailsStore.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stores/enter-details/useEnterDetailsStore.test.tsx b/stores/enter-details/useEnterDetailsStore.test.tsx index dc6e3b442..cfb6c9827 100644 --- a/stores/enter-details/useEnterDetailsStore.test.tsx +++ b/stores/enter-details/useEnterDetailsStore.test.tsx @@ -97,7 +97,7 @@ function Wrapper({ children }: PropsWithChildren) { Date: Wed, 8 Jan 2025 13:26:56 +0000 Subject: [PATCH 09/33] Merged in feat/SW-1169-map-bedtype-icons (pull request #1113) Feat/SW-1169 map bedtype icons * feat(SW-1169): Added bed icons * fix(SW-1169): update fill rule property * fix(SW-1169): update clip rule prop * feat(SW-1169): Added way of rendering bed type icons with extra beds * feat(SW-1169): update room schema to map mainBed to enum * feat(SW-1169): update bedtype icon color * feat(SW-1169): transform unknown bed types to BedTypeEnum.Other * test: update mock data with new schema Approved-by: Christel Westerberg --- .../BedType/bedOptions.module.css | 5 + .../EnterDetails/BedType/index.tsx | 38 ++++++- components/Icons/{ => Beds}/Bed.tsx | 0 components/Icons/{ => Beds}/BedDouble.tsx | 2 +- components/Icons/{ => Beds}/BedSingle.tsx | 2 +- components/Icons/Beds/ExtraBunkBed.tsx | 101 ++++++++++++++++++ components/Icons/Beds/ExtraPullOutBed.tsx | 29 +++++ components/Icons/Beds/ExtraSofaBed.tsx | 27 +++++ components/Icons/Beds/ExtraWallBed.tsx | 29 +++++ components/Icons/Beds/KingBed.tsx | 25 +++++ components/Icons/{ => Beds}/KingBedSmall.tsx | 2 +- components/Icons/Beds/QueenBed.tsx | 29 +++++ components/Icons/Beds/SingleBed.tsx | 29 +++++ components/Icons/Beds/TwinBeds.tsx | 29 +++++ components/Icons/KingBed.tsx | 24 ----- components/Icons/index.tsx | 17 ++- .../Form/ChoiceCard/_Card/card.module.css | 1 + constants/booking.ts | 45 ++++++++ server/routers/hotels/query.ts | 7 ++ server/routers/hotels/schemas/room.ts | 50 ++++++--- .../useEnterDetailsStore.test.tsx | 5 + .../hotelReservation/enterDetails/bedType.ts | 8 ++ 22 files changed, 454 insertions(+), 50 deletions(-) rename components/Icons/{ => Beds}/Bed.tsx (100%) rename components/Icons/{ => Beds}/BedDouble.tsx (97%) rename components/Icons/{ => Beds}/BedSingle.tsx (97%) create mode 100644 components/Icons/Beds/ExtraBunkBed.tsx create mode 100644 components/Icons/Beds/ExtraPullOutBed.tsx create mode 100644 components/Icons/Beds/ExtraSofaBed.tsx create mode 100644 components/Icons/Beds/ExtraWallBed.tsx create mode 100644 components/Icons/Beds/KingBed.tsx rename components/Icons/{ => Beds}/KingBedSmall.tsx (97%) create mode 100644 components/Icons/Beds/QueenBed.tsx create mode 100644 components/Icons/Beds/SingleBed.tsx create mode 100644 components/Icons/Beds/TwinBeds.tsx delete mode 100644 components/Icons/KingBed.tsx diff --git a/components/HotelReservation/EnterDetails/BedType/bedOptions.module.css b/components/HotelReservation/EnterDetails/BedType/bedOptions.module.css index 9bde9175b..b52fefa3e 100644 --- a/components/HotelReservation/EnterDetails/BedType/bedOptions.module.css +++ b/components/HotelReservation/EnterDetails/BedType/bedOptions.module.css @@ -10,3 +10,8 @@ grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); width: min(600px, 100%); } + +.iconContainer { + display: flex; + gap: var(--Spacing-x-one-and-half); +} diff --git a/components/HotelReservation/EnterDetails/BedType/index.tsx b/components/HotelReservation/EnterDetails/BedType/index.tsx index 9002f344e..064eb47a3 100644 --- a/components/HotelReservation/EnterDetails/BedType/index.tsx +++ b/components/HotelReservation/EnterDetails/BedType/index.tsx @@ -4,9 +4,13 @@ import { zodResolver } from "@hookform/resolvers/zod" import { useCallback, useEffect } from "react" import { FormProvider, useForm } from "react-hook-form" +import { + BED_TYPE_ICONS, + type BedTypeEnum, + type ExtraBedTypeEnum, +} from "@/constants/booking" import { useEnterDetailsStore } from "@/stores/enter-details" -import { KingBedIcon } from "@/components/Icons" import RadioCard from "@/components/TempDesignSystem/Form/ChoiceCard/Radio" import BedTypeInfo from "./BedTypeInfo" @@ -18,6 +22,7 @@ import type { BedTypeFormSchema, BedTypeProps, } from "@/types/components/hotelReservation/enterDetails/bedType" +import type { IconProps } from "@/types/components/icon" export default function BedType({ bedTypes }: BedTypeProps) { const initialBedType = useEnterDetailsStore( @@ -74,8 +79,13 @@ export default function BedType({ bedTypes }: BedTypeProps) { return ( ( + + )} id={roomType.value} name="bedType" subtitle={width} @@ -89,3 +99,25 @@ export default function BedType({ bedTypes }: BedTypeProps) { ) } + +function BedIconRenderer({ + mainBedType, + extraBedType, + props, +}: { + mainBedType: BedTypeEnum + extraBedType: ExtraBedTypeEnum | undefined + props: IconProps +}) { + const MainBedIcon = BED_TYPE_ICONS[mainBedType] + const ExtraBedIcon = extraBedType ? BED_TYPE_ICONS[extraBedType] : null + + return ( +
+ + {ExtraBedIcon && ( + + )} +
+ ) +} diff --git a/components/Icons/Bed.tsx b/components/Icons/Beds/Bed.tsx similarity index 100% rename from components/Icons/Bed.tsx rename to components/Icons/Beds/Bed.tsx diff --git a/components/Icons/BedDouble.tsx b/components/Icons/Beds/BedDouble.tsx similarity index 97% rename from components/Icons/BedDouble.tsx rename to components/Icons/Beds/BedDouble.tsx index be6e87ac8..1ede5a587 100644 --- a/components/Icons/BedDouble.tsx +++ b/components/Icons/Beds/BedDouble.tsx @@ -1,4 +1,4 @@ -import { iconVariants } from "./variants" +import { iconVariants } from "../variants" import type { IconProps } from "@/types/components/icon" diff --git a/components/Icons/BedSingle.tsx b/components/Icons/Beds/BedSingle.tsx similarity index 97% rename from components/Icons/BedSingle.tsx rename to components/Icons/Beds/BedSingle.tsx index 9ff42333a..8c6ceeb76 100644 --- a/components/Icons/BedSingle.tsx +++ b/components/Icons/Beds/BedSingle.tsx @@ -1,4 +1,4 @@ -import { iconVariants } from "./variants" +import { iconVariants } from "../variants" import type { IconProps } from "@/types/components/icon" diff --git a/components/Icons/Beds/ExtraBunkBed.tsx b/components/Icons/Beds/ExtraBunkBed.tsx new file mode 100644 index 000000000..597643bfa --- /dev/null +++ b/components/Icons/Beds/ExtraBunkBed.tsx @@ -0,0 +1,101 @@ +import { iconVariants } from "../variants" + +import type { IconProps } from "@/types/components/icon" + +export default function ExtraBunkBedIcon({ + className, + color, + ...props +}: IconProps) { + const classNames = iconVariants({ className, color }) + return ( + + + + + + + + + + + + + + + + ) +} diff --git a/components/Icons/Beds/ExtraPullOutBed.tsx b/components/Icons/Beds/ExtraPullOutBed.tsx new file mode 100644 index 000000000..649fb7f53 --- /dev/null +++ b/components/Icons/Beds/ExtraPullOutBed.tsx @@ -0,0 +1,29 @@ +import { iconVariants } from "../variants" + +import type { IconProps } from "@/types/components/icon" + +export default function ExtraPullOutBedIcon({ + className, + color, + ...props +}: IconProps) { + const classNames = iconVariants({ className, color }) + return ( + + + + ) +} diff --git a/components/Icons/Beds/ExtraSofaBed.tsx b/components/Icons/Beds/ExtraSofaBed.tsx new file mode 100644 index 000000000..3bd95eb96 --- /dev/null +++ b/components/Icons/Beds/ExtraSofaBed.tsx @@ -0,0 +1,27 @@ +import { iconVariants } from "../variants" + +import type { IconProps } from "@/types/components/icon" + +export default function ExtraSofaBedIcon({ + className, + color, + ...props +}: IconProps) { + const classNames = iconVariants({ className, color }) + return ( + + + + ) +} diff --git a/components/Icons/Beds/ExtraWallBed.tsx b/components/Icons/Beds/ExtraWallBed.tsx new file mode 100644 index 000000000..b6f05c335 --- /dev/null +++ b/components/Icons/Beds/ExtraWallBed.tsx @@ -0,0 +1,29 @@ +import { iconVariants } from "../variants" + +import type { IconProps } from "@/types/components/icon" + +export default function ExtraWallBedIcon({ + className, + color, + ...props +}: IconProps) { + const classNames = iconVariants({ className, color }) + return ( + + + + ) +} diff --git a/components/Icons/Beds/KingBed.tsx b/components/Icons/Beds/KingBed.tsx new file mode 100644 index 000000000..6b65cbc3f --- /dev/null +++ b/components/Icons/Beds/KingBed.tsx @@ -0,0 +1,25 @@ +import { iconVariants } from "../variants" + +import type { IconProps } from "@/types/components/icon" + +export default function KingBedIcon({ className, color, ...props }: IconProps) { + const classNames = iconVariants({ className, color }) + return ( + + + + ) +} diff --git a/components/Icons/KingBedSmall.tsx b/components/Icons/Beds/KingBedSmall.tsx similarity index 97% rename from components/Icons/KingBedSmall.tsx rename to components/Icons/Beds/KingBedSmall.tsx index 6c3bc9be6..bdd230642 100644 --- a/components/Icons/KingBedSmall.tsx +++ b/components/Icons/Beds/KingBedSmall.tsx @@ -1,4 +1,4 @@ -import { iconVariants } from "./variants" +import { iconVariants } from "../variants" import type { IconProps } from "@/types/components/icon" diff --git a/components/Icons/Beds/QueenBed.tsx b/components/Icons/Beds/QueenBed.tsx new file mode 100644 index 000000000..2445148a8 --- /dev/null +++ b/components/Icons/Beds/QueenBed.tsx @@ -0,0 +1,29 @@ +import { iconVariants } from "../variants" + +import type { IconProps } from "@/types/components/icon" + +export default function QueenBedIcon({ + className, + color, + ...props +}: IconProps) { + const classNames = iconVariants({ className, color }) + return ( + + + + ) +} diff --git a/components/Icons/Beds/SingleBed.tsx b/components/Icons/Beds/SingleBed.tsx new file mode 100644 index 000000000..a6122139b --- /dev/null +++ b/components/Icons/Beds/SingleBed.tsx @@ -0,0 +1,29 @@ +import { iconVariants } from "../variants" + +import type { IconProps } from "@/types/components/icon" + +export default function SingleBedIcon({ + className, + color, + ...props +}: IconProps) { + const classNames = iconVariants({ className, color }) + return ( + + + + ) +} diff --git a/components/Icons/Beds/TwinBeds.tsx b/components/Icons/Beds/TwinBeds.tsx new file mode 100644 index 000000000..ee55d973c --- /dev/null +++ b/components/Icons/Beds/TwinBeds.tsx @@ -0,0 +1,29 @@ +import { iconVariants } from "../variants" + +import type { IconProps } from "@/types/components/icon" + +export default function TwinBedsIcon({ + className, + color, + ...props +}: IconProps) { + const classNames = iconVariants({ className, color }) + return ( + + + + ) +} diff --git a/components/Icons/KingBed.tsx b/components/Icons/KingBed.tsx deleted file mode 100644 index 5e0f0615d..000000000 --- a/components/Icons/KingBed.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { iconVariants } from "./variants" - -import type { IconProps } from "@/types/components/icon" - -export default function KingBedIcon({ className, color, ...props }: IconProps) { - const classNames = iconVariants({ className, color }) - return ( - - - - ) -} diff --git a/components/Icons/index.tsx b/components/Icons/index.tsx index e5fe75235..1c4a1387d 100644 --- a/components/Icons/index.tsx +++ b/components/Icons/index.tsx @@ -10,11 +10,20 @@ export { default as ArrowUpIcon } from "./ArrowUp" export { default as BalconyIcon } from "./Balcony" export { default as BarIcon } from "./Bar" export { default as BathtubIcon } from "./Bathtub" -export { default as BedIcon } from "./Bed" -export { default as BedDoubleIcon } from "./BedDouble" export { default as BedHotelIcon } from "./BedHotel" export { default as BedroomParentIcon } from "./BedroomParent" -export { default as BedSingleIcon } from "./BedSingle" +export { default as BedIcon } from "./Beds/Bed" +export { default as BedDoubleIcon } from "./Beds/BedDouble" +export { default as BedSingleIcon } from "./Beds/BedSingle" +export { default as ExtraBunkBedIcon } from "./Beds/ExtraBunkBed" +export { default as ExtraPullOutBedIcon } from "./Beds/ExtraPullOutBed" +export { default as ExtraSofaBedIcon } from "./Beds/ExtraSofaBed" +export { default as ExtraWallBedIcon } from "./Beds/ExtraWallBed" +export { default as KingBedIcon } from "./Beds/KingBed" +export { default as KingBedSmallIcon } from "./Beds/KingBedSmall" +export { default as QueenBedIcon } from "./Beds/QueenBed" +export { default as SingleBedIcon } from "./Beds/SingleBed" +export { default as TwinBedsIcon } from "./Beds/TwinBeds" export { default as BikeIcon } from "./Bike" export { default as BikingIcon } from "./Biking" export { default as BreakfastIcon } from "./Breakfast" @@ -99,8 +108,6 @@ export { default as KayakingIcon } from "./Kayaking" export { default as KettleIcon } from "./Kettle" export { default as KidsIcon } from "./Kids" export { default as KidsMocktailIcon } from "./KidsMocktail" -export { default as KingBedIcon } from "./KingBed" -export { default as KingBedSmallIcon } from "./KingBedSmall" export { default as LampIcon } from "./Lamp" export { default as LaptopIcon } from "./Laptop" export { default as LaundryMachineIcon } from "./LaundryMachine" diff --git a/components/TempDesignSystem/Form/ChoiceCard/_Card/card.module.css b/components/TempDesignSystem/Form/ChoiceCard/_Card/card.module.css index d50df8a15..29c3a0ea5 100644 --- a/components/TempDesignSystem/Form/ChoiceCard/_Card/card.module.css +++ b/components/TempDesignSystem/Form/ChoiceCard/_Card/card.module.css @@ -8,6 +8,7 @@ padding: var(--Spacing-x-one-and-half) var(--Spacing-x2); transition: all 200ms ease; width: min(100%, 600px); + grid-column-gap: var(--Spacing-x2); } .label:hover { diff --git a/constants/booking.ts b/constants/booking.ts index 76e27b0e8..a7c9cf32f 100644 --- a/constants/booking.ts +++ b/constants/booking.ts @@ -1,3 +1,16 @@ +import { + ExtraBunkBedIcon, + ExtraPullOutBedIcon, + ExtraSofaBedIcon, + ExtraWallBedIcon, + KingBedIcon, + QueenBedIcon, + SingleBedIcon, + TwinBedsIcon, +} from "@/components/Icons" + +import type { IconProps } from "@/types/components/icon" + export enum BookingStatusEnum { BookingCompleted = "BookingCompleted", Cancelled = "Cancelled", @@ -101,3 +114,35 @@ export const PAYMENT_METHOD_ICONS: Record< chinaUnionPay: "/_static/icons/payment/china-union-pay.svg", discover: "/_static/icons/payment/discover.svg", } + +export enum BedTypeEnum { + King = "King", + Queen = "Queen", + Single = "Single", + Twin = "Twin", + Other = "Other", +} + +export enum ExtraBedTypeEnum { + SofaBed = "SofaBed", + WallBed = "WallBed", + PullOutBed = "PullOutBed", + BunkBed = "BunkBed", +} + +type BedTypes = keyof typeof BedTypeEnum | keyof typeof ExtraBedTypeEnum + +export const BED_TYPE_ICONS: Record< + BedTypes, + (props: IconProps) => JSX.Element +> = { + King: KingBedIcon, + Queen: QueenBedIcon, + Single: SingleBedIcon, + Twin: TwinBedsIcon, + SofaBed: ExtraSofaBedIcon, + WallBed: ExtraWallBedIcon, + BunkBed: ExtraBunkBedIcon, + PullOutBed: ExtraPullOutBedIcon, + Other: SingleBedIcon, +} diff --git a/server/routers/hotels/query.ts b/server/routers/hotels/query.ts index e17f1a51f..fc6a118ae 100644 --- a/server/routers/hotels/query.ts +++ b/server/routers/hotels/query.ts @@ -612,6 +612,13 @@ export const hotelQueryRouter = router({ description: matchingRoom.description, size: matchingRoom.mainBed.widthRange, value: matchingRoom.code, + type: matchingRoom.mainBed.type, + extraBed: matchingRoom.fixedExtraBed + ? { + type: matchingRoom.fixedExtraBed.type, + description: matchingRoom.fixedExtraBed.description, + } + : undefined, } } }) diff --git a/server/routers/hotels/schemas/room.ts b/server/routers/hotels/schemas/room.ts index 1de76227d..b12232708 100644 --- a/server/routers/hotels/schemas/room.ts +++ b/server/routers/hotels/schemas/room.ts @@ -1,5 +1,7 @@ import { z } from "zod" +import { BedTypeEnum, ExtraBedTypeEnum } from "@/constants/booking" + import { imageSchema } from "./image" const roomContentSchema = z.object({ @@ -17,22 +19,40 @@ const roomTypesSchema = z.object({ description: z.string(), code: z.string(), roomCount: z.number(), - mainBed: z.object({ - type: z.string(), - description: z.string(), - widthRange: z.object({ - min: z.number(), - max: z.number(), + mainBed: z + .object({ + type: z.string(), + description: z.string(), + widthRange: z.object({ + min: z.number(), + max: z.number(), + }), + }) + .transform((data) => ({ + type: + data.type in BedTypeEnum + ? (data.type as BedTypeEnum) + : BedTypeEnum.Other, + description: data.description, + widthRange: data.widthRange, + })), + fixedExtraBed: z + .object({ + type: z.string(), + description: z.string().optional(), + widthRange: z.object({ + min: z.number(), + max: z.number(), + }), + }) + .transform((data) => { + return data.type in ExtraBedTypeEnum + ? { + type: data.type as ExtraBedTypeEnum, + description: data.description, + } + : undefined }), - }), - fixedExtraBed: z.object({ - type: z.string(), - description: z.string().optional(), - widthRange: z.object({ - min: z.number(), - max: z.number(), - }), - }), roomSize: z.object({ min: z.number(), max: z.number(), diff --git a/stores/enter-details/useEnterDetailsStore.test.tsx b/stores/enter-details/useEnterDetailsStore.test.tsx index cfb6c9827..8b57e270f 100644 --- a/stores/enter-details/useEnterDetailsStore.test.tsx +++ b/stores/enter-details/useEnterDetailsStore.test.tsx @@ -2,6 +2,7 @@ import { describe, expect, test } from "@jest/globals" import { act, renderHook } from "@testing-library/react" import { type PropsWithChildren } from "react" +import { BedTypeEnum } from "@/constants/booking" import { Lang } from "@/constants/languages" import EnterDetailsProvider from "@/providers/EnterDetailsProvider" @@ -43,6 +44,7 @@ const booking = { const bedTypes = [ { + type: BedTypeEnum.King, description: "King-size bed", value: "SKS", size: { @@ -50,8 +52,10 @@ const bedTypes = [ max: 200, }, roomTypeCode: "SKS", + extraBed: undefined, }, { + type: BedTypeEnum.Queen, description: "Queen-size bed", value: "QZ", size: { @@ -59,6 +63,7 @@ const bedTypes = [ max: 200, }, roomTypeCode: "QZ", + extraBed: undefined, }, ] diff --git a/types/components/hotelReservation/enterDetails/bedType.ts b/types/components/hotelReservation/enterDetails/bedType.ts index 9f1fae723..0d4e0e11f 100644 --- a/types/components/hotelReservation/enterDetails/bedType.ts +++ b/types/components/hotelReservation/enterDetails/bedType.ts @@ -4,6 +4,7 @@ import type { bedTypeFormSchema, bedTypeSchema, } from "@/components/HotelReservation/EnterDetails/BedType/schema" +import type { BedTypeEnum, ExtraBedTypeEnum } from "@/constants/booking" export type BedTypeSelection = { description: string @@ -12,6 +13,13 @@ export type BedTypeSelection = { max: number } value: string + type: BedTypeEnum + extraBed: + | { + description: string + type: ExtraBedTypeEnum + } + | undefined } export type BedTypeProps = { bedTypes: BedTypeSelection[] From a620be9331e1212475aab962275a028f577950c2 Mon Sep 17 00:00:00 2001 From: Bianca Widstam Date: Wed, 8 Jan 2025 14:22:01 +0000 Subject: [PATCH 10/33] Merged in fix/SW-1145-occupancy-room-api-change (pull request #1148) Fix/SW-1145 occupancy room api change * fix(SW-1145): change occupancy to min and max * fix(SW-1145): small edit * fix(SW-1145): refactor to transform total room occupancy * fix(SW-1145): remove space * fix(SW-1145): small fix * fix(SW-1145): change to max for readability Approved-by: Erik Tiekstra Approved-by: Linus Flood --- .../HotelPage/Rooms/RoomCard/index.tsx | 8 ++++++-- .../HotelPage/SidePeeks/Room/index.tsx | 5 ++--- .../SelectRate/RoomSelection/RoomCard/index.tsx | 6 +++--- components/SidePeeks/RoomSidePeek/index.tsx | 3 +-- i18n/dictionaries/da.json | 6 +++--- i18n/dictionaries/de.json | 6 +++--- i18n/dictionaries/en.json | 6 +++--- i18n/dictionaries/fi.json | 6 +++--- i18n/dictionaries/no.json | 6 +++--- i18n/dictionaries/sv.json | 6 +++--- server/routers/hotels/schemas/room.ts | 15 ++++++++++++--- 11 files changed, 42 insertions(+), 31 deletions(-) diff --git a/components/ContentType/HotelPage/Rooms/RoomCard/index.tsx b/components/ContentType/HotelPage/Rooms/RoomCard/index.tsx index dba816cb5..1d8214451 100644 --- a/components/ContentType/HotelPage/Rooms/RoomCard/index.tsx +++ b/components/ContentType/HotelPage/Rooms/RoomCard/index.tsx @@ -16,7 +16,7 @@ import styles from "./roomCard.module.css" import type { RoomCardProps } from "@/types/components/hotelPage/room" export function RoomCard({ room }: RoomCardProps) { - const { images, name, roomSize, occupancy } = room + const { images, name, roomSize, totalOccupancy } = room const intl = useIntl() const size = @@ -46,7 +46,11 @@ export function RoomCard({ room }: RoomCardProps) { {intl.formatMessage( { id: "hotelPages.rooms.roomCard.persons" }, - { size, totalOccupancy: occupancy.total } + { + size, + max: totalOccupancy.max, + range: totalOccupancy.range, + } )}
diff --git a/components/ContentType/HotelPage/SidePeeks/Room/index.tsx b/components/ContentType/HotelPage/SidePeeks/Room/index.tsx index ded1acf8d..4736c89c3 100644 --- a/components/ContentType/HotelPage/SidePeeks/Room/index.tsx +++ b/components/ContentType/HotelPage/SidePeeks/Room/index.tsx @@ -17,9 +17,8 @@ import type { RoomSidePeekProps } from "@/types/components/hotelPage/sidepeek/ro export default async function RoomSidePeek({ room }: RoomSidePeekProps) { const intl = await getIntl() - const { roomSize, occupancy, descriptions, images } = room + const { roomSize, totalOccupancy, descriptions, images } = room const roomDescription = descriptions.medium - const totalOccupancy = occupancy.total // TODO: Not defined where this should lead. const ctaUrl = "" @@ -34,7 +33,7 @@ export default async function RoomSidePeek({ room }: RoomSidePeekProps) { m².{" "} {intl.formatMessage( { id: "booking.accommodatesUpTo" }, - { nrOfGuests: totalOccupancy } + { range: totalOccupancy.range, max: totalOccupancy.max } )}
diff --git a/components/HotelReservation/SelectRate/RoomSelection/RoomCard/index.tsx b/components/HotelReservation/SelectRate/RoomSelection/RoomCard/index.tsx index ee15f3b5e..43faad1bd 100644 --- a/components/HotelReservation/SelectRate/RoomSelection/RoomCard/index.tsx +++ b/components/HotelReservation/SelectRate/RoomSelection/RoomCard/index.tsx @@ -85,7 +85,7 @@ export default function RoomCard({ ) ) - const { name, roomSize, occupancy, images } = selectedRoom || {} + const { name, roomSize, totalOccupancy, images } = selectedRoom || {} const freeCancelation = intl.formatMessage({ id: "Free cancellation" }) const nonRefundable = intl.formatMessage({ id: "Non-refundable" }) @@ -150,13 +150,13 @@ export default function RoomCard({
- {occupancy?.total && ( + {totalOccupancy && ( {intl.formatMessage( { id: "booking.guests", }, - { nrOfGuests: occupancy.total } + { max: totalOccupancy.max, range: totalOccupancy.range } )} )} diff --git a/components/SidePeeks/RoomSidePeek/index.tsx b/components/SidePeeks/RoomSidePeek/index.tsx index 175eb14a0..86a1bb592 100644 --- a/components/SidePeeks/RoomSidePeek/index.tsx +++ b/components/SidePeeks/RoomSidePeek/index.tsx @@ -21,7 +21,6 @@ export default function RoomSidePeek({ const intl = useIntl() const roomSize = room.roomSize - const occupancy = room.occupancy.total const roomDescription = room.descriptions.medium const images = room.images @@ -40,7 +39,7 @@ export default function RoomSidePeek({ m².{" "} {intl.formatMessage( { id: "booking.accommodatesUpTo" }, - { nrOfGuests: occupancy } + { max: room.totalOccupancy.max, range: room.totalOccupancy.range } )}
diff --git a/i18n/dictionaries/da.json b/i18n/dictionaries/da.json index 2c988c4f0..7050f7cb8 100644 --- a/i18n/dictionaries/da.json +++ b/i18n/dictionaries/da.json @@ -494,7 +494,7 @@ "Zoom in": "Zoom ind", "Zoom out": "Zoom ud", "as of today": "pr. dags dato", - "booking.accommodatesUpTo": "Plads til {nrOfGuests, plural, one {# person} other {op til # personer}}", + "booking.accommodatesUpTo": "Plads til {max, plural, one {{range} person} other {op til {range} personer}}", "booking.adults": "{totalAdults, plural, one {# voksen} other {# voksne}}", "booking.adults.breakfasts": "{totalAdults, plural, one {# voksen} other {# voksne}}, {totalBreakfasts, plural, one {# morgenmad} other {# morgenmad}}", "booking.basedOnAvailability": "Baseret på tilgængelighed", @@ -505,7 +505,7 @@ "booking.confirmation.membershipInfo.text": "Din booking er bekræftet, men vi kunne ikke verificere dit medlemskab. Hvis du har booket med et medlemstilbud, skal du enten vise dit eksisterende medlemskab ved check-in, blive medlem eller betale prisdifferencen ved check-in. Tilmelding er foretrukket online før opholdet.", "booking.confirmation.text": "Tak fordi du bookede hos os! Vi glæder os til at byde dig velkommen og håber du får et behageligt ophold. Hvis du har spørgsmål eller har brug for at foretage ændringer i din reservation, bedes du kontakte os.", "booking.confirmation.title": "Booking bekræftelse", - "booking.guests": "Maks {nrOfGuests, plural, one {# gæst} other {# gæster}}", + "booking.guests": "Maks {max, plural, one {{range} gæst} other {{range} gæster}}", "booking.nights": "{totalNights, plural, one {# nat} other {# nætter}}", "booking.rooms": "{totalRooms, plural, one {# værelse} other {# værelser}}", "booking.selectRoom": "Zimmer auswählen", @@ -524,7 +524,7 @@ "guaranteeing": "garanti", "guest": "gæst", "guests": "gæster", - "hotelPages.rooms.roomCard.persons": "{size} ({totalOccupancy, plural, one {# person} other {# personer}})", + "hotelPages.rooms.roomCard.persons": "{size} ({max, plural, one {{range} person} other {{range} personer}})", "hotelPages.rooms.roomCard.seeRoomDetails": "Se værelsesdetaljer", "km to city center": "km til byens centrum", "lowercase letter": "lille bogstav", diff --git a/i18n/dictionaries/de.json b/i18n/dictionaries/de.json index 881059090..f42805b1d 100644 --- a/i18n/dictionaries/de.json +++ b/i18n/dictionaries/de.json @@ -493,7 +493,7 @@ "Zoom in": "Vergrößern", "Zoom out": "Verkleinern", "as of today": "Stand heute", - "booking.accommodatesUpTo": "Bietet Platz für {nrOfGuests, plural, one {# Person } other {bis zu # Personen}}", + "booking.accommodatesUpTo": "Bietet Platz für {max, plural, one {{range} Person } other {bis zu {range} Personen}}", "booking.adults": "{totalAdults, plural, one {# erwachsene} other {# erwachsene}}", "booking.adults.breakfasts": "{totalAdults, plural, one {# erwachsene} other {# erwachsene}}, {totalBreakfasts, plural, one {# frühstück} other {# frühstücke}}", "booking.basedOnAvailability": "Abhängig von der Verfügbarkeit", @@ -504,7 +504,7 @@ "booking.confirmation.membershipInfo.text": "Ihre Buchung ist bestätigt, aber wir konnten Ihr Mitglied nicht verifizieren. Wenn Sie mit einem Mitgliederrabatt gebucht haben, müssen Sie entweder Ihr vorhandenes Mitgliedschaftsnummer bei der Anreise präsentieren, ein Mitglied werden oder die Preisdifferenz bei der Anreise bezahlen. Die Anmeldung ist vorzugsweise online vor der Aufenthaltsdauer erfolgreich.", "booking.confirmation.text": "Vielen Dank, dass Sie bei uns gebucht haben! Wir freuen uns, Sie bei uns begrüßen zu dürfen und wünschen Ihnen einen angenehmen Aufenthalt. Wenn Sie Fragen haben oder Änderungen an Ihrer Buchung vornehmen müssen, kontaktieren Sie uns bitte..", "booking.confirmation.title": "Buchungsbestätigung", - "booking.guests": "Max {nrOfGuests, plural, one {# gast} other {# gäste}}", + "booking.guests": "Max {max, plural, one {{range} gast} other {{range} gäste}}", "booking.nights": "{totalNights, plural, one {# nacht} other {# Nächte}}", "booking.rooms": "{totalRooms, plural, one {# zimmer} other {# räume}}", "booking.selectRoom": "Vælg værelse", @@ -523,7 +523,7 @@ "guaranteeing": "garantiert", "guest": "gast", "guests": "gäste", - "hotelPages.rooms.roomCard.persons": "{size} ({totalOccupancy, plural, one {# person} other {# personen}})", + "hotelPages.rooms.roomCard.persons": "{size} ({max, plural, one {{range} person} other {{range} personen}})", "hotelPages.rooms.roomCard.seeRoomDetails": "Zimmerdetails ansehen", "km to city center": "km bis zum Stadtzentrum", "lowercase letter": "Kleinbuchstabe", diff --git a/i18n/dictionaries/en.json b/i18n/dictionaries/en.json index d58076530..ff1238111 100644 --- a/i18n/dictionaries/en.json +++ b/i18n/dictionaries/en.json @@ -537,7 +537,7 @@ "Zoom in": "Zoom in", "Zoom out": "Zoom out", "as of today": "as of today", - "booking.accommodatesUpTo": "Accommodates up to {nrOfGuests, plural, one {# person} other {# people}}", + "booking.accommodatesUpTo": "Accommodates up to {max, plural, one {{range} person} other {{range} people}}", "booking.adults": "{totalAdults, plural, one {# adult} other {# adults}}", "booking.adults.breakfasts": "{totalAdults, plural, one {# adult} other {# adults}}, {totalBreakfasts, plural, one {# breakfast} other {# breakfasts}}", "booking.basedOnAvailability": "Based on availability", @@ -548,7 +548,7 @@ "booking.confirmation.membershipInfo.text": "Your booking(s) is confirmed but we could not verify your membership. If you have booked with a member discount, you'll either need to present your existing membership number upon check-in, become a member or pay the price difference at the hotel. Signing up is preferably done online before the stay.", "booking.confirmation.text": "Thank you for booking with us! We look forward to welcoming you and hope you have a pleasant stay. If you have any questions or need to make changes to your reservation, please contact us.", "booking.confirmation.title": "Booking confirmation", - "booking.guests": "Max {nrOfGuests, plural, one {# guest} other {# guests}}", + "booking.guests": "Max {max, plural, one {{range} guest} other {{range} guests}}", "booking.nights": "{totalNights, plural, one {# night} other {# nights}}", "booking.rooms": "{totalRooms, plural, one {# room} other {# rooms}}", "booking.selectRoom": "Select room", @@ -569,7 +569,7 @@ "guest.paid": "{amount} {currency} has been paid", "guests": "guests", "has been paid": "has been paid", - "hotelPages.rooms.roomCard.persons": "{size} ({totalOccupancy, plural, one {# person} other {# persons}})", + "hotelPages.rooms.roomCard.persons": "{size} ({max, plural, one {{range} person} other {{range} persons}})", "hotelPages.rooms.roomCard.seeRoomDetails": "See room details", "km to city center": "km to city center", "lowercase letter": "lowercase letter", diff --git a/i18n/dictionaries/fi.json b/i18n/dictionaries/fi.json index 707506af9..6e20af368 100644 --- a/i18n/dictionaries/fi.json +++ b/i18n/dictionaries/fi.json @@ -492,7 +492,7 @@ "Zoom in": "Lähennä", "Zoom out": "Loitonna", "as of today": "tänään", - "booking.accommodatesUpTo": "Huoneeseen {nrOfGuests, plural, one {# person} other {mahtuu 2 henkilöä}}", + "booking.accommodatesUpTo": "Huoneeseen {max, plural, one {{range} henkilö} other {mahtuu enintään {range} henkilöä}", "booking.adults": "{totalAdults, plural, one {# aikuinen} other {# aikuiset}}", "booking.adults.breakfasts": "{totalAdults, plural, one {# aikuinen} other {# aikuiset}}, {totalBreakfasts, plural, one {# aamiainen} other {# aamiaista}}", "booking.basedOnAvailability": "Saatavuuden mukaan", @@ -503,7 +503,7 @@ "booking.confirmation.membershipInfo.text": "Varauksesi on vahvistettu, mutta jäsenyytesi ei voitu vahvistaa. Jos olet bookeutunut jäsenyysalennoilla, sinun on joko esitettävä olemassa olevan jäsenyysnumero tarkistukseen, tulla jäseneksi tai maksamaan hinnan eron hotellissa. Jäsenyyden tilittäminen on suositeltavampaa tehdä verkkoon ennen majoittumista.", "booking.confirmation.text": "Kiitos, että teit varauksen meiltä! Toivotamme sinut tervetulleeksi ja toivomme sinulle miellyttävää oleskelua. Jos sinulla on kysyttävää tai haluat tehdä muutoksia varaukseesi, ota meihin yhteyttä.", "booking.confirmation.title": "Varausvahvistus", - "booking.guests": "Max {nrOfGuests, plural, one {# vieras} other {# vieraita}}", + "booking.guests": "Max {max, plural, one {{range} vieras} other {{range} vieraita}}", "booking.nights": "{totalNights, plural, one {# yö} other {# yötä}}", "booking.rooms": "{totalRooms, plural, one {# huone} other {# sviitti}}", "booking.selectRoom": "Valitse huone", @@ -522,7 +522,7 @@ "guaranteeing": "varmistetaan", "guest": "Vieras", "guests": "Vieraita", - "hotelPages.rooms.roomCard.persons": "{size} ({totalOccupancy, plural, one {# henkilö} other {# Henkilöä}})", + "hotelPages.rooms.roomCard.persons": "{size} ({max, plural, one {{range} henkilö} other {{range} Henkilöä}})", "hotelPages.rooms.roomCard.seeRoomDetails": "Katso huoneen tiedot", "km to city center": "km keskustaan", "lowercase letter": "pien kirjain", diff --git a/i18n/dictionaries/no.json b/i18n/dictionaries/no.json index 5e4948dc6..28237b83b 100644 --- a/i18n/dictionaries/no.json +++ b/i18n/dictionaries/no.json @@ -492,7 +492,7 @@ "Zoom in": "Zoom inn", "Zoom out": "Zoom ut", "as of today": "per i dag", - "booking.accommodatesUpTo": "Plass til {nrOfGuests, plural, one {# person} other {opptil # personer}}", + "booking.accommodatesUpTo": "Plass til {max, plural, one {{range} person} other {opptil {range} personer}}", "booking.adults": "{totalAdults, plural, one {# voksen} other {# voksne}}", "booking.adults.breakfasts": "{totalAdults, plural, one {# voksen} other {# voksne}}, {totalBreakfasts, plural, one {# frokost} other {# frokoster}}", "booking.basedOnAvailability": "Basert på tilgjengelighet", @@ -503,7 +503,7 @@ "booking.confirmation.membershipInfo.text": "Din bestilling er bekreftet, men vi kunne ikke verifisere medlemskapet ditt. Hvis du har booke ut med et medlemsrabatt, må du enten presentere eksisterende medlemsnummer ved check-in, bli medlem eller betale prisdifferansen ved hotellet. Registrering er foretrukket gjort online før oppholdet.", "booking.confirmation.text": "Takk for at du booket hos oss! Vi ser frem til å ønske deg velkommen og håper du får et hyggelig opphold. Hvis du har spørsmål eller trenger å gjøre endringer i bestillingen din, vennligst kontakt oss.", "booking.confirmation.title": "Bestillingsbekreftelse", - "booking.guests": "Maks {nrOfGuests, plural, one {# gjest} other {# gjester}}", + "booking.guests": "Maks {max, plural, one {{range} gjest} other {{range} gjester}}", "booking.nights": "{totalNights, plural, one {# natt} other {# netter}}", "booking.rooms": "{totalRooms, plural, one {# rom} other {# rom}}", "booking.selectRoom": "Velg rom", @@ -522,7 +522,7 @@ "guaranteeing": "garantiert", "guest": "gjest", "guests": "gjester", - "hotelPages.rooms.roomCard.persons": "{size} ({totalOccupancy, plural, one {# person} other {# personer}})", + "hotelPages.rooms.roomCard.persons": "{size} ({max, plural, one {{range} person} other {{range} personer}})", "hotelPages.rooms.roomCard.seeRoomDetails": "Se detaljer om rommet", "km to city center": "km til sentrum", "lowercase letter": "liten bokstav", diff --git a/i18n/dictionaries/sv.json b/i18n/dictionaries/sv.json index ecdd2e364..d7c9327d7 100644 --- a/i18n/dictionaries/sv.json +++ b/i18n/dictionaries/sv.json @@ -492,7 +492,7 @@ "Zoom in": "Zooma in", "Zoom out": "Zooma ut", "as of today": "från och med idag", - "booking.accommodatesUpTo": "Rymmer {nrOfGuests, plural, one {# person} other {upp till # personer}}", + "booking.accommodatesUpTo": "Rymmer {max, plural, one {{range} person} other {upp till {range} personer}}", "booking.adults": "{totalAdults, plural, one {# vuxen} other {# vuxna}}", "booking.adults.breakfasts": "{totalAdults, plural, one {# vuxen} other {# vuxna}}, {totalBreakfasts, plural, one {# frukost} other {# frukostar}}", "booking.basedOnAvailability": "Baserat på tillgänglighet", @@ -503,7 +503,7 @@ "booking.confirmation.membershipInfo.text": "Din bokning är bekräftad, men vi kunde inte verifiera ditt medlemskap. Om du har bokat med ett medlemsrabatt måste du antingen presentera ditt befintliga medlemsnummer vid check-in, bli medlem eller betala prisdifferensen vid hotell. Registrering är föredragen gjord online före vistelsen.", "booking.confirmation.text": "Tack för att du bokar hos oss! Vi ser fram emot att välkomna dig och hoppas att du får en trevlig vistelse. Om du har några frågor eller behöver göra ändringar i din bokning, vänligen kontakta oss.", "booking.confirmation.title": "Bokningsbekräftelse", - "booking.guests": "Max {nrOfGuests, plural, one {# gäst} other {# gäster}}", + "booking.guests": "Max {max, plural, one {{range} gäst} other {{range} gäster}}", "booking.nights": "{totalNights, plural, one {# natt} other {# nätter}}", "booking.rooms": "{totalRooms, plural, one {# rum} other {# rum}}", "booking.selectRoom": "Välj rum", @@ -522,7 +522,7 @@ "guaranteeing": "garanterar", "guest": "gäst", "guests": "gäster", - "hotelPages.rooms.roomCard.persons": "{size} ({totalOccupancy, plural, one {# person} other {# personer}})", + "hotelPages.rooms.roomCard.persons": "{size} ({max, plural, one {{range} person} other {{range} personer}})", "hotelPages.rooms.roomCard.seeRoomDetails": "Se information om rummet", "km to city center": "km till stadens centrum", "lowercase letter": "liten bokstav", diff --git a/server/routers/hotels/schemas/room.ts b/server/routers/hotels/schemas/room.ts index b12232708..caf6317a0 100644 --- a/server/routers/hotels/schemas/room.ts +++ b/server/routers/hotels/schemas/room.ts @@ -83,9 +83,8 @@ export const roomSchema = z roomTypes: z.array(roomTypesSchema), roomFacilities: z.array(roomFacilitiesSchema), occupancy: z.object({ - total: z.number().optional(), - adults: z.number().optional(), - children: z.number().optional(), + min: z.number(), + max: z.number(), }), roomSize: z.object({ min: z.number(), @@ -102,6 +101,16 @@ export const roomSchema = z images: data.attributes.content.images, name: data.attributes.name, occupancy: data.attributes.occupancy, + totalOccupancy: + data.attributes.occupancy.min === data.attributes.occupancy.max + ? { + max: data.attributes.occupancy.max, + range: `${data.attributes.occupancy.max}`, + } + : { + max: data.attributes.occupancy.max, + range: `${data.attributes.occupancy.min}-${data.attributes.occupancy.max}`, + }, roomSize: data.attributes.roomSize, roomTypes: data.attributes.roomTypes, sortOrder: data.attributes.sortOrder, From 8308d004640c365a350be19581057a9114fba37a Mon Sep 17 00:00:00 2001 From: Linus Flood Date: Wed, 8 Jan 2025 15:24:20 +0100 Subject: [PATCH 11/33] fix: valid session check in rooms container --- .../HotelReservation/SelectRate/RoomSelection/index.tsx | 6 ++++-- .../HotelReservation/SelectRate/Rooms/RoomsContainer.tsx | 3 --- components/HotelReservation/SelectRate/Rooms/index.tsx | 2 -- .../components/hotelReservation/selectRate/roomSelection.ts | 2 -- 4 files changed, 4 insertions(+), 9 deletions(-) diff --git a/components/HotelReservation/SelectRate/RoomSelection/index.tsx b/components/HotelReservation/SelectRate/RoomSelection/index.tsx index ffd712563..cd3d8831e 100644 --- a/components/HotelReservation/SelectRate/RoomSelection/index.tsx +++ b/components/HotelReservation/SelectRate/RoomSelection/index.tsx @@ -1,8 +1,10 @@ "use client" import { usePathname, useRouter, useSearchParams } from "next/navigation" +import { useSession } from "next-auth/react" import { useCallback, useEffect, useMemo, useRef } from "react" import { debounce } from "@/utils/debounce" +import { isValidSession } from "@/utils/session" import RateSummary from "./RateSummary" import RoomCard from "./RoomCard" @@ -20,7 +22,6 @@ import type { RoomSelectionProps } from "@/types/components/hotelReservation/sel export default function RoomSelection({ roomsAvailability, roomCategories, - user, availablePackages, selectedPackages, setRateCode, @@ -30,7 +31,8 @@ export default function RoomSelection({ const router = useRouter() const pathname = usePathname() const searchParams = useSearchParams() - const isUserLoggedIn = !!user + const session = useSession() + const isUserLoggedIn = isValidSession(session.data) const roomRefs = useRef([]) const { roomConfigurations, rateDefinitions } = roomsAvailability diff --git a/components/HotelReservation/SelectRate/Rooms/RoomsContainer.tsx b/components/HotelReservation/SelectRate/Rooms/RoomsContainer.tsx index d8570e159..ff6986108 100644 --- a/components/HotelReservation/SelectRate/Rooms/RoomsContainer.tsx +++ b/components/HotelReservation/SelectRate/Rooms/RoomsContainer.tsx @@ -32,8 +32,6 @@ export async function RoomsContainer({ childArray, lang, }: Props) { - const user = await getProfileSafely() - const fromDateString = dt(fromDate).format("YYYY-MM-DD") const toDateString = dt(toDate).format("YYYY-MM-DD") @@ -92,7 +90,6 @@ export async function RoomsContainer({ return ( > @@ -18,7 +17,6 @@ export interface RoomSelectionProps { export interface SelectRateProps { roomsAvailability: RoomsAvailability roomCategories: RoomData[] - user: SafeUser availablePackages: RoomPackageData hotelType: string | undefined } From 5c6f357c01ef52260718996d20176a783742356e Mon Sep 17 00:00:00 2001 From: Christian Andolf Date: Wed, 8 Jan 2025 15:26:42 +0100 Subject: [PATCH 12/33] fix: corrected intent for button to fix broken padding --- components/TempDesignSystem/Toasts/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/TempDesignSystem/Toasts/index.tsx b/components/TempDesignSystem/Toasts/index.tsx index daa555dbe..a33cf64f5 100644 --- a/components/TempDesignSystem/Toasts/index.tsx +++ b/components/TempDesignSystem/Toasts/index.tsx @@ -47,7 +47,7 @@ export function Toast({ children, message, onClose, variant }: ToastsProps) {
{children}
)} {onClose ? ( - ) : null} From 85c9ec5b3ba584d2e1e7257decb0c176a077ea8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matilda=20Landstr=C3=B6m?= Date: Wed, 8 Jan 2025 15:08:24 +0000 Subject: [PATCH 13/33] Merged in feat/SW-1084-spa-page (pull request #1117) Feat/SW-1084 Spa page option on Hotel page * chore(SW-1084): add separate spa page from CS for hotel page * fix(SW-1084): Cleanup Approved-by: Erik Tiekstra Approved-by: Fredrik Thorsson --- .../SidePeeks/WellnessAndExercise/index.tsx | 28 ++++++-- .../wellnessAndExercise.module.css | 2 + components/ContentType/HotelPage/index.tsx | 22 ++++-- lib/graphql/Query/HotelPage/HotelPage.graphql | 14 ++++ .../contentstack/contentPage/output.ts | 6 -- .../routers/contentstack/hotelPage/output.ts | 43 +++++++++++- .../contentstack/schemas/blocks/spaPage.ts | 68 +++++++++++++++++++ types/components/hotelPage/facilities.ts | 2 +- .../hotelPage/sidepeek/wellnessAndExercise.ts | 6 +- types/enums/hotelPage.ts | 1 + types/trpc/routers/contentstack/hotelPage.ts | 5 +- 11 files changed, 173 insertions(+), 24 deletions(-) create mode 100644 server/routers/contentstack/schemas/blocks/spaPage.ts diff --git a/components/ContentType/HotelPage/SidePeeks/WellnessAndExercise/index.tsx b/components/ContentType/HotelPage/SidePeeks/WellnessAndExercise/index.tsx index 97ac09a91..6d690a643 100644 --- a/components/ContentType/HotelPage/SidePeeks/WellnessAndExercise/index.tsx +++ b/components/ContentType/HotelPage/SidePeeks/WellnessAndExercise/index.tsx @@ -14,7 +14,8 @@ import type { WellnessAndExerciseSidePeekProps } from "@/types/components/hotelP export default async function WellnessAndExerciseSidePeek({ healthFacilities, - buttonUrl, + wellnessExerciseButton, + spaPage, }: WellnessAndExerciseSidePeekProps) { const intl = await getIntl() const lang = getLang() @@ -29,13 +30,26 @@ export default async function WellnessAndExerciseSidePeek({ ))}
- {buttonUrl && ( + {(spaPage || wellnessExerciseButton) && (
- + {spaPage && ( + + )} + {wellnessExerciseButton && ( + + )}
)} diff --git a/components/ContentType/HotelPage/SidePeeks/WellnessAndExercise/wellnessAndExercise.module.css b/components/ContentType/HotelPage/SidePeeks/WellnessAndExercise/wellnessAndExercise.module.css index 11a410f13..f6f47761e 100644 --- a/components/ContentType/HotelPage/SidePeeks/WellnessAndExercise/wellnessAndExercise.module.css +++ b/components/ContentType/HotelPage/SidePeeks/WellnessAndExercise/wellnessAndExercise.module.css @@ -15,4 +15,6 @@ position: absolute; left: 0; bottom: 0; + display: grid; + gap: var(--Spacing-x2); } diff --git a/components/ContentType/HotelPage/index.tsx b/components/ContentType/HotelPage/index.tsx index 992cdc2d2..180a5d16d 100644 --- a/components/ContentType/HotelPage/index.tsx +++ b/components/ContentType/HotelPage/index.tsx @@ -9,7 +9,6 @@ import Breadcrumbs from "@/components/Breadcrumbs" import SidePeekProvider from "@/components/SidePeeks/SidePeekProvider" import Alert from "@/components/TempDesignSystem/Alert" import BreadcrumbsSkeleton from "@/components/TempDesignSystem/Breadcrumbs/BreadcrumbsSkeleton" -import SidePeek from "@/components/TempDesignSystem/SidePeek" import { getIntl } from "@/i18n" import { getLang } from "@/i18n/serverContext" import { getRestaurantHeading } from "@/utils/facilityCards" @@ -43,6 +42,10 @@ import type { HotelPageProps } from "@/types/components/hotelPage/hotelPage" import { HotelHashValues } from "@/types/components/hotelPage/tabNavigation" import type { Facility } from "@/types/hotel" import { PageContentTypeEnum } from "@/types/requests/contentType" +import type { + ActivitiesCard, + SpaPage, +} from "@/types/trpc/routers/contentstack/hotelPage" export default async function HotelPage({ hotelId }: HotelPageProps) { const lang = getLang() @@ -83,7 +86,8 @@ export default async function HotelPage({ hotelId }: HotelPageProps) { const restaurants = hotelData.included?.restaurants || [] const images = gallery?.smallerImages const description = hotelContent.texts.descriptions.medium - const activitiesCard = content?.[0]?.upcoming_activities_card || null + + const { spaPage, activitiesCard } = content const facilities: Facility[] = [ { @@ -162,7 +166,10 @@ export default async function HotelPage({ hotelId }: HotelPageProps) { ) : null}
- + {faq.accordions.length > 0 && ( )} @@ -200,10 +207,15 @@ export default async function HotelPage({ hotelId }: HotelPageProps) { ecoLabels={hotelFacts.ecoLabels} descriptions={hotelContent.texts} /> - + {activitiesCard && ( - + )} { + let spaPage: SpaPage | undefined + let activitiesCard: ActivitiesCard | undefined + data?.map((block) => { + switch (block.typename) { + case HotelPageEnum.ContentStack.blocks.ActivitiesCard: + activitiesCard = block + break + case HotelPageEnum.ContentStack.blocks.SpaPage: + spaPage = block + break + default: + break + } + }) + return { spaPage, activitiesCard } + }), faq: hotelFaqSchema, hotel_page_id: z.string(), title: z.string(), @@ -40,14 +70,21 @@ export const hotelPageSchema = z.object({ }) /** REFS */ -const hotelPageActiviesCardRefs = z +const hotelPageActivitiesCardRefs = z .object({ __typename: z.literal(HotelPageEnum.ContentStack.blocks.ActivitiesCard), }) .merge(activitiesCardRefSchema) +const hotelPageSpaPageRefs = z + .object({ + __typename: z.literal(HotelPageEnum.ContentStack.blocks.SpaPage), + }) + .merge(spaPageRefSchema) + const hotelPageBlockRefsItem = z.discriminatedUnion("__typename", [ - hotelPageActiviesCardRefs, + hotelPageActivitiesCardRefs, + hotelPageSpaPageRefs, ]) export const hotelPageRefsSchema = z.object({ diff --git a/server/routers/contentstack/schemas/blocks/spaPage.ts b/server/routers/contentstack/schemas/blocks/spaPage.ts new file mode 100644 index 000000000..f353bd736 --- /dev/null +++ b/server/routers/contentstack/schemas/blocks/spaPage.ts @@ -0,0 +1,68 @@ +import { z } from "zod" + +import * as pageLinks from "@/server/routers/contentstack/schemas/pageLinks" + +import { removeMultipleSlashes } from "@/utils/url" + +import { HotelPageEnum } from "@/types/enums/hotelPage" + +export const spaPageSchema = z.object({ + typename: z + .literal(HotelPageEnum.ContentStack.blocks.SpaPage) + .optional() + .default(HotelPageEnum.ContentStack.blocks.SpaPage), + spa_page: z + .object({ + button_cta: z.string(), + pageConnection: z.object({ + edges: z.array( + z.object({ + node: z.object({ + title: z.string(), + url: z.string(), + system: z.object({ + content_type_uid: z.string(), + locale: z.string(), + uid: z.string(), + }), + web: z.object({ original_url: z.string() }), + }), + }) + ), + }), + }) + .transform((data) => { + let url = "" + if (data.pageConnection.edges.length) { + const page = data.pageConnection.edges[0].node + if (page.web.original_url) { + url = page.web.original_url + } else { + url = removeMultipleSlashes(`/${page.system.locale}/${page.url}`) + } + } + return { + buttonCTA: data.button_cta, + url: url, + } + }), +}) + +export const spaPageRefSchema = z.object({ + spa_page: z + .object({ + pageConnection: z.object({ + edges: z.array( + z.object({ + node: z.discriminatedUnion("__typename", [ + pageLinks.contentPageRefSchema, + pageLinks.collectionPageRefSchema, + ]), + }) + ), + }), + }) + .transform((data) => { + return data.pageConnection.edges.flatMap(({ node }) => node.system) || [] + }), +}) diff --git a/types/components/hotelPage/facilities.ts b/types/components/hotelPage/facilities.ts index 5c753797a..dcc58651f 100644 --- a/types/components/hotelPage/facilities.ts +++ b/types/components/hotelPage/facilities.ts @@ -4,7 +4,7 @@ import type { CardProps } from "@/components/TempDesignSystem/Card/card" export type FacilitiesProps = { facilities: Facility[] - activitiesCard: ActivityCard | null + activitiesCard?: ActivityCard } export type FacilityImage = { diff --git a/types/components/hotelPage/sidepeek/wellnessAndExercise.ts b/types/components/hotelPage/sidepeek/wellnessAndExercise.ts index 828f3ee8b..4e70b9210 100644 --- a/types/components/hotelPage/sidepeek/wellnessAndExercise.ts +++ b/types/components/hotelPage/sidepeek/wellnessAndExercise.ts @@ -2,5 +2,9 @@ import type { Hotel } from "@/types/hotel" export type WellnessAndExerciseSidePeekProps = { healthFacilities: Hotel["healthFacilities"] - buttonUrl?: string + wellnessExerciseButton?: string + spaPage?: { + buttonCTA: string + url: string + } } diff --git a/types/enums/hotelPage.ts b/types/enums/hotelPage.ts index bfae4bfbe..8cc5f8839 100644 --- a/types/enums/hotelPage.ts +++ b/types/enums/hotelPage.ts @@ -3,6 +3,7 @@ export namespace HotelPageEnum { export const enum blocks { Faq = "HotelPageFaq", ActivitiesCard = "HotelPageContentUpcomingActivitiesCard", + SpaPage = "HotelPageContentSpaPage", } } } diff --git a/types/trpc/routers/contentstack/hotelPage.ts b/types/trpc/routers/contentstack/hotelPage.ts index 6b98f5ca2..7a983d419 100644 --- a/types/trpc/routers/contentstack/hotelPage.ts +++ b/types/trpc/routers/contentstack/hotelPage.ts @@ -7,13 +7,16 @@ import type { hotelPageUrlSchema, } from "@/server/routers/contentstack/hotelPage/output" import type { activitiesCardSchema } from "@/server/routers/contentstack/schemas/blocks/activitiesCard" +import type { spaPageSchema } from "@/server/routers/contentstack/schemas/blocks/spaPage" export interface GetHotelPageData extends z.input {} export interface HotelPage extends z.output {} export interface ActivitiesCard extends z.output {} export type ActivityCard = ActivitiesCard["upcoming_activities_card"] -export interface ContentBlock extends z.output {} + +export interface SpaPage extends z.output {} +export type ContentBlock = z.output export interface GetHotelPageRefsSchema extends z.input {} From b4060d720b8a635c22b4d8f31047e3e7c815de92 Mon Sep 17 00:00:00 2001 From: Pontus Dreij Date: Wed, 8 Jan 2025 15:09:29 +0000 Subject: [PATCH 14/33] Merged in feat/SW-716-multiroom-guest-picker (pull request #1084) feat(SW-716) Added UI to add more rooms in guest/room picker * feat(SW-716) Added UI to add more rooms in guest/room picker * feat(SW-716) Renamed GuestRoom Type and updated html structure * Feat(SW-716): Created a BookingFlowIteration1 folder * feat(SW-716) Moved forms/bookingwidget to new flow * feat(SW-716) Added new ENABLE_BOOKING_FLOW_ITERATION_1 and ENABLE_BOOKING_FLOW_ITERATION_2 * feat(SW-716) Re added booking widget into interaction1 of how it looks now in prod * Revert "feat(SW-716) Re added booking widget into interaction1 of how it looks now in prod" This reverts commit 9a5514e8e71b1487e610bf64986ca77a538c0023. * Revert "feat(SW-716) Added new ENABLE_BOOKING_FLOW_ITERATION_1 and ENABLE_BOOKING_FLOW_ITERATION_2" This reverts commit b00bfc08cb7878d91483220ba3e8322671c145e4. * Revert "feat(SW-716) Moved forms/bookingwidget to new flow" This reverts commit 6c81635fe929a71fb3a42d8f174706787d8578ed. * Revert "Feat(SW-716): Created a BookingFlowIteration1 folder" This reverts commit db41f1c7fcd8e3adf15713d5d36f0da11e03e3a4. * feat(SW-716): Added NEXT_PUBLIC_HIDE_FOR_NEXT_RELEASE * feat(SW-716) Readded Tooltip if NEXT_PUBLIC_HIDE_FOR_NEXT_RELEASE is true * feat(SW-716) remove log Approved-by: Niclas Edenvin Approved-by: Christian Andolf --- app/[lang]/(live-current)/layout.tsx | 1 + components/DatePicker/index.tsx | 2 +- .../BookingWidget/FormContent/Input/index.tsx | 2 +- components/GuestsRoomsPicker/Form.tsx | 218 ++++++++++-------- .../GuestsRoomsPicker/GuestsRoom/index.tsx | 72 ++++++ .../guests-rooms-picker.module.css | 45 +++- components/GuestsRoomsPicker/index.tsx | 63 +++-- .../bookingWidget/guestsRoomsPicker.ts | 2 +- types/components/bookingWidget/index.ts | 4 +- 9 files changed, 266 insertions(+), 143 deletions(-) create mode 100644 components/GuestsRoomsPicker/GuestsRoom/index.tsx diff --git a/app/[lang]/(live-current)/layout.tsx b/app/[lang]/(live-current)/layout.tsx index ef9477a87..3867b76d1 100644 --- a/app/[lang]/(live-current)/layout.tsx +++ b/app/[lang]/(live-current)/layout.tsx @@ -4,6 +4,7 @@ import "@scandic-hotels/design-system/style.css" import Script from "next/script" import TokenRefresher from "@/components/Auth/TokenRefresher" +import BookingWidget from "@/components/BookingWidget" import CookieBotConsent from "@/components/CookieBot" import AdobeScript from "@/components/Current/AdobeScript" import Footer from "@/components/Current/Footer" diff --git a/components/DatePicker/index.tsx b/components/DatePicker/index.tsx index 9cf6b3446..d56aaba34 100644 --- a/components/DatePicker/index.tsx +++ b/components/DatePicker/index.tsx @@ -129,7 +129,7 @@ export default function DatePickerForm({ name = "date" }: DatePickerFormProps) { onClick={() => setIsOpen(true)} type="button" > - + {selectedFromDate} - {selectedToDate} diff --git a/components/Forms/BookingWidget/FormContent/Input/index.tsx b/components/Forms/BookingWidget/FormContent/Input/index.tsx index dfa5e018a..9871d70f0 100644 --- a/components/Forms/BookingWidget/FormContent/Input/index.tsx +++ b/components/Forms/BookingWidget/FormContent/Input/index.tsx @@ -10,7 +10,7 @@ const Input = forwardRef< InputHTMLAttributes >(function InputComponent(props, ref) { return ( - + ) diff --git a/components/GuestsRoomsPicker/Form.tsx b/components/GuestsRoomsPicker/Form.tsx index 97048bf4c..0204f414e 100644 --- a/components/GuestsRoomsPicker/Form.tsx +++ b/components/GuestsRoomsPicker/Form.tsx @@ -1,61 +1,77 @@ "use client" -import { useEffect } from "react" +import { useCallback, useEffect } from "react" import { useFormContext, useWatch } from "react-hook-form" import { useIntl } from "react-intl" +import { env } from "@/env/client" + import { CloseLargeIcon, PlusCircleIcon, PlusIcon } from "../Icons" import Button from "../TempDesignSystem/Button" -import Divider from "../TempDesignSystem/Divider" -import Subtitle from "../TempDesignSystem/Text/Subtitle" import { Tooltip } from "../TempDesignSystem/Tooltip" -import AdultSelector from "./AdultSelector" -import ChildSelector from "./ChildSelector" +import { GuestsRoom } from "./GuestsRoom" import styles from "./guests-rooms-picker.module.css" import type { BookingWidgetSchema } from "@/types/components/bookingWidget" -import { ChildBedMapEnum } from "@/types/components/bookingWidget/enums" -import type { GuestsRoom } from "@/types/components/bookingWidget/guestsRoomsPicker" +import type { TGuestsRoom } from "@/types/components/bookingWidget/guestsRoomsPicker" + +const MAX_ROOMS = 4 + +interface GuestsRoomsPickerDialogProps { + rooms: TGuestsRoom[] + onClose: () => void + isOverflowed?: boolean // ToDo Remove once Tooltip below is no longer required +} export default function GuestsRoomsPickerDialog({ rooms, onClose, isOverflowed = false, -}: { - rooms: GuestsRoom[] - onClose: () => void - isOverflowed?: boolean // ToDo Remove once Tooltip below is no longer required -}) { +}: GuestsRoomsPickerDialogProps) { const intl = useIntl() + const { getFieldState, trigger, setValue } = + useFormContext() + const roomsValue = useWatch({ name: "rooms" }) + const addRoomLabel = intl.formatMessage({ id: "Add Room" }) const doneLabel = intl.formatMessage({ id: "Done" }) - const roomLabel = intl.formatMessage({ id: "Room" }) const disabledBookingOptionsHeader = intl.formatMessage({ id: "Disabled booking options header", }) const disabledBookingOptionsText = intl.formatMessage({ id: "Disabled adding room", }) - const addRoomLabel = intl.formatMessage({ id: "Add Room" }) - const { getFieldState, trigger } = useFormContext() + const handleClose = useCallback(async () => { + const isValid = await trigger("rooms") + if (isValid) onClose() + }, [trigger, onClose]) - const roomsValue = useWatch({ name: "rooms" }) + const handleAddRoom = useCallback(() => { + setValue("rooms", [...roomsValue, { adults: 1, child: [] }], { + shouldValidate: true, + }) + }, [roomsValue, setValue]) - async function handleOnClose() { - const state = await trigger("rooms") - if (state) { - onClose() - } - } - - const fieldState = getFieldState("rooms") + const handleRemoveRoom = useCallback( + (index: number) => { + setValue( + "rooms", + roomsValue.filter((_, i) => i !== index), + { shouldValidate: true } + ) + }, + [roomsValue, setValue] + ) + // Validate rooms when they change useEffect(() => { - if (fieldState.invalid) { - trigger("rooms") - } - }, [roomsValue, fieldState.invalid, trigger]) + const fieldState = getFieldState("rooms") + if (fieldState.invalid) trigger("rooms") + }, [roomsValue, getFieldState, trigger]) + + const isInvalid = getFieldState("rooms").invalid + const canAddRooms = rooms.length < MAX_ROOMS return ( <> @@ -65,97 +81,99 @@ export default function GuestsRoomsPickerDialog({
-
- {rooms.map((room, index) => { - const currentAdults = room.adults - const currentChildren = room.child - const childrenInAdultsBed = - currentChildren.filter( - (child) => child.bed === ChildBedMapEnum.IN_ADULTS_BED - ).length ?? 0 - return ( -
-
- - {roomLabel} {index + 1} - - - -
- -
- ) - })} -
- - {rooms.length < 4 ? ( +
+ {rooms.map((room, index) => ( + + ))} + + {env.NEXT_PUBLIC_HIDE_FOR_NEXT_RELEASE ? ( +
+ - ) : null} - -
+ +
+ ) : ( + canAddRooms && ( +
+ +
+ ) + )}
-
- - {rooms.length < 4 ? ( + {env.NEXT_PUBLIC_HIDE_FOR_NEXT_RELEASE ? ( +
+ - ) : null} - -
+
+
+ ) : ( + canAddRooms && ( +
+ +
+ ) + )} - +
) diff --git a/components/GuestsRoomsPicker/GuestsRoom/index.tsx b/components/GuestsRoomsPicker/GuestsRoom/index.tsx new file mode 100644 index 000000000..7122a0a31 --- /dev/null +++ b/components/GuestsRoomsPicker/GuestsRoom/index.tsx @@ -0,0 +1,72 @@ +import { useIntl } from "react-intl" + +import { DeleteIcon } from "@/components/Icons" +import Button from "@/components/TempDesignSystem/Button" +import Divider from "@/components/TempDesignSystem/Divider" +import Subtitle from "@/components/TempDesignSystem/Text/Subtitle" + +import AdultSelector from "../AdultSelector" +import ChildSelector from "../ChildSelector" + +import styles from "../guests-rooms-picker.module.css" + +import { ChildBedMapEnum } from "@/types/components/bookingWidget/enums" +import type { TGuestsRoom } from "@/types/components/bookingWidget/guestsRoomsPicker" + +export function GuestsRoom({ + room, + index, + onRemove, +}: { + room: TGuestsRoom + index: number + onRemove: (index: number) => void +}) { + const intl = useIntl() + const roomLabel = intl.formatMessage({ id: "Room" }) + + const childrenInAdultsBed = room.child.filter( + (child) => child.bed === ChildBedMapEnum.IN_ADULTS_BED + ).length + + return ( +
+
+ + {roomLabel} {index + 1} + + + + {index !== 0 && ( +
+ +
+ )} +
+ +
+ ) +} diff --git a/components/GuestsRoomsPicker/guests-rooms-picker.module.css b/components/GuestsRoomsPicker/guests-rooms-picker.module.css index dd43b2c32..281963d55 100644 --- a/components/GuestsRoomsPicker/guests-rooms-picker.module.css +++ b/components/GuestsRoomsPicker/guests-rooms-picker.module.css @@ -56,9 +56,25 @@ } .footer { - display: grid; + display: flex; + flex-direction: row; gap: var(--Spacing-x1); - grid-template-columns: auto; +} + +.roomContainer { + padding: var(--Spacing-x2); +} +.roomContainer:last-of-type { + padding-bottom: calc(var(--sticky-button-height) + 20px); +} + +.roomActionsButton { + margin-left: auto; + color: var(--Base-Text-Accent); +} + +.footer button { + width: 100%; } @media screen and (max-width: 1366px) { @@ -71,7 +87,7 @@ .header { display: grid; grid-area: header; - padding: var(--Spacing-x3) var(--Spacing-x2); + padding: var(--Spacing-x3) var(--Spacing-x2) 0; } .close { @@ -83,13 +99,6 @@ padding: 0; } - .roomContainer { - padding: 0 var(--Spacing-x2); - } - .roomContainer:last-of-type { - padding-bottom: calc(var(--sticky-button-height) + 20px); - } - .footer { background: linear-gradient( 180deg, @@ -125,6 +134,17 @@ grid-template-rows: auto; } + .roomContainer { + padding: var(--Spacing-x2) 0 0 0; + } + .roomContainer:first-of-type { + padding-top: 0; + } + + .roomContainer:last-of-type { + padding-bottom: 0; + } + .contentContainer { overflow-y: visible; } @@ -163,6 +183,11 @@ padding-top: var(--Spacing-x2); } + .footer button { + margin-left: auto; + width: 125px; + } + .footer .hideOnDesktop, .addRoomMobileContainer { display: none; diff --git a/components/GuestsRoomsPicker/index.tsx b/components/GuestsRoomsPicker/index.tsx index e45a671bd..d3bb9e68e 100644 --- a/components/GuestsRoomsPicker/index.tsx +++ b/components/GuestsRoomsPicker/index.tsx @@ -18,11 +18,11 @@ import PickerForm from "./Form" import styles from "./guests-rooms-picker.module.css" -import type { GuestsRoom } from "@/types/components/bookingWidget/guestsRoomsPicker" +import type { TGuestsRoom } from "@/types/components/bookingWidget/guestsRoomsPicker" export default function GuestsRoomsPickerForm() { const { watch, trigger } = useFormContext() - const rooms = watch("rooms") as GuestsRoom[] + const rooms = watch("rooms") as TGuestsRoom[] const checkIsDesktop = useMediaQuery("(min-width: 1367px)") const [isDesktop, setIsDesktop] = useState(true) @@ -83,10 +83,10 @@ export default function GuestsRoomsPickerForm() { }, [containerHeight]) useEffect(() => { - if (typeof window !== undefined && isDesktop) { + if (typeof window !== undefined && isDesktop && rooms.length > 0) { updateHeight() } - }, [childCount, isDesktop, updateHeight]) + }, [childCount, isDesktop, updateHeight, rooms]) return isDesktop ? ( @@ -104,13 +104,7 @@ export default function GuestsRoomsPickerForm() { style={containerHeight ? { overflow: "auto" } : {}} > - {({ close }) => ( - - )} + {({ close }) => } @@ -137,7 +131,7 @@ function Trigger({ className, triggerFn, }: { - rooms: GuestsRoom[] + rooms: TGuestsRoom[] className: string triggerFn?: () => void }) { @@ -149,27 +143,30 @@ function Trigger({ type="button" onPress={triggerFn} > - - {rooms.map((room, i) => ( - - {intl.formatMessage( - { id: "booking.rooms" }, - { totalRooms: rooms.length } - )} - {", "} - {intl.formatMessage( - { id: "booking.adults" }, - { totalAdults: room.adults } - )} - {room.child.length > 0 - ? ", " + - intl.formatMessage( - { id: "booking.children" }, - { totalChildren: room.child.length } - ) - : null} - - ))} + + + {intl.formatMessage( + { id: "booking.rooms" }, + { totalRooms: rooms.length } + )} + {", "} + {intl.formatMessage( + { id: "booking.adults" }, + { totalAdults: rooms.reduce((acc, room) => acc + room.adults, 0) } + )} + {rooms.some((room) => room.child.length > 0) + ? ", " + + intl.formatMessage( + { id: "booking.children" }, + { + totalChildren: rooms.reduce( + (acc, room) => acc + room.child.length, + 0 + ), + } + ) + : null} + ) diff --git a/types/components/bookingWidget/guestsRoomsPicker.ts b/types/components/bookingWidget/guestsRoomsPicker.ts index a075c9f8d..b672df7ea 100644 --- a/types/components/bookingWidget/guestsRoomsPicker.ts +++ b/types/components/bookingWidget/guestsRoomsPicker.ts @@ -8,7 +8,7 @@ export type Child = { bed: number } -export type GuestsRoom = { +export type TGuestsRoom = { adults: number child: Child[] } diff --git a/types/components/bookingWidget/index.ts b/types/components/bookingWidget/index.ts index 7e9c79d16..e8eda85c8 100644 --- a/types/components/bookingWidget/index.ts +++ b/types/components/bookingWidget/index.ts @@ -4,7 +4,7 @@ import type { z } from "zod" import type { Locations } from "@/types/trpc/routers/hotel/locations" import type { bookingWidgetSchema } from "@/components/Forms/BookingWidget/schema" import type { bookingWidgetVariants } from "@/components/Forms/BookingWidget/variants" -import type { GuestsRoom } from "./guestsRoomsPicker" +import type { TGuestsRoom } from "./guestsRoomsPicker" export type BookingWidgetSchema = z.output @@ -13,7 +13,7 @@ export type BookingWidgetSearchParams = { hotel?: string fromDate?: string toDate?: string - room?: GuestsRoom[] + room?: TGuestsRoom[] } export type BookingWidgetType = VariantProps< From dfa7395aa407ed1d740ebd2a67f3b7779cfb6095 Mon Sep 17 00:00:00 2001 From: Linus Flood Date: Wed, 8 Jan 2025 15:11:01 +0000 Subject: [PATCH 15/33] Merged in fix/null-check-valid-session (pull request #1153) fix: added null check on session * fix: added null check on session * fix: added null check on session --- components/HotelReservation/SelectRate/RoomSelection/index.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/components/HotelReservation/SelectRate/RoomSelection/index.tsx b/components/HotelReservation/SelectRate/RoomSelection/index.tsx index cd3d8831e..20eb24931 100644 --- a/components/HotelReservation/SelectRate/RoomSelection/index.tsx +++ b/components/HotelReservation/SelectRate/RoomSelection/index.tsx @@ -32,7 +32,8 @@ export default function RoomSelection({ const pathname = usePathname() const searchParams = useSearchParams() const session = useSession() - const isUserLoggedIn = isValidSession(session.data) + const isUserLoggedIn = + !!session && !!session.data && isValidSession(session.data) const roomRefs = useRef([]) const { roomConfigurations, rateDefinitions } = roomsAvailability From a8763ca80c4ccbe7699792b1310c108c0c9aed49 Mon Sep 17 00:00:00 2001 From: Christel Westerberg Date: Wed, 8 Jan 2025 15:13:32 +0000 Subject: [PATCH 16/33] Merged in fix/margins-loyalty-layout (pull request #1152) fix: add correct negative margins on profile page * fix: add correct negative margins on profile page Approved-by: Linus Flood --- .../(live)/(protected)/my-pages/profile/profileLayout.css | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/app/[lang]/(live)/(protected)/my-pages/profile/profileLayout.css b/app/[lang]/(live)/(protected)/my-pages/profile/profileLayout.css index aefe1b30f..216869dbb 100644 --- a/app/[lang]/(live)/(protected)/my-pages/profile/profileLayout.css +++ b/app/[lang]/(live)/(protected)/my-pages/profile/profileLayout.css @@ -9,10 +9,18 @@ display: grid; gap: var(--Spacing-x4); padding: var(--Spacing-x2) var(--Spacing-x2) var(--Spacing-x4); + margin: 0 calc(var(--Layout-Mobile-Margin-Margin-min) * -1); } @media screen and (min-width: 768px) { .profile-layout { padding: var(--Spacing-x3) var(--Spacing-x3) var(--Spacing-x4); + margin: 0 calc(var(--Layout-Tablet-Margin-Margin-min) * -1); + } +} + +@media screen and (min-width: 1367px) { + .profile-layout { + margin: 0; } } From fa20f128ef7080ac8e3a3fab193e412ba71241c9 Mon Sep 17 00:00:00 2001 From: Linus Flood Date: Wed, 8 Jan 2025 15:38:36 +0000 Subject: [PATCH 17/33] Merged in feat/check-session-fix-2 (pull request #1154) fix: is user logged in fix * fix: is user logged in fix --- .../HotelReservation/SelectRate/RoomSelection/index.tsx | 4 +--- .../HotelReservation/SelectRate/Rooms/RoomsContainer.tsx | 6 +++++- components/HotelReservation/SelectRate/Rooms/index.tsx | 2 ++ .../components/hotelReservation/selectRate/roomSelection.ts | 2 ++ 4 files changed, 10 insertions(+), 4 deletions(-) diff --git a/components/HotelReservation/SelectRate/RoomSelection/index.tsx b/components/HotelReservation/SelectRate/RoomSelection/index.tsx index 20eb24931..b784c94bd 100644 --- a/components/HotelReservation/SelectRate/RoomSelection/index.tsx +++ b/components/HotelReservation/SelectRate/RoomSelection/index.tsx @@ -24,6 +24,7 @@ export default function RoomSelection({ roomCategories, availablePackages, selectedPackages, + isUserLoggedIn, setRateCode, rateSummary, hotelType, @@ -31,9 +32,6 @@ export default function RoomSelection({ const router = useRouter() const pathname = usePathname() const searchParams = useSearchParams() - const session = useSession() - const isUserLoggedIn = - !!session && !!session.data && isValidSession(session.data) const roomRefs = useRef([]) const { roomConfigurations, rateDefinitions } = roomsAvailability diff --git a/components/HotelReservation/SelectRate/Rooms/RoomsContainer.tsx b/components/HotelReservation/SelectRate/Rooms/RoomsContainer.tsx index ff6986108..ea5f79a26 100644 --- a/components/HotelReservation/SelectRate/Rooms/RoomsContainer.tsx +++ b/components/HotelReservation/SelectRate/Rooms/RoomsContainer.tsx @@ -2,11 +2,12 @@ import { dt } from "@/lib/dt" import { getHotelData, getPackages, - getProfileSafely, getRoomsAvailability, } from "@/lib/trpc/memoizedRequests" +import { auth } from "@/auth" import { safeTry } from "@/utils/safeTry" +import { isValidSession } from "@/utils/session" import { generateChildrenString } from "../RoomSelection/utils" import Rooms from "." @@ -32,6 +33,8 @@ export async function RoomsContainer({ childArray, lang, }: Props) { + const session = await auth() + const isUserLoggedIn = isValidSession(session) const fromDateString = dt(fromDate).format("YYYY-MM-DD") const toDateString = dt(toDate).format("YYYY-MM-DD") @@ -94,6 +97,7 @@ export async function RoomsContainer({ roomsAvailability={roomsAvailability} roomCategories={hotelData?.included?.rooms ?? []} hotelType={hotelData?.data.attributes?.hotelType} + isUserLoggedIn={isUserLoggedIn} /> ) } diff --git a/components/HotelReservation/SelectRate/Rooms/index.tsx b/components/HotelReservation/SelectRate/Rooms/index.tsx index abb2ee340..90e2663a1 100644 --- a/components/HotelReservation/SelectRate/Rooms/index.tsx +++ b/components/HotelReservation/SelectRate/Rooms/index.tsx @@ -25,6 +25,7 @@ export default function Rooms({ roomCategories = [], availablePackages, hotelType, + isUserLoggedIn, }: SelectRateProps) { const visibleRooms: RoomConfiguration[] = useMemo(() => { const deduped = filterDuplicateRoomTypesByLowestPrice( @@ -188,6 +189,7 @@ export default function Rooms({ setRateCode={setSelectedRate} rateSummary={rateSummary} hotelType={hotelType} + isUserLoggedIn={isUserLoggedIn} />
) diff --git a/types/components/hotelReservation/selectRate/roomSelection.ts b/types/components/hotelReservation/selectRate/roomSelection.ts index 58b5af91c..7a0faf0af 100644 --- a/types/components/hotelReservation/selectRate/roomSelection.ts +++ b/types/components/hotelReservation/selectRate/roomSelection.ts @@ -12,6 +12,7 @@ export interface RoomSelectionProps { setRateCode: React.Dispatch> rateSummary: Rate | null hotelType: string | undefined + isUserLoggedIn: boolean } export interface SelectRateProps { @@ -19,4 +20,5 @@ export interface SelectRateProps { roomCategories: RoomData[] availablePackages: RoomPackageData hotelType: string | undefined + isUserLoggedIn: boolean } From 29d1721a8a8a0624576e78210e541c757cd4cc5f Mon Sep 17 00:00:00 2001 From: Linus Flood Date: Thu, 9 Jan 2025 08:39:15 +0100 Subject: [PATCH 18/33] hotfix: hide booking widget on public pages --- app/[lang]/(live)/@bookingwidget/[contentType]/[uid]/page.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/[lang]/(live)/@bookingwidget/[contentType]/[uid]/page.tsx b/app/[lang]/(live)/@bookingwidget/[contentType]/[uid]/page.tsx index 6951d700a..2f3916668 100644 --- a/app/[lang]/(live)/@bookingwidget/[contentType]/[uid]/page.tsx +++ b/app/[lang]/(live)/@bookingwidget/[contentType]/[uid]/page.tsx @@ -11,7 +11,9 @@ export default async function BookingWidgetPage({ params, searchParams, }: PageArgs) { - if (!env.ENABLE_BOOKING_WIDGET_HOTELRESERVATION_PATH) return null + if (!env.ENABLE_BOOKING_WIDGET) { + return null + } preload() From 9584478b34325498ad12d417799abb8613aaabd9 Mon Sep 17 00:00:00 2001 From: Christian Andolf Date: Fri, 20 Dec 2024 16:56:33 +0100 Subject: [PATCH 19/33] feat(LOY-63): redeem campaign --- .../Rewards/CurrentRewards/Client.tsx | 2 +- .../Rewards/CurrentRewards/Redeem.tsx | 262 ------------------ .../Rewards/CurrentRewards/current.module.css | 116 -------- .../Rewards/Redeem/ActiveRedeemedBadge.tsx | 29 ++ .../Rewards/Redeem/Campaign.tsx | 59 ++++ .../Rewards/Redeem/MembershipNumberBadge.tsx | 21 ++ .../DynamicContent/Rewards/Redeem/Tier.tsx | 92 ++++++ .../Rewards/Redeem/TimedRedeemedBadge.tsx | 25 ++ .../DynamicContent/Rewards/Redeem/index.tsx | 133 +++++++++ .../Rewards/Redeem/redeem.module.css | 115 ++++++++ .../Rewards/Redeem/useRedeemFlow.ts | 44 +++ i18n/dictionaries/en.json | 1 + server/routers/contentstack/reward/output.ts | 17 +- server/routers/contentstack/reward/query.ts | 5 + types/components/blocks/surprises.ts | 2 +- .../components/myPages/myPage/accountPage.ts | 6 + utils/rewards.ts | 14 +- 17 files changed, 552 insertions(+), 391 deletions(-) delete mode 100644 components/Blocks/DynamicContent/Rewards/CurrentRewards/Redeem.tsx create mode 100644 components/Blocks/DynamicContent/Rewards/Redeem/ActiveRedeemedBadge.tsx create mode 100644 components/Blocks/DynamicContent/Rewards/Redeem/Campaign.tsx create mode 100644 components/Blocks/DynamicContent/Rewards/Redeem/MembershipNumberBadge.tsx create mode 100644 components/Blocks/DynamicContent/Rewards/Redeem/Tier.tsx create mode 100644 components/Blocks/DynamicContent/Rewards/Redeem/TimedRedeemedBadge.tsx create mode 100644 components/Blocks/DynamicContent/Rewards/Redeem/index.tsx create mode 100644 components/Blocks/DynamicContent/Rewards/Redeem/redeem.module.css create mode 100644 components/Blocks/DynamicContent/Rewards/Redeem/useRedeemFlow.ts diff --git a/components/Blocks/DynamicContent/Rewards/CurrentRewards/Client.tsx b/components/Blocks/DynamicContent/Rewards/CurrentRewards/Client.tsx index 693b8a409..6dab0e030 100644 --- a/components/Blocks/DynamicContent/Rewards/CurrentRewards/Client.tsx +++ b/components/Blocks/DynamicContent/Rewards/CurrentRewards/Client.tsx @@ -7,7 +7,7 @@ import Pagination from "@/components/MyPages/Pagination" import Grids from "@/components/TempDesignSystem/Grids" import Title from "@/components/TempDesignSystem/Text/Title" -import Redeem from "./Redeem" +import Redeem from "../Redeem" import styles from "./current.module.css" diff --git a/components/Blocks/DynamicContent/Rewards/CurrentRewards/Redeem.tsx b/components/Blocks/DynamicContent/Rewards/CurrentRewards/Redeem.tsx deleted file mode 100644 index 1c18fea56..000000000 --- a/components/Blocks/DynamicContent/Rewards/CurrentRewards/Redeem.tsx +++ /dev/null @@ -1,262 +0,0 @@ -"use client" - -import { motion } from "framer-motion" -import { useState } from "react" -import { - Dialog, - DialogTrigger, - Modal, - ModalOverlay, -} from "react-aria-components" -import { useIntl } from "react-intl" - -import { trpc } from "@/lib/trpc/client" - -import Countdown from "@/components/Countdown" -import { CheckCircleIcon, CloseLargeIcon } from "@/components/Icons" -import Button from "@/components/TempDesignSystem/Button" -import Body from "@/components/TempDesignSystem/Text/Body" -import Caption from "@/components/TempDesignSystem/Text/Caption" -import Title from "@/components/TempDesignSystem/Text/Title" -import { isRestaurantOnSiteTierReward } from "@/utils/rewards" - -import { RewardIcon } from "../RewardIcon" - -import styles from "./current.module.css" - -import type { - RedeemModalState, - RedeemProps, - RedeemStep, -} from "@/types/components/myPages/myPage/accountPage" -import type { Reward } from "@/server/routers/contentstack/reward/output" - -const MotionOverlay = motion(ModalOverlay) -const MotionModal = motion(Modal) - -export default function Redeem({ reward, membershipNumber }: RedeemProps) { - const [animation, setAnimation] = useState("unmounted") - const intl = useIntl() - const update = trpc.contentstack.rewards.redeem.useMutation() - const [redeemStep, setRedeemStep] = useState("initial") - - function onProceed() { - if (reward.id) { - update.mutate( - { rewardId: reward.id }, - { - onSuccess() { - setRedeemStep("redeemed") - }, - onError(error) { - console.error("Failed to redeem", error) - }, - } - ) - } - } - - function modalStateHandler(newAnimationState: RedeemModalState) { - setAnimation((currentAnimationState) => - newAnimationState === "hidden" && currentAnimationState === "hidden" - ? "unmounted" - : currentAnimationState - ) - if (newAnimationState === "unmounted") { - setRedeemStep("initial") - } - } - - return ( - setAnimation(isOpen ? "visible" : "hidden")} - > - - - - - {({ close }) => ( - <> -
- -
-
- {redeemStep === "redeemed" && ( - - )} - - - {reward.label} - - - {redeemStep === "initial" && ( - {reward.description} - )} - - {redeemStep === "confirmation" && - "redeem_description" in reward && ( - - {reward.redeem_description} - - )} - {redeemStep === "redeemed" && - isRestaurantOnSiteTierReward(reward) && - membershipNumber && ( - - )} -
- {redeemStep === "initial" && ( -
- -
- )} - - {redeemStep === "confirmation" && ( -
- - -
- )} - - )} -
-
-
-
- ) -} - -const variants = { - fade: { - hidden: { - opacity: 0, - transition: { duration: 0.4, ease: "easeInOut" }, - }, - visible: { - opacity: 1, - transition: { duration: 0.4, ease: "easeInOut" }, - }, - }, - - slideInOut: { - hidden: { - opacity: 0, - y: 32, - transition: { duration: 0.4, ease: "easeInOut" }, - }, - visible: { - opacity: 1, - y: 0, - transition: { duration: 0.4, ease: "easeInOut" }, - }, - }, -} - -function ConfirmationBadge({ reward }: { reward: Reward }) { - return ( -
- {isRestaurantOnSiteTierReward(reward) ? ( - - ) : ( - - )} -
- ) -} - -function ActiveRedeemedBadge() { - const intl = useIntl() - - return ( -
- - - - {intl.formatMessage({ id: "Active" })} -
- ) -} - -function TimedRedeemedBadge() { - const intl = useIntl() - - return ( - <> -
- - - {intl.formatMessage({ - id: "Redeemed & valid through:", - })} - -
- - - ) -} - -function MembershipNumberBadge({ - membershipNumber, -}: { - membershipNumber: string -}) { - const intl = useIntl() - - return ( -
- - {intl.formatMessage({ id: "Membership ID:" })} {membershipNumber} - -
- ) -} diff --git a/components/Blocks/DynamicContent/Rewards/CurrentRewards/current.module.css b/components/Blocks/DynamicContent/Rewards/CurrentRewards/current.module.css index 605e8835b..ba158485d 100644 --- a/components/Blocks/DynamicContent/Rewards/CurrentRewards/current.module.css +++ b/components/Blocks/DynamicContent/Rewards/CurrentRewards/current.module.css @@ -27,119 +27,3 @@ .btnContainer { padding: 0 var(--Spacing-x3) var(--Spacing-x3); } - -.badge { - border-radius: var(--Small, 4px); - border: 1px solid var(--Base-Border-Subtle); - display: flex; - padding: var(--Spacing-x1) var(--Spacing-x2); - flex-direction: column; - justify-content: center; - align-items: center; -} - -.redeemed { - display: flex; - justify-content: center; - align-items: center; - gap: var(--Spacing-x-half); - align-self: stretch; -} - -.overlay { - background: rgba(0, 0, 0, 0.5); - height: var(--visual-viewport-height); - position: fixed; - top: 0; - left: 0; - width: 100vw; - z-index: 100; -} - -@media screen and (min-width: 768px) { - .overlay { - display: flex; - justify-content: center; - align-items: center; - } -} - -.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); - width: 100%; - position: absolute; - left: 0; - bottom: 0; - z-index: 101; -} - -@media screen and (min-width: 768px) { - .modal { - left: auto; - bottom: auto; - width: 400px; - } -} - -.dialog { - display: flex; - flex-direction: column; - padding-bottom: var(--Spacing-x3); -} - -.modalHeader { - --button-height: 32px; - box-sizing: content-box; - display: flex; - align-items: center; - height: var(--button-height); - position: relative; - justify-content: center; - padding: var(--Spacing-x3) var(--Spacing-x2) 0; -} - -.modalContent { - display: flex; - flex-direction: column; - align-items: center; - gap: var(--Spacing-x2); - padding: 0 var(--Spacing-x3) var(--Spacing-x3); -} - -.modalFooter { - display: flex; - flex-direction: column; - padding: 0 var(--Spacing-x3) var(--Spacing-x1); - gap: var(--Spacing-x-one-and-half); -} - -.modalFooter > button { - flex: 1 0 100%; -} - -.modalClose { - background: none; - border: none; - cursor: pointer; - position: absolute; - right: var(--Spacing-x2); - width: 32px; - height: var(--button-height); - display: flex; - align-items: center; -} - -.active { - display: flex; - align-items: center; - gap: var(--Spacing-x-half); - color: var(--UI-Semantic-Success); -} - -.membershipNumberBadge { - border-radius: var(--Small); - padding: var(--Spacing-x1) var(--Spacing-x-one-and-half); - background: var(--Base-Surface-Secondary-light-Normal); -} diff --git a/components/Blocks/DynamicContent/Rewards/Redeem/ActiveRedeemedBadge.tsx b/components/Blocks/DynamicContent/Rewards/Redeem/ActiveRedeemedBadge.tsx new file mode 100644 index 000000000..32f69b0f3 --- /dev/null +++ b/components/Blocks/DynamicContent/Rewards/Redeem/ActiveRedeemedBadge.tsx @@ -0,0 +1,29 @@ +import { motion } from "framer-motion" +import { useIntl } from "react-intl" + +import { CheckCircleIcon } from "@/components/Icons" +import Caption from "@/components/TempDesignSystem/Text/Caption" + +import styles from "./redeem.module.css" + +export default function ActiveRedeemedBadge() { + const intl = useIntl() + + return ( +
+ + + + {intl.formatMessage({ id: "Active" })} +
+ ) +} diff --git a/components/Blocks/DynamicContent/Rewards/Redeem/Campaign.tsx b/components/Blocks/DynamicContent/Rewards/Redeem/Campaign.tsx new file mode 100644 index 000000000..0b3c0afd4 --- /dev/null +++ b/components/Blocks/DynamicContent/Rewards/Redeem/Campaign.tsx @@ -0,0 +1,59 @@ +"use client" + +import { useIntl } from "react-intl" + +import CopyIcon from "@/components/Icons/Copy" +import Button from "@/components/TempDesignSystem/Button" +import Body from "@/components/TempDesignSystem/Text/Body" +import Title from "@/components/TempDesignSystem/Text/Title" +import { toast } from "@/components/TempDesignSystem/Toasts" + +import { RewardIcon } from "../RewardIcon" +import MembershipNumberBadge from "./MembershipNumberBadge" + +import styles from "./redeem.module.css" + +import type { RewardWithRedeem } from "@/server/routers/contentstack/reward/output" + +export default function Campaign({ + reward, + membershipNumber, +}: { + reward: RewardWithRedeem + membershipNumber: string +}) { + const intl = useIntl() + + function handleCopy() { + navigator.clipboard.writeText(reward.operaRewardId) + toast.success(intl.formatMessage({ id: "Copied to clipboard" })) + } + + return ( + <> +
+ + + {reward.label} + + {reward.description} + {membershipNumber && ( + + )} +
+
+ +
+ + ) +} diff --git a/components/Blocks/DynamicContent/Rewards/Redeem/MembershipNumberBadge.tsx b/components/Blocks/DynamicContent/Rewards/Redeem/MembershipNumberBadge.tsx new file mode 100644 index 000000000..b382d05bc --- /dev/null +++ b/components/Blocks/DynamicContent/Rewards/Redeem/MembershipNumberBadge.tsx @@ -0,0 +1,21 @@ +import { useIntl } from "react-intl" + +import Caption from "@/components/TempDesignSystem/Text/Caption" + +import styles from "./redeem.module.css" + +export default function MembershipNumberBadge({ + membershipNumber, +}: { + membershipNumber: string +}) { + const intl = useIntl() + + return ( +
+ + {intl.formatMessage({ id: "Membership ID:" })} {membershipNumber} + +
+ ) +} diff --git a/components/Blocks/DynamicContent/Rewards/Redeem/Tier.tsx b/components/Blocks/DynamicContent/Rewards/Redeem/Tier.tsx new file mode 100644 index 000000000..596dcc051 --- /dev/null +++ b/components/Blocks/DynamicContent/Rewards/Redeem/Tier.tsx @@ -0,0 +1,92 @@ +"use client" + +import { useIntl } from "react-intl" + +import Button from "@/components/TempDesignSystem/Button" +import Body from "@/components/TempDesignSystem/Text/Body" +import Title from "@/components/TempDesignSystem/Text/Title" +import { isRestaurantOnSiteTierReward } from "@/utils/rewards" + +import { RewardIcon } from "../RewardIcon" +import ActiveRedeemedBadge from "./ActiveRedeemedBadge" +import MembershipNumberBadge from "./MembershipNumberBadge" +import TimedRedeemedBadge from "./TimedRedeemedBadge" +import useRedeemFlow from "./useRedeemFlow" + +import styles from "./redeem.module.css" + +import type { RewardWithRedeem } from "@/server/routers/contentstack/reward/output" + +export default function Tier({ + reward, + membershipNumber, +}: { + reward: RewardWithRedeem + membershipNumber: string +}) { + const { onRedeem, redeemStep, setRedeemStep, isRedeeming } = + useRedeemFlow(reward) + const intl = useIntl() + + return ( + <> +
+ {redeemStep === "redeemed" && ( +
+ {isRestaurantOnSiteTierReward(reward) ? ( + + ) : ( + + )} +
+ )} + + + {reward.label} + + + {redeemStep === "initial" && ( + {reward.description} + )} + + {redeemStep === "confirmation" && ( + {reward.redeem_description} + )} + + {redeemStep === "redeemed" && + isRestaurantOnSiteTierReward(reward) && + membershipNumber && ( + + )} +
+ + {redeemStep === "initial" && ( +
+ +
+ )} + + {redeemStep === "confirmation" && ( +
+ + +
+ )} + + ) +} diff --git a/components/Blocks/DynamicContent/Rewards/Redeem/TimedRedeemedBadge.tsx b/components/Blocks/DynamicContent/Rewards/Redeem/TimedRedeemedBadge.tsx new file mode 100644 index 000000000..0797c8340 --- /dev/null +++ b/components/Blocks/DynamicContent/Rewards/Redeem/TimedRedeemedBadge.tsx @@ -0,0 +1,25 @@ +import { useIntl } from "react-intl" + +import Countdown from "@/components/Countdown" +import { CheckCircleIcon } from "@/components/Icons" +import Caption from "@/components/TempDesignSystem/Text/Caption" + +import styles from "./redeem.module.css" + +export default function TimedRedeemedBadge() { + const intl = useIntl() + + return ( + <> +
+ + + {intl.formatMessage({ + id: "Redeemed & valid through:", + })} + +
+ + + ) +} diff --git a/components/Blocks/DynamicContent/Rewards/Redeem/index.tsx b/components/Blocks/DynamicContent/Rewards/Redeem/index.tsx new file mode 100644 index 000000000..cec81df1e --- /dev/null +++ b/components/Blocks/DynamicContent/Rewards/Redeem/index.tsx @@ -0,0 +1,133 @@ +"use client" + +import { motion } from "framer-motion" +import { useState } from "react" +import { + Dialog, + DialogTrigger, + Modal, + ModalOverlay, +} from "react-aria-components" +import { useIntl } from "react-intl" + +import { trpc } from "@/lib/trpc/client" + +import { CloseLargeIcon } from "@/components/Icons" +import Button from "@/components/TempDesignSystem/Button" + +import Campaign from "./Campaign" +import Tier from "./Tier" +import { RedeemContext } from "./useRedeemFlow" + +import styles from "./redeem.module.css" + +import type { + RedeemModalState, + RedeemProps, + RedeemStep, +} from "@/types/components/myPages/myPage/accountPage" +import type { RewardWithRedeem } from "@/server/routers/contentstack/reward/output" + +const MotionOverlay = motion(ModalOverlay) +const MotionModal = motion(Modal) + +export default function Redeem({ reward, membershipNumber }: RedeemProps) { + const [animation, setAnimation] = useState("unmounted") + const intl = useIntl() + const [redeemStep, setRedeemStep] = useState("initial") + + function modalStateHandler(newAnimationState: RedeemModalState) { + setAnimation((currentAnimationState) => + newAnimationState === "hidden" && currentAnimationState === "hidden" + ? "unmounted" + : currentAnimationState + ) + if (newAnimationState === "unmounted") { + setRedeemStep("initial") + } + } + + return ( + + setAnimation(isOpen ? "visible" : "hidden")} + > + + + + + {({ close }) => ( + <> +
+ +
+ + {getRedeemFlow(reward, membershipNumber || "")} + + )} +
+
+
+
+
+ ) +} + +const variants = { + fade: { + hidden: { + opacity: 0, + transition: { duration: 0.4, ease: "easeInOut" }, + }, + visible: { + opacity: 1, + transition: { duration: 0.4, ease: "easeInOut" }, + }, + }, + + slideInOut: { + hidden: { + opacity: 0, + y: 32, + transition: { duration: 0.4, ease: "easeInOut" }, + }, + visible: { + opacity: 1, + y: 0, + transition: { duration: 0.4, ease: "easeInOut" }, + }, + }, +} + +function getRedeemFlow(reward: RewardWithRedeem, membershipNumber: string) { + switch (reward.rewardType) { + case "Campaign": + return + case "Surprise": + case "Tier": + return + default: + console.warn("Unsupported reward type for redeem:", reward.rewardType) + return null + } +} diff --git a/components/Blocks/DynamicContent/Rewards/Redeem/redeem.module.css b/components/Blocks/DynamicContent/Rewards/Redeem/redeem.module.css new file mode 100644 index 000000000..4c3bda574 --- /dev/null +++ b/components/Blocks/DynamicContent/Rewards/Redeem/redeem.module.css @@ -0,0 +1,115 @@ +.badge { + border-radius: var(--Small, 4px); + border: 1px solid var(--Base-Border-Subtle); + display: flex; + padding: var(--Spacing-x1) var(--Spacing-x2); + flex-direction: column; + justify-content: center; + align-items: center; +} + +.redeemed { + display: flex; + justify-content: center; + align-items: center; + gap: var(--Spacing-x-half); + align-self: stretch; +} + +.overlay { + background: rgba(0, 0, 0, 0.5); + height: var(--visual-viewport-height); + position: fixed; + top: 0; + left: 0; + width: 100vw; + z-index: 100; +} + +@media screen and (min-width: 768px) { + .overlay { + display: flex; + justify-content: center; + align-items: center; + } +} + +.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); + width: 100%; + position: absolute; + left: 0; + bottom: 0; + z-index: 101; +} + +@media screen and (min-width: 768px) { + .modal { + left: auto; + bottom: auto; + width: 400px; + } +} + +.dialog { + display: flex; + flex-direction: column; + padding-bottom: var(--Spacing-x3); +} + +.modalHeader { + --button-height: 32px; + box-sizing: content-box; + display: flex; + align-items: center; + height: var(--button-height); + position: relative; + justify-content: center; + padding: var(--Spacing-x3) var(--Spacing-x2) 0; +} + +.modalContent { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--Spacing-x2); + padding: 0 var(--Spacing-x3) var(--Spacing-x3); +} + +.modalFooter { + display: flex; + flex-direction: column; + padding: 0 var(--Spacing-x3) var(--Spacing-x1); + gap: var(--Spacing-x-one-and-half); +} + +.modalFooter > button { + flex: 1 0 100%; +} + +.modalClose { + background: none; + border: none; + cursor: pointer; + position: absolute; + right: var(--Spacing-x2); + width: 32px; + height: var(--button-height); + display: flex; + align-items: center; +} + +.active { + display: flex; + align-items: center; + gap: var(--Spacing-x-half); + color: var(--UI-Semantic-Success); +} + +.membershipNumberBadge { + border-radius: var(--Corner-radius-Small); + padding: var(--Spacing-x1) var(--Spacing-x-one-and-half); + background: var(--Base-Surface-Secondary-light-Normal); +} diff --git a/components/Blocks/DynamicContent/Rewards/Redeem/useRedeemFlow.ts b/components/Blocks/DynamicContent/Rewards/Redeem/useRedeemFlow.ts new file mode 100644 index 000000000..280bab67a --- /dev/null +++ b/components/Blocks/DynamicContent/Rewards/Redeem/useRedeemFlow.ts @@ -0,0 +1,44 @@ +"use client" + +import { createContext, useCallback, useContext, useState } from "react" + +import { trpc } from "@/lib/trpc/client" + +import type { RedeemFlowContext } from "@/types/components/myPages/myPage/accountPage" +import type { RewardWithRedeem } from "@/server/routers/contentstack/reward/output" + +export const RedeemContext = createContext({ + redeemStep: "initial", + setRedeemStep: () => undefined, +}) + +export default function useRedeemFlow(reward: RewardWithRedeem) { + const { redeemStep, setRedeemStep } = useContext(RedeemContext) + + const update = trpc.contentstack.rewards.redeem.useMutation<{ + rewards: RewardWithRedeem[] + }>() + + const onRedeem = useCallback(() => { + if (reward?.id) { + update.mutate( + { rewardId: reward.id }, + { + onSuccess() { + setRedeemStep("redeemed") + }, + onError(error) { + console.error("Failed to redeem", error) + }, + } + ) + } + }, [reward, update, setRedeemStep]) + + return { + onRedeem, + redeemStep, + setRedeemStep, + isRedeeming: update.isPending, + } +} diff --git a/i18n/dictionaries/en.json b/i18n/dictionaries/en.json index ff1238111..fee5d1339 100644 --- a/i18n/dictionaries/en.json +++ b/i18n/dictionaries/en.json @@ -103,6 +103,7 @@ "Contact information": "Contact information", "Contact us": "Contact us", "Continue": "Continue", + "Copied to clipboard": "Copied to clipboard", "Copyright all rights reserved": "Scandic AB All rights reserved", "Could not find requested resource": "Could not find requested resource", "Country": "Country", diff --git a/server/routers/contentstack/reward/output.ts b/server/routers/contentstack/reward/output.ts index f3a38b11b..0c5875310 100644 --- a/server/routers/contentstack/reward/output.ts +++ b/server/routers/contentstack/reward/output.ts @@ -37,6 +37,7 @@ const SurpriseReward = z.object({ rewardType: z.string().optional(), endsAt: z.string().datetime({ offset: true }).optional(), coupons: z.array(Coupon).optional(), + operaRewardId: z.string().default(""), }) export const validateApiRewardSchema = z @@ -53,6 +54,7 @@ export const validateApiRewardSchema = z autoApplyReward: z.boolean().default(false), rewardType: z.string().optional(), rewardTierLevel: z.string().optional(), + operaRewardId: z.string().default(""), }), SurpriseReward, ]) @@ -87,6 +89,7 @@ export const validateApiTierRewardsSchema = z.record( autoApplyReward: z.boolean().default(false), rewardType: z.string().optional(), rewardTierLevel: z.string().optional(), + operaRewardId: z.string().default(""), }) ) ) @@ -99,7 +102,7 @@ export const validateCmsRewardsSchema = z z.object({ taxonomies: z.array( z.object({ - term_uid: z.string().optional(), + term_uid: z.string().optional().default(""), }) ), label: z.string().optional(), @@ -123,7 +126,7 @@ export const validateCmsRewardsWithRedeemSchema = z z.object({ taxonomies: z.array( z.object({ - term_uid: z.string().optional(), + term_uid: z.string().optional().default(""), }) ), label: z.string().optional(), @@ -163,12 +166,14 @@ export type Reward = CMSReward & { id: string | undefined rewardType: string | undefined redeemLocation: string | undefined + operaRewardId: string } export type RewardWithRedeem = CMSRewardWithRedeem & { id: string | undefined rewardType: string | undefined redeemLocation: string | undefined + operaRewardId: string } // New endpoint related types and schemas. @@ -178,16 +183,15 @@ const BenefitReward = z.object({ id: z.string().optional(), redeemLocation: z.string().optional(), rewardId: z.string().optional(), - rewardType: z.string().optional(), + rewardType: z.string().optional(), // TODO: Should be "Tier" but can't because of backwards compatibility rewardTierLevel: z.string().optional(), status: z.string().optional(), }) -const CouponState = z.enum(["claimed", "redeemed", "viewed"]) const CouponData = z.object({ couponCode: z.string().optional(), unwrapped: z.boolean().default(false), - state: CouponState, + state: z.enum(["claimed", "redeemed", "viewed"]), expiresAt: z.string().datetime({ offset: true }).optional(), }) @@ -195,8 +199,9 @@ const CouponReward = z.object({ title: z.string().optional(), id: z.string().optional(), rewardId: z.string().optional(), - rewardType: z.string().optional(), + rewardType: z.enum(["Surprise", "Campaign"]), redeemLocation: z.string().optional(), + operaRewardId: z.string().default(""), status: z.string().optional(), coupon: z.array(CouponData).optional(), }) diff --git a/server/routers/contentstack/reward/query.ts b/server/routers/contentstack/reward/query.ts index 3a3165aaa..f2e6e1ed9 100644 --- a/server/routers/contentstack/reward/query.ts +++ b/server/routers/contentstack/reward/query.ts @@ -202,6 +202,7 @@ export const rewardQueryRouter = router({ } const data = await apiResponse.json() + const validatedApiRewards = isNewEndpoint ? validateCategorizedRewardsSchema.safeParse(data) : validateApiRewardSchema.safeParse(data) @@ -256,6 +257,10 @@ export const rewardQueryRouter = router({ id: apiReward?.id, rewardType: apiReward?.rewardType, redeemLocation: apiReward?.redeemLocation, + operaRewardId: + apiReward && "operaRewardId" in apiReward + ? apiReward.operaRewardId + : "", } }) diff --git a/types/components/blocks/surprises.ts b/types/components/blocks/surprises.ts index 281a8134c..9e3b8d52c 100644 --- a/types/components/blocks/surprises.ts +++ b/types/components/blocks/surprises.ts @@ -1,6 +1,6 @@ import type { Reward } from "@/server/routers/contentstack/reward/output" -export interface Surprise extends Reward { +export interface Surprise extends Omit { coupons: { couponCode?: string; expiresAt?: string }[] } diff --git a/types/components/myPages/myPage/accountPage.ts b/types/components/myPages/myPage/accountPage.ts index e5133c758..d545df0ce 100644 --- a/types/components/myPages/myPage/accountPage.ts +++ b/types/components/myPages/myPage/accountPage.ts @@ -1,3 +1,4 @@ +import type { Dispatch, ReactNode, SetStateAction } from "react" import type { z } from "zod" import type { DynamicContent } from "@/types/trpc/routers/contentstack/blocks" @@ -37,3 +38,8 @@ export interface RedeemProps { export type RedeemModalState = "unmounted" | "hidden" | "visible" export type RedeemStep = "initial" | "confirmation" | "redeemed" + +export type RedeemFlowContext = { + redeemStep: RedeemStep + setRedeemStep: Dispatch> +} diff --git a/utils/rewards.ts b/utils/rewards.ts index 272ce3954..77e57d8e9 100644 --- a/utils/rewards.ts +++ b/utils/rewards.ts @@ -4,7 +4,7 @@ import type { RestaurantRewardId, RewardId, } from "@/types/components/myPages/rewards" -import type { Reward } from "@/server/routers/contentstack/reward/output" +import type { RewardWithRedeem } from "@/server/routers/contentstack/reward/output" export function isValidRewardId(id: string): id is RewardId { return Object.values(REWARD_IDS).includes(id) @@ -17,22 +17,26 @@ export function isRestaurantReward( } export function redeemLocationIsOnSite( - location: Reward["redeemLocation"] + location: RewardWithRedeem["redeemLocation"] ): location is "On-site" { return location === "On-site" } -export function isTierType(type: Reward["rewardType"]): type is "Tier" { +export function isTierType( + type: RewardWithRedeem["rewardType"] +): type is "Tier" { return type === "Tier" } -export function isOnSiteTierReward(reward: Reward): boolean { +export function isOnSiteTierReward(reward: RewardWithRedeem): boolean { return ( redeemLocationIsOnSite(reward.redeemLocation) && isTierType(reward.rewardType) ) } -export function isRestaurantOnSiteTierReward(reward: Reward): boolean { +export function isRestaurantOnSiteTierReward( + reward: RewardWithRedeem +): boolean { return isOnSiteTierReward(reward) && isRestaurantReward(reward.reward_id) } From 6941c1d006e365a89906f7b574ffb014f6cc5e6f Mon Sep 17 00:00:00 2001 From: Christian Andolf Date: Wed, 8 Jan 2025 14:20:48 +0100 Subject: [PATCH 20/33] fix(LOY-63): move redeem flows to separate folder add use client directive --- .../Rewards/Redeem/ActiveRedeemedBadge.tsx | 2 ++ .../Rewards/Redeem/{ => Flows}/Campaign.tsx | 6 +++--- .../Rewards/Redeem/{ => Flows}/Tier.tsx | 12 ++++++------ .../Rewards/Redeem/TimedRedeemedBadge.tsx | 2 ++ .../Blocks/DynamicContent/Rewards/Redeem/index.tsx | 6 ++---- 5 files changed, 15 insertions(+), 13 deletions(-) rename components/Blocks/DynamicContent/Rewards/Redeem/{ => Flows}/Campaign.tsx (90%) rename components/Blocks/DynamicContent/Rewards/Redeem/{ => Flows}/Tier.tsx (88%) diff --git a/components/Blocks/DynamicContent/Rewards/Redeem/ActiveRedeemedBadge.tsx b/components/Blocks/DynamicContent/Rewards/Redeem/ActiveRedeemedBadge.tsx index 32f69b0f3..8ff056af3 100644 --- a/components/Blocks/DynamicContent/Rewards/Redeem/ActiveRedeemedBadge.tsx +++ b/components/Blocks/DynamicContent/Rewards/Redeem/ActiveRedeemedBadge.tsx @@ -1,3 +1,5 @@ +"use client" + import { motion } from "framer-motion" import { useIntl } from "react-intl" diff --git a/components/Blocks/DynamicContent/Rewards/Redeem/Campaign.tsx b/components/Blocks/DynamicContent/Rewards/Redeem/Flows/Campaign.tsx similarity index 90% rename from components/Blocks/DynamicContent/Rewards/Redeem/Campaign.tsx rename to components/Blocks/DynamicContent/Rewards/Redeem/Flows/Campaign.tsx index 0b3c0afd4..f8c0013a0 100644 --- a/components/Blocks/DynamicContent/Rewards/Redeem/Campaign.tsx +++ b/components/Blocks/DynamicContent/Rewards/Redeem/Flows/Campaign.tsx @@ -8,10 +8,10 @@ import Body from "@/components/TempDesignSystem/Text/Body" import Title from "@/components/TempDesignSystem/Text/Title" import { toast } from "@/components/TempDesignSystem/Toasts" -import { RewardIcon } from "../RewardIcon" -import MembershipNumberBadge from "./MembershipNumberBadge" +import { RewardIcon } from "../../RewardIcon" +import MembershipNumberBadge from "../MembershipNumberBadge" -import styles from "./redeem.module.css" +import styles from "../redeem.module.css" import type { RewardWithRedeem } from "@/server/routers/contentstack/reward/output" diff --git a/components/Blocks/DynamicContent/Rewards/Redeem/Tier.tsx b/components/Blocks/DynamicContent/Rewards/Redeem/Flows/Tier.tsx similarity index 88% rename from components/Blocks/DynamicContent/Rewards/Redeem/Tier.tsx rename to components/Blocks/DynamicContent/Rewards/Redeem/Flows/Tier.tsx index 596dcc051..0323ba7f8 100644 --- a/components/Blocks/DynamicContent/Rewards/Redeem/Tier.tsx +++ b/components/Blocks/DynamicContent/Rewards/Redeem/Flows/Tier.tsx @@ -7,13 +7,13 @@ import Body from "@/components/TempDesignSystem/Text/Body" import Title from "@/components/TempDesignSystem/Text/Title" import { isRestaurantOnSiteTierReward } from "@/utils/rewards" -import { RewardIcon } from "../RewardIcon" -import ActiveRedeemedBadge from "./ActiveRedeemedBadge" -import MembershipNumberBadge from "./MembershipNumberBadge" -import TimedRedeemedBadge from "./TimedRedeemedBadge" -import useRedeemFlow from "./useRedeemFlow" +import { RewardIcon } from "../../RewardIcon" +import ActiveRedeemedBadge from "../ActiveRedeemedBadge" +import MembershipNumberBadge from "../MembershipNumberBadge" +import TimedRedeemedBadge from "../TimedRedeemedBadge" +import useRedeemFlow from "../useRedeemFlow" -import styles from "./redeem.module.css" +import styles from "../redeem.module.css" import type { RewardWithRedeem } from "@/server/routers/contentstack/reward/output" diff --git a/components/Blocks/DynamicContent/Rewards/Redeem/TimedRedeemedBadge.tsx b/components/Blocks/DynamicContent/Rewards/Redeem/TimedRedeemedBadge.tsx index 0797c8340..605187255 100644 --- a/components/Blocks/DynamicContent/Rewards/Redeem/TimedRedeemedBadge.tsx +++ b/components/Blocks/DynamicContent/Rewards/Redeem/TimedRedeemedBadge.tsx @@ -1,3 +1,5 @@ +"use client" + import { useIntl } from "react-intl" import Countdown from "@/components/Countdown" diff --git a/components/Blocks/DynamicContent/Rewards/Redeem/index.tsx b/components/Blocks/DynamicContent/Rewards/Redeem/index.tsx index cec81df1e..ec8911d11 100644 --- a/components/Blocks/DynamicContent/Rewards/Redeem/index.tsx +++ b/components/Blocks/DynamicContent/Rewards/Redeem/index.tsx @@ -10,13 +10,11 @@ import { } from "react-aria-components" import { useIntl } from "react-intl" -import { trpc } from "@/lib/trpc/client" - import { CloseLargeIcon } from "@/components/Icons" import Button from "@/components/TempDesignSystem/Button" -import Campaign from "./Campaign" -import Tier from "./Tier" +import Campaign from "./Flows/Campaign" +import Tier from "./Flows/Tier" import { RedeemContext } from "./useRedeemFlow" import styles from "./redeem.module.css" From 9452f24df973d265fa88f6b0cf504e2dc4e081a2 Mon Sep 17 00:00:00 2001 From: Christian Andolf Date: Wed, 8 Jan 2025 16:05:07 +0100 Subject: [PATCH 21/33] feat(LOY-63): add promo code badge to campaign redeem --- .../Rewards/Redeem/Flows/Campaign.tsx | 21 +++++++++---------- .../Rewards/Redeem/MembershipNumberBadge.tsx | 2 +- .../DynamicContent/Rewards/Redeem/index.tsx | 2 +- .../Rewards/Redeem/redeem.module.css | 4 +++- 4 files changed, 15 insertions(+), 14 deletions(-) diff --git a/components/Blocks/DynamicContent/Rewards/Redeem/Flows/Campaign.tsx b/components/Blocks/DynamicContent/Rewards/Redeem/Flows/Campaign.tsx index f8c0013a0..ca0513bf5 100644 --- a/components/Blocks/DynamicContent/Rewards/Redeem/Flows/Campaign.tsx +++ b/components/Blocks/DynamicContent/Rewards/Redeem/Flows/Campaign.tsx @@ -5,23 +5,17 @@ import { useIntl } from "react-intl" import CopyIcon from "@/components/Icons/Copy" import Button from "@/components/TempDesignSystem/Button" import Body from "@/components/TempDesignSystem/Text/Body" +import Caption from "@/components/TempDesignSystem/Text/Caption" import Title from "@/components/TempDesignSystem/Text/Title" import { toast } from "@/components/TempDesignSystem/Toasts" import { RewardIcon } from "../../RewardIcon" -import MembershipNumberBadge from "../MembershipNumberBadge" import styles from "../redeem.module.css" import type { RewardWithRedeem } from "@/server/routers/contentstack/reward/output" -export default function Campaign({ - reward, - membershipNumber, -}: { - reward: RewardWithRedeem - membershipNumber: string -}) { +export default function Campaign({ reward }: { reward: RewardWithRedeem }) { const intl = useIntl() function handleCopy() { @@ -37,9 +31,14 @@ export default function Campaign({ {reward.label} {reward.description} - {membershipNumber && ( - - )} +
+ + {intl.formatMessage({ id: "Promo code" })} + + + {reward.operaRewardId} + +
} + title={name} + subtitle={paymentTerm} > - - {name} - - {priceInformation?.map((info) => ( - - {info} - - ))} - +
+ {priceInformation?.map((info) => ( + + + {info} + + ))} +
+
{name} ({paymentTerm}) From 9a25b3569d3110c59ba25bf63a444a5d71aee3a9 Mon Sep 17 00:00:00 2001 From: Hrishikesh Vaipurkar Date: Wed, 8 Jan 2025 14:50:27 +0100 Subject: [PATCH 24/33] feat: SW-1182 Implntd rate terms modal in confirmation page and Updated styles --- .../BookingConfirmation/Receipt/index.tsx | 56 +++++++++++++++---- .../Receipt/receipt.module.css | 16 ++++++ .../EnterDetails/Summary/UI/index.tsx | 41 +++++++++----- .../EnterDetails/Summary/UI/ui.module.css | 13 +++++ .../flexibilityOption.module.css | 6 +- .../RoomSelection/FlexibilityOption/index.tsx | 3 +- 6 files changed, 106 insertions(+), 29 deletions(-) diff --git a/components/HotelReservation/BookingConfirmation/Receipt/index.tsx b/components/HotelReservation/BookingConfirmation/Receipt/index.tsx index 546b1bb80..36270b2d3 100644 --- a/components/HotelReservation/BookingConfirmation/Receipt/index.tsx +++ b/components/HotelReservation/BookingConfirmation/Receipt/index.tsx @@ -2,7 +2,11 @@ import { notFound } from "next/navigation" import { useIntl } from "react-intl" -import { ChevronRightSmallIcon, InfoCircleIcon } from "@/components/Icons" +import { + CheckIcon, + ChevronRightSmallIcon, + InfoCircleIcon, +} from "@/components/Icons" import Button from "@/components/TempDesignSystem/Button" import Divider from "@/components/TempDesignSystem/Divider" import Link from "@/components/TempDesignSystem/Link" @@ -11,6 +15,8 @@ import Caption from "@/components/TempDesignSystem/Text/Caption" import Subtitle from "@/components/TempDesignSystem/Text/Subtitle" import { formatPrice } from "@/utils/numberFormatting" +import Modal from "../../Modal" + import styles from "./receipt.module.css" import type { BookingConfirmationReceiptProps } from "@/types/components/hotelReservation/bookingConfirmation/receipt" @@ -64,16 +70,46 @@ export default function Receipt({ {booking.rateDefinition.cancellationText} - + + {intl.formatMessage({ id: "Reservation policy" })} + + + + } + title={booking.rateDefinition.cancellationText || ""} + subtitle={ + booking.rateDefinition.cancellationRule == "CancellableBefore6PM" + ? intl.formatMessage({ id: "Pay later" }) + : intl.formatMessage({ id: "Pay now" }) + } > - {intl.formatMessage({ id: "Reservation policy" })} - - +
+ {booking.rateDefinition.generalTerms?.map((info) => ( + + + {info} + + ))} +
+
{room.bedType.description} diff --git a/components/HotelReservation/BookingConfirmation/Receipt/receipt.module.css b/components/HotelReservation/BookingConfirmation/Receipt/receipt.module.css index 3625add0e..ff010a681 100644 --- a/components/HotelReservation/BookingConfirmation/Receipt/receipt.module.css +++ b/components/HotelReservation/BookingConfirmation/Receipt/receipt.module.css @@ -33,6 +33,22 @@ padding: 0; } +.termsLink { + justify-self: flex-start; +} +.terms { + margin-top: var(--Spacing-x3); + margin-bottom: var(--Spacing-x3); +} +.termsText:nth-child(n) { + display: flex; + align-items: center; + margin-bottom: var(--Spacing-x1); +} +.terms .termsIcon { + margin-right: var(--Spacing-x1); +} + @media screen and (min-width: 1367px) { .receipt { padding: var(--Spacing-x3); diff --git a/components/HotelReservation/EnterDetails/Summary/UI/index.tsx b/components/HotelReservation/EnterDetails/Summary/UI/index.tsx index 2f24ea221..a64126bcb 100644 --- a/components/HotelReservation/EnterDetails/Summary/UI/index.tsx +++ b/components/HotelReservation/EnterDetails/Summary/UI/index.tsx @@ -8,12 +8,12 @@ import { useEnterDetailsStore } from "@/stores/enter-details" import SignupPromoDesktop from "@/components/HotelReservation/SignupPromo/Desktop" import { ArrowRightIcon, + CheckIcon, ChevronDownSmallIcon, ChevronRightSmallIcon, } from "@/components/Icons" import Button from "@/components/TempDesignSystem/Button" import Divider from "@/components/TempDesignSystem/Divider" -import Popover from "@/components/TempDesignSystem/Popover" import Body from "@/components/TempDesignSystem/Text/Body" import Caption from "@/components/TempDesignSystem/Text/Caption" import Subtitle from "@/components/TempDesignSystem/Text/Subtitle" @@ -170,23 +170,34 @@ export default function SummaryUI({ }`} {cancellationText} - - {intl.formatMessage({ id: "Rate details" })} - + + + {intl.formatMessage({ id: "Rate details" })} + + } + title={cancellationText} > - - +
+
{packages ? packages.map((roomPackage) => ( diff --git a/components/HotelReservation/EnterDetails/Summary/UI/ui.module.css b/components/HotelReservation/EnterDetails/Summary/UI/ui.module.css index b4d3e41ac..58ff5770c 100644 --- a/components/HotelReservation/EnterDetails/Summary/UI/ui.module.css +++ b/components/HotelReservation/EnterDetails/Summary/UI/ui.module.css @@ -72,6 +72,19 @@ width: 560px; } +.terms { + margin-top: var(--Spacing-x3); + margin-bottom: var(--Spacing-x3); +} +.termsText:nth-child(n) { + display: flex; + align-items: center; + margin-bottom: var(--Spacing-x1); +} +.terms .termsIcon { + margin-right: var(--Spacing-x1); +} + @media screen and (min-width: 1367px) { .bottomDivider { display: block; diff --git a/components/HotelReservation/SelectRate/RoomSelection/FlexibilityOption/flexibilityOption.module.css b/components/HotelReservation/SelectRate/RoomSelection/FlexibilityOption/flexibilityOption.module.css index 037921ea2..433127ac7 100644 --- a/components/HotelReservation/SelectRate/RoomSelection/FlexibilityOption/flexibilityOption.module.css +++ b/components/HotelReservation/SelectRate/RoomSelection/FlexibilityOption/flexibilityOption.module.css @@ -77,9 +77,6 @@ input[type="radio"]:checked + .card .checkIcon { margin: 0 auto var(--Spacing-x2); } -.modal section:focus-visible { - outline: none; -} .terms { margin-top: var(--Spacing-x3); margin-bottom: var(--Spacing-x3); @@ -89,3 +86,6 @@ input[type="radio"]:checked + .card .checkIcon { align-items: center; margin-bottom: var(--Spacing-x1); } +.termsIcon { + margin-right: var(--Spacing-x1); +} diff --git a/components/HotelReservation/SelectRate/RoomSelection/FlexibilityOption/index.tsx b/components/HotelReservation/SelectRate/RoomSelection/FlexibilityOption/index.tsx index 4dec9a48a..74f2d62db 100644 --- a/components/HotelReservation/SelectRate/RoomSelection/FlexibilityOption/index.tsx +++ b/components/HotelReservation/SelectRate/RoomSelection/FlexibilityOption/index.tsx @@ -4,7 +4,7 @@ import { useSearchParams } from "next/navigation" import { useEffect, useRef } from "react" import { useIntl } from "react-intl" -import { CheckIcon, InfoCircleIcon } from "@/components/Icons" +import { CheckCircleIcon, CheckIcon, InfoCircleIcon } from "@/components/Icons" import Button from "@/components/TempDesignSystem/Button" import Label from "@/components/TempDesignSystem/Form/Label" import Body from "@/components/TempDesignSystem/Text/Body" @@ -142,6 +142,7 @@ export default function FlexibilityOption({ color="uiSemanticSuccess" width={20} height={20} + className={styles.termsIcon} > {info} From 7baae056555b7f52b87cfacbc59640d2528c0430 Mon Sep 17 00:00:00 2001 From: Hrishikesh Vaipurkar Date: Wed, 8 Jan 2025 15:02:28 +0100 Subject: [PATCH 25/33] feat: SW-1182 Moved modal to components folder --- .../HotelReservation/BookingConfirmation/Receipt/index.tsx | 3 +-- .../EnterDetails/Details/MemberPriceModal/index.tsx | 3 +-- components/HotelReservation/EnterDetails/Summary/UI/index.tsx | 2 +- .../SelectRate/RoomSelection/FlexibilityOption/index.tsx | 2 +- components/{HotelReservation => }/Modal/index.tsx | 0 components/{HotelReservation => }/Modal/modal.module.css | 0 components/{HotelReservation => }/Modal/modal.ts | 0 components/{HotelReservation => }/Modal/motionVariants.ts | 0 8 files changed, 4 insertions(+), 6 deletions(-) rename components/{HotelReservation => }/Modal/index.tsx (100%) rename components/{HotelReservation => }/Modal/modal.module.css (100%) rename components/{HotelReservation => }/Modal/modal.ts (100%) rename components/{HotelReservation => }/Modal/motionVariants.ts (100%) diff --git a/components/HotelReservation/BookingConfirmation/Receipt/index.tsx b/components/HotelReservation/BookingConfirmation/Receipt/index.tsx index 36270b2d3..888e31bb1 100644 --- a/components/HotelReservation/BookingConfirmation/Receipt/index.tsx +++ b/components/HotelReservation/BookingConfirmation/Receipt/index.tsx @@ -7,6 +7,7 @@ import { ChevronRightSmallIcon, InfoCircleIcon, } from "@/components/Icons" +import Modal from "@/components/Modal" import Button from "@/components/TempDesignSystem/Button" import Divider from "@/components/TempDesignSystem/Divider" import Link from "@/components/TempDesignSystem/Link" @@ -15,8 +16,6 @@ import Caption from "@/components/TempDesignSystem/Text/Caption" import Subtitle from "@/components/TempDesignSystem/Text/Subtitle" import { formatPrice } from "@/utils/numberFormatting" -import Modal from "../../Modal" - import styles from "./receipt.module.css" import type { BookingConfirmationReceiptProps } from "@/types/components/hotelReservation/bookingConfirmation/receipt" diff --git a/components/HotelReservation/EnterDetails/Details/MemberPriceModal/index.tsx b/components/HotelReservation/EnterDetails/Details/MemberPriceModal/index.tsx index d7fb1283f..63822e12f 100644 --- a/components/HotelReservation/EnterDetails/Details/MemberPriceModal/index.tsx +++ b/components/HotelReservation/EnterDetails/Details/MemberPriceModal/index.tsx @@ -5,14 +5,13 @@ import { useIntl } from "react-intl" import { useEnterDetailsStore } from "@/stores/enter-details" import { MagicWandIcon } from "@/components/Icons" +import Modal from "@/components/Modal" import Button from "@/components/TempDesignSystem/Button" import Body from "@/components/TempDesignSystem/Text/Body" import Subtitle from "@/components/TempDesignSystem/Text/Subtitle" import Title from "@/components/TempDesignSystem/Text/Title" import { formatPrice } from "@/utils/numberFormatting" -import Modal from "../../../Modal" - import styles from "./modal.module.css" import type { Dispatch, SetStateAction } from "react" diff --git a/components/HotelReservation/EnterDetails/Summary/UI/index.tsx b/components/HotelReservation/EnterDetails/Summary/UI/index.tsx index a64126bcb..9c30c2775 100644 --- a/components/HotelReservation/EnterDetails/Summary/UI/index.tsx +++ b/components/HotelReservation/EnterDetails/Summary/UI/index.tsx @@ -12,6 +12,7 @@ import { ChevronDownSmallIcon, ChevronRightSmallIcon, } from "@/components/Icons" +import Modal from "@/components/Modal" import Button from "@/components/TempDesignSystem/Button" import Divider from "@/components/TempDesignSystem/Divider" import Body from "@/components/TempDesignSystem/Text/Body" @@ -20,7 +21,6 @@ import Subtitle from "@/components/TempDesignSystem/Text/Subtitle" import useLang from "@/hooks/useLang" import { formatPrice } from "@/utils/numberFormatting" -import Modal from "../../../Modal" import PriceDetailsTable from "../PriceDetailsTable" import styles from "./ui.module.css" diff --git a/components/HotelReservation/SelectRate/RoomSelection/FlexibilityOption/index.tsx b/components/HotelReservation/SelectRate/RoomSelection/FlexibilityOption/index.tsx index 74f2d62db..a6da82392 100644 --- a/components/HotelReservation/SelectRate/RoomSelection/FlexibilityOption/index.tsx +++ b/components/HotelReservation/SelectRate/RoomSelection/FlexibilityOption/index.tsx @@ -5,12 +5,12 @@ import { useEffect, useRef } from "react" import { useIntl } from "react-intl" import { CheckCircleIcon, CheckIcon, InfoCircleIcon } from "@/components/Icons" +import Modal from "@/components/Modal" import Button from "@/components/TempDesignSystem/Button" import Label from "@/components/TempDesignSystem/Form/Label" import Body from "@/components/TempDesignSystem/Text/Body" import Caption from "@/components/TempDesignSystem/Text/Caption" -import Modal from "../../../Modal/index" import { RATE_CARD_EQUAL_HEIGHT_CLASS } from "../utils" import PriceTable from "./PriceList" diff --git a/components/HotelReservation/Modal/index.tsx b/components/Modal/index.tsx similarity index 100% rename from components/HotelReservation/Modal/index.tsx rename to components/Modal/index.tsx diff --git a/components/HotelReservation/Modal/modal.module.css b/components/Modal/modal.module.css similarity index 100% rename from components/HotelReservation/Modal/modal.module.css rename to components/Modal/modal.module.css diff --git a/components/HotelReservation/Modal/modal.ts b/components/Modal/modal.ts similarity index 100% rename from components/HotelReservation/Modal/modal.ts rename to components/Modal/modal.ts diff --git a/components/HotelReservation/Modal/motionVariants.ts b/components/Modal/motionVariants.ts similarity index 100% rename from components/HotelReservation/Modal/motionVariants.ts rename to components/Modal/motionVariants.ts From 5d5de0de31d2e9d82c0a55153d6e272a8ae37d31 Mon Sep 17 00:00:00 2001 From: Hrishikesh Vaipurkar Date: Wed, 8 Jan 2025 15:42:54 +0100 Subject: [PATCH 26/33] feat: SW-1182 Removed outline and typeOf --- components/Modal/index.tsx | 2 +- components/Modal/modal.module.css | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/components/Modal/index.tsx b/components/Modal/index.tsx index 89baac915..8ea3e5312 100644 --- a/components/Modal/index.tsx +++ b/components/Modal/index.tsx @@ -121,7 +121,7 @@ export default function Modal({ isOpen ? AnimationStateEnum.visible : AnimationStateEnum.hidden ) } - if (typeof isOpen === "undefined") { + if (isOpen === undefined) { setAnimation(AnimationStateEnum.unmounted) } }, [isOpen]) diff --git a/components/Modal/modal.module.css b/components/Modal/modal.module.css index 14ad542ad..120952cf8 100644 --- a/components/Modal/modal.module.css +++ b/components/Modal/modal.module.css @@ -23,6 +23,9 @@ 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; From 56276a6f19938bce4a8b538ef48d3643611910d4 Mon Sep 17 00:00:00 2001 From: Christian Andolf Date: Thu, 9 Jan 2025 14:20:54 +0100 Subject: [PATCH 27/33] fix: added some missing redeem flow translations and fixed copy on button --- .../Blocks/DynamicContent/Rewards/Redeem/Flows/Campaign.tsx | 2 +- .../DynamicContent/Rewards/Redeem/MembershipNumberBadge.tsx | 2 +- i18n/dictionaries/en.json | 2 ++ 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/components/Blocks/DynamicContent/Rewards/Redeem/Flows/Campaign.tsx b/components/Blocks/DynamicContent/Rewards/Redeem/Flows/Campaign.tsx index ca0513bf5..0adba114b 100644 --- a/components/Blocks/DynamicContent/Rewards/Redeem/Flows/Campaign.tsx +++ b/components/Blocks/DynamicContent/Rewards/Redeem/Flows/Campaign.tsx @@ -49,8 +49,8 @@ export default function Campaign({ reward }: { reward: RewardWithRedeem }) { theme="base" intent="primary" > - {reward.operaRewardId} + {intl.formatMessage({ id: "Copy promotion code" })}
diff --git a/components/Blocks/DynamicContent/Rewards/Redeem/MembershipNumberBadge.tsx b/components/Blocks/DynamicContent/Rewards/Redeem/MembershipNumberBadge.tsx index 694a066cb..ab1adae6e 100644 --- a/components/Blocks/DynamicContent/Rewards/Redeem/MembershipNumberBadge.tsx +++ b/components/Blocks/DynamicContent/Rewards/Redeem/MembershipNumberBadge.tsx @@ -14,7 +14,7 @@ export default function MembershipNumberBadge({ return (
- {intl.formatMessage({ id: "Membership ID:" })} {membershipNumber} + {intl.formatMessage({ id: "Membership ID" })}: {membershipNumber}
) diff --git a/i18n/dictionaries/en.json b/i18n/dictionaries/en.json index fee5d1339..782f8c114 100644 --- a/i18n/dictionaries/en.json +++ b/i18n/dictionaries/en.json @@ -104,6 +104,7 @@ "Contact us": "Contact us", "Continue": "Continue", "Copied to clipboard": "Copied to clipboard", + "Copy promotion code": "Copy promotion code", "Copyright all rights reserved": "Scandic AB All rights reserved", "Could not find requested resource": "Could not find requested resource", "Country": "Country", @@ -364,6 +365,7 @@ "Print confirmation": "Print confirmation", "Proceed to login": "Proceed to login", "Proceed to payment method": "Proceed to payment method", + "Promo code": "Promo code", "Provide a payment card in the next step": "Provide a payment card in the next step", "Public price from": "Public price from", "Public transport": "Public transport", From d11b9e8aee5a66d2e5b22148f3b65fd7b549d8d8 Mon Sep 17 00:00:00 2001 From: Erik Tiekstra Date: Wed, 8 Jan 2025 14:18:12 +0100 Subject: [PATCH 28/33] feat(SW-395): Added tracking to header and footer --- .../Footer/Details/SocialLink/index.tsx | 29 +++++++++++++++++++ components/Footer/Details/index.tsx | 24 ++------------- .../Footer/Navigation/MainNav/index.tsx | 5 +++- .../Footer/Navigation/SecondaryNav/index.tsx | 29 +++++++++++-------- components/LoginButton/index.tsx | 4 +-- types/components/footer/socialLink.ts | 6 ++++ utils/tracking.ts | 24 +++++++++++++-- 7 files changed, 83 insertions(+), 38 deletions(-) create mode 100644 components/Footer/Details/SocialLink/index.tsx create mode 100644 types/components/footer/socialLink.ts diff --git a/components/Footer/Details/SocialLink/index.tsx b/components/Footer/Details/SocialLink/index.tsx new file mode 100644 index 000000000..2303c7a24 --- /dev/null +++ b/components/Footer/Details/SocialLink/index.tsx @@ -0,0 +1,29 @@ +"use client" + +import { getIconByIconName } from "@/components/Icons/get-icon-by-icon-name" +import { trackSocialMediaClick } from "@/utils/tracking" + +import type { SocialIconsProps } from "@/types/components/footer/socialIcons" +import type { SocialLinkProps } from "@/types/components/footer/socialLink" +import type { IconName } from "@/types/components/icon" + +function SocialIcon({ iconName }: SocialIconsProps) { + const SocialIcon = getIconByIconName(iconName as IconName) + return SocialIcon ? : {iconName} +} + +export default function SocialLink({ link }: SocialLinkProps) { + const { href, title } = link + return ( + trackSocialMediaClick(title)} + > + + + ) +} diff --git a/components/Footer/Details/index.tsx b/components/Footer/Details/index.tsx index 7827f6043..2471fafca 100644 --- a/components/Footer/Details/index.tsx +++ b/components/Footer/Details/index.tsx @@ -1,6 +1,5 @@ import { getFooter, getLanguageSwitcher } from "@/lib/trpc/memoizedRequests" -import { getIconByIconName } from "@/components/Icons/get-icon-by-icon-name" import Image from "@/components/Image" import LanguageSwitcher from "@/components/LanguageSwitcher" import SkeletonShimmer from "@/components/SkeletonShimmer" @@ -9,16 +8,10 @@ import Footnote from "@/components/TempDesignSystem/Text/Footnote" import { getIntl } from "@/i18n" import { getLang } from "@/i18n/serverContext" +import SocialLink from "./SocialLink" + import styles from "./details.module.css" -import type { SocialIconsProps } from "@/types/components/footer/socialIcons" -import type { IconName } from "@/types/components/icon" - -function SocialIcon({ iconName }: SocialIconsProps) { - const SocialIcon = getIconByIconName(iconName as IconName) - return SocialIcon ? : {iconName} -} - export default async function FooterDetails() { const lang = getLang() const intl = await getIntl() @@ -40,18 +33,7 @@ export default async function FooterDetails() { diff --git a/components/Footer/Navigation/MainNav/index.tsx b/components/Footer/Navigation/MainNav/index.tsx index d9b499603..6e7b4a861 100644 --- a/components/Footer/Navigation/MainNav/index.tsx +++ b/components/Footer/Navigation/MainNav/index.tsx @@ -1,7 +1,10 @@ +"use client" + import { ArrowRightIcon } from "@/components/Icons" import SkeletonShimmer from "@/components/SkeletonShimmer" import Link from "@/components/TempDesignSystem/Link" import Subtitle from "@/components/TempDesignSystem/Text/Subtitle" +import { trackFooterClick } from "@/utils/tracking" import styles from "./mainnav.module.css" @@ -19,9 +22,9 @@ export default function FooterMainNav({ mainLinks }: FooterMainNavProps) { href={link.url} className={styles.mainNavigationLink} target={link.openInNewTab ? "_blank" : undefined} + onClick={() => trackFooterClick("main", link.title)} > {link.title} - diff --git a/components/Footer/Navigation/SecondaryNav/index.tsx b/components/Footer/Navigation/SecondaryNav/index.tsx index 1af85c16c..e65a34c53 100644 --- a/components/Footer/Navigation/SecondaryNav/index.tsx +++ b/components/Footer/Navigation/SecondaryNav/index.tsx @@ -1,8 +1,11 @@ +"use client" + import Image from "@/components/Image" import SkeletonShimmer from "@/components/SkeletonShimmer" import Link from "@/components/TempDesignSystem/Link" import Caption from "@/components/TempDesignSystem/Text/Caption" -import { getLang } from "@/i18n/serverContext" +import useLang from "@/hooks/useLang" +import { trackFooterClick, trackSocialMediaClick } from "@/utils/tracking" import styles from "./secondarynav.module.css" @@ -13,7 +16,7 @@ export default function FooterSecondaryNav({ secondaryLinks, appDownloads, }: FooterSecondaryNavProps) { - const lang = getLang() + const lang = useLang() return (
@@ -28,18 +31,19 @@ export default function FooterSecondaryNav({