From f0c7aa349c671918b38e0a9d0af759548ae86db8 Mon Sep 17 00:00:00 2001 From: Christel Westerberg Date: Mon, 18 Nov 2024 10:54:35 +0100 Subject: [PATCH 01/24] fix: update booking service schemas --- .../(standard)/[step]/page.tsx | 8 ++--- .../BookingConfirmation/Details/index.tsx | 2 +- .../EnterDetails/Payment/index.tsx | 6 ++++ server/routers/booking/input.ts | 36 ++++++++++++++----- server/routers/booking/output.ts | 24 +++++++++++-- .../hotelReservation/selectRate/section.ts | 2 +- 6 files changed, 61 insertions(+), 17 deletions(-) diff --git a/app/[lang]/(live)/(public)/hotelreservation/(standard)/[step]/page.tsx b/app/[lang]/(live)/(public)/hotelreservation/(standard)/[step]/page.tsx index 70aef0ada..ae04c61a6 100644 --- a/app/[lang]/(live)/(public)/hotelreservation/(standard)/[step]/page.tsx +++ b/app/[lang]/(live)/(public)/hotelreservation/(standard)/[step]/page.tsx @@ -107,10 +107,10 @@ export default async function StepPage({ id: "Select payment method", }) - const roomPrice = - user && roomAvailability.memberRate - ? roomAvailability.memberRate?.localPrice.pricePerStay - : roomAvailability.publicRate!.localPrice.pricePerStay + const roomPrice = { + memberPrice: roomAvailability.memberRate?.localPrice.pricePerStay, + publicPrice: roomAvailability.publicRate!.localPrice.pricePerStay, + } return (
diff --git a/components/HotelReservation/BookingConfirmation/Details/index.tsx b/components/HotelReservation/BookingConfirmation/Details/index.tsx index 956ad8e45..5d23e55a8 100644 --- a/components/HotelReservation/BookingConfirmation/Details/index.tsx +++ b/components/HotelReservation/BookingConfirmation/Details/index.tsx @@ -49,7 +49,7 @@ export default async function Details({
  • {intl.formatMessage({ id: "Cancellation policy" })} - N/A + {booking.rateDefinition.cancellationText}
  • {intl.formatMessage({ id: "Rebooking" })} diff --git a/components/HotelReservation/EnterDetails/Payment/index.tsx b/components/HotelReservation/EnterDetails/Payment/index.tsx index 303fc2d5c..84caf0967 100644 --- a/components/HotelReservation/EnterDetails/Payment/index.tsx +++ b/components/HotelReservation/EnterDetails/Payment/index.tsx @@ -77,6 +77,9 @@ export default function Payment({ breakfast, bedType, membershipNo, + join, + dateOfBirth, + zipCode, } = userData const { toDate, fromDate, rooms: rooms, hotel } = roomData @@ -181,6 +184,9 @@ export default function Payment({ phoneNumber, countryCode, membershipNumber: membershipNo, + becomeMember: join, + dateOfBirth, + postalCode: zipCode, }, packages: { breakfast: breakfast !== BreakfastPackageEnum.NO_BREAKFAST, diff --git a/server/routers/booking/input.ts b/server/routers/booking/input.ts index 4c7d802ef..f838d201f 100644 --- a/server/routers/booking/input.ts +++ b/server/routers/booking/input.ts @@ -2,6 +2,15 @@ import { z } from "zod" import { ChildBedTypeEnum } from "@/constants/booking" +const signupSchema = z.discriminatedUnion("becomeMember", [ + z.object({ + dateOfBirth: z.string(), + postalCode: z.string(), + becomeMember: z.literal(true), + }), + z.object({ becomeMember: z.literal(false) }), +]) + const roomsSchema = z.array( z.object({ adults: z.number().int().nonnegative(), @@ -15,14 +24,17 @@ const roomsSchema = z.array( .default([]), rateCode: z.string(), roomTypeCode: z.coerce.string(), - guest: z.object({ - firstName: z.string(), - lastName: z.string(), - email: z.string().email(), - phoneNumber: z.string(), - countryCode: z.string(), - membershipNumber: z.string().optional(), - }), + guest: z.intersection( + z.object({ + firstName: z.string(), + lastName: z.string(), + email: z.string().email(), + phoneNumber: z.string(), + countryCode: z.string(), + membershipNumber: z.string().optional(), + }), + signupSchema + ), smsConfirmationRequested: z.boolean(), packages: z.object({ breakfast: z.boolean(), @@ -30,7 +42,13 @@ const roomsSchema = z.array( petFriendly: z.boolean(), accessibility: z.boolean(), }), - roomPrice: z.number().or(z.string().transform((val) => Number(val))), + roomPrice: z.object({ + publicPrice: z.number().or(z.string().transform((val) => Number(val))), + memberPrice: z + .number() + .or(z.string().transform((val) => Number(val))) + .optional(), + }), }) ) diff --git a/server/routers/booking/output.ts b/server/routers/booking/output.ts index 5fd34ac00..83a185e1e 100644 --- a/server/routers/booking/output.ts +++ b/server/routers/booking/output.ts @@ -15,7 +15,18 @@ export const createBookingSchema = z cancellationNumber: z.string().nullable(), reservationStatus: z.string(), paymentUrl: z.string().nullable(), - metadata: z.any(), // TODO: define metadata schema (not sure what it does) + metadata: z + .object({ + errorCode: z.number().optional(), + errorMessage: z.string().optional(), + priceChangedMetadata: z + .object({ + roomPrice: z.number().optional(), + totalPrice: z.number().optional(), + }) + .optional(), + }) + .nullable(), }), type: z.string(), id: z.string(), @@ -77,7 +88,16 @@ export const bookingConfirmationSchema = z guest: guestSchema, hotelId: z.string(), packages: z.array(packageSchema), - rateCode: z.string(), + rateDefinition: z.object({ + rateCode: z.string(), + title: z.string().nullable(), + breakfastIncluded: z.boolean(), + isMemberRate: z.boolean(), + generalTerms: z.array(z.string()).optional(), + cancellationRule: z.string().optional(), + cancellationText: z.string().optional(), + mustBeGuaranteed: z.boolean(), + }), reservationStatus: z.string(), roomPrice: z.number().int(), roomTypeCode: z.string(), diff --git a/types/components/hotelReservation/selectRate/section.ts b/types/components/hotelReservation/selectRate/section.ts index 578819fb1..05d86ff6c 100644 --- a/types/components/hotelReservation/selectRate/section.ts +++ b/types/components/hotelReservation/selectRate/section.ts @@ -28,7 +28,7 @@ export interface BreakfastSelectionProps extends SectionProps { export interface DetailsProps extends SectionProps {} export interface PaymentProps { - roomPrice: number + roomPrice: { publicPrice: number; memberPrice: number | undefined } otherPaymentOptions: string[] savedCreditCards: CreditCard[] | null mustBeGuaranteed: boolean From cf0173ef57258e774fd1bc9fc4de163ff0ea9667 Mon Sep 17 00:00:00 2001 From: Fredrik Thorsson Date: Wed, 13 Nov 2024 16:49:36 +0100 Subject: [PATCH 02/24] feat(SW-880): add wellness and exercise sidepeek --- .../SidePeeks/WellnessAndExercise/index.tsx | 71 +++++++++++++++++++ .../wellnessAndExercise.module.css | 41 +++++++++++ components/ContentType/HotelPage/index.tsx | 10 +-- i18n/dictionaries/da.json | 5 ++ i18n/dictionaries/de.json | 5 ++ i18n/dictionaries/en.json | 5 ++ i18n/dictionaries/fi.json | 5 ++ i18n/dictionaries/no.json | 5 ++ i18n/dictionaries/sv.json | 5 ++ server/routers/hotels/query.ts | 1 + .../hotelPage/sidepeek/wellnessAndExercise.ts | 5 ++ 11 files changed, 151 insertions(+), 7 deletions(-) create mode 100644 components/ContentType/HotelPage/SidePeeks/WellnessAndExercise/index.tsx create mode 100644 components/ContentType/HotelPage/SidePeeks/WellnessAndExercise/wellnessAndExercise.module.css create mode 100644 types/components/hotelPage/sidepeek/wellnessAndExercise.ts diff --git a/components/ContentType/HotelPage/SidePeeks/WellnessAndExercise/index.tsx b/components/ContentType/HotelPage/SidePeeks/WellnessAndExercise/index.tsx new file mode 100644 index 000000000..642664cee --- /dev/null +++ b/components/ContentType/HotelPage/SidePeeks/WellnessAndExercise/index.tsx @@ -0,0 +1,71 @@ +import { wellnessAndExercise } from "@/constants/routes/hotelPageParams" + +import Image from "@/components/Image" +import Button from "@/components/TempDesignSystem/Button" +import Link from "@/components/TempDesignSystem/Link" +import SidePeek from "@/components/TempDesignSystem/SidePeek" +import Body from "@/components/TempDesignSystem/Text/Body" +import Subtitle from "@/components/TempDesignSystem/Text/Subtitle" +import Title from "@/components/TempDesignSystem/Text/Title" +import { getIntl } from "@/i18n" +import { getLang } from "@/i18n/serverContext" + +import styles from "./wellnessAndExercise.module.css" + +import { WellnessAndExerciseSidePeekProps } from "@/types/components/hotelPage/sidepeek/wellnessAndExercise" + +export default async function WellnessAndExerciseSidePeek({ + healthFacilities, +}: WellnessAndExerciseSidePeekProps) { + const intl = await getIntl() + const lang = getLang() + + return ( + +
    + {healthFacilities.map((facility) => ( +
    + {facility.content.images[0]?.metaData.altText} +
    + + {facility.type} + +
    + + {intl.formatMessage({ id: " Opening Hours" })} + + + {facility.openingDetails.openingHours.ordinary.alwaysOpen + ? `${intl.formatMessage({ id: "Mon-Fri" })} ${intl.formatMessage({ id: "Always open" })}` + : `${intl.formatMessage({ id: "Mon-Fri" })} ${facility.openingDetails.openingHours.ordinary.openingTime}-${facility.openingDetails.openingHours.ordinary.closingTime}`} + + + {facility.openingDetails.openingHours.weekends.alwaysOpen + ? `${intl.formatMessage({ id: "Sat-Sun" })} ${intl.formatMessage({ id: "Always open" })}` + : `${intl.formatMessage({ id: "Sat-Sun" })} ${facility.openingDetails.openingHours.weekends.openingTime}-${facility.openingDetails.openingHours.weekends.closingTime}`} + + Placeholder text +
    +
    +
    + ))} +
    +
    + +
    +
    + ) +} diff --git a/components/ContentType/HotelPage/SidePeeks/WellnessAndExercise/wellnessAndExercise.module.css b/components/ContentType/HotelPage/SidePeeks/WellnessAndExercise/wellnessAndExercise.module.css new file mode 100644 index 000000000..0c33da7cd --- /dev/null +++ b/components/ContentType/HotelPage/SidePeeks/WellnessAndExercise/wellnessAndExercise.module.css @@ -0,0 +1,41 @@ +.wrapper { + display: flex; + flex-direction: column; + gap: var(--Spacing-x4); + margin-bottom: calc( + var(--Spacing-x4) * 2 + 80px + ); /* Creates space between the wrapper and buttonContainer */ +} + +.content { + display: flex; + flex-direction: column; + gap: var(--Spacing-x2); +} + +.image { + width: 100%; + height: 270px; + object-fit: cover; + border-radius: var(--Corner-radius-Medium); +} + +.information { + display: flex; + flex-direction: column; + gap: var(--Spacing-x-one-and-half); +} + +.body { + margin-top: var(--Spacing-x1); +} + +.buttonContainer { + background-color: var(--Base-Background-Primary-Normal); + border-top: 1px solid var(--Base-Border-Subtle); + padding: var(--Spacing-x4) var(--Spacing-x2); + width: 100%; + position: absolute; + left: 0; + bottom: 0; +} diff --git a/components/ContentType/HotelPage/index.tsx b/components/ContentType/HotelPage/index.tsx index 97969867b..eb9d1dd37 100644 --- a/components/ContentType/HotelPage/index.tsx +++ b/components/ContentType/HotelPage/index.tsx @@ -16,6 +16,7 @@ import MapCard from "./Map/MapCard" import MapWithCardWrapper from "./Map/MapWithCard" import MobileMapToggle from "./Map/MobileMapToggle" import StaticMap from "./Map/StaticMap" +import WellnessAndExerciseSidePeek from "./SidePeeks/WellnessAndExercise" import AmenitiesList from "./AmenitiesList" import Facilities from "./Facilities" import IntroSection from "./IntroSection" @@ -52,6 +53,7 @@ export default async function HotelPage() { facilities, faq, alerts, + healthFacilities, } = hotelData const topThreePois = pointsOfInterest.slice(0, 3) @@ -145,13 +147,7 @@ export default async function HotelPage() { {/* TODO */} Restaurant & Bar - - {/* TODO */} - Wellness & Exercise - + Date: Thu, 14 Nov 2024 11:35:02 +0100 Subject: [PATCH 03/24] feat(SW-880): add function for type mapping --- .../HotelPage/SidePeeks/Utils/getType.ts | 17 +++++++++++++++++ .../SidePeeks/WellnessAndExercise/index.tsx | 12 +++++++----- i18n/dictionaries/da.json | 4 ++++ i18n/dictionaries/de.json | 4 ++++ i18n/dictionaries/en.json | 4 ++++ i18n/dictionaries/fi.json | 4 ++++ i18n/dictionaries/no.json | 4 ++++ i18n/dictionaries/sv.json | 4 ++++ 8 files changed, 48 insertions(+), 5 deletions(-) create mode 100644 components/ContentType/HotelPage/SidePeeks/Utils/getType.ts diff --git a/components/ContentType/HotelPage/SidePeeks/Utils/getType.ts b/components/ContentType/HotelPage/SidePeeks/Utils/getType.ts new file mode 100644 index 000000000..33e39b220 --- /dev/null +++ b/components/ContentType/HotelPage/SidePeeks/Utils/getType.ts @@ -0,0 +1,17 @@ +import { getIntl } from "@/i18n" + +export async function getType(type: string) { + const intl = await getIntl() + switch (type) { + case "OutdoorPool": + return intl.formatMessage({ id: "Outdoor pool" }) + case "Sauna": + return intl.formatMessage({ id: "Sauna" }) + case "Relax": + return intl.formatMessage({ id: "Relax" }) + case "Gym": + return intl.formatMessage({ id: "Gym" }) + default: + return type + } +} diff --git a/components/ContentType/HotelPage/SidePeeks/WellnessAndExercise/index.tsx b/components/ContentType/HotelPage/SidePeeks/WellnessAndExercise/index.tsx index 642664cee..ee9f01b85 100644 --- a/components/ContentType/HotelPage/SidePeeks/WellnessAndExercise/index.tsx +++ b/components/ContentType/HotelPage/SidePeeks/WellnessAndExercise/index.tsx @@ -10,6 +10,8 @@ import Title from "@/components/TempDesignSystem/Text/Title" import { getIntl } from "@/i18n" import { getLang } from "@/i18n/serverContext" +import { getType } from "../Utils/getType" + import styles from "./wellnessAndExercise.module.css" import { WellnessAndExerciseSidePeekProps } from "@/types/components/hotelPage/sidepeek/wellnessAndExercise" @@ -36,19 +38,19 @@ export default async function WellnessAndExerciseSidePeek({ width={200} />
    - - {facility.type} + + {getType(facility.type)}
    - + {intl.formatMessage({ id: " Opening Hours" })} - + {facility.openingDetails.openingHours.ordinary.alwaysOpen ? `${intl.formatMessage({ id: "Mon-Fri" })} ${intl.formatMessage({ id: "Always open" })}` : `${intl.formatMessage({ id: "Mon-Fri" })} ${facility.openingDetails.openingHours.ordinary.openingTime}-${facility.openingDetails.openingHours.ordinary.closingTime}`} - + {facility.openingDetails.openingHours.weekends.alwaysOpen ? `${intl.formatMessage({ id: "Sat-Sun" })} ${intl.formatMessage({ id: "Always open" })}` : `${intl.formatMessage({ id: "Sat-Sun" })} ${facility.openingDetails.openingHours.weekends.openingTime}-${facility.openingDetails.openingHours.weekends.closingTime}`} diff --git a/i18n/dictionaries/da.json b/i18n/dictionaries/da.json index 44e24d47d..232348983 100644 --- a/i18n/dictionaries/da.json +++ b/i18n/dictionaries/da.json @@ -143,6 +143,7 @@ "Guarantee booking with credit card": "Garantere booking med kreditkort", "Guest information": "Gæsteinformation", "Guests & Rooms": "Gæster & værelser", + "Gym": "Fitnesscenter", "Hi": "Hei", "Highest level": "Højeste niveau", "Hospital": "Hospital", @@ -238,6 +239,7 @@ "Open menu": "Åbn menuen", "Open my pages menu": "Åbn mine sider menuen", "Opening Hours": "Åbningstider", + "Outdoor pool": "Udendørs pool", "Overview": "Oversigt", "PETR": "Kæledyr", "Parking": "Parkering", @@ -278,6 +280,7 @@ "Read more & book a table": "Read more & book a table", "Read more about the hotel": "Læs mere om hotellet", "Read more about wellness & exercise": "Read more about wellness & exercise", + "Relax": "Slap af", "Remove card from member profile": "Fjern kortet fra medlemsprofilen", "Request bedtype": "Anmod om sengetype", "Restaurant": "{count, plural, one {#Restaurant} other {#Restaurants}}", @@ -290,6 +293,7 @@ "Rooms": "Værelser", "Rooms & Guests": "Værelser & gæster", "Sat-Sun": "Lør-Søn", + "Sauna": "Sauna", "Sauna and gym": "Sauna and gym", "Save": "Gemme", "Scandic Friends Mastercard": "Scandic Friends Mastercard", diff --git a/i18n/dictionaries/de.json b/i18n/dictionaries/de.json index a8e785126..d025df242 100644 --- a/i18n/dictionaries/de.json +++ b/i18n/dictionaries/de.json @@ -143,6 +143,7 @@ "Guarantee booking with credit card": "Buchung mit Kreditkarte garantieren", "Guest information": "Informationen für Gäste", "Guests & Rooms": "Gäste & Zimmer", + "Gym": "Fitnessstudio", "Hi": "Hallo", "Highest level": "Höchstes Level", "Hospital": "Krankenhaus", @@ -236,6 +237,7 @@ "Open menu": "Menü öffnen", "Open my pages menu": "Meine Seiten Menü öffnen", "Opening Hours": "Öffnungszeiten", + "Outdoor pool": "Außenpool", "Overview": "Übersicht", "PETR": "Haustier", "Parking": "Parken", @@ -276,6 +278,7 @@ "Read more & book a table": "Read more & book a table", "Read more about the hotel": "Lesen Sie mehr über das Hotel", "Read more about wellness & exercise": "Read more about wellness & exercise", + "Relax": "Entspannen", "Remove card from member profile": "Karte aus dem Mitgliedsprofil entfernen", "Request bedtype": "Bettentyp anfragen", "Restaurant": "{count, plural, one {#Restaurant} other {#Restaurants}}", @@ -289,6 +292,7 @@ "Rooms": "Räume", "Rooms & Guests": "Zimmer & Gäste", "Sat-Sun": "Sa-So", + "Sauna": "Sauna", "Sauna and gym": "Sauna and gym", "Save": "Speichern", "Scandic Friends Mastercard": "Scandic Friends Mastercard", diff --git a/i18n/dictionaries/en.json b/i18n/dictionaries/en.json index ba17eaf15..c7d7336a5 100644 --- a/i18n/dictionaries/en.json +++ b/i18n/dictionaries/en.json @@ -155,6 +155,7 @@ "Guest": "Guest", "Guest information": "Guest information", "Guests & Rooms": "Guests & Rooms", + "Gym": "Gym", "Hi": "Hi", "Highest level": "Highest level", "Hospital": "Hospital", @@ -255,6 +256,7 @@ "Open menu": "Open menu", "Open my pages menu": "Open my pages menu", "Opening Hours": "Opening Hours", + "Outdoor pool": "Outdoor pool", "Overview": "Overview", "PETR": "Pet", "Parking": "Parking", @@ -304,6 +306,7 @@ "Read more about wellness & exercise": "Read more about wellness & exercise", "Rebooking": "Rebooking", "Reference #{bookingNr}": "Reference #{bookingNr}", + "Relax": "Relax", "Remove card from member profile": "Remove card from member profile", "Request bedtype": "Request bedtype", "Restaurant": "{count, plural, one {#Restaurant} other {#Restaurants}}", @@ -317,6 +320,7 @@ "Rooms": "Rooms", "Rooms & Guests": "Rooms & Guests", "Sat-Sun": "Sat-Sun", + "Sauna": "Sauna", "Sauna and gym": "Sauna and gym", "Save": "Save", "Save card to profile": "Save card to profile", diff --git a/i18n/dictionaries/fi.json b/i18n/dictionaries/fi.json index 2210591e5..add731307 100644 --- a/i18n/dictionaries/fi.json +++ b/i18n/dictionaries/fi.json @@ -143,6 +143,7 @@ "Guarantee booking with credit card": "Varmista varaus luottokortilla", "Guest information": "Vieraan tiedot", "Guests & Rooms": "Vieraat & Huoneet", + "Gym": "Kuntosali", "Hi": "Hi", "Highest level": "Korkein taso", "Hospital": "Sairaala", @@ -238,6 +239,7 @@ "Open menu": "Avaa valikko", "Open my pages menu": "Avaa omat sivut -valikko", "Opening Hours": "Aukioloajat", + "Outdoor pool": "Ulkouima-allas", "Overview": "Yleiskatsaus", "PETR": "Lemmikki", "Parking": "Pysäköinti", @@ -278,6 +280,7 @@ "Read more & book a table": "Read more & book a table", "Read more about the hotel": "Lue lisää hotellista", "Read more about wellness & exercise": "Read more about wellness & exercise", + "Relax": "Rentoutua", "Remove card from member profile": "Poista kortti jäsenprofiilista", "Request bedtype": "Pyydä sänkytyyppiä", "Restaurant": "{count, plural, one {#Ravintola} other {#Restaurants}}", @@ -291,6 +294,7 @@ "Rooms & Guests": "Huoneet & Vieraat", "Rooms & Guestss": "Huoneet & Vieraat", "Sat-Sun": "La-Su", + "Sauna": "Sauna", "Sauna and gym": "Sauna and gym", "Save": "Tallenna", "Scandic Friends Mastercard": "Scandic Friends Mastercard", diff --git a/i18n/dictionaries/no.json b/i18n/dictionaries/no.json index 5482b868a..4c6415e42 100644 --- a/i18n/dictionaries/no.json +++ b/i18n/dictionaries/no.json @@ -142,6 +142,7 @@ "Guarantee booking with credit card": "Garantere booking med kredittkort", "Guest information": "Informasjon til gjester", "Guests & Rooms": "Gjester & rom", + "Gym": "Treningsstudio", "Hi": "Hei", "Highest level": "Høyeste nivå", "Hospital": "Sykehus", @@ -236,6 +237,7 @@ "Open menu": "Åpne menyen", "Open my pages menu": "Åpne mine sider menyen", "Opening Hours": "Åpningstider", + "Outdoor pool": "Utendørs basseng", "Overview": "Oversikt", "PETR": "Kjæledyr", "Parking": "Parkering", @@ -276,6 +278,7 @@ "Read more & book a table": "Read more & book a table", "Read more about the hotel": "Les mer om hotellet", "Read more about wellness & exercise": "Read more about wellness & exercise", + "Relax": "Slappe av", "Remove card from member profile": "Fjern kortet fra medlemsprofilen", "Request bedtype": "Be om sengetype", "Restaurant": "{count, plural, one {#Restaurant} other {#Restaurants}}", @@ -288,6 +291,7 @@ "Rooms": "Rom", "Rooms & Guests": "Rom og gjester", "Sat-Sun": "Lør-Søn", + "Sauna": "Badstue", "Sauna and gym": "Sauna and gym", "Save": "Lagre", "Scandic Friends Mastercard": "Scandic Friends Mastercard", diff --git a/i18n/dictionaries/sv.json b/i18n/dictionaries/sv.json index 612e84812..47c8096ca 100644 --- a/i18n/dictionaries/sv.json +++ b/i18n/dictionaries/sv.json @@ -142,6 +142,7 @@ "Guarantee booking with credit card": "Garantera bokning med kreditkort", "Guest information": "Information till gästerna", "Guests & Rooms": "Gäster & rum", + "Gym": "Gym", "Hi": "Hej", "Highest level": "Högsta nivå", "Hospital": "Sjukhus", @@ -236,6 +237,7 @@ "Open menu": "Öppna menyn", "Open my pages menu": "Öppna mina sidor menyn", "Opening Hours": "Öppettider", + "Outdoor pool": "Utomhuspool", "Overview": "Översikt", "PETR": "Husdjur", "Parking": "Parkering", @@ -276,6 +278,7 @@ "Read more & book a table": "Read more & book a table", "Read more about the hotel": "Läs mer om hotellet", "Read more about wellness & exercise": "Read more about wellness & exercise", + "Relax": "Koppla av", "Remove card from member profile": "Ta bort kortet från medlemsprofilen", "Request bedtype": "Request bedtype", "Restaurant": "{count, plural, one {#Restaurang} other {#Restauranger}}", @@ -288,6 +291,7 @@ "Rooms": "Rum", "Rooms & Guests": "Rum och gäster", "Sat-Sun": "Lör-Sön", + "Sauna": "Bastu", "Sauna and gym": "Sauna and gym", "Save": "Spara", "Scandic Friends Mastercard": "Scandic Friends Mastercard", From c7327e07bf045c27d5700eb1f4912a7f8929fb62 Mon Sep 17 00:00:00 2001 From: Fredrik Thorsson Date: Thu, 14 Nov 2024 14:10:51 +0100 Subject: [PATCH 04/24] feat(SW-880): refactor getType --- .../HotelPage/SidePeeks/Utils/getType.ts | 15 +++++++++++---- .../SidePeeks/WellnessAndExercise/index.tsx | 4 +++- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/components/ContentType/HotelPage/SidePeeks/Utils/getType.ts b/components/ContentType/HotelPage/SidePeeks/Utils/getType.ts index 33e39b220..47cbf00da 100644 --- a/components/ContentType/HotelPage/SidePeeks/Utils/getType.ts +++ b/components/ContentType/HotelPage/SidePeeks/Utils/getType.ts @@ -2,15 +2,22 @@ import { getIntl } from "@/i18n" export async function getType(type: string) { const intl = await getIntl() + + /* TODO: Get full list of types */ + const outdoorPool = intl.formatMessage({ id: "Outdoor pool" }) + const sauna = intl.formatMessage({ id: "Sauna" }) + const relax = intl.formatMessage({ id: "Relax" }) + const gym = intl.formatMessage({ id: "Gym" }) + switch (type) { case "OutdoorPool": - return intl.formatMessage({ id: "Outdoor pool" }) + return outdoorPool case "Sauna": - return intl.formatMessage({ id: "Sauna" }) + return sauna case "Relax": - return intl.formatMessage({ id: "Relax" }) + return relax case "Gym": - return intl.formatMessage({ id: "Gym" }) + return gym default: return type } diff --git a/components/ContentType/HotelPage/SidePeeks/WellnessAndExercise/index.tsx b/components/ContentType/HotelPage/SidePeeks/WellnessAndExercise/index.tsx index ee9f01b85..6bd73d703 100644 --- a/components/ContentType/HotelPage/SidePeeks/WellnessAndExercise/index.tsx +++ b/components/ContentType/HotelPage/SidePeeks/WellnessAndExercise/index.tsx @@ -55,7 +55,9 @@ export default async function WellnessAndExerciseSidePeek({ ? `${intl.formatMessage({ id: "Sat-Sun" })} ${intl.formatMessage({ id: "Always open" })}` : `${intl.formatMessage({ id: "Sat-Sun" })} ${facility.openingDetails.openingHours.weekends.openingTime}-${facility.openingDetails.openingHours.weekends.closingTime}`} - Placeholder text + + {/* TODO: Determine what details should be displayed about the facility type */} +
    From 7a8ce0d8f6a4ae7c35434821d11b697a6b29ce69 Mon Sep 17 00:00:00 2001 From: Fredrik Thorsson Date: Thu, 14 Nov 2024 14:21:01 +0100 Subject: [PATCH 05/24] feat(SW-880): add import type --- components/ContentType/HotelPage/SidePeeks/Utils/getType.ts | 2 +- .../HotelPage/SidePeeks/WellnessAndExercise/index.tsx | 6 +++--- types/components/hotelPage/sidepeek/wellnessAndExercise.ts | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/components/ContentType/HotelPage/SidePeeks/Utils/getType.ts b/components/ContentType/HotelPage/SidePeeks/Utils/getType.ts index 47cbf00da..9cab458c7 100644 --- a/components/ContentType/HotelPage/SidePeeks/Utils/getType.ts +++ b/components/ContentType/HotelPage/SidePeeks/Utils/getType.ts @@ -1,6 +1,6 @@ import { getIntl } from "@/i18n" -export async function getType(type: string) { +export async function getFacilityType(type: string) { const intl = await getIntl() /* TODO: Get full list of types */ diff --git a/components/ContentType/HotelPage/SidePeeks/WellnessAndExercise/index.tsx b/components/ContentType/HotelPage/SidePeeks/WellnessAndExercise/index.tsx index 6bd73d703..dee9d73e1 100644 --- a/components/ContentType/HotelPage/SidePeeks/WellnessAndExercise/index.tsx +++ b/components/ContentType/HotelPage/SidePeeks/WellnessAndExercise/index.tsx @@ -10,11 +10,11 @@ import Title from "@/components/TempDesignSystem/Text/Title" import { getIntl } from "@/i18n" import { getLang } from "@/i18n/serverContext" -import { getType } from "../Utils/getType" +import { getFacilityType } from "../Utils/getType" import styles from "./wellnessAndExercise.module.css" -import { WellnessAndExerciseSidePeekProps } from "@/types/components/hotelPage/sidepeek/wellnessAndExercise" +import type { WellnessAndExerciseSidePeekProps } from "@/types/components/hotelPage/sidepeek/wellnessAndExercise" export default async function WellnessAndExerciseSidePeek({ healthFacilities, @@ -39,7 +39,7 @@ export default async function WellnessAndExerciseSidePeek({ />
    - {getType(facility.type)} + {getFacilityType(facility.type)}
    diff --git a/types/components/hotelPage/sidepeek/wellnessAndExercise.ts b/types/components/hotelPage/sidepeek/wellnessAndExercise.ts index f64ccd003..a75499f2d 100644 --- a/types/components/hotelPage/sidepeek/wellnessAndExercise.ts +++ b/types/components/hotelPage/sidepeek/wellnessAndExercise.ts @@ -1,4 +1,4 @@ -import { Hotel } from "@/types/hotel" +import type { Hotel } from "@/types/hotel" export type WellnessAndExerciseSidePeekProps = { healthFacilities: Hotel["healthFacilities"] From 5377a43f5677f7dc10f66851f57c5a11712baf5b Mon Sep 17 00:00:00 2001 From: Fredrik Thorsson Date: Thu, 14 Nov 2024 22:38:53 +0100 Subject: [PATCH 06/24] feat(SW-880): add translations --- .../ContentType/HotelPage/SidePeeks/Utils/getType.ts | 7 ++++++- 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 ++ 7 files changed, 18 insertions(+), 1 deletion(-) diff --git a/components/ContentType/HotelPage/SidePeeks/Utils/getType.ts b/components/ContentType/HotelPage/SidePeeks/Utils/getType.ts index 9cab458c7..1a6f87a63 100644 --- a/components/ContentType/HotelPage/SidePeeks/Utils/getType.ts +++ b/components/ContentType/HotelPage/SidePeeks/Utils/getType.ts @@ -3,21 +3,26 @@ import { getIntl } from "@/i18n" export async function getFacilityType(type: string) { const intl = await getIntl() - /* TODO: Get full list of types */ const outdoorPool = intl.formatMessage({ id: "Outdoor pool" }) + const indoorPool = intl.formatMessage({ id: "Indoor pool" }) const sauna = intl.formatMessage({ id: "Sauna" }) const relax = intl.formatMessage({ id: "Relax" }) const gym = intl.formatMessage({ id: "Gym" }) + const jacuzzi = intl.formatMessage({ id: "Jacuzzi" }) switch (type) { case "OutdoorPool": return outdoorPool + case "IndoorPool": + return indoorPool case "Sauna": return sauna case "Relax": return relax case "Gym": return gym + case "Jacuzzi": + return jacuzzi default: return type } diff --git a/i18n/dictionaries/da.json b/i18n/dictionaries/da.json index 232348983..14c46b7be 100644 --- a/i18n/dictionaries/da.json +++ b/i18n/dictionaries/da.json @@ -160,7 +160,9 @@ "In crib": "i tremmeseng", "In extra bed": "i ekstra seng", "Included": "Inkluderet", + "Indoor pool": "Indendørs pool", "It is not posible to manage your communication preferences right now, please try again later or contact support if the problem persists.": "Det er ikke muligt at administrere dine kommunikationspræferencer lige nu, prøv venligst igen senere eller kontakt support, hvis problemet fortsætter.", + "Jacuzzi": "Jacuzzi", "Join Scandic Friends": "Tilmeld dig Scandic Friends", "Join at no cost": "Tilmeld dig uden omkostninger", "Join or log in while booking for member pricing.": "Tilmeld dig eller log ind under booking for medlemspris.", diff --git a/i18n/dictionaries/de.json b/i18n/dictionaries/de.json index d025df242..403f0db22 100644 --- a/i18n/dictionaries/de.json +++ b/i18n/dictionaries/de.json @@ -160,7 +160,9 @@ "In crib": "im Kinderbett", "In extra bed": "im zusätzlichen Bett", "Included": "Iinklusive", + "Indoor pool": "Innenpool", "It is not posible to manage your communication preferences right now, please try again later or contact support if the problem persists.": "Es ist derzeit nicht möglich, Ihre Kommunikationseinstellungen zu verwalten. Bitte versuchen Sie es später erneut oder wenden Sie sich an den Support, wenn das Problem weiterhin besteht.", + "Jacuzzi": "Whirlpool", "Join Scandic Friends": "Treten Sie Scandic Friends bei", "Join at no cost": "Kostenlos beitreten", "Join or log in while booking for member pricing.": "Treten Sie Scandic Friends bei oder loggen Sie sich ein, um den Mitgliederpreis zu erhalten.", diff --git a/i18n/dictionaries/en.json b/i18n/dictionaries/en.json index c7d7336a5..2bb99e5f2 100644 --- a/i18n/dictionaries/en.json +++ b/i18n/dictionaries/en.json @@ -172,7 +172,9 @@ "In crib": "In crib", "In extra bed": "In extra bed", "Included": "Included", + "Indoor pool": "Indoor pool", "It is not posible to manage your communication preferences right now, please try again later or contact support if the problem persists.": "It is not posible to manage your communication preferences right now, please try again later or contact support if the problem persists.", + "Jacuzzi": "Jacuzzi", "Join Scandic Friends": "Join Scandic Friends", "Join at no cost": "Join at no cost", "Join or log in while booking for member pricing.": "Join or log in while booking for member pricing.", diff --git a/i18n/dictionaries/fi.json b/i18n/dictionaries/fi.json index add731307..bfaa69ec6 100644 --- a/i18n/dictionaries/fi.json +++ b/i18n/dictionaries/fi.json @@ -160,7 +160,9 @@ "In crib": "Pinnasängyssä", "In extra bed": "Oma vuodepaikka", "Included": "Sisälly hintaan", + "Indoor pool": "Sisäuima-allas", "It is not posible to manage your communication preferences right now, please try again later or contact support if the problem persists.": "Viestintäasetuksiasi ei voi hallita juuri nyt. Yritä myöhemmin uudelleen tai ota yhteyttä tukeen, jos ongelma jatkuu.", + "Jacuzzi": "Poreallas", "Join Scandic Friends": "Liity jäseneksi", "Join at no cost": "Liity maksutta", "Join or log in while booking for member pricing.": "Liity tai kirjaudu sisään, kun varaat jäsenhinnan.", diff --git a/i18n/dictionaries/no.json b/i18n/dictionaries/no.json index 4c6415e42..b593f1c83 100644 --- a/i18n/dictionaries/no.json +++ b/i18n/dictionaries/no.json @@ -158,7 +158,9 @@ "In crib": "i sprinkelseng", "In extra bed": "i ekstraseng", "Included": "Inkludert", + "Indoor pool": "Innendørs basseng", "It is not posible to manage your communication preferences right now, please try again later or contact support if the problem persists.": "Det er ikke mulig å administrere kommunikasjonspreferansene dine akkurat nå, prøv igjen senere eller kontakt support hvis problemet vedvarer.", + "Jacuzzi": "Boblebad", "Join Scandic Friends": "Bli med i Scandic Friends", "Join at no cost": "Bli med uten kostnad", "Join or log in while booking for member pricing.": "Bli med eller logg inn under bestilling for medlemspris.", diff --git a/i18n/dictionaries/sv.json b/i18n/dictionaries/sv.json index 47c8096ca..05c775020 100644 --- a/i18n/dictionaries/sv.json +++ b/i18n/dictionaries/sv.json @@ -158,7 +158,9 @@ "In crib": "I spjälsäng", "In extra bed": "Egen sängplats", "Included": "Inkluderad", + "Indoor pool": "Inomhuspool", "It is not posible to manage your communication preferences right now, please try again later or contact support if the problem persists.": "Det gick inte att hantera dina kommunikationsinställningar just nu, försök igen senare eller kontakta supporten om problemet kvarstår.", + "Jacuzzi": "Jacuzzi", "Join Scandic Friends": "Gå med i Scandic Friends", "Join at no cost": "Gå med utan kostnad", "Join or log in while booking for member pricing.": "Bli medlem eller logga in när du bokar för medlemspriser.", From 4002d63c5976b10ad65b4bdcf78bc302a885c7ea Mon Sep 17 00:00:00 2001 From: Fredrik Thorsson Date: Fri, 15 Nov 2024 14:21:52 +0100 Subject: [PATCH 07/24] feat(SW-880): update facility type rendering --- .../HotelPage/SidePeeks/Utils/getType.ts | 29 ------------- .../SidePeeks/WellnessAndExercise/index.tsx | 42 ++++++++++--------- i18n/dictionaries/da.json | 4 +- i18n/dictionaries/de.json | 4 +- i18n/dictionaries/en.json | 4 +- i18n/dictionaries/fi.json | 4 +- i18n/dictionaries/no.json | 4 +- i18n/dictionaries/sv.json | 4 +- .../hotelPage/sidepeek/wellnessAndExercise.ts | 1 + 9 files changed, 35 insertions(+), 61 deletions(-) delete mode 100644 components/ContentType/HotelPage/SidePeeks/Utils/getType.ts diff --git a/components/ContentType/HotelPage/SidePeeks/Utils/getType.ts b/components/ContentType/HotelPage/SidePeeks/Utils/getType.ts deleted file mode 100644 index 1a6f87a63..000000000 --- a/components/ContentType/HotelPage/SidePeeks/Utils/getType.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { getIntl } from "@/i18n" - -export async function getFacilityType(type: string) { - const intl = await getIntl() - - const outdoorPool = intl.formatMessage({ id: "Outdoor pool" }) - const indoorPool = intl.formatMessage({ id: "Indoor pool" }) - const sauna = intl.formatMessage({ id: "Sauna" }) - const relax = intl.formatMessage({ id: "Relax" }) - const gym = intl.formatMessage({ id: "Gym" }) - const jacuzzi = intl.formatMessage({ id: "Jacuzzi" }) - - switch (type) { - case "OutdoorPool": - return outdoorPool - case "IndoorPool": - return indoorPool - case "Sauna": - return sauna - case "Relax": - return relax - case "Gym": - return gym - case "Jacuzzi": - return jacuzzi - default: - return type - } -} diff --git a/components/ContentType/HotelPage/SidePeeks/WellnessAndExercise/index.tsx b/components/ContentType/HotelPage/SidePeeks/WellnessAndExercise/index.tsx index dee9d73e1..5314e3f18 100644 --- a/components/ContentType/HotelPage/SidePeeks/WellnessAndExercise/index.tsx +++ b/components/ContentType/HotelPage/SidePeeks/WellnessAndExercise/index.tsx @@ -10,14 +10,13 @@ import Title from "@/components/TempDesignSystem/Text/Title" import { getIntl } from "@/i18n" import { getLang } from "@/i18n/serverContext" -import { getFacilityType } from "../Utils/getType" - import styles from "./wellnessAndExercise.module.css" import type { WellnessAndExerciseSidePeekProps } from "@/types/components/hotelPage/sidepeek/wellnessAndExercise" export default async function WellnessAndExerciseSidePeek({ healthFacilities, + buttonUrl, }: WellnessAndExerciseSidePeekProps) { const intl = await getIntl() const lang = getLang() @@ -30,16 +29,20 @@ export default async function WellnessAndExerciseSidePeek({
    {healthFacilities.map((facility) => (
    - {facility.content.images[0]?.metaData.altText} + {facility.content.images[0]?.imageSizes.medium && ( + {facility.content.images[0].metaData.altText + )}
    - {getFacilityType(facility.type)} + + {intl.formatMessage({ id: `${facility.type}` })} +
    @@ -55,21 +58,20 @@ export default async function WellnessAndExerciseSidePeek({ ? `${intl.formatMessage({ id: "Sat-Sun" })} ${intl.formatMessage({ id: "Always open" })}` : `${intl.formatMessage({ id: "Sat-Sun" })} ${facility.openingDetails.openingHours.weekends.openingTime}-${facility.openingDetails.openingHours.weekends.closingTime}`} - - {/* TODO: Determine what details should be displayed about the facility type */} -
    ))}
    -
    - -
    + {buttonUrl && ( +
    + +
    + )} ) } diff --git a/i18n/dictionaries/da.json b/i18n/dictionaries/da.json index 14c46b7be..c453589ea 100644 --- a/i18n/dictionaries/da.json +++ b/i18n/dictionaries/da.json @@ -160,7 +160,7 @@ "In crib": "i tremmeseng", "In extra bed": "i ekstra seng", "Included": "Inkluderet", - "Indoor pool": "Indendørs pool", + "IndoorPool": "Indendørs pool", "It is not posible to manage your communication preferences right now, please try again later or contact support if the problem persists.": "Det er ikke muligt at administrere dine kommunikationspræferencer lige nu, prøv venligst igen senere eller kontakt support, hvis problemet fortsætter.", "Jacuzzi": "Jacuzzi", "Join Scandic Friends": "Tilmeld dig Scandic Friends", @@ -241,7 +241,7 @@ "Open menu": "Åbn menuen", "Open my pages menu": "Åbn mine sider menuen", "Opening Hours": "Åbningstider", - "Outdoor pool": "Udendørs pool", + "OutdoorPool": "Udendørs pool", "Overview": "Oversigt", "PETR": "Kæledyr", "Parking": "Parkering", diff --git a/i18n/dictionaries/de.json b/i18n/dictionaries/de.json index 403f0db22..04d750494 100644 --- a/i18n/dictionaries/de.json +++ b/i18n/dictionaries/de.json @@ -160,7 +160,7 @@ "In crib": "im Kinderbett", "In extra bed": "im zusätzlichen Bett", "Included": "Iinklusive", - "Indoor pool": "Innenpool", + "IndoorPool": "Innenpool", "It is not posible to manage your communication preferences right now, please try again later or contact support if the problem persists.": "Es ist derzeit nicht möglich, Ihre Kommunikationseinstellungen zu verwalten. Bitte versuchen Sie es später erneut oder wenden Sie sich an den Support, wenn das Problem weiterhin besteht.", "Jacuzzi": "Whirlpool", "Join Scandic Friends": "Treten Sie Scandic Friends bei", @@ -239,7 +239,7 @@ "Open menu": "Menü öffnen", "Open my pages menu": "Meine Seiten Menü öffnen", "Opening Hours": "Öffnungszeiten", - "Outdoor pool": "Außenpool", + "OutdoorPool": "Außenpool", "Overview": "Übersicht", "PETR": "Haustier", "Parking": "Parken", diff --git a/i18n/dictionaries/en.json b/i18n/dictionaries/en.json index 2bb99e5f2..09cf02ec9 100644 --- a/i18n/dictionaries/en.json +++ b/i18n/dictionaries/en.json @@ -172,7 +172,7 @@ "In crib": "In crib", "In extra bed": "In extra bed", "Included": "Included", - "Indoor pool": "Indoor pool", + "IndoorPool": "Indoor pool", "It is not posible to manage your communication preferences right now, please try again later or contact support if the problem persists.": "It is not posible to manage your communication preferences right now, please try again later or contact support if the problem persists.", "Jacuzzi": "Jacuzzi", "Join Scandic Friends": "Join Scandic Friends", @@ -258,7 +258,7 @@ "Open menu": "Open menu", "Open my pages menu": "Open my pages menu", "Opening Hours": "Opening Hours", - "Outdoor pool": "Outdoor pool", + "OutdoorPool": "Outdoor pool", "Overview": "Overview", "PETR": "Pet", "Parking": "Parking", diff --git a/i18n/dictionaries/fi.json b/i18n/dictionaries/fi.json index bfaa69ec6..64903ea74 100644 --- a/i18n/dictionaries/fi.json +++ b/i18n/dictionaries/fi.json @@ -160,7 +160,7 @@ "In crib": "Pinnasängyssä", "In extra bed": "Oma vuodepaikka", "Included": "Sisälly hintaan", - "Indoor pool": "Sisäuima-allas", + "IndoorPool": "Sisäuima-allas", "It is not posible to manage your communication preferences right now, please try again later or contact support if the problem persists.": "Viestintäasetuksiasi ei voi hallita juuri nyt. Yritä myöhemmin uudelleen tai ota yhteyttä tukeen, jos ongelma jatkuu.", "Jacuzzi": "Poreallas", "Join Scandic Friends": "Liity jäseneksi", @@ -241,7 +241,7 @@ "Open menu": "Avaa valikko", "Open my pages menu": "Avaa omat sivut -valikko", "Opening Hours": "Aukioloajat", - "Outdoor pool": "Ulkouima-allas", + "OutdoorPool": "Ulkouima-allas", "Overview": "Yleiskatsaus", "PETR": "Lemmikki", "Parking": "Pysäköinti", diff --git a/i18n/dictionaries/no.json b/i18n/dictionaries/no.json index b593f1c83..ef15b0823 100644 --- a/i18n/dictionaries/no.json +++ b/i18n/dictionaries/no.json @@ -158,7 +158,7 @@ "In crib": "i sprinkelseng", "In extra bed": "i ekstraseng", "Included": "Inkludert", - "Indoor pool": "Innendørs basseng", + "IndoorPool": "Innendørs basseng", "It is not posible to manage your communication preferences right now, please try again later or contact support if the problem persists.": "Det er ikke mulig å administrere kommunikasjonspreferansene dine akkurat nå, prøv igjen senere eller kontakt support hvis problemet vedvarer.", "Jacuzzi": "Boblebad", "Join Scandic Friends": "Bli med i Scandic Friends", @@ -239,7 +239,7 @@ "Open menu": "Åpne menyen", "Open my pages menu": "Åpne mine sider menyen", "Opening Hours": "Åpningstider", - "Outdoor pool": "Utendørs basseng", + "OutdoorPool": "Utendørs basseng", "Overview": "Oversikt", "PETR": "Kjæledyr", "Parking": "Parkering", diff --git a/i18n/dictionaries/sv.json b/i18n/dictionaries/sv.json index 05c775020..8be596fc1 100644 --- a/i18n/dictionaries/sv.json +++ b/i18n/dictionaries/sv.json @@ -158,7 +158,7 @@ "In crib": "I spjälsäng", "In extra bed": "Egen sängplats", "Included": "Inkluderad", - "Indoor pool": "Inomhuspool", + "IndoorPool": "Inomhuspool", "It is not posible to manage your communication preferences right now, please try again later or contact support if the problem persists.": "Det gick inte att hantera dina kommunikationsinställningar just nu, försök igen senare eller kontakta supporten om problemet kvarstår.", "Jacuzzi": "Jacuzzi", "Join Scandic Friends": "Gå med i Scandic Friends", @@ -239,7 +239,7 @@ "Open menu": "Öppna menyn", "Open my pages menu": "Öppna mina sidor menyn", "Opening Hours": "Öppettider", - "Outdoor pool": "Utomhuspool", + "OutdoorPool": "Utomhuspool", "Overview": "Översikt", "PETR": "Husdjur", "Parking": "Parkering", diff --git a/types/components/hotelPage/sidepeek/wellnessAndExercise.ts b/types/components/hotelPage/sidepeek/wellnessAndExercise.ts index a75499f2d..828f3ee8b 100644 --- a/types/components/hotelPage/sidepeek/wellnessAndExercise.ts +++ b/types/components/hotelPage/sidepeek/wellnessAndExercise.ts @@ -2,4 +2,5 @@ import type { Hotel } from "@/types/hotel" export type WellnessAndExerciseSidePeekProps = { healthFacilities: Hotel["healthFacilities"] + buttonUrl?: string } From b5704dee216712ab10be315d0eb6ebcb2ec2f1e1 Mon Sep 17 00:00:00 2001 From: Fredrik Thorsson Date: Mon, 18 Nov 2024 10:24:45 +0100 Subject: [PATCH 08/24] feat(SW-880): create facility card component --- .../FacilityCard/facilityCard.module.css | 22 ++++++++ .../FacilityCard/index.tsx | 54 +++++++++++++++++++ .../SidePeeks/WellnessAndExercise/index.tsx | 47 ++++------------ .../wellnessAndExercise.module.css | 23 -------- components/ContentType/HotelPage/index.tsx | 5 +- .../hotelPage/sidepeek/facilityCard.ts | 19 +++++++ 6 files changed, 109 insertions(+), 61 deletions(-) create mode 100644 components/ContentType/HotelPage/SidePeeks/WellnessAndExercise/FacilityCard/facilityCard.module.css create mode 100644 components/ContentType/HotelPage/SidePeeks/WellnessAndExercise/FacilityCard/index.tsx create mode 100644 types/components/hotelPage/sidepeek/facilityCard.ts diff --git a/components/ContentType/HotelPage/SidePeeks/WellnessAndExercise/FacilityCard/facilityCard.module.css b/components/ContentType/HotelPage/SidePeeks/WellnessAndExercise/FacilityCard/facilityCard.module.css new file mode 100644 index 000000000..5abac9f32 --- /dev/null +++ b/components/ContentType/HotelPage/SidePeeks/WellnessAndExercise/FacilityCard/facilityCard.module.css @@ -0,0 +1,22 @@ +.content { + display: flex; + flex-direction: column; + gap: var(--Spacing-x2); +} + +.image { + width: 100%; + height: 270px; + object-fit: cover; + border-radius: var(--Corner-radius-Medium); +} + +.information { + display: flex; + flex-direction: column; + gap: var(--Spacing-x-one-and-half); +} + +.body { + margin-top: var(--Spacing-x1); +} diff --git a/components/ContentType/HotelPage/SidePeeks/WellnessAndExercise/FacilityCard/index.tsx b/components/ContentType/HotelPage/SidePeeks/WellnessAndExercise/FacilityCard/index.tsx new file mode 100644 index 000000000..ba3925baf --- /dev/null +++ b/components/ContentType/HotelPage/SidePeeks/WellnessAndExercise/FacilityCard/index.tsx @@ -0,0 +1,54 @@ +import Image from "@/components/Image" +import Body from "@/components/TempDesignSystem/Text/Body" +import Subtitle from "@/components/TempDesignSystem/Text/Subtitle" +import Title from "@/components/TempDesignSystem/Text/Title" +import { getIntl } from "@/i18n" + +import styles from "./facilityCard.module.css" + +import type { FacilityCardProps } from "@/types/components/hotelPage/sidepeek/facilityCard" + +export default async function FacilityCard({ + imgUrl, + imgAltText, + facilityType, + ordinaryOpeningTimes, + weekendOpeningTimes, +}: FacilityCardProps) { + const intl = await getIntl() + return ( +
    + {imgUrl && ( + {imgAltText + )} +
    + + + {intl.formatMessage({ id: `${facilityType}` })} + + +
    + + {intl.formatMessage({ id: " Opening Hours" })} + + + {ordinaryOpeningTimes.alwaysOpen + ? `${intl.formatMessage({ id: "Mon-Fri" })} ${intl.formatMessage({ id: "Always open" })}` + : `${intl.formatMessage({ id: "Mon-Fri" })} ${ordinaryOpeningTimes.openingTime}-${ordinaryOpeningTimes.closingTime}`} + + + {weekendOpeningTimes.alwaysOpen + ? `${intl.formatMessage({ id: "Sat-Sun" })} ${intl.formatMessage({ id: "Always open" })}` + : `${intl.formatMessage({ id: "Sat-Sun" })} ${weekendOpeningTimes.openingTime}-${weekendOpeningTimes.closingTime}`} + +
    +
    +
    + ) +} diff --git a/components/ContentType/HotelPage/SidePeeks/WellnessAndExercise/index.tsx b/components/ContentType/HotelPage/SidePeeks/WellnessAndExercise/index.tsx index 5314e3f18..d7f381d9b 100644 --- a/components/ContentType/HotelPage/SidePeeks/WellnessAndExercise/index.tsx +++ b/components/ContentType/HotelPage/SidePeeks/WellnessAndExercise/index.tsx @@ -1,15 +1,13 @@ import { wellnessAndExercise } from "@/constants/routes/hotelPageParams" -import Image from "@/components/Image" import Button from "@/components/TempDesignSystem/Button" import Link from "@/components/TempDesignSystem/Link" import SidePeek from "@/components/TempDesignSystem/SidePeek" -import Body from "@/components/TempDesignSystem/Text/Body" -import Subtitle from "@/components/TempDesignSystem/Text/Subtitle" -import Title from "@/components/TempDesignSystem/Text/Title" import { getIntl } from "@/i18n" import { getLang } from "@/i18n/serverContext" +import FacilityCard from "./FacilityCard" + import styles from "./wellnessAndExercise.module.css" import type { WellnessAndExerciseSidePeekProps } from "@/types/components/hotelPage/sidepeek/wellnessAndExercise" @@ -28,39 +26,14 @@ export default async function WellnessAndExerciseSidePeek({ >
    {healthFacilities.map((facility) => ( -
    - {facility.content.images[0]?.imageSizes.medium && ( - {facility.content.images[0].metaData.altText - )} -
    - - - {intl.formatMessage({ id: `${facility.type}` })} - - -
    - - {intl.formatMessage({ id: " Opening Hours" })} - - - {facility.openingDetails.openingHours.ordinary.alwaysOpen - ? `${intl.formatMessage({ id: "Mon-Fri" })} ${intl.formatMessage({ id: "Always open" })}` - : `${intl.formatMessage({ id: "Mon-Fri" })} ${facility.openingDetails.openingHours.ordinary.openingTime}-${facility.openingDetails.openingHours.ordinary.closingTime}`} - - - {facility.openingDetails.openingHours.weekends.alwaysOpen - ? `${intl.formatMessage({ id: "Sat-Sun" })} ${intl.formatMessage({ id: "Always open" })}` - : `${intl.formatMessage({ id: "Sat-Sun" })} ${facility.openingDetails.openingHours.weekends.openingTime}-${facility.openingDetails.openingHours.weekends.closingTime}`} - -
    -
    -
    + ))}
    {buttonUrl && ( diff --git a/components/ContentType/HotelPage/SidePeeks/WellnessAndExercise/wellnessAndExercise.module.css b/components/ContentType/HotelPage/SidePeeks/WellnessAndExercise/wellnessAndExercise.module.css index 0c33da7cd..11a410f13 100644 --- a/components/ContentType/HotelPage/SidePeeks/WellnessAndExercise/wellnessAndExercise.module.css +++ b/components/ContentType/HotelPage/SidePeeks/WellnessAndExercise/wellnessAndExercise.module.css @@ -7,29 +7,6 @@ ); /* Creates space between the wrapper and buttonContainer */ } -.content { - display: flex; - flex-direction: column; - gap: var(--Spacing-x2); -} - -.image { - width: 100%; - height: 270px; - object-fit: cover; - border-radius: var(--Corner-radius-Medium); -} - -.information { - display: flex; - flex-direction: column; - gap: var(--Spacing-x-one-and-half); -} - -.body { - margin-top: var(--Spacing-x1); -} - .buttonContainer { background-color: var(--Base-Background-Primary-Normal); border-top: 1px solid var(--Base-Border-Subtle); diff --git a/components/ContentType/HotelPage/index.tsx b/components/ContentType/HotelPage/index.tsx index eb9d1dd37..a565ea117 100644 --- a/components/ContentType/HotelPage/index.tsx +++ b/components/ContentType/HotelPage/index.tsx @@ -147,7 +147,10 @@ export default async function HotelPage() { {/* TODO */} Restaurant & Bar - + Date: Mon, 18 Nov 2024 11:23:34 +0100 Subject: [PATCH 09/24] feat(SW-880): refactor facility component --- .../facility.module.css} | 2 +- .../WellnessAndExercise/Facility/index.tsx | 64 +++++++++++++++++++ .../FacilityCard/index.tsx | 54 ---------------- .../SidePeeks/WellnessAndExercise/index.tsx | 11 +--- .../components/hotelPage/sidepeek/facility.ts | 5 ++ .../hotelPage/sidepeek/facilityCard.ts | 19 ------ 6 files changed, 72 insertions(+), 83 deletions(-) rename components/ContentType/HotelPage/SidePeeks/WellnessAndExercise/{FacilityCard/facilityCard.module.css => Facility/facility.module.css} (95%) create mode 100644 components/ContentType/HotelPage/SidePeeks/WellnessAndExercise/Facility/index.tsx delete mode 100644 components/ContentType/HotelPage/SidePeeks/WellnessAndExercise/FacilityCard/index.tsx create mode 100644 types/components/hotelPage/sidepeek/facility.ts delete mode 100644 types/components/hotelPage/sidepeek/facilityCard.ts diff --git a/components/ContentType/HotelPage/SidePeeks/WellnessAndExercise/FacilityCard/facilityCard.module.css b/components/ContentType/HotelPage/SidePeeks/WellnessAndExercise/Facility/facility.module.css similarity index 95% rename from components/ContentType/HotelPage/SidePeeks/WellnessAndExercise/FacilityCard/facilityCard.module.css rename to components/ContentType/HotelPage/SidePeeks/WellnessAndExercise/Facility/facility.module.css index 5abac9f32..22aea5bce 100644 --- a/components/ContentType/HotelPage/SidePeeks/WellnessAndExercise/FacilityCard/facilityCard.module.css +++ b/components/ContentType/HotelPage/SidePeeks/WellnessAndExercise/Facility/facility.module.css @@ -17,6 +17,6 @@ gap: var(--Spacing-x-one-and-half); } -.body { +.openingHours { margin-top: var(--Spacing-x1); } diff --git a/components/ContentType/HotelPage/SidePeeks/WellnessAndExercise/Facility/index.tsx b/components/ContentType/HotelPage/SidePeeks/WellnessAndExercise/Facility/index.tsx new file mode 100644 index 000000000..474ccb901 --- /dev/null +++ b/components/ContentType/HotelPage/SidePeeks/WellnessAndExercise/Facility/index.tsx @@ -0,0 +1,64 @@ +import Image from "@/components/Image" +import Body from "@/components/TempDesignSystem/Text/Body" +import Subtitle from "@/components/TempDesignSystem/Text/Subtitle" +import Title from "@/components/TempDesignSystem/Text/Title" +import { getIntl } from "@/i18n" + +import styles from "./facility.module.css" + +import { FacilityProps } from "@/types/components/hotelPage/sidepeek/facility" + +export default async function Facility({ data }: FacilityProps) { + const intl = await getIntl() + const imgUrl = data.content.images[0]?.imageSizes.medium + const imgAltText = data.content.images[0]?.metaData.altText + const facilityType = data.type + const ordinaryOpeningTimes = { + alwaysOpen: data.openingDetails.openingHours.ordinary.alwaysOpen, + openingTime: data.openingDetails.openingHours.ordinary.openingTime, + closingTime: data.openingDetails.openingHours.ordinary.closingTime, + } + const weekendOpeningTimes = { + alwaysOpen: data.openingDetails.openingHours.weekends.alwaysOpen, + openingTime: data.openingDetails.openingHours.weekends.openingTime, + closingTime: data.openingDetails.openingHours.weekends.closingTime, + } + + return ( +
    + {imgUrl && ( + {imgAltText + )} +
    + + + {intl.formatMessage({ id: `${facilityType}` })} + + +
    + + {intl.formatMessage({ id: " Opening Hours" })} + +
    + + {ordinaryOpeningTimes.alwaysOpen + ? `${intl.formatMessage({ id: "Mon-Fri" })} ${intl.formatMessage({ id: "Always open" })}` + : `${intl.formatMessage({ id: "Mon-Fri" })} ${ordinaryOpeningTimes.openingTime}-${ordinaryOpeningTimes.closingTime}`} + + + {weekendOpeningTimes.alwaysOpen + ? `${intl.formatMessage({ id: "Sat-Sun" })} ${intl.formatMessage({ id: "Always open" })}` + : `${intl.formatMessage({ id: "Sat-Sun" })} ${weekendOpeningTimes.openingTime}-${weekendOpeningTimes.closingTime}`} + +
    +
    +
    +
    + ) +} diff --git a/components/ContentType/HotelPage/SidePeeks/WellnessAndExercise/FacilityCard/index.tsx b/components/ContentType/HotelPage/SidePeeks/WellnessAndExercise/FacilityCard/index.tsx deleted file mode 100644 index ba3925baf..000000000 --- a/components/ContentType/HotelPage/SidePeeks/WellnessAndExercise/FacilityCard/index.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import Image from "@/components/Image" -import Body from "@/components/TempDesignSystem/Text/Body" -import Subtitle from "@/components/TempDesignSystem/Text/Subtitle" -import Title from "@/components/TempDesignSystem/Text/Title" -import { getIntl } from "@/i18n" - -import styles from "./facilityCard.module.css" - -import type { FacilityCardProps } from "@/types/components/hotelPage/sidepeek/facilityCard" - -export default async function FacilityCard({ - imgUrl, - imgAltText, - facilityType, - ordinaryOpeningTimes, - weekendOpeningTimes, -}: FacilityCardProps) { - const intl = await getIntl() - return ( -
    - {imgUrl && ( - {imgAltText - )} -
    - - - {intl.formatMessage({ id: `${facilityType}` })} - - -
    - - {intl.formatMessage({ id: " Opening Hours" })} - - - {ordinaryOpeningTimes.alwaysOpen - ? `${intl.formatMessage({ id: "Mon-Fri" })} ${intl.formatMessage({ id: "Always open" })}` - : `${intl.formatMessage({ id: "Mon-Fri" })} ${ordinaryOpeningTimes.openingTime}-${ordinaryOpeningTimes.closingTime}`} - - - {weekendOpeningTimes.alwaysOpen - ? `${intl.formatMessage({ id: "Sat-Sun" })} ${intl.formatMessage({ id: "Always open" })}` - : `${intl.formatMessage({ id: "Sat-Sun" })} ${weekendOpeningTimes.openingTime}-${weekendOpeningTimes.closingTime}`} - -
    -
    -
    - ) -} diff --git a/components/ContentType/HotelPage/SidePeeks/WellnessAndExercise/index.tsx b/components/ContentType/HotelPage/SidePeeks/WellnessAndExercise/index.tsx index d7f381d9b..97ac09a91 100644 --- a/components/ContentType/HotelPage/SidePeeks/WellnessAndExercise/index.tsx +++ b/components/ContentType/HotelPage/SidePeeks/WellnessAndExercise/index.tsx @@ -6,7 +6,7 @@ import SidePeek from "@/components/TempDesignSystem/SidePeek" import { getIntl } from "@/i18n" import { getLang } from "@/i18n/serverContext" -import FacilityCard from "./FacilityCard" +import Facility from "./Facility" import styles from "./wellnessAndExercise.module.css" @@ -26,14 +26,7 @@ export default async function WellnessAndExerciseSidePeek({ >
    {healthFacilities.map((facility) => ( - + ))}
    {buttonUrl && ( diff --git a/types/components/hotelPage/sidepeek/facility.ts b/types/components/hotelPage/sidepeek/facility.ts new file mode 100644 index 000000000..6cb6f0796 --- /dev/null +++ b/types/components/hotelPage/sidepeek/facility.ts @@ -0,0 +1,5 @@ +import type { Hotel } from "@/types/hotel" + +export type FacilityProps = { + data: Hotel["healthFacilities"][number] +} diff --git a/types/components/hotelPage/sidepeek/facilityCard.ts b/types/components/hotelPage/sidepeek/facilityCard.ts deleted file mode 100644 index fbf6e9942..000000000 --- a/types/components/hotelPage/sidepeek/facilityCard.ts +++ /dev/null @@ -1,19 +0,0 @@ -export type FacilityCardProps = { - imgUrl: string - imgAltText: string - facilityType: string - ordinaryOpeningTimes: { - alwaysOpen: boolean - isClosed: boolean - openingTime?: string - closingTime?: string - sortOrder?: number - } - weekendOpeningTimes: { - alwaysOpen: boolean - isClosed: boolean - openingTime?: string - closingTime?: string - sortOrder?: number - } -} From dc5746a902e0711dce94068fd7757f363bb06bec Mon Sep 17 00:00:00 2001 From: Fredrik Thorsson Date: Mon, 18 Nov 2024 12:13:23 +0100 Subject: [PATCH 10/24] feat(SW-880): refactor variables --- .../WellnessAndExercise/Facility/index.tsx | 26 +++++-------------- 1 file changed, 7 insertions(+), 19 deletions(-) diff --git a/components/ContentType/HotelPage/SidePeeks/WellnessAndExercise/Facility/index.tsx b/components/ContentType/HotelPage/SidePeeks/WellnessAndExercise/Facility/index.tsx index 474ccb901..e00ed5964 100644 --- a/components/ContentType/HotelPage/SidePeeks/WellnessAndExercise/Facility/index.tsx +++ b/components/ContentType/HotelPage/SidePeeks/WellnessAndExercise/Facility/index.tsx @@ -10,26 +10,16 @@ import { FacilityProps } from "@/types/components/hotelPage/sidepeek/facility" export default async function Facility({ data }: FacilityProps) { const intl = await getIntl() - const imgUrl = data.content.images[0]?.imageSizes.medium - const imgAltText = data.content.images[0]?.metaData.altText - const facilityType = data.type - const ordinaryOpeningTimes = { - alwaysOpen: data.openingDetails.openingHours.ordinary.alwaysOpen, - openingTime: data.openingDetails.openingHours.ordinary.openingTime, - closingTime: data.openingDetails.openingHours.ordinary.closingTime, - } - const weekendOpeningTimes = { - alwaysOpen: data.openingDetails.openingHours.weekends.alwaysOpen, - openingTime: data.openingDetails.openingHours.weekends.openingTime, - closingTime: data.openingDetails.openingHours.weekends.closingTime, - } + const image = data.content.images[0] + const ordinaryOpeningTimes = data.openingDetails.openingHours.ordinary + const weekendOpeningTimes = data.openingDetails.openingHours.weekends return (
    - {imgUrl && ( + {image.imageSizes.medium && ( {imgAltText - - {intl.formatMessage({ id: `${facilityType}` })} - + {intl.formatMessage({ id: `${data.type}` })}
    From 24124209d9a25fcc60cef849a2656257f415a926 Mon Sep 17 00:00:00 2001 From: Pontus Dreij Date: Mon, 18 Nov 2024 14:05:07 +0100 Subject: [PATCH 11/24] feat(SW-705): Updated URLs for hotelreservation --- .../HotelReservation/HotelCard/index.tsx | 6 +++--- constants/routes/hotelReservation.js | 13 +++++------- next.config.js | 21 ------------------- 3 files changed, 8 insertions(+), 32 deletions(-) diff --git a/components/HotelReservation/HotelCard/index.tsx b/components/HotelReservation/HotelCard/index.tsx index 4c478d275..f996578c7 100644 --- a/components/HotelReservation/HotelCard/index.tsx +++ b/components/HotelReservation/HotelCard/index.tsx @@ -3,7 +3,7 @@ import { useParams } from "next/dist/client/components/navigation" import { useIntl } from "react-intl" import { Lang } from "@/constants/languages" -import { selectHotelMap } from "@/constants/routes/hotelReservation" +import { selectHotelMap, selectRate } from "@/constants/routes/hotelReservation" import { mapFacilityToIcon } from "@/components/ContentType/HotelPage/data" import ImageGallery from "@/components/ImageGallery" @@ -150,9 +150,9 @@ export default function HotelCard({ size="small" className={styles.button} > - {/* TODO: Localize link and also use correct search params */} + {/* TODO: use correct search params */} diff --git a/constants/routes/hotelReservation.js b/constants/routes/hotelReservation.js index 2224b17b6..94a9cef18 100644 --- a/constants/routes/hotelReservation.js +++ b/constants/routes/hotelReservation.js @@ -1,14 +1,13 @@ /** @type {import('@/types/routes').LangRoute} */ export const hotelReservation = { en: "/en/hotelreservation", - sv: "/sv/hotellbokning", - no: "/no/hotell-reservasjon", - fi: "/fi/hotellivaraus", - da: "/da/hotel-reservation", - de: "/de/hotelreservierung", + sv: "/sv/hotelreservation", + no: "/no/hotelreservation", + fi: "/fi/hotelreservation", + da: "/da/hotelreservation", + de: "/de/hotelreservation", } -// TODO: Translate paths export const selectHotel = { en: `${hotelReservation.en}/select-hotel`, sv: `${hotelReservation.sv}/select-hotel`, @@ -18,7 +17,6 @@ export const selectHotel = { de: `${hotelReservation.de}/select-hotel`, } -// TODO: Translate paths export const selectRate = { en: `${hotelReservation.en}/select-rate`, sv: `${hotelReservation.sv}/select-rate`, @@ -68,7 +66,6 @@ export const payment = { de: `${hotelReservation.de}/payment`, } -// TODO: Translate paths export const selectHotelMap = { en: `${selectHotel.en}/map`, sv: `${selectHotel.sv}/map`, diff --git a/next.config.js b/next.config.js index c65772ab7..29012f230 100644 --- a/next.config.js +++ b/next.config.js @@ -2,7 +2,6 @@ import createJiti from "jiti" import { fileURLToPath } from "url" import { login, logout } from "./constants/routes/handleAuth.js" -import { hotelReservation } from "./constants/routes/hotelReservation.js" import { myPages } from "./constants/routes/myPages.js" const path = fileURLToPath(new URL(import.meta.url)) @@ -278,26 +277,6 @@ const nextConfig = { source: `${myPages.sv}/:path*`, destination: `/sv/my-pages/:path*`, }, - { - source: `${hotelReservation.da}/:path*`, - destination: "/da/hotelreservation/:path*", - }, - { - source: `${hotelReservation.de}/:path*`, - destination: "/de/hotelreservation/:path*", - }, - { - source: `${hotelReservation.fi}/:path*`, - destination: "/fi/hotelreservation/:path*", - }, - { - source: `${hotelReservation.no}/:path*`, - destination: "/no/hotelreservation/:path*", - }, - { - source: `${hotelReservation.sv}/:path*`, - destination: "/sv/hotelreservation/:path*", - }, ], } }, From d18bc45b19ef1b3595f2c104184bc75513bbb6a9 Mon Sep 17 00:00:00 2001 From: Niclas Edenvin Date: Mon, 18 Nov 2024 14:02:32 +0000 Subject: [PATCH 12/24] Merged in feat/SW-342-filtering-and-sorting-mobile (pull request #919) Feat/SW-342 filtering and sorting mobile * feat(SW-342): add sort and filter on mobile * Use zustand for state management * Add count and translations * Clear filters * Small fixes * Fixes Approved-by: Pontus Dreij --- .../(standard)/select-hotel/page.module.css | 22 +++- .../(standard)/select-hotel/page.tsx | 8 +- .../HotelCardListing/index.tsx | 19 ++- .../filterAndSortModal.module.css | 99 ++++++++++++++++ .../SelectHotel/FilterAndSortModal/index.tsx | 87 ++++++++++++++ .../FilterCheckbox/filterCheckbox.module.css | 29 +++++ .../HotelFilter/FilterCheckbox/index.tsx | 35 ++++++ .../HotelFilter/hotelFilter.module.css | 7 -- .../SelectHotel/HotelFilter/index.tsx | 111 ++++++++++-------- .../HotelSorter/hotelSorter.module.css | 9 -- .../SelectHotel/HotelSorter/index.tsx | 25 ++-- .../MobileMapButtonContainer/index.tsx | 31 ++--- .../mobileMapButtonContainer.module.css | 4 +- i18n/dictionaries/da.json | 3 + i18n/dictionaries/de.json | 3 + i18n/dictionaries/en.json | 3 + i18n/dictionaries/fi.json | 3 + i18n/dictionaries/no.json | 3 + i18n/dictionaries/sv.json | 3 + stores/hotel-filters.ts | 27 +++++ .../selectHotel/filterAndSortModal.ts | 5 + .../selectHotel/filterCheckbox.ts | 6 + .../selectHotel/hotelFilters.ts | 1 + .../selectHotel/hotelSorter.ts | 4 + 24 files changed, 434 insertions(+), 113 deletions(-) create mode 100644 components/HotelReservation/SelectHotel/FilterAndSortModal/filterAndSortModal.module.css create mode 100644 components/HotelReservation/SelectHotel/FilterAndSortModal/index.tsx create mode 100644 components/HotelReservation/SelectHotel/HotelFilter/FilterCheckbox/filterCheckbox.module.css create mode 100644 components/HotelReservation/SelectHotel/HotelFilter/FilterCheckbox/index.tsx delete mode 100644 components/HotelReservation/SelectHotel/HotelSorter/hotelSorter.module.css create mode 100644 stores/hotel-filters.ts create mode 100644 types/components/hotelReservation/selectHotel/filterAndSortModal.ts create mode 100644 types/components/hotelReservation/selectHotel/filterCheckbox.ts diff --git a/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-hotel/page.module.css b/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-hotel/page.module.css index 8bf36ee38..e42544196 100644 --- a/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-hotel/page.module.css +++ b/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-hotel/page.module.css @@ -20,10 +20,13 @@ gap: var(--Spacing-x1); } +.sorter { + display: none; +} + .sideBar { display: flex; flex-direction: column; - max-width: 340px; } .link { @@ -47,6 +50,10 @@ gap: var(--Spacing-x3); } +.filter { + display: none; +} + @media (min-width: 768px) { .main { padding: var(--Spacing-x5); @@ -58,6 +65,11 @@ var(--Spacing-x5); } + .sorter { + display: block; + width: 339px; + } + .title { margin: 0 auto; display: flex; @@ -65,6 +77,14 @@ align-items: center; justify-content: space-between; } + + .sideBar { + max-width: 340px; + } + .filter { + display: block; + } + .link { display: flex; padding-bottom: var(--Spacing-x6); diff --git a/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-hotel/page.tsx b/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-hotel/page.tsx index 0493c9d70..791773f94 100644 --- a/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-hotel/page.tsx +++ b/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-hotel/page.tsx @@ -74,9 +74,11 @@ export default async function SelectHotelPage({ {city.name} {hotels.length} hotels
    - +
    + +
    - +
    @@ -118,7 +120,7 @@ export default async function SelectHotelPage({ />
    )} - +
    {!hotels.length && ( diff --git a/components/HotelReservation/HotelCardListing/index.tsx b/components/HotelReservation/HotelCardListing/index.tsx index 4ba65ed9c..c4a5a1eee 100644 --- a/components/HotelReservation/HotelCardListing/index.tsx +++ b/components/HotelReservation/HotelCardListing/index.tsx @@ -2,7 +2,7 @@ import { useSearchParams } from "next/navigation" import { useMemo } from "react" -import Title from "@/components/TempDesignSystem/Text/Title" +import { useHotelFilterStore } from "@/stores/hotel-filters" import HotelCard from "../HotelCard" import { DEFAULT_SORT } from "../SelectHotel/HotelSorter" @@ -22,6 +22,8 @@ export default function HotelCardListing({ onHotelCardHover, }: HotelCardListingProps) { const searchParams = useSearchParams() + const activeFilters = useHotelFilterStore((state) => state.activeFilters) + const setResultCount = useHotelFilterStore((state) => state.setResultCount) const sortBy = useMemo( () => searchParams.get("sort") ?? DEFAULT_SORT, @@ -57,17 +59,22 @@ export default function HotelCardListing({ }, [hotelData, sortBy]) const hotels = useMemo(() => { - const appliedFilters = searchParams.get("filters")?.split(",") - if (!appliedFilters || appliedFilters.length === 0) return sortedHotels + if (activeFilters.length === 0) { + setResultCount(sortedHotels.length) + return sortedHotels + } - return sortedHotels.filter((hotel) => - appliedFilters.every((appliedFilterId) => + const filteredHotels = sortedHotels.filter((hotel) => + activeFilters.every((appliedFilterId) => hotel.hotelData.detailedFacilities.some( (facility) => facility.id.toString() === appliedFilterId ) ) ) - }, [searchParams, sortedHotels]) + + setResultCount(filteredHotels.length) + return filteredHotels + }, [activeFilters, sortedHotels, setResultCount]) return (
    diff --git a/components/HotelReservation/SelectHotel/FilterAndSortModal/filterAndSortModal.module.css b/components/HotelReservation/SelectHotel/FilterAndSortModal/filterAndSortModal.module.css new file mode 100644 index 000000000..6768d2c56 --- /dev/null +++ b/components/HotelReservation/SelectHotel/FilterAndSortModal/filterAndSortModal.module.css @@ -0,0 +1,99 @@ +@keyframes modal-fade { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} +@keyframes modal-slide-up { + from { + bottom: -100%; + } + + to { + bottom: 0; + } +} + +.overlay { + align-items: center; + background: rgba(0, 0, 0, 0.5); + display: flex; + height: var(--visual-viewport-height); + justify-content: center; + left: 0; + position: fixed; + top: 0; + width: 100vw; + z-index: 100; + + &[data-entering] { + animation: modal-fade 200ms; + } + + &[data-exiting] { + animation: modal-fade 150ms reverse ease-in; + } +} + +.modal { + position: absolute; + left: 0; + bottom: 0; + height: calc(100dvh - 20px); + background-color: var(--Base-Surface-Primary-light-Normal); + border-radius: var(--Corner-radius-Medium); + box-shadow: 0px 4px 24px 0px rgba(38, 32, 30, 0.08); + width: 100%; + + &[data-entering] { + animation: modal-slide-up 200ms; + } + + &[data-existing] { + animation: modal-slide-up 200ms reverse; + } +} + +.content { + flex-direction: column; + gap: var(--Spacing-x3); + display: flex; + height: 100%; +} + +.sorter { + padding: var(--Spacing-x2); + flex: 0 0 auto; +} + +.filters { + padding: var(--Spacing-x2); + flex: 1 1 auto; + overflow-y: auto; +} + +.header { + text-align: right; + padding: var(--Spacing-x-one-and-half); + flex: 0 0 auto; +} + +.close { + background: none; + border: none; + cursor: pointer; + justify-self: flex-end; + padding: 0; +} + +.footer { + display: flex; + flex-direction: column-reverse; + gap: var(--Spacing-x1); + padding: var(--Spacing-x2); + flex: 0 0 auto; + border-top: 1px solid var(--Base-Border-Subtle); +} diff --git a/components/HotelReservation/SelectHotel/FilterAndSortModal/index.tsx b/components/HotelReservation/SelectHotel/FilterAndSortModal/index.tsx new file mode 100644 index 000000000..be1a1bc9c --- /dev/null +++ b/components/HotelReservation/SelectHotel/FilterAndSortModal/index.tsx @@ -0,0 +1,87 @@ +"use client" + +import { + Dialog as AriaDialog, + DialogTrigger, + Modal, + ModalOverlay, +} from "react-aria-components" +import { useIntl } from "react-intl" + +import { useHotelFilterStore } from "@/stores/hotel-filters" + +import { CloseLargeIcon, FilterIcon } from "@/components/Icons" +import Button from "@/components/TempDesignSystem/Button" + +import HotelFilter from "../HotelFilter" +import HotelSorter from "../HotelSorter" + +import styles from "./filterAndSortModal.module.css" + +import type { FilterAndSortModalProps } from "@/types/components/hotelReservation/selectHotel/filterAndSortModal" + +export default function FilterAndSortModal({ + filters, +}: FilterAndSortModalProps) { + const intl = useIntl() + const resultCount = useHotelFilterStore((state) => state.resultCount) + const setFilters = useHotelFilterStore((state) => state.setFilters) + + return ( + <> + + + + + + {({ close }) => ( + <> +
    + +
    +
    + +
    +
    + +
    +
    + + + +
    + + )} +
    +
    +
    +
    + + ) +} diff --git a/components/HotelReservation/SelectHotel/HotelFilter/FilterCheckbox/filterCheckbox.module.css b/components/HotelReservation/SelectHotel/HotelFilter/FilterCheckbox/filterCheckbox.module.css new file mode 100644 index 000000000..4b3b94787 --- /dev/null +++ b/components/HotelReservation/SelectHotel/HotelFilter/FilterCheckbox/filterCheckbox.module.css @@ -0,0 +1,29 @@ +.container { + display: flex; + flex-direction: column; + color: var(--text-color); +} + +.container[data-selected] .checkbox { + border: none; + background: var(--UI-Input-Controls-Fill-Selected); +} + +.checkboxContainer { + display: flex; + align-items: center; + gap: var(--Spacing-x-one-and-half); +} + +.checkbox { + width: 24px; + height: 24px; + min-width: 24px; + border: 1px solid var(--UI-Input-Controls-Border-Normal); + border-radius: 4px; + transition: all 200ms; + display: flex; + align-items: center; + justify-content: center; + forced-color-adjust: none; +} diff --git a/components/HotelReservation/SelectHotel/HotelFilter/FilterCheckbox/index.tsx b/components/HotelReservation/SelectHotel/HotelFilter/FilterCheckbox/index.tsx new file mode 100644 index 000000000..6767f666b --- /dev/null +++ b/components/HotelReservation/SelectHotel/HotelFilter/FilterCheckbox/index.tsx @@ -0,0 +1,35 @@ +"use client" + +import { Checkbox as AriaCheckbox } from "react-aria-components" + +import CheckIcon from "@/components/Icons/Check" + +import styles from "./filterCheckbox.module.css" + +import type { FilterCheckboxProps } from "@/types/components/hotelReservation/selectHotel/filterCheckbox" + +export default function FilterCheckbox({ + isSelected, + name, + id, + onChange, +}: FilterCheckboxProps) { + return ( + onChange(id)} + > + {({ isSelected }) => ( + <> + + + {isSelected && } + + {name} + + + )} + + ) +} diff --git a/components/HotelReservation/SelectHotel/HotelFilter/hotelFilter.module.css b/components/HotelReservation/SelectHotel/HotelFilter/hotelFilter.module.css index c81b31cbd..8a4fcebff 100644 --- a/components/HotelReservation/SelectHotel/HotelFilter/hotelFilter.module.css +++ b/components/HotelReservation/SelectHotel/HotelFilter/hotelFilter.module.css @@ -1,6 +1,5 @@ .container { min-width: 272px; - display: none; } .container form { @@ -39,9 +38,3 @@ height: 1.25rem; margin: 0; } - -@media (min-width: 768px) { - .container { - display: block; - } -} diff --git a/components/HotelReservation/SelectHotel/HotelFilter/index.tsx b/components/HotelReservation/SelectHotel/HotelFilter/index.tsx index a3b68b28e..c428894a3 100644 --- a/components/HotelReservation/SelectHotel/HotelFilter/index.tsx +++ b/components/HotelReservation/SelectHotel/HotelFilter/index.tsx @@ -1,37 +1,42 @@ "use client" import { usePathname, useSearchParams } from "next/navigation" -import { useCallback, useEffect } from "react" -import { FormProvider, useForm } from "react-hook-form" +import { useEffect } from "react" import { useIntl } from "react-intl" -import Checkbox from "@/components/TempDesignSystem/Form/Checkbox" +import { useHotelFilterStore } from "@/stores/hotel-filters" + import Subtitle from "@/components/TempDesignSystem/Text/Subtitle" import Title from "@/components/TempDesignSystem/Text/Title" +import FilterCheckbox from "./FilterCheckbox" + import styles from "./hotelFilter.module.css" import type { HotelFiltersProps } from "@/types/components/hotelReservation/selectHotel/hotelFilters" -export default function HotelFilter({ filters }: HotelFiltersProps) { +export default function HotelFilter({ className, filters }: HotelFiltersProps) { const intl = useIntl() const searchParams = useSearchParams() const pathname = usePathname() + const toggleFilter = useHotelFilterStore((state) => state.toggleFilter) + const setFilters = useHotelFilterStore((state) => state.setFilters) + const activeFilters = useHotelFilterStore((state) => state.activeFilters) - const methods = useForm>({ - defaultValues: searchParams - ?.get("filters") - ?.split(",") - .reduce((acc, curr) => ({ ...acc, [curr]: true }), {}), - }) - const { watch, handleSubmit, getValues, register } = methods + // Initialize the filters from the URL + useEffect(() => { + const filtersFromUrl = searchParams.get("filters") + if (filtersFromUrl) { + setFilters(filtersFromUrl.split(",")) + } else { + setFilters([]) + } + }, [searchParams, setFilters]) - const submitFilter = useCallback(() => { + // Update the URL when the filters changes + useEffect(() => { const newSearchParams = new URLSearchParams(searchParams) - const values = Object.entries(getValues()) - .filter(([_, value]) => !!value) - .map(([key, _]) => key) - .join(",") + const values = activeFilters.join(",") if (values === "") { newSearchParams.delete("filters") @@ -46,49 +51,51 @@ export default function HotelFilter({ filters }: HotelFiltersProps) { `${pathname}?${newSearchParams.toString()}` ) } - }, [getValues, pathname, searchParams]) - - useEffect(() => { - const subscription = watch(() => handleSubmit(submitFilter)()) - return () => subscription.unsubscribe() - }, [handleSubmit, watch, submitFilter]) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activeFilters]) if (!filters.facilityFilters.length && !filters.surroundingsFilters.length) { return null } return ( -
    )}
  • -
    - - -
    + ) diff --git a/components/HotelReservation/HotelCardDialogListing/utils.ts b/components/HotelReservation/HotelCardDialogListing/utils.ts index 6183c5da4..ba40ccc3a 100644 --- a/components/HotelReservation/HotelCardDialogListing/utils.ts +++ b/components/HotelReservation/HotelCardDialogListing/utils.ts @@ -8,9 +8,9 @@ export function getHotelPins(hotels: HotelData[]): HotelPin[] { lng: hotel.hotelData.location.longitude, }, name: hotel.hotelData.name, - publicPrice: hotel.price?.regularAmount ?? null, - memberPrice: hotel.price?.memberAmount ?? null, - currency: hotel.price?.currency || null, + publicPrice: hotel.price?.public?.localPrice.pricePerNight ?? null, + memberPrice: hotel.price?.member?.localPrice.pricePerNight ?? null, + currency: hotel.price?.public?.localPrice.currency || null, images: [ hotel.hotelData.hotelContent.images, ...(hotel.hotelData.gallery?.heroImages ?? []), diff --git a/components/HotelReservation/HotelCardListing/index.tsx b/components/HotelReservation/HotelCardListing/index.tsx index c4a5a1eee..ab2d45d3e 100644 --- a/components/HotelReservation/HotelCardListing/index.tsx +++ b/components/HotelReservation/HotelCardListing/index.tsx @@ -12,6 +12,7 @@ import styles from "./hotelCardListing.module.css" import { type HotelCardListingProps, HotelCardListingTypeEnum, + type HotelData, } from "@/types/components/hotelReservation/selectHotel/hotelCardListingProps" import { SortOrder } from "@/types/components/hotelReservation/selectHotel/hotelSorter" @@ -43,10 +44,15 @@ export default function HotelCardListing({ (a.hotelData.ratings?.tripAdvisor.rating ?? 0) ) case SortOrder.Price: + const getPricePerNight = (hotel: HotelData): number => { + return ( + hotel.price?.member?.localPrice?.pricePerNight ?? + hotel.price?.public?.localPrice?.pricePerNight ?? + 0 + ) + } return [...hotelData].sort( - (a, b) => - parseInt(a.price?.memberAmount ?? "0", 10) - - parseInt(b.price?.memberAmount ?? "0", 10) + (a, b) => getPricePerNight(a) - getPricePerNight(b) ) case SortOrder.Distance: default: diff --git a/components/HotelReservation/SelectRate/Rooms/utils.ts b/components/HotelReservation/SelectRate/Rooms/utils.ts index 9d6f24e00..af80e1f4d 100644 --- a/components/HotelReservation/SelectRate/Rooms/utils.ts +++ b/components/HotelReservation/SelectRate/Rooms/utils.ts @@ -49,7 +49,7 @@ export function filterDuplicateRoomTypesByLowestPrice( const previousLowest = roomMap.get(roomType) const currentRequestedPrice = Math.min( - Number(publicRequestedPrice.pricePerNight) ?? Infinity, + Number(publicRequestedPrice?.pricePerNight) ?? Infinity, Number(memberRequestedPrice?.pricePerNight) ?? Infinity ) const currentLocalPrice = Math.min( diff --git a/i18n/dictionaries/de.json b/i18n/dictionaries/de.json index 02e7da88d..433a0ba33 100644 --- a/i18n/dictionaries/de.json +++ b/i18n/dictionaries/de.json @@ -348,7 +348,6 @@ "Terms and conditions": "Geschäftsbedingungen", "Thank you": "Danke", "Theatre": "Theater", - "There are no rooms available that match your request": "Es sind keine Zimmer verfügbar, die Ihrer Anfrage entsprechen", "There are no rooms available that match your request.": "Es sind keine Zimmer verfügbar, die Ihrer Anfrage entsprechen.", "There are no transactions to display": "Es sind keine Transaktionen zum Anzeigen vorhanden", "Things nearby HOTEL_NAME": "Dinge in der Nähe von {hotelName}", diff --git a/i18n/dictionaries/en.json b/i18n/dictionaries/en.json index 2263cf43f..38a5da82b 100644 --- a/i18n/dictionaries/en.json +++ b/i18n/dictionaries/en.json @@ -378,7 +378,6 @@ "Terms and conditions": "Terms and conditions", "Thank you": "Thank you", "Theatre": "Theatre", - "There are no rooms available that match your request": "There are no rooms available that match your request", "There are no rooms available that match your request.": "There are no rooms available that match your request.", "There are no transactions to display": "There are no transactions to display", "Things nearby HOTEL_NAME": "Things nearby {hotelName}", diff --git a/i18n/dictionaries/fi.json b/i18n/dictionaries/fi.json index 59097c54a..218abed13 100644 --- a/i18n/dictionaries/fi.json +++ b/i18n/dictionaries/fi.json @@ -350,7 +350,6 @@ "Terms and conditions": "Käyttöehdot", "Thank you": "Kiitos", "Theatre": "Teatteri", - "There are no rooms available that match your request": "Pyyntöäsi vastaavia huoneita ei ole saatavilla", "There are no rooms available that match your request.": "Ei huoneita saatavilla, jotka vastaavat pyyntöäsi.", "There are no transactions to display": "Näytettäviä tapahtumia ei ole", "Things nearby HOTEL_NAME": "Lähellä olevia asioita {hotelName}", diff --git a/i18n/dictionaries/no.json b/i18n/dictionaries/no.json index f5f131e8c..7cf8e985a 100644 --- a/i18n/dictionaries/no.json +++ b/i18n/dictionaries/no.json @@ -347,7 +347,6 @@ "Terms and conditions": "Vilkår og betingelser", "Thank you": "Takk", "Theatre": "Teater", - "There are no rooms available that match your request": "Det er ingen tilgjengelige rom som samsvarer med forespørselen din", "There are no rooms available that match your request.": "Det er ingen rom tilgjengelige som matcher din forespørsel.", "There are no transactions to display": "Det er ingen transaksjoner å vise", "Things nearby HOTEL_NAME": "Ting i nærheten av {hotelName}", diff --git a/i18n/dictionaries/sv.json b/i18n/dictionaries/sv.json index bca60e315..7f935f88a 100644 --- a/i18n/dictionaries/sv.json +++ b/i18n/dictionaries/sv.json @@ -347,7 +347,6 @@ "Terms and conditions": "Allmänna villkor", "Thank you": "Tack", "Theatre": "Teater", - "There are no rooms available that match your request": "Det finns inga tillgängliga rum som matchar din förfrågan", "There are no rooms available that match your request.": "Det finns inga rum tillgängliga som matchar din begäran.", "There are no transactions to display": "Det finns inga transaktioner att visa", "Things nearby HOTEL_NAME": "Saker i närheten av {hotelName}", diff --git a/lib/trpc/memoizedRequests/index.ts b/lib/trpc/memoizedRequests/index.ts index 407175703..4ab1aa5ea 100644 --- a/lib/trpc/memoizedRequests/index.ts +++ b/lib/trpc/memoizedRequests/index.ts @@ -82,7 +82,7 @@ export const getRoomAvailability = cache( roomStayStartDate, roomStayEndDate, children, - promotionCode, + bookingCode, rateCode, }: GetRoomsAvailabilityInput) { return serverClient().hotel.availability.rooms({ @@ -91,7 +91,7 @@ export const getRoomAvailability = cache( roomStayStartDate, roomStayEndDate, children, - promotionCode, + bookingCode, rateCode, }) } diff --git a/server/routers/hotels/input.ts b/server/routers/hotels/input.ts index 04bb16b17..9bfecaf6a 100644 --- a/server/routers/hotels/input.ts +++ b/server/routers/hotels/input.ts @@ -8,9 +8,7 @@ export const getHotelsAvailabilityInputSchema = z.object({ roomStayEndDate: z.string(), adults: z.number(), children: z.string().optional(), - promotionCode: z.string().optional().default(""), - reservationProfileType: z.string().optional().default(""), - attachedProfileId: z.string().optional().default(""), + bookingCode: z.string().optional().default(""), }) export const getRoomsAvailabilityInputSchema = z.object({ @@ -19,9 +17,7 @@ export const getRoomsAvailabilityInputSchema = z.object({ roomStayEndDate: z.string(), adults: z.number(), children: z.string().optional(), - promotionCode: z.string().optional(), - reservationProfileType: z.string().optional().default(""), - attachedProfileId: z.string().optional().default(""), + bookingCode: z.string().optional(), rateCode: z.string().optional(), }) @@ -31,9 +27,7 @@ export const getSelectedRoomAvailabilityInputSchema = z.object({ roomStayEndDate: z.string(), adults: z.number(), children: z.string().optional(), - promotionCode: z.string().optional(), - reservationProfileType: z.string().optional().default(""), - attachedProfileId: z.string().optional().default(""), + bookingCode: z.string().optional(), rateCode: z.string(), roomTypeCode: z.string(), packageCodes: z.array(z.nativeEnum(RoomPackageCodeEnum)).optional(), diff --git a/server/routers/hotels/output.ts b/server/routers/hotels/output.ts index 9bc81225e..b68e87fc0 100644 --- a/server/routers/hotels/output.ts +++ b/server/routers/hotels/output.ts @@ -491,22 +491,6 @@ const occupancySchema = z.object({ children: z.array(childrenSchema), }) -const bestPricePerStaySchema = z.object({ - currency: z.string(), - // TODO: remove optional when API is ready - regularAmount: z.string().optional(), - // TODO: remove optional when API is ready - memberAmount: z.string().optional(), -}) - -const bestPricePerNightSchema = z.object({ - currency: z.string(), - // TODO: remove optional when API is ready - regularAmount: z.string().optional(), - // TODO: remove optional when API is ready - memberAmount: z.string().optional(), -}) - const linksSchema = z.object({ links: z.array( z.object({ @@ -516,30 +500,6 @@ const linksSchema = z.object({ ), }) -const hotelsAvailabilitySchema = z.object({ - data: z.array( - z.object({ - attributes: z.object({ - checkInDate: z.string(), - checkOutDate: z.string(), - occupancy: occupancySchema.optional(), - status: z.string(), - hotelId: z.number(), - ratePlanSet: z.string().optional(), - bestPricePerStay: bestPricePerStaySchema.optional(), - bestPricePerNight: bestPricePerNightSchema.optional(), - }), - relationships: linksSchema.optional(), - type: z.string().optional(), - }) - ), -}) - -export const getHotelsAvailabilitySchema = hotelsAvailabilitySchema -export type HotelsAvailability = z.infer -export type HotelsAvailabilityPrices = - HotelsAvailability["data"][number]["attributes"]["bestPricePerNight"] - export const priceSchema = z.object({ pricePerNight: z.coerce.number(), pricePerStay: z.coerce.number(), @@ -550,7 +510,7 @@ export const productTypePriceSchema = z.object({ rateCode: z.string(), rateType: z.string().optional(), localPrice: priceSchema, - requestedPrice: priceSchema, + requestedPrice: priceSchema.optional(), }) const productSchema = z.object({ @@ -560,6 +520,34 @@ const productSchema = z.object({ }), }) +const hotelsAvailabilitySchema = z.object({ + data: z.array( + z.object({ + attributes: z.object({ + checkInDate: z.string(), + checkOutDate: z.string(), + occupancy: occupancySchema, + status: z.string(), + hotelId: z.number(), + productType: z + .object({ + public: productTypePriceSchema.optional(), + member: productTypePriceSchema.optional(), + }) + .optional(), + }), + relationships: linksSchema.optional(), + type: z.string().optional(), + }) + ), +}) + +export const getHotelsAvailabilitySchema = hotelsAvailabilitySchema +export type HotelsAvailability = z.infer +export type ProductType = + HotelsAvailability["data"][number]["attributes"]["productType"] +export type ProductTypePrices = z.infer + const roomConfigurationSchema = z.object({ status: z.string(), roomTypeCode: z.string(), diff --git a/server/routers/hotels/query.ts b/server/routers/hotels/query.ts index 28dd458d9..ea094de0f 100644 --- a/server/routers/hotels/query.ts +++ b/server/routers/hotels/query.ts @@ -369,9 +369,7 @@ export const hotelQueryRouter = router({ roomStayEndDate, adults, children, - promotionCode, - reservationProfileType, - attachedProfileId, + bookingCode, } = input const params: Record = { @@ -379,9 +377,7 @@ export const hotelQueryRouter = router({ roomStayEndDate, adults, ...(children && { children }), - promotionCode, - reservationProfileType, - attachedProfileId, + bookingCode, language: apiLang, } hotelsAvailabilityCounter.add(1, { @@ -390,8 +386,7 @@ export const hotelQueryRouter = router({ roomStayEndDate, adults, children, - promotionCode, - reservationProfileType, + bookingCode, }) console.info( "api.hotels.hotelsAvailability start", @@ -414,8 +409,7 @@ export const hotelQueryRouter = router({ roomStayEndDate, adults, children, - promotionCode, - reservationProfileType, + bookingCode, error_type: "http_error", error: JSON.stringify({ status: apiResponse.status, @@ -446,8 +440,7 @@ export const hotelQueryRouter = router({ roomStayEndDate, adults, children, - promotionCode, - reservationProfileType, + bookingCode, error_type: "validation_error", error: JSON.stringify(validateAvailabilityData.error), }) @@ -466,8 +459,7 @@ export const hotelQueryRouter = router({ roomStayEndDate, adults, children, - promotionCode, - reservationProfileType, + bookingCode, }) console.info( "api.hotels.hotelsAvailability success", @@ -493,9 +485,7 @@ export const hotelQueryRouter = router({ roomStayEndDate, adults, children, - promotionCode, - reservationProfileType, - attachedProfileId, + bookingCode, rateCode, } = input @@ -504,9 +494,7 @@ export const hotelQueryRouter = router({ roomStayEndDate, adults, ...(children && { children }), - promotionCode, - reservationProfileType, - attachedProfileId, + bookingCode, } roomsAvailabilityCounter.add(1, { @@ -515,8 +503,7 @@ export const hotelQueryRouter = router({ roomStayEndDate, adults, children, - promotionCode, - reservationProfileType, + bookingCode, }) console.info( "api.hotels.roomsAvailability start", @@ -540,8 +527,7 @@ export const hotelQueryRouter = router({ roomStayEndDate, adults, children, - promotionCode, - reservationProfileType, + bookingCode, error_type: "http_error", error: JSON.stringify({ status: apiResponse.status, @@ -572,8 +558,7 @@ export const hotelQueryRouter = router({ roomStayEndDate, adults, children, - promotionCode, - reservationProfileType, + bookingCode, error_type: "validation_error", error: JSON.stringify(validateAvailabilityData.error), }) @@ -592,8 +577,7 @@ export const hotelQueryRouter = router({ roomStayEndDate, adults, children, - promotionCode, - reservationProfileType, + bookingCode, }) console.info( "api.hotels.roomsAvailability success", @@ -620,9 +604,7 @@ export const hotelQueryRouter = router({ roomStayEndDate, adults, children, - promotionCode, - reservationProfileType, - attachedProfileId, + bookingCode, rateCode, roomTypeCode, packageCodes, @@ -633,9 +615,7 @@ export const hotelQueryRouter = router({ roomStayEndDate, adults, ...(children && { children }), - promotionCode, - reservationProfileType, - attachedProfileId, + bookingCode, language: toApiLang(ctx.lang), } @@ -645,8 +625,7 @@ export const hotelQueryRouter = router({ roomStayEndDate, adults, children, - promotionCode, - reservationProfileType, + bookingCode, }) console.info( "api.hotels.selectedRoomAvailability start", @@ -670,8 +649,7 @@ export const hotelQueryRouter = router({ roomStayEndDate, adults, children, - promotionCode, - reservationProfileType, + bookingCode, error_type: "http_error", error: JSON.stringify({ status: apiResponseAvailability.status, @@ -702,8 +680,7 @@ export const hotelQueryRouter = router({ roomStayEndDate, adults, children, - promotionCode, - reservationProfileType, + bookingCode, error_type: "validation_error", error: JSON.stringify(validateAvailabilityData.error), }) @@ -797,8 +774,7 @@ export const hotelQueryRouter = router({ roomStayEndDate, adults, children, - promotionCode, - reservationProfileType, + bookingCode, }) console.info( "api.hotels.selectedRoomAvailability success", diff --git a/stores/enter-details.ts b/stores/enter-details.ts index 374f1dc89..b5f99cc38 100644 --- a/stores/enter-details.ts +++ b/stores/enter-details.ts @@ -25,7 +25,7 @@ const SESSION_STORAGE_KEY = "enterDetails" type TotalPrice = { local: { price: number; currency: string } - euro: { price: number; currency: string } + euro?: { price: number; currency: string } } export interface EnterDetailsState { diff --git a/types/components/hotelReservation/enterDetails/bookingData.ts b/types/components/hotelReservation/enterDetails/bookingData.ts index 74ed4bda4..628fa3f8b 100644 --- a/types/components/hotelReservation/enterDetails/bookingData.ts +++ b/types/components/hotelReservation/enterDetails/bookingData.ts @@ -25,7 +25,7 @@ type Price = { export type RoomsData = { roomType: string localPrice: Price - euroPrice: Price + euroPrice: Price | undefined adults: number children?: Child[] cancellationText: string diff --git a/types/components/hotelReservation/selectHotel/availabilityInput.ts b/types/components/hotelReservation/selectHotel/availabilityInput.ts index 5b3a51b93..d8b7aad26 100644 --- a/types/components/hotelReservation/selectHotel/availabilityInput.ts +++ b/types/components/hotelReservation/selectHotel/availabilityInput.ts @@ -4,7 +4,5 @@ export type AvailabilityInput = { roomStayEndDate: string adults: number children?: string - promotionCode?: string - reservationProfileType?: string - attachedProfileId?: string + bookingCode?: string } diff --git a/types/components/hotelReservation/selectHotel/hotePriceListProps.ts b/types/components/hotelReservation/selectHotel/hotePriceListProps.ts index 2464fad43..4144abf45 100644 --- a/types/components/hotelReservation/selectHotel/hotePriceListProps.ts +++ b/types/components/hotelReservation/selectHotel/hotePriceListProps.ts @@ -1,5 +1,6 @@ -import type { HotelsAvailabilityPrices } from "@/server/routers/hotels/output" +import type { ProductType } from "@/server/routers/hotels/output" export type HotelPriceListProps = { - price: HotelsAvailabilityPrices + price: ProductType + hotelId: string } diff --git a/types/components/hotelReservation/selectHotel/hotelCardListingProps.ts b/types/components/hotelReservation/selectHotel/hotelCardListingProps.ts index 9c56f9949..68a6174ed 100644 --- a/types/components/hotelReservation/selectHotel/hotelCardListingProps.ts +++ b/types/components/hotelReservation/selectHotel/hotelCardListingProps.ts @@ -1,4 +1,4 @@ -import { HotelsAvailabilityPrices } from "@/server/routers/hotels/output" +import { ProductType } from "@/server/routers/hotels/output" import { Hotel } from "@/types/hotel" @@ -16,5 +16,5 @@ export type HotelCardListingProps = { export type HotelData = { hotelData: Hotel - price: HotelsAvailabilityPrices + price: ProductType } diff --git a/types/components/hotelReservation/selectHotel/map.ts b/types/components/hotelReservation/selectHotel/map.ts index 233fc2105..810dba573 100644 --- a/types/components/hotelReservation/selectHotel/map.ts +++ b/types/components/hotelReservation/selectHotel/map.ts @@ -29,8 +29,8 @@ type ImageMetaData = z.infer export type HotelPin = { name: string coordinates: Coordinates - publicPrice: string | null - memberPrice: string | null + publicPrice: number | null + memberPrice: number | null currency: string | null images: { imageSizes: ImageSizes diff --git a/types/components/hotelReservation/selectHotel/priceCardProps.ts b/types/components/hotelReservation/selectHotel/priceCardProps.ts index d339b4a06..a56a67d0f 100644 --- a/types/components/hotelReservation/selectHotel/priceCardProps.ts +++ b/types/components/hotelReservation/selectHotel/priceCardProps.ts @@ -1,5 +1,6 @@ +import { ProductTypePrices } from "@/server/routers/hotels/output" + export type PriceCardProps = { - currency: string - memberAmount?: string | undefined - regularAmount?: string | undefined + productTypePrices: ProductTypePrices + isMemberPrice?: boolean } From 94f693c4f0e41372b6bb36d6778f5ce7a0c3f469 Mon Sep 17 00:00:00 2001 From: Simon Emanuelsson Date: Mon, 18 Nov 2024 09:13:23 +0100 Subject: [PATCH 16/24] feat: make steps of enter details flow dynamic depending on data --- .../[step]/@hotelHeader/[...paths]/page.tsx | 1 - .../(standard)/[step]/@hotelHeader/page.tsx | 25 --- .../(standard)/select-hotel/page.tsx | 2 +- .../{[step] => step}/@hotelHeader/loading.tsx | 0 .../step/@hotelHeader/page.module.css | 8 +- .../(standard)/step/@hotelHeader/page.tsx | 35 ++- .../{[step] => step}/@summary/page.module.css | 0 .../{[step] => step}/@summary/page.tsx | 2 +- .../(standard)/{[step] => step}/_preload.ts | 0 .../{[step] => step}/enterDetailsLayout.css | 0 .../(standard)/{[step] => step}/layout.tsx | 11 +- .../(standard)/{[step] => step}/page.tsx | 120 +++++----- .../payment-callback/[lang]/[status]/route.ts | 4 +- components/Forms/BookingWidget/index.tsx | 2 +- .../BedType/bedOptions.module.css | 3 +- .../EnterDetails/BedType/index.tsx | 20 +- .../Breakfast/breakfast.module.css | 1 - .../EnterDetails/Breakfast/index.tsx | 39 ++-- .../EnterDetails/Breakfast/schema.ts | 8 +- .../EnterDetails/Details/details.module.css | 1 - .../EnterDetails/Details/index.tsx | 42 ++-- .../HistoryStateManager/index.tsx | 6 +- .../EnterDetails/Payment/index.tsx | 16 +- .../EnterDetails/Provider/index.tsx | 29 --- .../EnterDetails/SectionAccordion/index.tsx | 29 +-- .../sectionAccordion.module.css | 7 +- .../EnterDetails/SelectedRoom/index.tsx | 9 +- .../Summary/BottomSheet/index.tsx | 6 +- .../EnterDetails/Summary/index.tsx | 192 ++++++++-------- .../HotelCard/HotelPriceList/index.tsx | 2 +- .../HotelReservation/HotelCard/index.tsx | 2 +- .../HotelCardDialog/index.tsx | 2 +- .../MobileMapButtonContainer/index.tsx | 2 +- .../SelectHotel/SelectHotelMap/index.tsx | 2 +- .../SelectRate/RoomSelection/utils.ts | 23 -- .../Form/ChoiceCard/_Card/index.tsx | 2 + components/TempDesignSystem/Select/index.tsx | 3 + constants/routes/hotelReservation.js | 126 ++++------- contexts/Details.ts | 5 + contexts/Steps.ts | 5 + hooks/useSetOverflowVisibleOnRA.ts | 11 + middlewares/bookingFlow.ts | 6 +- next.config.js | 5 + providers/DetailsProvider.tsx | 30 +++ providers/StepsProvider.tsx | 53 +++++ server/routers/hotels/schemas/packages.ts | 62 ++++++ stores/details.ts | 195 ++++++++++++++++ stores/enter-details.ts | 209 ------------------ stores/steps.ts | 159 +++++++++++++ .../enterDetails/breakfast.ts | 2 +- .../hotelReservation/enterDetails/step.ts | 7 +- .../hotelReservation/enterDetails/store.ts | 3 - .../hotelReservation/enterDetails/summary.ts | 6 + .../selectRate/sectionAccordion.ts | 2 +- types/contexts/details.ts | 3 + types/contexts/steps.ts | 3 + types/enums/breakfast.ts | 1 - types/enums/step.ts | 6 + types/providers/details.ts | 3 + types/providers/steps.ts | 10 + types/stores/details.ts | 40 ++++ types/stores/steps.ts | 10 + 62 files changed, 959 insertions(+), 659 deletions(-) delete mode 100644 app/[lang]/(live)/(public)/hotelreservation/(standard)/[step]/@hotelHeader/[...paths]/page.tsx delete mode 100644 app/[lang]/(live)/(public)/hotelreservation/(standard)/[step]/@hotelHeader/page.tsx rename app/[lang]/(live)/(public)/hotelreservation/(standard)/{[step] => step}/@hotelHeader/loading.tsx (100%) rename components/HotelReservation/HotelSelectionHeader/hotelSelectionHeader.module.css => app/[lang]/(live)/(public)/hotelreservation/(standard)/step/@hotelHeader/page.module.css (90%) rename components/HotelReservation/HotelSelectionHeader/index.tsx => app/[lang]/(live)/(public)/hotelreservation/(standard)/step/@hotelHeader/page.tsx (63%) rename app/[lang]/(live)/(public)/hotelreservation/(standard)/{[step] => step}/@summary/page.module.css (100%) rename app/[lang]/(live)/(public)/hotelreservation/(standard)/{[step] => step}/@summary/page.tsx (99%) rename app/[lang]/(live)/(public)/hotelreservation/(standard)/{[step] => step}/_preload.ts (100%) rename app/[lang]/(live)/(public)/hotelreservation/(standard)/{[step] => step}/enterDetailsLayout.css (100%) rename app/[lang]/(live)/(public)/hotelreservation/(standard)/{[step] => step}/layout.tsx (73%) rename app/[lang]/(live)/(public)/hotelreservation/(standard)/{[step] => step}/page.tsx (59%) delete mode 100644 components/HotelReservation/EnterDetails/Provider/index.tsx create mode 100644 contexts/Details.ts create mode 100644 contexts/Steps.ts create mode 100644 hooks/useSetOverflowVisibleOnRA.ts create mode 100644 providers/DetailsProvider.tsx create mode 100644 providers/StepsProvider.tsx create mode 100644 server/routers/hotels/schemas/packages.ts create mode 100644 stores/details.ts delete mode 100644 stores/enter-details.ts create mode 100644 stores/steps.ts delete mode 100644 types/components/hotelReservation/enterDetails/store.ts create mode 100644 types/components/hotelReservation/enterDetails/summary.ts create mode 100644 types/contexts/details.ts create mode 100644 types/contexts/steps.ts create mode 100644 types/enums/step.ts create mode 100644 types/providers/details.ts create mode 100644 types/providers/steps.ts create mode 100644 types/stores/details.ts create mode 100644 types/stores/steps.ts diff --git a/app/[lang]/(live)/(public)/hotelreservation/(standard)/[step]/@hotelHeader/[...paths]/page.tsx b/app/[lang]/(live)/(public)/hotelreservation/(standard)/[step]/@hotelHeader/[...paths]/page.tsx deleted file mode 100644 index 03a82e5f5..000000000 --- a/app/[lang]/(live)/(public)/hotelreservation/(standard)/[step]/@hotelHeader/[...paths]/page.tsx +++ /dev/null @@ -1 +0,0 @@ -export { default } from "../page" diff --git a/app/[lang]/(live)/(public)/hotelreservation/(standard)/[step]/@hotelHeader/page.tsx b/app/[lang]/(live)/(public)/hotelreservation/(standard)/[step]/@hotelHeader/page.tsx deleted file mode 100644 index 75101475a..000000000 --- a/app/[lang]/(live)/(public)/hotelreservation/(standard)/[step]/@hotelHeader/page.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { redirect } from "next/navigation" - -import { getHotelData } from "@/lib/trpc/memoizedRequests" - -import HotelSelectionHeader from "@/components/HotelReservation/HotelSelectionHeader" - -import type { LangParams, PageArgs } from "@/types/params" - -export default async function HotelHeader({ - params, - searchParams, -}: PageArgs) { - const home = `/${params.lang}` - if (!searchParams.hotel) { - redirect(home) - } - const hotel = await getHotelData({ - hotelId: searchParams.hotel, - language: params.lang, - }) - if (!hotel?.data) { - redirect(home) - } - return -} diff --git a/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-hotel/page.tsx b/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-hotel/page.tsx index 791773f94..a12639acf 100644 --- a/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-hotel/page.tsx +++ b/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-hotel/page.tsx @@ -86,7 +86,7 @@ export default async function SelectHotelPage({
    diff --git a/app/[lang]/(live)/(public)/hotelreservation/(standard)/[step]/@hotelHeader/loading.tsx b/app/[lang]/(live)/(public)/hotelreservation/(standard)/step/@hotelHeader/loading.tsx similarity index 100% rename from app/[lang]/(live)/(public)/hotelreservation/(standard)/[step]/@hotelHeader/loading.tsx rename to app/[lang]/(live)/(public)/hotelreservation/(standard)/step/@hotelHeader/loading.tsx diff --git a/components/HotelReservation/HotelSelectionHeader/hotelSelectionHeader.module.css b/app/[lang]/(live)/(public)/hotelreservation/(standard)/step/@hotelHeader/page.module.css similarity index 90% rename from components/HotelReservation/HotelSelectionHeader/hotelSelectionHeader.module.css rename to app/[lang]/(live)/(public)/hotelreservation/(standard)/step/@hotelHeader/page.module.css index 9eefdfb33..82d6353ac 100644 --- a/components/HotelReservation/HotelSelectionHeader/hotelSelectionHeader.module.css +++ b/app/[lang]/(live)/(public)/hotelreservation/(standard)/step/@hotelHeader/page.module.css @@ -1,9 +1,9 @@ -.hotelSelectionHeader { +.header { background-color: var(--Base-Surface-Subtle-Normal); padding: var(--Spacing-x3) var(--Spacing-x2); } -.hotelSelectionHeaderWrapper { +.wrapper { display: flex; flex-direction: column; gap: var(--Spacing-x3); @@ -35,11 +35,11 @@ } @media (min-width: 768px) { - .hotelSelectionHeader { + .header { padding: var(--Spacing-x4) 0; } - .hotelSelectionHeaderWrapper { + .wrapper { flex-direction: row; gap: var(--Spacing-x6); margin: 0 auto; diff --git a/components/HotelReservation/HotelSelectionHeader/index.tsx b/app/[lang]/(live)/(public)/hotelreservation/(standard)/step/@hotelHeader/page.tsx similarity index 63% rename from components/HotelReservation/HotelSelectionHeader/index.tsx rename to app/[lang]/(live)/(public)/hotelreservation/(standard)/step/@hotelHeader/page.tsx index c0045dff5..83412f1d1 100644 --- a/components/HotelReservation/HotelSelectionHeader/index.tsx +++ b/app/[lang]/(live)/(public)/hotelreservation/(standard)/step/@hotelHeader/page.tsx @@ -1,23 +1,38 @@ -"use client" -import { useIntl } from "react-intl" +import { redirect } from "next/navigation" + +import { getHotelData } from "@/lib/trpc/memoizedRequests" import Divider from "@/components/TempDesignSystem/Divider" import Body from "@/components/TempDesignSystem/Text/Body" import Caption from "@/components/TempDesignSystem/Text/Caption" import Title from "@/components/TempDesignSystem/Text/Title" +import { getIntl } from "@/i18n" -import styles from "./hotelSelectionHeader.module.css" +import styles from "./page.module.css" -import { HotelSelectionHeaderProps } from "@/types/components/hotelReservation/selectRate/hotelSelectionHeader" +import type { LangParams, PageArgs } from "@/types/params" -export default function HotelSelectionHeader({ - hotel, -}: HotelSelectionHeaderProps) { - const intl = useIntl() +export default async function HotelHeader({ + params, + searchParams, +}: PageArgs) { + const home = `/${params.lang}` + if (!searchParams.hotel) { + redirect(home) + } + const hotelData = await getHotelData({ + hotelId: searchParams.hotel, + language: params.lang, + }) + if (!hotelData?.data) { + redirect(home) + } + const intl = await getIntl() + const hotel = hotelData.data.attributes return ( -
    -
    +
    +
    {hotel.name} diff --git a/app/[lang]/(live)/(public)/hotelreservation/(standard)/[step]/@summary/page.module.css b/app/[lang]/(live)/(public)/hotelreservation/(standard)/step/@summary/page.module.css similarity index 100% rename from app/[lang]/(live)/(public)/hotelreservation/(standard)/[step]/@summary/page.module.css rename to app/[lang]/(live)/(public)/hotelreservation/(standard)/step/@summary/page.module.css diff --git a/app/[lang]/(live)/(public)/hotelreservation/(standard)/[step]/@summary/page.tsx b/app/[lang]/(live)/(public)/hotelreservation/(standard)/step/@summary/page.tsx similarity index 99% rename from app/[lang]/(live)/(public)/hotelreservation/(standard)/[step]/@summary/page.tsx rename to app/[lang]/(live)/(public)/hotelreservation/(standard)/step/@summary/page.tsx index d3228e7b7..da3554f50 100644 --- a/app/[lang]/(live)/(public)/hotelreservation/(standard)/[step]/@summary/page.tsx +++ b/app/[lang]/(live)/(public)/hotelreservation/(standard)/step/@summary/page.tsx @@ -61,7 +61,7 @@ export default async function SummaryPage({ if (!availability || !availability.selectedRoom) { console.error("No hotel or availability data", availability) // TODO: handle this case - redirect(selectRate[params.lang]) + redirect(selectRate(params.lang)) } const prices = diff --git a/app/[lang]/(live)/(public)/hotelreservation/(standard)/[step]/_preload.ts b/app/[lang]/(live)/(public)/hotelreservation/(standard)/step/_preload.ts similarity index 100% rename from app/[lang]/(live)/(public)/hotelreservation/(standard)/[step]/_preload.ts rename to app/[lang]/(live)/(public)/hotelreservation/(standard)/step/_preload.ts diff --git a/app/[lang]/(live)/(public)/hotelreservation/(standard)/[step]/enterDetailsLayout.css b/app/[lang]/(live)/(public)/hotelreservation/(standard)/step/enterDetailsLayout.css similarity index 100% rename from app/[lang]/(live)/(public)/hotelreservation/(standard)/[step]/enterDetailsLayout.css rename to app/[lang]/(live)/(public)/hotelreservation/(standard)/step/enterDetailsLayout.css diff --git a/app/[lang]/(live)/(public)/hotelreservation/(standard)/[step]/layout.tsx b/app/[lang]/(live)/(public)/hotelreservation/(standard)/step/layout.tsx similarity index 73% rename from app/[lang]/(live)/(public)/hotelreservation/(standard)/[step]/layout.tsx rename to app/[lang]/(live)/(public)/hotelreservation/(standard)/step/layout.tsx index fbd462544..2bd8a5102 100644 --- a/app/[lang]/(live)/(public)/hotelreservation/(standard)/[step]/layout.tsx +++ b/app/[lang]/(live)/(public)/hotelreservation/(standard)/step/layout.tsx @@ -1,20 +1,19 @@ import { getProfileSafely } from "@/lib/trpc/memoizedRequests" -import EnterDetailsProvider from "@/components/HotelReservation/EnterDetails/Provider" import { setLang } from "@/i18n/serverContext" +import DetailsProvider from "@/providers/DetailsProvider" import { preload } from "./_preload" -import { StepEnum } from "@/types/components/hotelReservation/enterDetails/step" import type { LangParams, LayoutArgs } from "@/types/params" export default async function StepLayout({ - summary, children, hotelHeader, params, + summary, }: React.PropsWithChildren< - LayoutArgs<LangParams & { step: StepEnum }> & { + LayoutArgs<LangParams> & { hotelHeader: React.ReactNode summary: React.ReactNode } @@ -25,7 +24,7 @@ export default async function StepLayout({ const user = await getProfileSafely() return ( - <EnterDetailsProvider step={params.step} isMember={!!user}> + <DetailsProvider isMember={!!user}> <main className="enter-details-layout__layout"> {hotelHeader} <div className={"enter-details-layout__container"}> @@ -35,6 +34,6 @@ export default async function StepLayout({ </aside> </div> </main> - </EnterDetailsProvider> + </DetailsProvider> ) } diff --git a/app/[lang]/(live)/(public)/hotelreservation/(standard)/[step]/page.tsx b/app/[lang]/(live)/(public)/hotelreservation/(standard)/step/page.tsx similarity index 59% rename from app/[lang]/(live)/(public)/hotelreservation/(standard)/[step]/page.tsx rename to app/[lang]/(live)/(public)/hotelreservation/(standard)/step/page.tsx index ae04c61a6..648cdff93 100644 --- a/app/[lang]/(live)/(public)/hotelreservation/(standard)/[step]/page.tsx +++ b/app/[lang]/(live)/(public)/hotelreservation/(standard)/step/page.tsx @@ -1,6 +1,6 @@ import "./enterDetailsLayout.css" -import { notFound } from "next/navigation" +import { notFound, redirect, RedirectType } from "next/navigation" import { getBreakfastPackages, @@ -22,9 +22,10 @@ import { getQueryParamsForEnterDetails, } from "@/components/HotelReservation/SelectRate/RoomSelection/utils" import { getIntl } from "@/i18n" +import StepsProvider from "@/providers/StepsProvider" -import { StepEnum } from "@/types/components/hotelReservation/enterDetails/step" import { SelectRateSearchParams } from "@/types/components/hotelReservation/selectRate/selectRate" +import { StepEnum } from "@/types/enums/step" import type { LangParams, PageArgs } from "@/types/params" function isValidStep(step: string): step is StepEnum { @@ -32,11 +33,9 @@ function isValidStep(step: string): step is StepEnum { } export default async function StepPage({ - params, + params: { lang }, searchParams, -}: PageArgs<LangParams & { step: StepEnum }, SelectRateSearchParams>) { - const { lang } = params - +}: PageArgs<LangParams, SelectRateSearchParams & { step: StepEnum }>) { const intl = await getIntl() const selectRoomParams = new URLSearchParams(searchParams) const { @@ -88,7 +87,7 @@ export default async function StepPage({ const user = await getProfileSafely() const savedCreditCards = await getCreditCardsSafely() - if (!isValidStep(params.step) || !hotelData || !roomAvailability) { + if (!isValidStep(searchParams.step) || !hotelData || !roomAvailability) { return notFound() } @@ -113,54 +112,65 @@ export default async function StepPage({ } return ( - <section> - <HistoryStateManager /> - <SelectedRoom - hotelId={hotelId} - room={roomAvailability.selectedRoom} - rateDescription={roomAvailability.cancellationText} - /> - - {/* TODO: How to handle no beds found? */} - {roomAvailability.bedTypes ? ( - <SectionAccordion - header="Select bed" - step={StepEnum.selectBed} - label={intl.formatMessage({ id: "Request bedtype" })} - > - <BedType bedTypes={roomAvailability.bedTypes} /> - </SectionAccordion> - ) : null} - - <SectionAccordion - header={intl.formatMessage({ id: "Food options" })} - step={StepEnum.breakfast} - label={intl.formatMessage({ id: "Select breakfast options" })} - > - <Breakfast packages={breakfastPackages} /> - </SectionAccordion> - <SectionAccordion - header={intl.formatMessage({ id: "Details" })} - step={StepEnum.details} - label={intl.formatMessage({ id: "Enter your details" })} - > - <Details user={user} /> - </SectionAccordion> - <SectionAccordion - header={mustBeGuaranteed ? paymentGuarantee : payment} - step={StepEnum.payment} - label={mustBeGuaranteed ? guaranteeWithCard : selectPaymentMethod} - > - <Payment - roomPrice={roomPrice} - otherPaymentOptions={ - hotelData.data.attributes.merchantInformationData - .alternatePaymentOptions - } - savedCreditCards={savedCreditCards} - mustBeGuaranteed={mustBeGuaranteed} + <StepsProvider + bedTypes={roomAvailability.bedTypes} + breakfastPackages={breakfastPackages} + isMember={!!user} + step={searchParams.step} + > + <section> + <HistoryStateManager /> + <SelectedRoom + hotelId={hotelId} + room={roomAvailability.selectedRoom} + rateDescription={roomAvailability.cancellationText} /> - </SectionAccordion> - </section> + + {/* TODO: How to handle no beds found? */} + {roomAvailability.bedTypes ? ( + <SectionAccordion + header={intl.formatMessage({ id: "Select bed" })} + step={StepEnum.selectBed} + label={intl.formatMessage({ id: "Request bedtype" })} + > + <BedType bedTypes={roomAvailability.bedTypes} /> + </SectionAccordion> + ) : null} + + {breakfastPackages?.length ? ( + <SectionAccordion + header={intl.formatMessage({ id: "Food options" })} + step={StepEnum.breakfast} + label={intl.formatMessage({ id: "Select breakfast options" })} + > + <Breakfast packages={breakfastPackages} /> + </SectionAccordion> + ) : null} + + <SectionAccordion + header={intl.formatMessage({ id: "Details" })} + step={StepEnum.details} + label={intl.formatMessage({ id: "Enter your details" })} + > + <Details user={user} /> + </SectionAccordion> + + <SectionAccordion + header={mustBeGuaranteed ? paymentGuarantee : payment} + step={StepEnum.payment} + label={mustBeGuaranteed ? guaranteeWithCard : selectPaymentMethod} + > + <Payment + roomPrice={roomPrice} + otherPaymentOptions={ + hotelData.data.attributes.merchantInformationData + .alternatePaymentOptions + } + savedCreditCards={savedCreditCards} + mustBeGuaranteed={mustBeGuaranteed} + /> + </SectionAccordion> + </section> + </StepsProvider> ) } diff --git a/app/api/web/payment-callback/[lang]/[status]/route.ts b/app/api/web/payment-callback/[lang]/[status]/route.ts index 624df1f52..5884d63f9 100644 --- a/app/api/web/payment-callback/[lang]/[status]/route.ts +++ b/app/api/web/payment-callback/[lang]/[status]/route.ts @@ -26,7 +26,7 @@ export async function GET( const confirmationNumber = queryParams.get(BOOKING_CONFIRMATION_NUMBER) if (status === "success" && confirmationNumber) { - const confirmationUrl = new URL(`${publicURL}/${bookingConfirmation[lang]}`) + const confirmationUrl = new URL(`${publicURL}/${bookingConfirmation(lang)}`) confirmationUrl.searchParams.set( BOOKING_CONFIRMATION_NUMBER, confirmationNumber @@ -36,7 +36,7 @@ export async function GET( return NextResponse.redirect(confirmationUrl) } - const returnUrl = new URL(`${publicURL}/${payment[lang]}`) + const returnUrl = new URL(`${publicURL}/${payment(lang)}`) returnUrl.search = queryParams.toString() if (confirmationNumber) { diff --git a/components/Forms/BookingWidget/index.tsx b/components/Forms/BookingWidget/index.tsx index b9ea569e8..b47ae74aa 100644 --- a/components/Forms/BookingWidget/index.tsx +++ b/components/Forms/BookingWidget/index.tsx @@ -35,7 +35,7 @@ export default function Form({ const locationData: Location = JSON.parse(decodeURIComponent(data.location)) const bookingFlowPage = - locationData.type == "cities" ? selectHotel[lang] : selectRate[lang] + locationData.type == "cities" ? selectHotel(lang) : selectRate(lang) const bookingWidgetParams = new URLSearchParams(data.date) if (locationData.type == "cities") diff --git a/components/HotelReservation/EnterDetails/BedType/bedOptions.module.css b/components/HotelReservation/EnterDetails/BedType/bedOptions.module.css index 81fd223b9..844ed4a6b 100644 --- a/components/HotelReservation/EnterDetails/BedType/bedOptions.module.css +++ b/components/HotelReservation/EnterDetails/BedType/bedOptions.module.css @@ -1,7 +1,6 @@ .form { display: grid; gap: var(--Spacing-x2); - grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); - padding-bottom: var(--Spacing-x3); + grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); width: min(600px, 100%); } diff --git a/components/HotelReservation/EnterDetails/BedType/index.tsx b/components/HotelReservation/EnterDetails/BedType/index.tsx index 1bf78fee5..eeb8237a0 100644 --- a/components/HotelReservation/EnterDetails/BedType/index.tsx +++ b/components/HotelReservation/EnterDetails/BedType/index.tsx @@ -4,7 +4,8 @@ import { zodResolver } from "@hookform/resolvers/zod" import { useCallback, useEffect } from "react" import { FormProvider, useForm } from "react-hook-form" -import { useEnterDetailsStore } from "@/stores/enter-details" +import { useDetailsStore } from "@/stores/details" +import { useStepsStore } from "@/stores/steps" import { KingBedIcon } from "@/components/Icons" import RadioCard from "@/components/TempDesignSystem/Form/ChoiceCard/Radio" @@ -19,22 +20,18 @@ import type { } from "@/types/components/hotelReservation/enterDetails/bedType" export default function BedType({ bedTypes }: BedTypeProps) { - const bedType = useEnterDetailsStore((state) => state.userData.bedType) + const bedType = useDetailsStore((state) => state.data.bedType?.roomTypeCode) + const completeStep = useStepsStore((state) => state.completeStep) + const updateBedType = useDetailsStore((state) => state.actions.updateBedType) const methods = useForm<BedTypeFormSchema>({ - defaultValues: bedType?.roomTypeCode - ? { - bedType: bedType.roomTypeCode, - } - : undefined, + defaultValues: bedType ? { bedType } : undefined, criteriaMode: "all", mode: "all", resolver: zodResolver(bedTypeFormSchema), reValidateMode: "onChange", }) - const completeStep = useEnterDetailsStore((state) => state.completeStep) - const onSubmit = useCallback( (bedTypeRoomCode: BedTypeFormSchema) => { const matchingRoom = bedTypes.find( @@ -45,10 +42,11 @@ export default function BedType({ bedTypes }: BedTypeProps) { description: matchingRoom.description, roomTypeCode: matchingRoom.value, } - completeStep({ bedType }) + updateBedType(bedType) + completeStep() } }, - [completeStep, bedTypes] + [bedTypes, completeStep, updateBedType] ) useEffect(() => { diff --git a/components/HotelReservation/EnterDetails/Breakfast/breakfast.module.css b/components/HotelReservation/EnterDetails/Breakfast/breakfast.module.css index 81fd223b9..f24c6ba64 100644 --- a/components/HotelReservation/EnterDetails/Breakfast/breakfast.module.css +++ b/components/HotelReservation/EnterDetails/Breakfast/breakfast.module.css @@ -2,6 +2,5 @@ display: grid; gap: var(--Spacing-x2); grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); - padding-bottom: var(--Spacing-x3); width: min(600px, 100%); } diff --git a/components/HotelReservation/EnterDetails/Breakfast/index.tsx b/components/HotelReservation/EnterDetails/Breakfast/index.tsx index 00d5ab4cc..fdaec3a84 100644 --- a/components/HotelReservation/EnterDetails/Breakfast/index.tsx +++ b/components/HotelReservation/EnterDetails/Breakfast/index.tsx @@ -5,7 +5,8 @@ import { useCallback, useEffect } from "react" import { FormProvider, useForm } from "react-hook-form" import { useIntl } from "react-intl" -import { useEnterDetailsStore } from "@/stores/enter-details" +import { useDetailsStore } from "@/stores/details" +import { useStepsStore } from "@/stores/steps" import { Highlight } from "@/components/TempDesignSystem/Form/ChoiceCard/_Card" import RadioCard from "@/components/TempDesignSystem/Form/ChoiceCard/Radio" @@ -23,34 +24,37 @@ import { BreakfastPackageEnum } from "@/types/enums/breakfast" export default function Breakfast({ packages }: BreakfastProps) { const intl = useIntl() - const breakfast = useEnterDetailsStore((state) => state.userData.breakfast) + const breakfast = useDetailsStore(({ data }) => + data.breakfast + ? data.breakfast.code + : data.breakfast === false + ? "false" + : data.breakfast + ) + const updateBreakfast = useDetailsStore( + (state) => state.actions.updateBreakfast + ) + const completeStep = useStepsStore((state) => state.completeStep) - let defaultValues = undefined - if (breakfast === BreakfastPackageEnum.NO_BREAKFAST) { - defaultValues = { breakfast: BreakfastPackageEnum.NO_BREAKFAST } - } else if (breakfast?.code) { - defaultValues = { breakfast: breakfast.code } - } const methods = useForm<BreakfastFormSchema>({ - defaultValues, + defaultValues: breakfast ? { breakfast } : undefined, criteriaMode: "all", mode: "all", resolver: zodResolver(breakfastFormSchema), reValidateMode: "onChange", }) - const completeStep = useEnterDetailsStore((state) => state.completeStep) - const onSubmit = useCallback( (values: BreakfastFormSchema) => { const pkg = packages?.find((p) => p.code === values.breakfast) if (pkg) { - completeStep({ breakfast: pkg }) + updateBreakfast(pkg) } else { - completeStep({ breakfast: BreakfastPackageEnum.NO_BREAKFAST }) + updateBreakfast(false) } + completeStep() }, - [completeStep, packages] + [completeStep, packages, updateBreakfast] ) useEffect(() => { @@ -61,10 +65,6 @@ export default function Breakfast({ packages }: BreakfastProps) { return () => subscription.unsubscribe() }, [methods, onSubmit]) - if (!packages) { - return null - } - return ( <FormProvider {...methods}> <form className={styles.form} onSubmit={methods.handleSubmit(onSubmit)}> @@ -100,7 +100,6 @@ export default function Breakfast({ packages }: BreakfastProps) { /> ))} <RadioCard - id={BreakfastPackageEnum.NO_BREAKFAST} name="breakfast" subtitle={intl.formatMessage( { id: "{amount} {currency}" }, @@ -113,7 +112,7 @@ export default function Breakfast({ packages }: BreakfastProps) { id: "You can always change your mind later and add breakfast at the hotel.", })} title={intl.formatMessage({ id: "No breakfast" })} - value={BreakfastPackageEnum.NO_BREAKFAST} + value="false" /> </form> </FormProvider> diff --git a/components/HotelReservation/EnterDetails/Breakfast/schema.ts b/components/HotelReservation/EnterDetails/Breakfast/schema.ts index 5f8c1f354..4766980cb 100644 --- a/components/HotelReservation/EnterDetails/Breakfast/schema.ts +++ b/components/HotelReservation/EnterDetails/Breakfast/schema.ts @@ -2,14 +2,10 @@ import { z } from "zod" import { breakfastPackageSchema } from "@/server/routers/hotels/output" -import { BreakfastPackageEnum } from "@/types/enums/breakfast" - export const breakfastStoreSchema = z.object({ - breakfast: breakfastPackageSchema.or( - z.literal(BreakfastPackageEnum.NO_BREAKFAST) - ), + breakfast: breakfastPackageSchema.or(z.literal(false)), }) export const breakfastFormSchema = z.object({ - breakfast: z.string().or(z.literal(BreakfastPackageEnum.NO_BREAKFAST)), + breakfast: z.string().or(z.literal("false")), }) diff --git a/components/HotelReservation/EnterDetails/Details/details.module.css b/components/HotelReservation/EnterDetails/Details/details.module.css index 62a947e3e..f89dfa7cc 100644 --- a/components/HotelReservation/EnterDetails/Details/details.module.css +++ b/components/HotelReservation/EnterDetails/Details/details.module.css @@ -1,7 +1,6 @@ .form { display: grid; gap: var(--Spacing-x2); - padding: var(--Spacing-x3) 0px; } .container { diff --git a/components/HotelReservation/EnterDetails/Details/index.tsx b/components/HotelReservation/EnterDetails/Details/index.tsx index 806776ce8..dd5959c31 100644 --- a/components/HotelReservation/EnterDetails/Details/index.tsx +++ b/components/HotelReservation/EnterDetails/Details/index.tsx @@ -1,9 +1,11 @@ "use client" import { zodResolver } from "@hookform/resolvers/zod" +import { useCallback } from "react" import { FormProvider, useForm } from "react-hook-form" import { useIntl } from "react-intl" -import { useEnterDetailsStore } from "@/stores/enter-details" +import { useDetailsStore } from "@/stores/details" +import { useStepsStore } from "@/stores/steps" import Button from "@/components/TempDesignSystem/Button" import CountrySelect from "@/components/TempDesignSystem/Form/Country" @@ -24,19 +26,22 @@ import type { const formID = "enter-details" export default function Details({ user }: DetailsProps) { const intl = useIntl() - const initialData = useEnterDetailsStore((state) => ({ - countryCode: state.userData.countryCode, - email: state.userData.email, - firstName: state.userData.firstName, - lastName: state.userData.lastName, - phoneNumber: state.userData.phoneNumber, - join: state.userData.join, - dateOfBirth: state.userData.dateOfBirth, - zipCode: state.userData.zipCode, - termsAccepted: state.userData.termsAccepted, - membershipNo: state.userData.membershipNo, + const initialData = useDetailsStore((state) => ({ + countryCode: state.data.countryCode, + email: state.data.email, + firstName: state.data.firstName, + lastName: state.data.lastName, + phoneNumber: state.data.phoneNumber, + join: state.data.join, + dateOfBirth: state.data.dateOfBirth, + zipCode: state.data.zipCode, + termsAccepted: state.data.termsAccepted, + membershipNo: state.data.membershipNo, })) + const updateDetails = useDetailsStore((state) => state.actions.updateDetails) + const completeStep = useStepsStore((state) => state.completeStep) + const methods = useForm<DetailsSchema>({ defaultValues: { countryCode: user?.address?.countryCode ?? initialData.countryCode, @@ -56,14 +61,20 @@ export default function Details({ user }: DetailsProps) { reValidateMode: "onChange", }) - const completeStep = useEnterDetailsStore((state) => state.completeStep) + const onSubmit = useCallback( + (values: DetailsSchema) => { + updateDetails(values) + completeStep() + }, + [completeStep, updateDetails] + ) return ( <FormProvider {...methods}> <form className={styles.form} id={formID} - onSubmit={methods.handleSubmit(completeStep)} + onSubmit={methods.handleSubmit(onSubmit)} > {user ? null : <Signup name="join" />} <Footnote @@ -107,7 +118,7 @@ export default function Details({ user }: DetailsProps) { readOnly={!!user} registerOptions={{ required: true }} /> - {user ? null : ( + {user || methods.watch("join") ? null : ( <Input className={styles.membershipNo} label={intl.formatMessage({ id: "Membership no" })} @@ -119,7 +130,6 @@ export default function Details({ user }: DetailsProps) { <footer className={styles.footer}> <Button disabled={!methods.formState.isValid} - form={formID} intent="secondary" size="small" theme="base" diff --git a/components/HotelReservation/EnterDetails/HistoryStateManager/index.tsx b/components/HotelReservation/EnterDetails/HistoryStateManager/index.tsx index 0ce1b1080..dc83a8072 100644 --- a/components/HotelReservation/EnterDetails/HistoryStateManager/index.tsx +++ b/components/HotelReservation/EnterDetails/HistoryStateManager/index.tsx @@ -2,11 +2,11 @@ import { useCallback, useEffect } from "react" -import { useEnterDetailsStore } from "@/stores/enter-details" +import { useStepsStore } from "@/stores/steps" export default function HistoryStateManager() { - const setCurrentStep = useEnterDetailsStore((state) => state.setCurrentStep) - const currentStep = useEnterDetailsStore((state) => state.currentStep) + const setCurrentStep = useStepsStore((state) => state.setStep) + const currentStep = useStepsStore((state) => state.currentStep) const handleBackButton = useCallback( (event: PopStateEvent) => { diff --git a/components/HotelReservation/EnterDetails/Payment/index.tsx b/components/HotelReservation/EnterDetails/Payment/index.tsx index ff402bf2d..a912f74b9 100644 --- a/components/HotelReservation/EnterDetails/Payment/index.tsx +++ b/components/HotelReservation/EnterDetails/Payment/index.tsx @@ -18,7 +18,7 @@ import { } from "@/constants/currentWebHrefs" import { env } from "@/env/client" import { trpc } from "@/lib/trpc/client" -import { useEnterDetailsStore } from "@/stores/enter-details" +import { useDetailsStore } from "@/stores/details" import LoadingSpinner from "@/components/LoadingSpinner" import Button from "@/components/TempDesignSystem/Button" @@ -40,7 +40,6 @@ import styles from "./payment.module.css" import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter" import { PaymentProps } from "@/types/components/hotelReservation/selectRate/section" -import { BreakfastPackageEnum } from "@/types/enums/breakfast" const maxRetries = 4 const retryInterval = 2000 @@ -61,12 +60,9 @@ export default function Payment({ const lang = useLang() const intl = useIntl() const queryParams = useSearchParams() - const { userData, roomData, setIsSubmittingDisabled } = useEnterDetailsStore( - (state) => ({ - userData: state.userData, - roomData: state.roomData, - setIsSubmittingDisabled: state.setIsSubmittingDisabled, - }) + const { booking, ...userData } = useDetailsStore((state) => state.data) + const setIsSubmittingDisabled = useDetailsStore( + (state) => state.actions.setIsSubmittingDisabled ) const { @@ -82,7 +78,7 @@ export default function Payment({ dateOfBirth, zipCode, } = userData - const { toDate, fromDate, rooms: rooms, hotel } = roomData + const { toDate, fromDate, rooms, hotel } = booking const [confirmationNumber, setConfirmationNumber] = useState<string>("") const [availablePaymentOptions, setAvailablePaymentOptions] = @@ -204,7 +200,7 @@ export default function Payment({ postalCode: zipCode, }, packages: { - breakfast: breakfast !== BreakfastPackageEnum.NO_BREAKFAST, + breakfast: !!(breakfast && breakfast.code), allergyFriendly: room.packages?.includes(RoomPackageCodeEnum.ALLERGY_ROOM) ?? false, petFriendly: diff --git a/components/HotelReservation/EnterDetails/Provider/index.tsx b/components/HotelReservation/EnterDetails/Provider/index.tsx deleted file mode 100644 index 82bfdbd82..000000000 --- a/components/HotelReservation/EnterDetails/Provider/index.tsx +++ /dev/null @@ -1,29 +0,0 @@ -"use client" -import { useSearchParams } from "next/navigation" -import { PropsWithChildren, useRef } from "react" - -import { - EnterDetailsContext, - type EnterDetailsStore, - initEditDetailsState, -} from "@/stores/enter-details" - -import { EnterDetailsProviderProps } from "@/types/components/hotelReservation/enterDetails/store" - -export default function EnterDetailsProvider({ - step, - isMember, - children, -}: PropsWithChildren<EnterDetailsProviderProps>) { - const searchParams = useSearchParams() - const initialStore = useRef<EnterDetailsStore>() - if (!initialStore.current) { - initialStore.current = initEditDetailsState(step, searchParams, isMember) - } - - return ( - <EnterDetailsContext.Provider value={initialStore.current}> - {children} - </EnterDetailsContext.Provider> - ) -} diff --git a/components/HotelReservation/EnterDetails/SectionAccordion/index.tsx b/components/HotelReservation/EnterDetails/SectionAccordion/index.tsx index dee985295..ce548ae74 100644 --- a/components/HotelReservation/EnterDetails/SectionAccordion/index.tsx +++ b/components/HotelReservation/EnterDetails/SectionAccordion/index.tsx @@ -2,7 +2,8 @@ import { useEffect, useState } from "react" import { useIntl } from "react-intl" -import { useEnterDetailsStore } from "@/stores/enter-details" +import { useDetailsStore } from "@/stores/details" +import { useStepsStore } from "@/stores/steps" import { CheckIcon, ChevronDownIcon } from "@/components/Icons" import Footnote from "@/components/TempDesignSystem/Text/Footnote" @@ -10,12 +11,9 @@ import Subtitle from "@/components/TempDesignSystem/Text/Subtitle" import styles from "./sectionAccordion.module.css" -import { - StepEnum, - StepStoreKeys, -} from "@/types/components/hotelReservation/enterDetails/step" +import { StepStoreKeys } from "@/types/components/hotelReservation/enterDetails/step" import { SectionAccordionProps } from "@/types/components/hotelReservation/selectRate/sectionAccordion" -import { BreakfastPackageEnum } from "@/types/enums/breakfast" +import { StepEnum } from "@/types/enums/step" export default function SectionAccordion({ header, @@ -24,12 +22,12 @@ export default function SectionAccordion({ children, }: React.PropsWithChildren<SectionAccordionProps>) { const intl = useIntl() - const currentStep = useEnterDetailsStore((state) => state.currentStep) + const currentStep = useStepsStore((state) => state.currentStep) const [isComplete, setIsComplete] = useState(false) const [isOpen, setIsOpen] = useState(false) - const isValid = useEnterDetailsStore((state) => state.isValid[step]) - const navigate = useEnterDetailsStore((state) => state.navigate) - const stepData = useEnterDetailsStore((state) => state.userData) + const isValid = useDetailsStore((state) => state.isValid[step]) + const navigate = useStepsStore((state) => state.navigate) + const stepData = useDetailsStore((state) => state.data) const stepStoreKey = StepStoreKeys[step] const [title, setTitle] = useState(label) @@ -39,9 +37,12 @@ export default function SectionAccordion({ value && setTitle(value.description) } // If breakfast step, check if an option has been selected - if (step === StepEnum.breakfast && stepData.breakfast) { + if ( + step === StepEnum.breakfast && + (stepData.breakfast || stepData.breakfast === false) + ) { const value = stepData.breakfast - if (value === BreakfastPackageEnum.NO_BREAKFAST) { + if (value === false) { setTitle(intl.formatMessage({ id: "No breakfast" })) } else { setTitle(intl.formatMessage({ id: "Breakfast buffet" })) @@ -94,7 +95,9 @@ export default function SectionAccordion({ )} </button> </header> - <div className={styles.content}>{children}</div> + <div className={styles.content}> + <div className={styles.contentWrapper}>{children}</div> + </div> </div> </section> ) diff --git a/components/HotelReservation/EnterDetails/SectionAccordion/sectionAccordion.module.css b/components/HotelReservation/EnterDetails/SectionAccordion/sectionAccordion.module.css index fc3de1764..ed91cb9e2 100644 --- a/components/HotelReservation/EnterDetails/SectionAccordion/sectionAccordion.module.css +++ b/components/HotelReservation/EnterDetails/SectionAccordion/sectionAccordion.module.css @@ -31,7 +31,6 @@ .main { display: grid; - gap: var(--Spacing-x3); width: 100%; border-bottom: 1px solid var(--Primary-Light-On-Surface-Divider-subtle); padding-bottom: var(--Spacing-x3); @@ -80,6 +79,10 @@ overflow: hidden; } +.contentWrapper { + padding-top: var(--Spacing-x3); +} + @media screen and (min-width: 1367px) { .wrapper { gap: var(--Spacing-x3); @@ -98,4 +101,4 @@ content: ""; border-left: 1px solid var(--Primary-Light-On-Surface-Divider-subtle); } -} +} \ No newline at end of file diff --git a/components/HotelReservation/EnterDetails/SelectedRoom/index.tsx b/components/HotelReservation/EnterDetails/SelectedRoom/index.tsx index 64e9e0960..9d373f871 100644 --- a/components/HotelReservation/EnterDetails/SelectedRoom/index.tsx +++ b/components/HotelReservation/EnterDetails/SelectedRoom/index.tsx @@ -2,12 +2,13 @@ import { useIntl } from "react-intl" -import { useEnterDetailsStore } from "@/stores/enter-details" +import { selectRate } from "@/constants/routes/hotelReservation" import { CheckIcon, EditIcon } from "@/components/Icons" import Link from "@/components/TempDesignSystem/Link" import Footnote from "@/components/TempDesignSystem/Text/Footnote" import Subtitle from "@/components/TempDesignSystem/Text/Subtitle" +import useLang from "@/hooks/useLang" import ToggleSidePeek from "./ToggleSidePeek" @@ -21,8 +22,7 @@ export default function SelectedRoom({ rateDescription, }: SelectedRoomProps) { const intl = useIntl() - - const selectRateUrl = useEnterDetailsStore((state) => state.selectRateUrl) + const lang = useLang() return ( <div className={styles.wrapper}> @@ -53,7 +53,8 @@ export default function SelectedRoom({ <Link className={styles.button} color="burgundy" - href={selectRateUrl} + href={selectRate(lang)} + keepSearchParams size="small" variant="icon" > diff --git a/components/HotelReservation/EnterDetails/Summary/BottomSheet/index.tsx b/components/HotelReservation/EnterDetails/Summary/BottomSheet/index.tsx index ac7921aec..9f99a56c0 100644 --- a/components/HotelReservation/EnterDetails/Summary/BottomSheet/index.tsx +++ b/components/HotelReservation/EnterDetails/Summary/BottomSheet/index.tsx @@ -3,7 +3,7 @@ import { PropsWithChildren } from "react" import { useIntl } from "react-intl" -import { useEnterDetailsStore } from "@/stores/enter-details" +import { useDetailsStore } from "@/stores/details" import Button from "@/components/TempDesignSystem/Button" import Caption from "@/components/TempDesignSystem/Text/Caption" @@ -17,9 +17,9 @@ export function SummaryBottomSheet({ children }: PropsWithChildren) { const intl = useIntl() const { isSummaryOpen, toggleSummaryOpen, totalPrice, isSubmittingDisabled } = - useEnterDetailsStore((state) => ({ + useDetailsStore((state) => ({ isSummaryOpen: state.isSummaryOpen, - toggleSummaryOpen: state.toggleSummaryOpen, + toggleSummaryOpen: state.actions.toggleSummaryOpen, totalPrice: state.totalPrice, isSubmittingDisabled: state.isSubmittingDisabled, })) diff --git a/components/HotelReservation/EnterDetails/Summary/index.tsx b/components/HotelReservation/EnterDetails/Summary/index.tsx index 4b093f8ea..c447c7f75 100644 --- a/components/HotelReservation/EnterDetails/Summary/index.tsx +++ b/components/HotelReservation/EnterDetails/Summary/index.tsx @@ -5,7 +5,7 @@ import { ChevronDown } from "react-feather" import { useIntl } from "react-intl" import { dt } from "@/lib/dt" -import { EnterDetailsState, useEnterDetailsStore } from "@/stores/enter-details" +import { useDetailsStore } from "@/stores/details" import { ArrowRightIcon } from "@/components/Icons" import Button from "@/components/TempDesignSystem/Button" @@ -18,45 +18,39 @@ import useLang from "@/hooks/useLang" import styles from "./summary.module.css" -import { BedTypeSchema } from "@/types/components/hotelReservation/enterDetails/bedType" -import { RoomsData } from "@/types/components/hotelReservation/enterDetails/bookingData" -import { BreakfastPackage } from "@/types/components/hotelReservation/enterDetails/breakfast" -import { BreakfastPackageEnum } from "@/types/enums/breakfast" +import type { BedTypeSchema } from "@/types/components/hotelReservation/enterDetails/bedType" +import type { BreakfastPackage } from "@/types/components/hotelReservation/enterDetails/breakfast" +import type { SummaryProps } from "@/types/components/hotelReservation/enterDetails/summary" +import type { DetailsState } from "@/types/stores/details" -function storeSelector(state: EnterDetailsState) { +function storeSelector(state: DetailsState) { return { - fromDate: state.roomData.fromDate, - toDate: state.roomData.toDate, - bedType: state.userData.bedType, - breakfast: state.userData.breakfast, - toggleSummaryOpen: state.toggleSummaryOpen, - setTotalPrice: state.setTotalPrice, + fromDate: state.data.booking.fromDate, + toDate: state.data.booking.toDate, + bedType: state.data.bedType, + breakfast: state.data.breakfast, + toggleSummaryOpen: state.actions.toggleSummaryOpen, + setTotalPrice: state.actions.setTotalPrice, totalPrice: state.totalPrice, } } -export default function Summary({ - showMemberPrice, - room, -}: { - showMemberPrice: boolean - room: RoomsData -}) { +export default function Summary({ showMemberPrice, room }: SummaryProps) { const [chosenBed, setChosenBed] = useState<BedTypeSchema>() const [chosenBreakfast, setChosenBreakfast] = useState< - BreakfastPackage | BreakfastPackageEnum.NO_BREAKFAST + BreakfastPackage | false >() const intl = useIntl() const lang = useLang() const { - fromDate, - toDate, bedType, breakfast, + fromDate, setTotalPrice, - totalPrice, + toDate, toggleSummaryOpen, - } = useEnterDetailsStore(storeSelector) + totalPrice, + } = useDetailsStore(storeSelector) const diff = dt(toDate).diff(fromDate, "days") @@ -88,36 +82,39 @@ export default function Summary({ setChosenBed(bedType) setChosenBreakfast(breakfast) - if (breakfast && breakfast !== BreakfastPackageEnum.NO_BREAKFAST) { - setTotalPrice({ - local: { - price: roomsPriceLocal + parseInt(breakfast.localPrice.totalPrice), - currency: room.localPrice.currency, - }, - euro: - room.euroPrice && roomsPriceEuro - ? { + if (breakfast || breakfast === false) { + setChosenBreakfast(breakfast) + if (breakfast === false) { + setTotalPrice({ + local: { + price: roomsPriceLocal, + currency: room.localPrice.currency, + }, + euro: + room.euroPrice && roomsPriceEuro + ? { + price: roomsPriceEuro, + currency: room.euroPrice.currency, + } + : undefined, + }) + } else { + setTotalPrice({ + local: { + price: roomsPriceLocal + parseInt(breakfast.localPrice.totalPrice), + currency: room.localPrice.currency, + }, + euro: + room.euroPrice && roomsPriceEuro + ? { price: roomsPriceEuro + parseInt(breakfast.requestedPrice.totalPrice), currency: room.euroPrice.currency, } - : undefined, - }) - } else { - setTotalPrice({ - local: { - price: roomsPriceLocal, - currency: room.localPrice.currency, - }, - euro: - room.euroPrice && roomsPriceEuro - ? { - price: roomsPriceEuro, - currency: room.euroPrice.currency, - } - : undefined, - }) + : undefined, + }) + } } }, [ bedType, @@ -187,24 +184,24 @@ export default function Summary({ </div> {room.packages ? room.packages.map((roomPackage) => ( - <div className={styles.entry} key={roomPackage.code}> - <div> - <Body color="uiTextHighContrast"> - {roomPackage.description} - </Body> - </div> - - <Caption color="uiTextHighContrast"> - {intl.formatMessage( - { id: "{amount} {currency}" }, - { - amount: roomPackage.localPrice.price, - currency: roomPackage.localPrice.currency, - } - )} - </Caption> + <div className={styles.entry} key={roomPackage.code}> + <div> + <Body color="uiTextHighContrast"> + {roomPackage.description} + </Body> </div> - )) + + <Caption color="uiTextHighContrast"> + {intl.formatMessage( + { id: "{amount} {currency}" }, + { + amount: roomPackage.localPrice.price, + currency: roomPackage.localPrice.currency, + } + )} + </Caption> + </div> + )) : null} {chosenBed ? ( <div className={styles.entry}> @@ -224,37 +221,36 @@ export default function Summary({ </div> ) : null} - {chosenBreakfast ? ( - chosenBreakfast === BreakfastPackageEnum.NO_BREAKFAST ? ( - <div className={styles.entry}> - <Body color="uiTextHighContrast"> - {intl.formatMessage({ id: "No breakfast" })} - </Body> - <Caption color="uiTextHighContrast"> - {intl.formatMessage( - { id: "{amount} {currency}" }, - { amount: "0", currency: room.localPrice.currency } - )} - </Caption> - </div> - ) : ( - <div className={styles.entry}> - <Body color="uiTextHighContrast"> - {intl.formatMessage({ id: "Breakfast buffet" })} - </Body> - <Caption color="uiTextHighContrast"> - {intl.formatMessage( - { id: "{amount} {currency}" }, - { - amount: chosenBreakfast.localPrice.totalPrice, - currency: chosenBreakfast.localPrice.currency, - } - )} - </Caption> - </div> - ) - ) : null} - </div> + {chosenBreakfast === false ? ( + <div className={styles.entry}> + <Body color="uiTextHighContrast"> + {intl.formatMessage({ id: "No breakfast" })} + </Body> + <Caption color="uiTextMediumContrast"> + {intl.formatMessage( + { id: "{amount} {currency}" }, + { amount: "0", currency: room.localPrice.currency } + )} + </Caption> + </div> + ) : chosenBreakfast?.code ? ( + <div className={styles.entry}> + <Body color="uiTextHighContrast"> + {intl.formatMessage({ id: "Breakfast buffet" })} + </Body> + <Caption color="uiTextMediumContrast"> + {intl.formatMessage( + { id: "{amount} {currency}" }, + { + amount: chosenBreakfast.localPrice.totalPrice, + currency: chosenBreakfast.localPrice.currency, + } + )} + </Caption> + </div> + ) : null + } + </div > <Divider color="primaryLightSubtle" /> <div className={styles.total}> <div className={styles.entry}> @@ -295,6 +291,6 @@ export default function Summary({ </div> <Divider className={styles.bottomDivider} color="primaryLightSubtle" /> </div> - </section> + </section > ) } diff --git a/components/HotelReservation/HotelCard/HotelPriceList/index.tsx b/components/HotelReservation/HotelCard/HotelPriceList/index.tsx index 1f55c2d8a..d98fdc260 100644 --- a/components/HotelReservation/HotelCard/HotelPriceList/index.tsx +++ b/components/HotelReservation/HotelCard/HotelPriceList/index.tsx @@ -39,7 +39,7 @@ export default function HotelPriceList({ className={styles.button} > <Link - href={`${selectRate[lang]}?hotel=${hotelId}`} + href={`${selectRate(lang)}?hotel=${hotelId}`} color="none" keepSearchParams > diff --git a/components/HotelReservation/HotelCard/index.tsx b/components/HotelReservation/HotelCard/index.tsx index feda19a05..808e9ac9f 100644 --- a/components/HotelReservation/HotelCard/index.tsx +++ b/components/HotelReservation/HotelCard/index.tsx @@ -93,7 +93,7 @@ export default function HotelCard({ </address> <Link className={styles.addressMobile} - href={`${selectHotelMap[lang]}?selectedHotel=${hotelData.name}`} + href={`${selectHotelMap(lang)}?selectedHotel=${hotelData.name}`} keepSearchParams > <Caption color="baseTextMediumContrast" type="underline"> diff --git a/components/HotelReservation/HotelCardDialog/index.tsx b/components/HotelReservation/HotelCardDialog/index.tsx index 16d1ce860..d444a1083 100644 --- a/components/HotelReservation/HotelCardDialog/index.tsx +++ b/components/HotelReservation/HotelCardDialog/index.tsx @@ -104,7 +104,7 @@ export default function HotelCardDialog({ <Button asChild theme="base" size="small" className={styles.button}> <Link - href={`${selectRate[lang]}?hotel=${data.operaId}`} + href={`${selectRate(lang)}?hotel=${data.operaId}`} color="none" keepSearchParams > diff --git a/components/HotelReservation/SelectHotel/MobileMapButtonContainer/index.tsx b/components/HotelReservation/SelectHotel/MobileMapButtonContainer/index.tsx index 558a3ef50..0b4881943 100644 --- a/components/HotelReservation/SelectHotel/MobileMapButtonContainer/index.tsx +++ b/components/HotelReservation/SelectHotel/MobileMapButtonContainer/index.tsx @@ -27,7 +27,7 @@ export default function MobileMapButtonContainer({ <div className={styles.buttonContainer}> <Button asChild variant="icon" intent="secondary" size="small"> <Link - href={`${selectHotelMap[lang]}`} + href={selectHotelMap(lang)} keepSearchParams color="burgundy" > diff --git a/components/HotelReservation/SelectHotel/SelectHotelMap/index.tsx b/components/HotelReservation/SelectHotel/SelectHotelMap/index.tsx index 21b804306..215c7ae66 100644 --- a/components/HotelReservation/SelectHotel/SelectHotelMap/index.tsx +++ b/components/HotelReservation/SelectHotel/SelectHotelMap/index.tsx @@ -71,7 +71,7 @@ export default function SelectHotelMap({ } function handlePageRedirect() { - router.push(`${selectHotel[lang]}?${searchParams.toString()}`) + router.push(`${selectHotel(lang)}?${searchParams.toString()}`) } const closeButton = ( diff --git a/components/HotelReservation/SelectRate/RoomSelection/utils.ts b/components/HotelReservation/SelectRate/RoomSelection/utils.ts index aa6ef2810..43af470e3 100644 --- a/components/HotelReservation/SelectRate/RoomSelection/utils.ts +++ b/components/HotelReservation/SelectRate/RoomSelection/utils.ts @@ -53,26 +53,3 @@ export function getQueryParamsForEnterDetails( })), } } - -export function createSelectRateUrl(roomData: BookingData) { - const { hotel, fromDate, toDate } = roomData - const params = new URLSearchParams({ fromDate, toDate, hotel }) - - roomData.rooms.forEach((room, index) => { - params.set(`room[${index}].adults`, room.adults.toString()) - - if (room.children) { - room.children.forEach((child, childIndex) => { - params.set( - `room[${index}].child[${childIndex}].age`, - child.age.toString() - ) - params.set( - `room[${index}].child[${childIndex}].bed`, - child.bed.toString() - ) - }) - } - }) - return params -} diff --git a/components/TempDesignSystem/Form/ChoiceCard/_Card/index.tsx b/components/TempDesignSystem/Form/ChoiceCard/_Card/index.tsx index 2a3faf57b..7d6ef8105 100644 --- a/components/TempDesignSystem/Form/ChoiceCard/_Card/index.tsx +++ b/components/TempDesignSystem/Form/ChoiceCard/_Card/index.tsx @@ -15,6 +15,7 @@ export default function Card({ iconHeight = 32, iconWidth = 32, declined = false, + defaultChecked, highlightSubtitle = false, id, list, @@ -45,6 +46,7 @@ export default function Card({ <input {...register(name)} aria-hidden + defaultChecked={defaultChecked} id={id || name} hidden type={type} diff --git a/components/TempDesignSystem/Select/index.tsx b/components/TempDesignSystem/Select/index.tsx index bce5132ab..816d8ddb9 100644 --- a/components/TempDesignSystem/Select/index.tsx +++ b/components/TempDesignSystem/Select/index.tsx @@ -12,6 +12,7 @@ import { import Label from "@/components/TempDesignSystem/Form/Label" import Body from "@/components/TempDesignSystem/Text/Body" +import useSetOverflowVisibleOnRA from "@/hooks/useSetOverflowVisibleOnRA" import SelectChevron from "../Form/SelectChevron" @@ -39,6 +40,7 @@ export default function Select({ discreet = false, }: SelectProps) { const [rootDiv, setRootDiv] = useState<SelectPortalContainer>(undefined) + const setOverflowVisible = useSetOverflowVisibleOnRA() function setRef(node: SelectPortalContainerArgs) { if (node) { @@ -60,6 +62,7 @@ export default function Select({ onSelectionChange={handleOnSelect} placeholder={placeholder} selectedKey={value as Key} + onOpenChange={setOverflowVisible} > <Body asChild fontOnly> <Button className={styles.input} data-testid={name}> diff --git a/constants/routes/hotelReservation.js b/constants/routes/hotelReservation.js index 94a9cef18..c7882f159 100644 --- a/constants/routes/hotelReservation.js +++ b/constants/routes/hotelReservation.js @@ -1,97 +1,59 @@ -/** @type {import('@/types/routes').LangRoute} */ -export const hotelReservation = { - en: "/en/hotelreservation", - sv: "/sv/hotelreservation", - no: "/no/hotelreservation", - fi: "/fi/hotelreservation", - da: "/da/hotelreservation", - de: "/de/hotelreservation", +/** + * @typedef {import('@/constants/languages').Lang} Lang + */ + +/** + * @param {Lang} lang + */ +function base(lang) { + return `/${lang}/hotelreservation` } -export const selectHotel = { - en: `${hotelReservation.en}/select-hotel`, - sv: `${hotelReservation.sv}/select-hotel`, - no: `${hotelReservation.no}/select-hotel`, - fi: `${hotelReservation.fi}/select-hotel`, - da: `${hotelReservation.da}/select-hotel`, - de: `${hotelReservation.de}/select-hotel`, +/** + * @param {Lang} lang + */ +export function bookingConfirmation(lang) { + return `${base(lang)}/booking-confirmation` } -export const selectRate = { - en: `${hotelReservation.en}/select-rate`, - sv: `${hotelReservation.sv}/select-rate`, - no: `${hotelReservation.no}/select-rate`, - fi: `${hotelReservation.fi}/select-rate`, - da: `${hotelReservation.da}/select-rate`, - de: `${hotelReservation.de}/select-rate`, +/** + * @param {Lang} lang + */ +export function details(lang) { + return `${base(lang)}/details` } -// TODO: Translate paths -export const selectBed = { - en: `${hotelReservation.en}/select-bed`, - sv: `${hotelReservation.sv}/select-bed`, - no: `${hotelReservation.no}/select-bed`, - fi: `${hotelReservation.fi}/select-bed`, - da: `${hotelReservation.da}/select-bed`, - de: `${hotelReservation.de}/select-bed`, +/** + * @param {Lang} lang + */ +export function payment(lang) { + return `${base(lang)}/payment` } -// TODO: Translate paths -export const breakfast = { - en: `${hotelReservation.en}/breakfast`, - sv: `${hotelReservation.sv}/breakfast`, - no: `${hotelReservation.no}/breakfast`, - fi: `${hotelReservation.fi}/breakfast`, - da: `${hotelReservation.da}/breakfast`, - de: `${hotelReservation.de}/breakfast`, +/** + * @param {Lang} lang + */ +export function selectBed(lang) { + return `${base(lang)}/select-bed` } -// TODO: Translate paths -export const details = { - en: `${hotelReservation.en}/details`, - sv: `${hotelReservation.sv}/details`, - no: `${hotelReservation.no}/details`, - fi: `${hotelReservation.fi}/details`, - da: `${hotelReservation.da}/details`, - de: `${hotelReservation.de}/details`, +/** + * @param {Lang} lang + */ +export function selectHotel(lang) { + return `${base(lang)}/select-hotel` } -// TODO: Translate paths -export const payment = { - en: `${hotelReservation.en}/payment`, - sv: `${hotelReservation.sv}/payment`, - no: `${hotelReservation.no}/payment`, - fi: `${hotelReservation.fi}/payment`, - da: `${hotelReservation.da}/payment`, - de: `${hotelReservation.de}/payment`, +/** + * @param {Lang} lang + */ +export function selectHotelMap(lang) { + return `${base(lang)}/map` } -export const selectHotelMap = { - en: `${selectHotel.en}/map`, - sv: `${selectHotel.sv}/map`, - no: `${selectHotel.no}/map`, - fi: `${selectHotel.fi}/map`, - da: `${selectHotel.da}/map`, - de: `${selectHotel.de}/map`, +/** + * @param {Lang} lang + */ +export function selectRate(lang) { + return `${base(lang)}/select-rate` } - -/** @type {import('@/types/routes').LangRoute} */ -export const bookingConfirmation = { - en: `${hotelReservation.en}/booking-confirmation`, - sv: `${hotelReservation.sv}/booking-confirmation`, - no: `${hotelReservation.no}/booking-confirmation`, - fi: `${hotelReservation.fi}/booking-confirmation`, - da: `${hotelReservation.da}/booking-confirmation`, - de: `${hotelReservation.de}/booking-confirmation`, -} - -export const bookingFlow = [ - ...Object.values(selectHotel), - ...Object.values(selectBed), - ...Object.values(breakfast), - ...Object.values(details), - ...Object.values(payment), - ...Object.values(selectHotelMap), - ...Object.values(bookingConfirmation), - ...Object.values(selectRate), -] diff --git a/contexts/Details.ts b/contexts/Details.ts new file mode 100644 index 000000000..7fb3a010a --- /dev/null +++ b/contexts/Details.ts @@ -0,0 +1,5 @@ +import { createContext } from "react" + +import type { DetailsStore } from "@/types/contexts/details" + +export const DetailsContext = createContext<DetailsStore | null>(null) diff --git a/contexts/Steps.ts b/contexts/Steps.ts new file mode 100644 index 000000000..220365fbe --- /dev/null +++ b/contexts/Steps.ts @@ -0,0 +1,5 @@ +import { createContext } from "react" + +import type { StepsStore } from "@/types/contexts/steps" + +export const StepsContext = createContext<StepsStore | null>(null) diff --git a/hooks/useSetOverflowVisibleOnRA.ts b/hooks/useSetOverflowVisibleOnRA.ts new file mode 100644 index 000000000..e9031b477 --- /dev/null +++ b/hooks/useSetOverflowVisibleOnRA.ts @@ -0,0 +1,11 @@ +export default function useSetOverflowVisibleOnRA() { + function setOverflowVisible(isOpen: boolean) { + if (isOpen) { + document.body.style.overflow = "visible" + } else { + document.body.style.overflow = "" + } + } + + return setOverflowVisible +} diff --git a/middlewares/bookingFlow.ts b/middlewares/bookingFlow.ts index 098ca9108..20e646e5a 100644 --- a/middlewares/bookingFlow.ts +++ b/middlewares/bookingFlow.ts @@ -1,7 +1,5 @@ import { NextResponse } from "next/server" -import { bookingFlow } from "@/constants/routes/hotelReservation" - import { getDefaultRequestHeaders } from "./utils" import type { NextMiddleware } from "next/server" @@ -18,5 +16,7 @@ export const middleware: NextMiddleware = async (request) => { } export const matcher: MiddlewareMatcher = (request) => { - return bookingFlow.includes(request.nextUrl.pathname) + return !!request.nextUrl.pathname.match( + /^\/(da|de|en|fi|no|sv)\/(hotelreservation)/ + ) } diff --git a/next.config.js b/next.config.js index 29012f230..222f085ac 100644 --- a/next.config.js +++ b/next.config.js @@ -277,6 +277,11 @@ const nextConfig = { source: `${myPages.sv}/:path*`, destination: `/sv/my-pages/:path*`, }, + { + source: + "/:lang/hotelreservation/:step(breakfast|details|payment|select-bed)", + destination: "/:lang/hotelreservation/step?step=:step", + }, ], } }, diff --git a/providers/DetailsProvider.tsx b/providers/DetailsProvider.tsx new file mode 100644 index 000000000..328307ee7 --- /dev/null +++ b/providers/DetailsProvider.tsx @@ -0,0 +1,30 @@ +"use client" +import { useSearchParams } from "next/navigation" +import { useRef } from "react" + +import { createDetailsStore } from "@/stores/details" + +import { getQueryParamsForEnterDetails } from "@/components/HotelReservation/SelectRate/RoomSelection/utils" +import { DetailsContext } from "@/contexts/Details" + +import type { DetailsStore } from "@/types/contexts/details" +import type { DetailsProviderProps } from "@/types/providers/details" + +export default function DetailsProvider({ + children, + isMember, +}: DetailsProviderProps) { + const storeRef = useRef<DetailsStore>() + const searchParams = useSearchParams() + + if (!storeRef.current) { + const booking = getQueryParamsForEnterDetails(searchParams) + storeRef.current = createDetailsStore({ booking }, isMember) + } + + return ( + <DetailsContext.Provider value={storeRef.current}> + {children} + </DetailsContext.Provider> + ) +} diff --git a/providers/StepsProvider.tsx b/providers/StepsProvider.tsx new file mode 100644 index 000000000..87594be02 --- /dev/null +++ b/providers/StepsProvider.tsx @@ -0,0 +1,53 @@ +"use client" +import { useRef } from "react" + +import { useDetailsStore } from "@/stores/details" +import { createStepsStore } from "@/stores/steps" + +import { StepsContext } from "@/contexts/Steps" + +import type { StepsStore } from "@/types/contexts/steps" +import type { StepsProviderProps } from "@/types/providers/steps" + +export default function StepsProvider({ + bedTypes, + breakfastPackages, + children, + isMember, + step, +}: StepsProviderProps) { + const storeRef = useRef<StepsStore>() + const updateBedType = useDetailsStore((state) => state.actions.updateBedType) + const updateBreakfast = useDetailsStore( + (state) => state.actions.updateBreakfast + ) + + if (!storeRef.current) { + const noBedChoices = bedTypes.length === 1 + const noBreakfast = !breakfastPackages?.length + + if (noBedChoices) { + updateBedType({ + description: bedTypes[0].description, + roomTypeCode: bedTypes[0].value, + }) + } + + if (noBreakfast) { + updateBreakfast(false) + } + + storeRef.current = createStepsStore( + step, + isMember, + noBedChoices, + noBreakfast + ) + } + + return ( + <StepsContext.Provider value={storeRef.current}> + {children} + </StepsContext.Provider> + ) +} diff --git a/server/routers/hotels/schemas/packages.ts b/server/routers/hotels/schemas/packages.ts new file mode 100644 index 000000000..988cf1ac5 --- /dev/null +++ b/server/routers/hotels/schemas/packages.ts @@ -0,0 +1,62 @@ +import { z } from "zod" + +import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter" +import { CurrencyEnum } from "@/types/enums/currency" + +export const getRoomPackagesInputSchema = z.object({ + hotelId: z.string(), + startDate: z.string(), + endDate: z.string(), + adults: z.number(), + children: z.number().optional().default(0), + packageCodes: z.array(z.string()).optional().default([]), +}) + +export const packagePriceSchema = z + .object({ + currency: z.nativeEnum(CurrencyEnum), + price: z.string(), + totalPrice: z.string(), + }) + .optional() + .default({ + currency: CurrencyEnum.SEK, + price: "0", + totalPrice: "0", + }) // TODO: Remove optional and default when the API change has been deployed + +export const packagesSchema = z.object({ + code: z.nativeEnum(RoomPackageCodeEnum), + description: z.string(), + localPrice: packagePriceSchema, + requestedPrice: packagePriceSchema, + inventories: z.array( + z.object({ + date: z.string(), + total: z.number(), + available: z.number(), + }) + ), +}) + +export const getRoomPackagesSchema = z + .object({ + data: z.object({ + attributes: z.object({ + hotelId: z.number(), + packages: z.array(packagesSchema).default([]), + }), + relationships: z + .object({ + links: z.array( + z.object({ + url: z.string(), + type: z.string(), + }) + ), + }) + .optional(), + type: z.string(), + }), + }) + .transform((data) => data.data.attributes.packages) diff --git a/stores/details.ts b/stores/details.ts new file mode 100644 index 000000000..5d23248e5 --- /dev/null +++ b/stores/details.ts @@ -0,0 +1,195 @@ +import merge from "deepmerge" +import { produce } from "immer" +import { useContext } from "react" +import { create, useStore } from "zustand" +import { createJSONStorage, persist } from "zustand/middleware" + +import { bedTypeSchema } from "@/components/HotelReservation/EnterDetails/BedType/schema" +import { breakfastStoreSchema } from "@/components/HotelReservation/EnterDetails/Breakfast/schema" +import { + guestDetailsSchema, + signedInDetailsSchema, +} from "@/components/HotelReservation/EnterDetails/Details/schema" +import { DetailsContext } from "@/contexts/Details" + +import { StepEnum } from "@/types/enums/step" +import type { DetailsState, InitialState } from "@/types/stores/details" + +export const storageName = "details-storage" +export function createDetailsStore( + initialState: InitialState, + isMember: boolean +) { + if (typeof window !== "undefined") { + /** + * We need to initialize the store from sessionStorage ourselves + * since `persist` does it first after render and therefore + * we cannot use the data as `defaultValues` for our forms. + * RHF caches defaultValues on mount. + */ + const detailsStorageUnparsed = sessionStorage.getItem(storageName) + if (detailsStorageUnparsed) { + const detailsStorage: Record< + "state", + Pick<DetailsState, "data"> + > = JSON.parse(detailsStorageUnparsed) + initialState = merge(initialState, detailsStorage.state.data) + } + } + return create<DetailsState>()( + persist( + (set) => ({ + actions: { + setIsSubmittingDisabled(isSubmittingDisabled) { + return set( + produce((state: DetailsState) => { + state.isSubmittingDisabled = isSubmittingDisabled + }) + ) + }, + setTotalPrice(totalPrice) { + return set( + produce((state: DetailsState) => { + state.totalPrice = totalPrice + }) + ) + }, + toggleSummaryOpen() { + return set( + produce((state: DetailsState) => { + state.isSummaryOpen = !state.isSummaryOpen + }) + ) + }, + updateBedType(bedType) { + return set( + produce((state: DetailsState) => { + state.isValid["select-bed"] = true + state.data.bedType = bedType + }) + ) + }, + updateBreakfast(breakfast) { + return set( + produce((state: DetailsState) => { + state.isValid.breakfast = true + state.data.breakfast = breakfast + }) + ) + }, + updateDetails(data) { + return set( + produce((state: DetailsState) => { + state.isValid.details = true + + state.data.countryCode = data.countryCode + state.data.dateOfBirth = data.dateOfBirth + state.data.email = data.email + state.data.firstName = data.firstName + state.data.join = data.join + state.data.lastName = data.lastName + if (data.join) { + state.data.membershipNo = undefined + } else { + state.data.membershipNo = data.membershipNo + } + state.data.phoneNumber = data.phoneNumber + state.data.termsAccepted = data.termsAccepted + state.data.zipCode = data.zipCode + }) + ) + }, + updateValidity(property, isValid) { + return set( + produce((state: DetailsState) => { + state.isValid[property] = isValid + }) + ) + }, + }, + + data: merge( + { + bedType: undefined, + breakfast: undefined, + countryCode: "", + dateOfBirth: "", + email: "", + firstName: "", + join: false, + lastName: "", + membershipNo: "", + phoneNumber: "", + termsAccepted: false, + zipCode: "", + }, + initialState + ), + + isSubmittingDisabled: false, + isSummaryOpen: false, + isValid: { + [StepEnum.selectBed]: false, + [StepEnum.breakfast]: false, + [StepEnum.details]: false, + [StepEnum.payment]: false, + }, + + totalPrice: { + euro: { currency: "", price: 0 }, + local: { currency: "", price: 0 }, + }, + }), + { + name: storageName, + onRehydrateStorage() { + return function (state) { + if (state) { + const validatedBedType = bedTypeSchema.safeParse(state.data) + if (validatedBedType.success) { + state.actions.updateValidity(StepEnum.selectBed, true) + } else { + state.actions.updateValidity(StepEnum.selectBed, false) + } + + const validatedBreakfast = breakfastStoreSchema.safeParse( + state.data + ) + if (validatedBreakfast.success) { + state.actions.updateValidity(StepEnum.breakfast, true) + } else { + state.actions.updateValidity(StepEnum.breakfast, false) + } + + const detailsSchema = isMember + ? signedInDetailsSchema + : guestDetailsSchema + const validatedDetails = detailsSchema.safeParse(state.data) + if (validatedDetails.success) { + state.actions.updateValidity(StepEnum.details, true) + } else { + state.actions.updateValidity(StepEnum.details, false) + } + } + } + }, + partialize(state) { + return { + data: state.data, + } + }, + storage: createJSONStorage(() => sessionStorage), + } + ) + ) +} + +export function useDetailsStore<T>(selector: (store: DetailsState) => T) { + const store = useContext(DetailsContext) + + if (!store) { + throw new Error("useDetailsStore must be used within DetailsProvider") + } + + return useStore(store, selector) +} diff --git a/stores/enter-details.ts b/stores/enter-details.ts deleted file mode 100644 index b5f99cc38..000000000 --- a/stores/enter-details.ts +++ /dev/null @@ -1,209 +0,0 @@ -import { produce } from "immer" -import { ReadonlyURLSearchParams } from "next/navigation" -import { createContext, useContext } from "react" -import { create, useStore } from "zustand" - -import { bedTypeSchema } from "@/components/HotelReservation/EnterDetails/BedType/schema" -import { breakfastStoreSchema } from "@/components/HotelReservation/EnterDetails/Breakfast/schema" -import { - guestDetailsSchema, - signedInDetailsSchema, -} from "@/components/HotelReservation/EnterDetails/Details/schema" -import { - createSelectRateUrl, - getQueryParamsForEnterDetails, -} from "@/components/HotelReservation/SelectRate/RoomSelection/utils" - -import type { BedTypeSchema } from "@/types/components/hotelReservation/enterDetails/bedType" -import { BookingData } from "@/types/components/hotelReservation/enterDetails/bookingData" -import { BreakfastPackage } from "@/types/components/hotelReservation/enterDetails/breakfast" -import { DetailsSchema } from "@/types/components/hotelReservation/enterDetails/details" -import { StepEnum } from "@/types/components/hotelReservation/enterDetails/step" -import { BreakfastPackageEnum } from "@/types/enums/breakfast" - -const SESSION_STORAGE_KEY = "enterDetails" - -type TotalPrice = { - local: { price: number; currency: string } - euro?: { price: number; currency: string } -} - -export interface EnterDetailsState { - userData: { - bedType: BedTypeSchema | undefined - breakfast: BreakfastPackage | BreakfastPackageEnum.NO_BREAKFAST | undefined - } & DetailsSchema - roomData: BookingData - steps: StepEnum[] - selectRateUrl: string - currentStep: StepEnum - totalPrice: TotalPrice - isSubmittingDisabled: boolean - isSummaryOpen: boolean - isValid: Record<StepEnum, boolean> - completeStep: (updatedData: Partial<EnterDetailsState["userData"]>) => void - navigate: ( - step: StepEnum, - updatedData?: Record< - string, - string | boolean | number | BreakfastPackage | BedTypeSchema - > - ) => void - setCurrentStep: (step: StepEnum) => void - toggleSummaryOpen: () => void - setTotalPrice: (totalPrice: TotalPrice) => void - setIsSubmittingDisabled: (isSubmittingDisabled: boolean) => void -} - -export function initEditDetailsState( - currentStep: StepEnum, - searchParams: ReadonlyURLSearchParams, - isMember: boolean -) { - const isBrowser = typeof window !== "undefined" - const sessionData = isBrowser - ? sessionStorage.getItem(SESSION_STORAGE_KEY) - : null - - let roomData: BookingData - let selectRateUrl: string - if (searchParams?.size) { - const data = getQueryParamsForEnterDetails(searchParams) - roomData = data - selectRateUrl = `select-rate?${createSelectRateUrl(data)}` - } - - const defaultUserData: EnterDetailsState["userData"] = { - bedType: undefined, - breakfast: undefined, - countryCode: "", - email: "", - firstName: "", - lastName: "", - phoneNumber: "", - join: false, - zipCode: "", - dateOfBirth: undefined, - termsAccepted: false, - membershipNo: "", - } - - let inputUserData = {} - if (sessionData) { - inputUserData = JSON.parse(sessionData) - } - - const validPaths = [StepEnum.selectBed] - - let initialData: EnterDetailsState["userData"] = defaultUserData - - const isValid = { - [StepEnum.selectBed]: false, - [StepEnum.breakfast]: false, - [StepEnum.details]: false, - [StepEnum.payment]: false, - } - - const validatedBedType = bedTypeSchema.safeParse(inputUserData) - if (validatedBedType.success) { - validPaths.push(StepEnum.breakfast) - initialData = { ...initialData, ...validatedBedType.data } - isValid[StepEnum.selectBed] = true - } - const validatedBreakfast = breakfastStoreSchema.safeParse(inputUserData) - if (validatedBreakfast.success) { - validPaths.push(StepEnum.details) - initialData = { ...initialData, ...validatedBreakfast.data } - isValid[StepEnum.breakfast] = true - } - const detailsSchema = isMember ? signedInDetailsSchema : guestDetailsSchema - const validatedDetails = detailsSchema.safeParse(inputUserData) - if (validatedDetails.success) { - validPaths.push(StepEnum.payment) - initialData = { ...initialData, ...validatedDetails.data } - isValid[StepEnum.details] = true - } - - if (!validPaths.includes(currentStep)) { - currentStep = validPaths.pop()! // We will always have at least one valid path - if (isBrowser) { - window.history.pushState( - { step: currentStep }, - "", - currentStep + window.location.search - ) - } - } - - return create<EnterDetailsState>()((set, get) => ({ - userData: initialData, - roomData, - selectRateUrl, - steps: Object.values(StepEnum), - totalPrice: { - local: { price: 0, currency: "" }, - euro: { price: 0, currency: "" }, - }, - isSummaryOpen: false, - isSubmittingDisabled: false, - setCurrentStep: (step) => set({ currentStep: step }), - navigate: (step, updatedData) => - set( - produce((state) => { - const sessionStorage = window.sessionStorage - - const previousDataString = sessionStorage.getItem(SESSION_STORAGE_KEY) - - const previousData = JSON.parse(previousDataString || "{}") - - sessionStorage.setItem( - SESSION_STORAGE_KEY, - JSON.stringify({ ...previousData, ...updatedData }) - ) - - state.currentStep = step - window.history.pushState({ step }, "", step + window.location.search) - }) - ), - currentStep, - isValid, - completeStep: (updatedData) => - set( - produce((state: EnterDetailsState) => { - state.isValid[state.currentStep] = true - - const nextStep = - state.steps[state.steps.indexOf(state.currentStep) + 1] - - state.userData = { - ...state.userData, - ...updatedData, - } - state.currentStep = nextStep - get().navigate(nextStep, updatedData) - }) - ), - toggleSummaryOpen: () => set({ isSummaryOpen: !get().isSummaryOpen }), - setTotalPrice: (totalPrice) => set({ totalPrice: totalPrice }), - setIsSubmittingDisabled: (isSubmittingDisabled) => - set({ isSubmittingDisabled }), - })) -} - -export type EnterDetailsStore = ReturnType<typeof initEditDetailsState> - -export const EnterDetailsContext = createContext<EnterDetailsStore | null>(null) - -export const useEnterDetailsStore = <T>( - selector: (store: EnterDetailsState) => T -): T => { - const enterDetailsContextStore = useContext(EnterDetailsContext) - - if (!enterDetailsContextStore) { - throw new Error( - `useEnterDetailsStore must be used within EnterDetailsContextProvider` - ) - } - - return useStore(enterDetailsContextStore, selector) -} diff --git a/stores/steps.ts b/stores/steps.ts new file mode 100644 index 000000000..f1e456af2 --- /dev/null +++ b/stores/steps.ts @@ -0,0 +1,159 @@ +"use client" +import merge from "deepmerge" +import { produce } from "immer" +import { useContext } from "react" +import { create, useStore } from "zustand" + +import { bedTypeSchema } from "@/components/HotelReservation/EnterDetails/BedType/schema" +import { breakfastStoreSchema } from "@/components/HotelReservation/EnterDetails/Breakfast/schema" +import { + guestDetailsSchema, + signedInDetailsSchema, +} from "@/components/HotelReservation/EnterDetails/Details/schema" +import { StepsContext } from "@/contexts/Steps" + +import { storageName as detailsStorageName } from "./details" + +import { StepEnum } from "@/types/enums/step" +import type { DetailsState } from "@/types/stores/details" +import type { StepState } from "@/types/stores/steps" + +function push(data: Record<string, string>, url: string) { + if (typeof window !== "undefined") { + window.history.pushState(data, "", url + window.location.search) + } +} + +export function createStepsStore( + currentStep: StepEnum, + isMember: boolean, + noBedChoices: boolean, + noBreakfast: boolean +) { + const isBrowser = typeof window !== "undefined" + const steps = [ + StepEnum.selectBed, + StepEnum.breakfast, + StepEnum.details, + StepEnum.payment, + ] + + /** + * TODO: + * - when included in rate, can packages still be received? + * - no hotels yet with breakfast included in the rate so + * impossible to build for atm. + * + * matching breakfast first so the steps array is altered + * before the bedTypes possible step altering + */ + if (noBreakfast) { + steps.splice(1, 1) + if (currentStep === StepEnum.breakfast) { + currentStep = steps[1] + push({ step: currentStep }, currentStep) + } + } + + if (noBedChoices) { + if (currentStep === StepEnum.selectBed) { + currentStep = steps[1] + push({ step: currentStep }, currentStep) + } + } + + const detailsStorageUnparsed = isBrowser + ? sessionStorage.getItem(detailsStorageName) + : null + if (detailsStorageUnparsed) { + const detailsStorage: Record< + "state", + Pick<DetailsState, "data"> + > = JSON.parse(detailsStorageUnparsed) + + const validPaths = [StepEnum.selectBed] + + const validatedBedType = bedTypeSchema.safeParse(detailsStorage.state.data) + if (validatedBedType.success) { + validPaths.push(steps[1]) + } + + const validatedBreakfast = breakfastStoreSchema.safeParse( + detailsStorage.state.data + ) + if (validatedBreakfast.success) { + validPaths.push(StepEnum.details) + } + + const detailsSchema = isMember ? signedInDetailsSchema : guestDetailsSchema + const validatedDetails = detailsSchema.safeParse(detailsStorage.state.data) + if (validatedDetails.success) { + validPaths.push(StepEnum.payment) + } + + if (!validPaths.includes(currentStep) && isBrowser) { + // We will always have at least one valid path + currentStep = validPaths.pop()! + push({ step: currentStep }, currentStep) + } + } + + const initalData = { + currentStep, + steps, + } + + return create<StepState>()((set) => + merge( + { + currentStep: StepEnum.selectBed, + steps: [], + + completeStep() { + return set( + produce((state: StepState) => { + const currentStepIndex = state.steps.indexOf(state.currentStep) + const nextStep = state.steps[currentStepIndex + 1] + state.currentStep = nextStep + window.history.pushState( + { step: nextStep }, + "", + nextStep + window.location.search + ) + }) + ) + }, + navigate(step: StepEnum) { + return set( + produce((state) => { + state.currentStep = step + window.history.pushState( + { step }, + "", + step + window.location.search + ) + }) + ) + }, + setStep(step: StepEnum) { + return set( + produce((state: StepState) => { + state.currentStep = step + }) + ) + }, + }, + initalData + ) + ) +} + +export function useStepsStore<T>(selector: (store: StepState) => T) { + const store = useContext(StepsContext) + + if (!store) { + throw new Error(`useStepsStore must be used within StepsProvider`) + } + + return useStore(store, selector) +} diff --git a/types/components/hotelReservation/enterDetails/breakfast.ts b/types/components/hotelReservation/enterDetails/breakfast.ts index 21ba37bd0..283d85028 100644 --- a/types/components/hotelReservation/enterDetails/breakfast.ts +++ b/types/components/hotelReservation/enterDetails/breakfast.ts @@ -17,5 +17,5 @@ export interface BreakfastPackage extends z.output<typeof breakfastPackageSchema> {} export interface BreakfastProps { - packages: BreakfastPackages | null + packages: BreakfastPackages } diff --git a/types/components/hotelReservation/enterDetails/step.ts b/types/components/hotelReservation/enterDetails/step.ts index 45de5a009..8c8c967ef 100644 --- a/types/components/hotelReservation/enterDetails/step.ts +++ b/types/components/hotelReservation/enterDetails/step.ts @@ -1,9 +1,4 @@ -export enum StepEnum { - selectBed = "select-bed", - breakfast = "breakfast", - details = "details", - payment = "payment", -} +import { StepEnum } from "@/types/enums/step" export const StepStoreKeys: Record<StepEnum, "bedType" | "breakfast" | null> = { "select-bed": "bedType", diff --git a/types/components/hotelReservation/enterDetails/store.ts b/types/components/hotelReservation/enterDetails/store.ts deleted file mode 100644 index 45dbd5f75..000000000 --- a/types/components/hotelReservation/enterDetails/store.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { StepEnum } from "./step" - -export type EnterDetailsProviderProps = { step: StepEnum; isMember: boolean } diff --git a/types/components/hotelReservation/enterDetails/summary.ts b/types/components/hotelReservation/enterDetails/summary.ts new file mode 100644 index 000000000..901113414 --- /dev/null +++ b/types/components/hotelReservation/enterDetails/summary.ts @@ -0,0 +1,6 @@ +import type { RoomsData } from "./bookingData" + +export interface SummaryProps { + showMemberPrice: boolean + room: RoomsData +} diff --git a/types/components/hotelReservation/selectRate/sectionAccordion.ts b/types/components/hotelReservation/selectRate/sectionAccordion.ts index 46b194db3..c50207f3a 100644 --- a/types/components/hotelReservation/selectRate/sectionAccordion.ts +++ b/types/components/hotelReservation/selectRate/sectionAccordion.ts @@ -1,4 +1,4 @@ -import { StepEnum } from "../enterDetails/step" +import { StepEnum } from "@/types/enums/step" export interface SectionAccordionProps { header: string diff --git a/types/contexts/details.ts b/types/contexts/details.ts new file mode 100644 index 000000000..ea6b65edd --- /dev/null +++ b/types/contexts/details.ts @@ -0,0 +1,3 @@ +import { createDetailsStore } from "@/stores/details" + +export type DetailsStore = ReturnType<typeof createDetailsStore> diff --git a/types/contexts/steps.ts b/types/contexts/steps.ts new file mode 100644 index 000000000..40c3cb55e --- /dev/null +++ b/types/contexts/steps.ts @@ -0,0 +1,3 @@ +import { createStepsStore } from "@/stores/steps" + +export type StepsStore = ReturnType<typeof createStepsStore> diff --git a/types/enums/breakfast.ts b/types/enums/breakfast.ts index 81ff51a2e..723326c37 100644 --- a/types/enums/breakfast.ts +++ b/types/enums/breakfast.ts @@ -1,5 +1,4 @@ export enum BreakfastPackageEnum { FREE_MEMBER_BREAKFAST = "BRF0", REGULAR_BREAKFAST = "BRF1", - NO_BREAKFAST = "NO_BREAKFAST", } diff --git a/types/enums/step.ts b/types/enums/step.ts new file mode 100644 index 000000000..e52d3c856 --- /dev/null +++ b/types/enums/step.ts @@ -0,0 +1,6 @@ +export enum StepEnum { + selectBed = "select-bed", + breakfast = "breakfast", + details = "details", + payment = "payment", +} diff --git a/types/providers/details.ts b/types/providers/details.ts new file mode 100644 index 000000000..c58effb2c --- /dev/null +++ b/types/providers/details.ts @@ -0,0 +1,3 @@ +export interface DetailsProviderProps extends React.PropsWithChildren { + isMember: boolean +} diff --git a/types/providers/steps.ts b/types/providers/steps.ts new file mode 100644 index 000000000..8c24fdc8f --- /dev/null +++ b/types/providers/steps.ts @@ -0,0 +1,10 @@ +import type { BedTypeSelection } from "@/types/components/hotelReservation/enterDetails/bedType" +import { StepEnum } from "@/types/enums/step" +import type { BreakfastPackage } from "../components/hotelReservation/enterDetails/breakfast" + +export interface StepsProviderProps extends React.PropsWithChildren { + bedTypes: BedTypeSelection[] + breakfastPackages: BreakfastPackage[] | null + isMember: boolean + step: StepEnum +} diff --git a/types/stores/details.ts b/types/stores/details.ts new file mode 100644 index 000000000..ef6d101dc --- /dev/null +++ b/types/stores/details.ts @@ -0,0 +1,40 @@ +import type { BedTypeSchema } from "@/types/components/hotelReservation/enterDetails/bedType" +import type { BookingData } from "@/types/components/hotelReservation/enterDetails/bookingData" +import type { BreakfastPackage } from "@/types/components/hotelReservation/enterDetails/breakfast" +import type { DetailsSchema } from "@/types/components/hotelReservation/enterDetails/details" +import { StepEnum } from "@/types/enums/step" + +export interface DetailsState { + actions: { + setIsSubmittingDisabled: (isSubmittingDisabled: boolean) => void + setTotalPrice: (totalPrice: TotalPrice) => void + toggleSummaryOpen: () => void, + updateBedType: (data: BedTypeSchema) => void + updateBreakfast: (data: BreakfastPackage | false) => void + updateDetails: (data: DetailsSchema) => void + updateValidity: (property: StepEnum, isValid: boolean) => void + } + data: DetailsSchema & { + bedType: BedTypeSchema | undefined + breakfast: BreakfastPackage | false | undefined + booking: BookingData + } + isSubmittingDisabled: boolean + isSummaryOpen: boolean + isValid: Record<StepEnum, boolean> + totalPrice: TotalPrice +} + +export interface InitialState extends Partial<DetailsState> { + booking: BookingData +} + +interface Price { + currency: string + price: number +} + +export interface TotalPrice { + euro: Price | undefined + local: Price +} \ No newline at end of file diff --git a/types/stores/steps.ts b/types/stores/steps.ts new file mode 100644 index 000000000..bfdafdae7 --- /dev/null +++ b/types/stores/steps.ts @@ -0,0 +1,10 @@ +import { StepEnum } from "@/types/enums/step" + +export interface StepState { + completeStep: () => void + navigate: (step: StepEnum) => void + setStep: (step: StepEnum) => void + + currentStep: StepEnum + steps: StepEnum[] +} From e7c7485ff869d43654bd1d615b06490d62faaeba Mon Sep 17 00:00:00 2001 From: Pontus Dreij <pontus.dreij@scandichotels.com> Date: Mon, 18 Nov 2024 16:41:02 +0100 Subject: [PATCH 17/24] feat(SW-589) Updated after comments --- .../SelectRate/RoomSelection/RoomCard/index.tsx | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/components/HotelReservation/SelectRate/RoomSelection/RoomCard/index.tsx b/components/HotelReservation/SelectRate/RoomSelection/RoomCard/index.tsx index e2829a1d8..8cf0b5c21 100644 --- a/components/HotelReservation/SelectRate/RoomSelection/RoomCard/index.tsx +++ b/components/HotelReservation/SelectRate/RoomSelection/RoomCard/index.tsx @@ -59,13 +59,11 @@ export default function RoomCard({ ?.generalTerms } - function getBreakfastInformation(rate: RateDefinition | undefined) { - return rateDefinitions.find((def) => def.rateCode === rate?.rateCode) - ?.breakfastIncluded - } + const getBreakfastMessage = (rate: RateDefinition | undefined) => { + const breakfastInfo = rateDefinitions.find( + (def) => def.rateCode === rate?.rateCode + )?.breakfastIncluded - const breakfastMessage = (rate: RateDefinition | undefined) => { - const breakfastInfo = getBreakfastInformation(rate) switch (breakfastInfo) { case true: return intl.formatMessage({ id: "Breakfast is included." }) @@ -187,7 +185,7 @@ export default function RoomCard({ </div> <div className={styles.container}> <Caption color="uiTextHighContrast" type="bold"> - {breakfastMessage(rates.flexRate)} + {getBreakfastMessage(rates.flexRate)} </Caption> {roomConfiguration.status === "NotAvailable" ? ( <div className={styles.noRoomsContainer}> From d86e11ac8589537b331a3f9e503551dd895fbdba Mon Sep 17 00:00:00 2001 From: Pontus Dreij <pontus.dreij@scandichotels.com> Date: Mon, 18 Nov 2024 16:43:02 +0100 Subject: [PATCH 18/24] feat(SW-589) reverted change on hotel card for alert (this will be removed) --- components/HotelReservation/HotelCard/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/HotelReservation/HotelCard/index.tsx b/components/HotelReservation/HotelCard/index.tsx index 2873bfaeb..4c478d275 100644 --- a/components/HotelReservation/HotelCard/index.tsx +++ b/components/HotelReservation/HotelCard/index.tsx @@ -136,7 +136,7 @@ export default function HotelCard({ {hotelData.specialAlerts.length > 0 && ( <div className={styles.specialAlerts}> {hotelData.specialAlerts.map((alert) => ( - <Alert key={alert.id} type={alert.type} text={alert.heading} /> + <Alert key={alert.id} type={alert.type} text={alert.text} /> ))} </div> )} From d56d2f84728d1ef742b978094354f48fb520e3f5 Mon Sep 17 00:00:00 2001 From: Pontus Dreij <pontus.dreij@scandichotels.com> Date: Mon, 18 Nov 2024 16:54:18 +0100 Subject: [PATCH 19/24] feat(SW-589) updated getRateDefinitionForRate --- .../SelectRate/RoomSelection/RoomCard/index.tsx | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/components/HotelReservation/SelectRate/RoomSelection/RoomCard/index.tsx b/components/HotelReservation/SelectRate/RoomSelection/RoomCard/index.tsx index 8cf0b5c21..a81e4169e 100644 --- a/components/HotelReservation/SelectRate/RoomSelection/RoomCard/index.tsx +++ b/components/HotelReservation/SelectRate/RoomSelection/RoomCard/index.tsx @@ -54,15 +54,12 @@ export default function RoomCard({ : undefined } - function getPriceInformationForRate(rate: RateDefinition | undefined) { + function getRateDefinitionForRate(rate: RateDefinition | undefined) { return rateDefinitions.find((def) => def.rateCode === rate?.rateCode) - ?.generalTerms } const getBreakfastMessage = (rate: RateDefinition | undefined) => { - const breakfastInfo = rateDefinitions.find( - (def) => def.rateCode === rate?.rateCode - )?.breakfastIncluded + const breakfastInfo = getRateDefinitionForRate(rate)?.breakfastIncluded switch (breakfastInfo) { case true: @@ -207,7 +204,7 @@ export default function RoomCard({ value={key.toLowerCase()} paymentTerm={key === "flexRate" ? payLater : payNow} product={findProductForRate(rate)} - priceInformation={getPriceInformationForRate(rate)} + priceInformation={getRateDefinitionForRate(rate)?.generalTerms} handleSelectRate={handleSelectRate} roomType={roomConfiguration.roomType} roomTypeCode={roomConfiguration.roomTypeCode} From 62442646f07f7c672f432d5dea3194d29a0f74d6 Mon Sep 17 00:00:00 2001 From: Pontus Dreij <pontus.dreij@scandichotels.com> Date: Mon, 18 Nov 2024 16:55:17 +0100 Subject: [PATCH 20/24] feat(SW-589): updated breakfastIncluded --- .../SelectRate/RoomSelection/RoomCard/index.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/components/HotelReservation/SelectRate/RoomSelection/RoomCard/index.tsx b/components/HotelReservation/SelectRate/RoomSelection/RoomCard/index.tsx index a81e4169e..d69391122 100644 --- a/components/HotelReservation/SelectRate/RoomSelection/RoomCard/index.tsx +++ b/components/HotelReservation/SelectRate/RoomSelection/RoomCard/index.tsx @@ -59,9 +59,9 @@ export default function RoomCard({ } const getBreakfastMessage = (rate: RateDefinition | undefined) => { - const breakfastInfo = getRateDefinitionForRate(rate)?.breakfastIncluded + const breakfastIncluded = getRateDefinitionForRate(rate)?.breakfastIncluded - switch (breakfastInfo) { + switch (breakfastIncluded) { case true: return intl.formatMessage({ id: "Breakfast is included." }) case false: From beb776bac9f22fbfe41f6904bd345594cfe8c1d8 Mon Sep 17 00:00:00 2001 From: Bianca Widstam <bianca.widstam@scandichotels.com> Date: Mon, 18 Nov 2024 17:30:55 +0000 Subject: [PATCH 21/24] Merged in fix/remove-filter-to-show-all-hotels (pull request #925) Fix/remove filter to show all hotels * fix: remove filter to show all hotels on select-hotel-page * fix: add missing translations Approved-by: Pontus Dreij --- i18n/dictionaries/da.json | 1 + i18n/dictionaries/de.json | 1 + i18n/dictionaries/en.json | 1 + i18n/dictionaries/fi.json | 1 + i18n/dictionaries/no.json | 1 + i18n/dictionaries/sv.json | 1 + server/routers/hotels/output.ts | 2 +- server/routers/hotels/query.ts | 9 +++------ server/routers/hotels/schemas/room.ts | 4 ++-- 9 files changed, 12 insertions(+), 9 deletions(-) diff --git a/i18n/dictionaries/da.json b/i18n/dictionaries/da.json index 076544355..336f5eef9 100644 --- a/i18n/dictionaries/da.json +++ b/i18n/dictionaries/da.json @@ -356,6 +356,7 @@ "This room is not available": "Dette værelse er ikke tilgængeligt", "To get the member price <span>{amount} {currency}</span>, log in or join when completing the booking.": "For at få medlemsprisen <span>{amount} {currency}</span>, log ind eller tilmeld dig, når du udfylder bookingen.", "To secure your reservation, we kindly ask you to provide your payment card details. Rest assured, no charges will be made at this time.": "For at sikre din reservation, beder vi om at du giver os dine betalingsoplysninger. Du kan så være sikker på, at ingen gebyrer vil blive opkrævet på dette tidspunkt.", + "Total": "Total", "Total Points": "Samlet antal point", "Total price": "Samlet pris", "Total price (incl VAT)": "Samlet pris (inkl. moms)", diff --git a/i18n/dictionaries/de.json b/i18n/dictionaries/de.json index 433a0ba33..78d9fd5cb 100644 --- a/i18n/dictionaries/de.json +++ b/i18n/dictionaries/de.json @@ -354,6 +354,7 @@ "This room is not available": "Dieses Zimmer ist nicht verfügbar", "To get the member price <span>{amount} {currency}</span>, log in or join when completing the booking.": "Um den Mitgliederpreis von <span>{amount} {currency}</span> zu erhalten, loggen Sie sich ein oder treten Sie Scandic Friends bei, wenn Sie die Buchung abschließen.", "To secure your reservation, we kindly ask you to provide your payment card details. Rest assured, no charges will be made at this time.": "Um Ihre Reservierung zu sichern, bitten wir Sie, Ihre Zahlungskarteninformationen zu geben. Sie können sicher sein, dass keine Gebühren zu diesem Zeitpunkt erhoben werden.", + "Total": "Gesamt", "Total Points": "Gesamtpunktzahl", "Total price": "Gesamtpreis", "Total price (incl VAT)": "Gesamtpreis (inkl. MwSt.)", diff --git a/i18n/dictionaries/en.json b/i18n/dictionaries/en.json index 38a5da82b..cb8a071d9 100644 --- a/i18n/dictionaries/en.json +++ b/i18n/dictionaries/en.json @@ -384,6 +384,7 @@ "This room is not available": "This room is not available", "To get the member price <span>{amount} {currency}</span>, log in or join when completing the booking.": "To get the member price <span>{amount} {currency}</span>, log in or join when completing the booking.", "To secure your reservation, we kindly ask you to provide your payment card details. Rest assured, no charges will be made at this time.": "To secure your reservation, we kindly ask you to provide your payment card details. Rest assured, no charges will be made at this time.", + "Total": "Total", "Total Points": "Total Points", "Total cost": "Total cost", "Total price": "Total price", diff --git a/i18n/dictionaries/fi.json b/i18n/dictionaries/fi.json index 218abed13..48931f7f2 100644 --- a/i18n/dictionaries/fi.json +++ b/i18n/dictionaries/fi.json @@ -356,6 +356,7 @@ "This room is not available": "Tämä huone ei ole käytettävissä", "To get the member price <span>{amount} {currency}</span>, log in or join when completing the booking.": "Jäsenhintaan saavat sisäänkirjautuneet tai liittyneet jäsenet.", "To secure your reservation, we kindly ask you to provide your payment card details. Rest assured, no charges will be made at this time.": "Varmistaaksesi varauksen, pyydämme sinua antamaan meille maksukortin tiedot. Varmista, että ei veloiteta maksusi tällä hetkellä.", + "Total": "Kokonais", "Total Points": "Kokonaispisteet", "Total price": "Kokonaishinta", "Total price (incl VAT)": "Kokonaishinta (sis. ALV)", diff --git a/i18n/dictionaries/no.json b/i18n/dictionaries/no.json index 7cf8e985a..313d0799d 100644 --- a/i18n/dictionaries/no.json +++ b/i18n/dictionaries/no.json @@ -353,6 +353,7 @@ "This room is not available": "Dette rommet er ikke tilgjengelig", "To get the member price <span>{amount} {currency}</span>, log in or join when completing the booking.": "For å få medlemsprisen <span>{amount} {currency}</span>, logg inn eller bli med når du fullfører bestillingen.", "To secure your reservation, we kindly ask you to provide your payment card details. Rest assured, no charges will be made at this time.": "For å sikre din reservasjon, ber vi om at du gir oss dine betalingskortdetaljer. Vær sikker på at ingen gebyrer vil bli belastet på dette tidspunktet.", + "Total": "Total", "Total Points": "Totale poeng", "Total incl VAT": "Sum inkl mva", "Total price": "Totalpris", diff --git a/i18n/dictionaries/sv.json b/i18n/dictionaries/sv.json index 7f935f88a..878bce352 100644 --- a/i18n/dictionaries/sv.json +++ b/i18n/dictionaries/sv.json @@ -353,6 +353,7 @@ "This room is not available": "Detta rum är inte tillgängligt", "To get the member price <span>{amount} {currency}</span>, log in or join when completing the booking.": "För att få medlemsprisen <span>{amount} {currency}</span>, logga in eller bli medlem när du slutför bokningen.", "To secure your reservation, we kindly ask you to provide your payment card details. Rest assured, no charges will be made at this time.": "För att säkra din bokning ber vi om att du ger oss dina betalkortdetaljer. Välj säker på att ingen avgifter kommer att debiteras just nu.", + "Total": "Totalt", "Total Points": "Poäng totalt", "Total incl VAT": "Totalt inkl moms", "Total price": "Totalpris", diff --git a/server/routers/hotels/output.ts b/server/routers/hotels/output.ts index b68e87fc0..d4f3d89c9 100644 --- a/server/routers/hotels/output.ts +++ b/server/routers/hotels/output.ts @@ -105,7 +105,7 @@ const hotelContentSchema = z.object({ imageSizes: imageSizesSchema, }), texts: z.object({ - facilityInformation: z.string(), + facilityInformation: z.string().optional(), surroundingInformation: z.string(), descriptions: z.object({ short: z.string(), diff --git a/server/routers/hotels/query.ts b/server/routers/hotels/query.ts index ea094de0f..af9460b7c 100644 --- a/server/routers/hotels/query.ts +++ b/server/routers/hotels/query.ts @@ -468,12 +468,9 @@ export const hotelQueryRouter = router({ }) ) return { - availability: validateAvailabilityData.data.data - .filter( - (hotels) => - hotels.attributes.status === AvailabilityEnum.Available - ) - .flatMap((hotels) => hotels.attributes), + availability: validateAvailabilityData.data.data.flatMap( + (hotels) => hotels.attributes + ), } }), rooms: serviceProcedure diff --git a/server/routers/hotels/schemas/room.ts b/server/routers/hotels/schemas/room.ts index 5a1480097..8b9291c1f 100644 --- a/server/routers/hotels/schemas/room.ts +++ b/server/routers/hotels/schemas/room.ts @@ -11,8 +11,8 @@ const roomContentSchema = z.object({ ), texts: z.object({ descriptions: z.object({ - short: z.string(), - medium: z.string(), + short: z.string().optional(), + medium: z.string().optional(), }), }), }) From 17df6d6c47347bd4a005bae1ae103c27e41b4c52 Mon Sep 17 00:00:00 2001 From: Arvid Norlin <arvid.norlin@scandichotels.com> Date: Wed, 6 Nov 2024 16:14:57 +0100 Subject: [PATCH 22/24] feat(SW-612): Add popover component --- .../(standard)/step/@summary/page.tsx | 2 + .../EnterDetails/Summary/index.tsx | 21 ++++++-- .../EnterDetails/Summary/summary.module.css | 8 +++ .../Popover/Arrow/arrow.module.css | 22 ++++++++ .../TempDesignSystem/Popover/Arrow/index.tsx | 19 +++++++ components/TempDesignSystem/Popover/index.tsx | 50 +++++++++++++++++++ .../Popover/popover.module.css | 27 ++++++++++ .../TempDesignSystem/Popover/popover.ts | 6 +++ server/routers/hotels/query.ts | 5 ++ .../enterDetails/bookingData.ts | 1 + 10 files changed, 158 insertions(+), 3 deletions(-) create mode 100644 components/TempDesignSystem/Popover/Arrow/arrow.module.css create mode 100644 components/TempDesignSystem/Popover/Arrow/index.tsx create mode 100644 components/TempDesignSystem/Popover/index.tsx create mode 100644 components/TempDesignSystem/Popover/popover.module.css create mode 100644 components/TempDesignSystem/Popover/popover.ts diff --git a/app/[lang]/(live)/(public)/hotelreservation/(standard)/step/@summary/page.tsx b/app/[lang]/(live)/(public)/hotelreservation/(standard)/step/@summary/page.tsx index da3554f50..337d501b3 100644 --- a/app/[lang]/(live)/(public)/hotelreservation/(standard)/step/@summary/page.tsx +++ b/app/[lang]/(live)/(public)/hotelreservation/(standard)/step/@summary/page.tsx @@ -104,6 +104,7 @@ export default async function SummaryPage({ euroPrice: prices.euro, adults, children, + rateDetails: availability.rateDetails, cancellationText: availability.cancellationText, packages, }} @@ -122,6 +123,7 @@ export default async function SummaryPage({ euroPrice: prices.euro, adults, children, + rateDetails: availability.rateDetails, cancellationText: availability.cancellationText, packages, }} diff --git a/components/HotelReservation/EnterDetails/Summary/index.tsx b/components/HotelReservation/EnterDetails/Summary/index.tsx index c447c7f75..2e0bdb2cd 100644 --- a/components/HotelReservation/EnterDetails/Summary/index.tsx +++ b/components/HotelReservation/EnterDetails/Summary/index.tsx @@ -11,6 +11,7 @@ import { ArrowRightIcon } from "@/components/Icons" import Button from "@/components/TempDesignSystem/Button" import Divider from "@/components/TempDesignSystem/Divider" import Link from "@/components/TempDesignSystem/Link" +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" @@ -178,9 +179,23 @@ export default function Summary({ showMemberPrice, room }: SummaryProps) { <Caption color="uiTextMediumContrast"> {room.cancellationText} </Caption> - <Link color="burgundy" href="#" variant="underscored" size="small"> - {intl.formatMessage({ id: "Rate details" })} - </Link> + <Popover + placement={"bottom left"} + triggerContent={ + <Caption color="burgundy" type="underline"> + {intl.formatMessage({ id: "Rate details" })} + </Caption> + } + > + <aside className={styles.rateDetailsPopover}> + <header> + <Caption type={"bold"}>{room.cancellationText}</Caption> + </header> + {room.rateDetails?.map((detail, idx) => ( + <Caption key={`rateDetails-${idx}`}>{detail}</Caption> + ))} + </aside> + </Popover> </div> {room.packages ? room.packages.map((roomPackage) => ( diff --git a/components/HotelReservation/EnterDetails/Summary/summary.module.css b/components/HotelReservation/EnterDetails/Summary/summary.module.css index 426afbc7d..e4ee465a8 100644 --- a/components/HotelReservation/EnterDetails/Summary/summary.module.css +++ b/components/HotelReservation/EnterDetails/Summary/summary.module.css @@ -41,6 +41,13 @@ gap: var(--Spacing-x-one-and-half); } +.rateDetailsPopover { + display: flex; + flex-direction: column; + gap: var(--Spacing-x-half); + max-width: 360px; +} + .entry { display: flex; gap: var(--Spacing-x-half); @@ -50,6 +57,7 @@ .entry > :last-child { justify-items: flex-end; } + .total { display: flex; flex-direction: column; diff --git a/components/TempDesignSystem/Popover/Arrow/arrow.module.css b/components/TempDesignSystem/Popover/Arrow/arrow.module.css new file mode 100644 index 000000000..522b5aff8 --- /dev/null +++ b/components/TempDesignSystem/Popover/Arrow/arrow.module.css @@ -0,0 +1,22 @@ +.arrow { + transform-origin: center; + transform: translateY(-2px); +} + +[data-placement="left"] .arrow, +[data-placement="left top"] .arrow, +[data-placement="left bottom"] .arrow { + transform: rotate(270deg) translateY(-6px); +} + +[data-placement="right"] .arrow, +[data-placement="right top"] .arrow, +[data-placement="right bottom"] .arrow { + transform: rotate(90deg) translateY(-6px); +} + +[data-placement="bottom"] .arrow, +[data-placement="bottom left"] .arrow, +[data-placement="bottom right"] .arrow { + transform: rotate(180deg) translateY(-2px); +} diff --git a/components/TempDesignSystem/Popover/Arrow/index.tsx b/components/TempDesignSystem/Popover/Arrow/index.tsx new file mode 100644 index 000000000..4c67b059b --- /dev/null +++ b/components/TempDesignSystem/Popover/Arrow/index.tsx @@ -0,0 +1,19 @@ +import styles from "./arrow.module.css" + +export function Arrow() { + return ( + <div className={styles.arrow}> + <svg + xmlns="http://www.w3.org/2000/svg" + width="27" + height="13" + fill="none" + > + <path + fill="#fff" + d="M13.093 12.193.9 0h25.8L14.508 12.193a1 1 0 0 1-1.415 0Z" + /> + </svg> + </div> + ) +} diff --git a/components/TempDesignSystem/Popover/index.tsx b/components/TempDesignSystem/Popover/index.tsx new file mode 100644 index 000000000..1d389c648 --- /dev/null +++ b/components/TempDesignSystem/Popover/index.tsx @@ -0,0 +1,50 @@ +import { useRef } from "react" +import { + Button, + Dialog, + DialogTrigger, + OverlayArrow, + Popover as RAPopover, +} from "react-aria-components" + +import { CloseLargeIcon } from "@/components/Icons" + +import { Arrow } from "./Arrow" +import { PopoverProps } from "./popover" + +import styles from "./popover.module.css" + +export default function Popover({ + triggerContent, + children, + ...props +}: PopoverProps) { + let triggerRef = useRef(null) + + return ( + <DialogTrigger> + <Button className={styles.trigger}>{triggerContent}</Button> + + <RAPopover + {...props} + offset={16} + crossOffset={-24} + className={styles.root} + > + <OverlayArrow> + <Arrow /> + </OverlayArrow> + <Dialog> + {({ close }) => ( + <> + <Button className={styles.closeButton} onPress={close}> + <CloseLargeIcon height={20} width={20} /> + </Button> + {children} + </> + )} + </Dialog> + </RAPopover> + </DialogTrigger> + ) +} diff --git a/components/TempDesignSystem/Popover/popover.module.css b/components/TempDesignSystem/Popover/popover.module.css new file mode 100644 index 000000000..242bd5072 --- /dev/null +++ b/components/TempDesignSystem/Popover/popover.module.css @@ -0,0 +1,27 @@ +.root { + background-color: var(--Base-Surface-Primary-light-Normal); + border-radius: var(--Corner-radius-Medium); + box-shadow: 0px 0px 14px 6px rgba(0, 0, 0, 0.1); + padding: var(--Spacing-x2); + max-width: calc(360px + var(--Spacing-x2) * 2); +} + +.root section:focus-visible { + outline: none; +} + +.trigger { + background: none; + border: none; + padding: 0; + cursor: pointer; +} +.closeButton { + position: absolute; + top: 8px; + right: 8px; + background: none; + border: none; + cursor: pointer; + padding: 0; +} diff --git a/components/TempDesignSystem/Popover/popover.ts b/components/TempDesignSystem/Popover/popover.ts new file mode 100644 index 000000000..c774ca398 --- /dev/null +++ b/components/TempDesignSystem/Popover/popover.ts @@ -0,0 +1,6 @@ +import type { PopoverProps as RAPopoverProps } from "react-aria-components" + +export interface PopoverProps extends Omit<RAPopoverProps, "children"> { + triggerContent: React.ReactNode + children: React.ReactNode +} diff --git a/server/routers/hotels/query.ts b/server/routers/hotels/query.ts index af9460b7c..7f2ef84c0 100644 --- a/server/routers/hotels/query.ts +++ b/server/routers/hotels/query.ts @@ -725,6 +725,10 @@ export const hotelQueryRouter = router({ return null } + const rateDetails = validateAvailabilityData.data.rateDefinitions.find( + (rateDef) => rateDef.rateCode === rateCode + )?.generalTerms + const rateTypes = selectedRoom.products.find( (rate) => rate.productType.public.rateCode === rateCode || @@ -782,6 +786,7 @@ export const hotelQueryRouter = router({ return { selectedRoom, + rateDetails, mustBeGuaranteed, cancellationText, memberRate: rates?.member, diff --git a/types/components/hotelReservation/enterDetails/bookingData.ts b/types/components/hotelReservation/enterDetails/bookingData.ts index 628fa3f8b..0afabf91a 100644 --- a/types/components/hotelReservation/enterDetails/bookingData.ts +++ b/types/components/hotelReservation/enterDetails/bookingData.ts @@ -28,6 +28,7 @@ export type RoomsData = { euroPrice: Price | undefined adults: number children?: Child[] + rateDetails?: string[] cancellationText: string packages: Packages | null } From 1004871afb7f19401c87ca78e8a9065a43435478 Mon Sep 17 00:00:00 2001 From: Arvid Norlin <arvid.norlin@scandichotels.com> Date: Fri, 15 Nov 2024 13:59:51 +0100 Subject: [PATCH 23/24] fix: use new Popover component in FlexibilityOption --- .../RoomSelection/FlexibilityOption/index.tsx | 67 ++++--------------- 1 file changed, 13 insertions(+), 54 deletions(-) diff --git a/components/HotelReservation/SelectRate/RoomSelection/FlexibilityOption/index.tsx b/components/HotelReservation/SelectRate/RoomSelection/FlexibilityOption/index.tsx index 5a046978c..941d6257d 100644 --- a/components/HotelReservation/SelectRate/RoomSelection/FlexibilityOption/index.tsx +++ b/components/HotelReservation/SelectRate/RoomSelection/FlexibilityOption/index.tsx @@ -1,13 +1,12 @@ "use client" -import { useRef, useState } from "react" -import { Button } from "react-aria-components" + import { useIntl } from "react-intl" import { CheckIcon, InfoCircleIcon } from "@/components/Icons" import Label from "@/components/TempDesignSystem/Form/Label" +import Popover from "@/components/TempDesignSystem/Popover" import Caption from "@/components/TempDesignSystem/Text/Caption" -import PricePopover from "./Popover" import PriceTable from "./PriceList" import styles from "./flexibilityOption.module.css" @@ -25,18 +24,8 @@ export default function FlexibilityOption({ petRoomPackage, handleSelectRate, }: FlexibilityOptionProps) { - const [rootDiv, setRootDiv] = useState<Element | undefined>(undefined) - const [isPopoverOpen, setIsPopoverOpen] = useState(false) - let triggerRef = useRef<HTMLButtonElement>(null) - const buttonClickedRef = useRef(false) const intl = useIntl() - function setRef(node: Element | null) { - if (node) { - setRootDiv(node) - } - } - if (!product) { return ( <div className={styles.noPricesCard}> @@ -68,15 +57,6 @@ export default function FlexibilityOption({ handleSelectRate(rate) } - function togglePopover() { - buttonClickedRef.current = !buttonClickedRef.current - setIsPopoverOpen(buttonClickedRef.current) - } - - function handlePopoverChange(isOpen: boolean) { - setIsPopoverOpen(isOpen) - } - return ( <label> <input @@ -86,37 +66,16 @@ export default function FlexibilityOption({ onChange={onChange} /> <div className={styles.card}> - <div className={styles.header} ref={setRef}> - <Button - aria-label="Help" - className={styles.button} - onPress={() => { - togglePopover() - }} - ref={triggerRef} - > - <InfoCircleIcon - width={16} - height={16} - color="uiTextMediumContrast" - /> - </Button> - <PricePopover - placement="bottom" - className={styles.popover} - isNonModal - shouldFlip={false} - shouldUpdatePosition={false} - /** - * react-aria uses portals to render Popover in body - * unless otherwise specified. We need it to be contained - * by this component to both access css variables assigned - * on the container as well as to not overflow it at any time. - */ - UNSTABLE_portalContainer={rootDiv} - triggerRef={triggerRef} - isOpen={isPopoverOpen} - onOpenChange={handlePopoverChange} + <div className={styles.header}> + <Popover + placement="bottom left" + triggerContent={ + <InfoCircleIcon + width={16} + height={16} + color="uiTextMediumContrast" + /> + } > <Caption color="uiTextHighContrast" @@ -134,7 +93,7 @@ export default function FlexibilityOption({ {info} </Caption> ))} - </PricePopover> + </Popover> <Caption color="uiTextHighContrast">{name}</Caption> <Caption color="uiTextPlaceholder">({paymentTerm})</Caption> </div> From e18a2fdc445cf37ca485abace0e6cf9cb879a68d Mon Sep 17 00:00:00 2001 From: Arvid Norlin <arvid.norlin@scandichotels.com> Date: Fri, 15 Nov 2024 15:14:00 +0100 Subject: [PATCH 24/24] feat: add useSetOverflowVisibleOnRA hook --- .../EnterDetails/Summary/index.tsx | 4 +-- .../FlexibilityOption/Popover/index.tsx | 36 ------------------- .../Popover/popover.module.css | 12 ------- components/TempDesignSystem/Popover/index.tsx | 6 ++-- .../selectRate/pricePopover.ts | 5 --- 5 files changed, 5 insertions(+), 58 deletions(-) delete mode 100644 components/HotelReservation/SelectRate/RoomSelection/FlexibilityOption/Popover/index.tsx delete mode 100644 components/HotelReservation/SelectRate/RoomSelection/FlexibilityOption/Popover/popover.module.css delete mode 100644 types/components/hotelReservation/selectRate/pricePopover.ts diff --git a/components/HotelReservation/EnterDetails/Summary/index.tsx b/components/HotelReservation/EnterDetails/Summary/index.tsx index 2e0bdb2cd..7507a6945 100644 --- a/components/HotelReservation/EnterDetails/Summary/index.tsx +++ b/components/HotelReservation/EnterDetails/Summary/index.tsx @@ -180,7 +180,7 @@ export default function Summary({ showMemberPrice, room }: SummaryProps) { {room.cancellationText} </Caption> <Popover - placement={"bottom left"} + placement="bottom left" triggerContent={ <Caption color="burgundy" type="underline"> {intl.formatMessage({ id: "Rate details" })} @@ -189,7 +189,7 @@ export default function Summary({ showMemberPrice, room }: SummaryProps) { > <aside className={styles.rateDetailsPopover}> <header> - <Caption type={"bold"}>{room.cancellationText}</Caption> + <Caption type="bold">{room.cancellationText}</Caption> </header> {room.rateDetails?.map((detail, idx) => ( <Caption key={`rateDetails-${idx}`}>{detail}</Caption> diff --git a/components/HotelReservation/SelectRate/RoomSelection/FlexibilityOption/Popover/index.tsx b/components/HotelReservation/SelectRate/RoomSelection/FlexibilityOption/Popover/index.tsx deleted file mode 100644 index 62979d2c7..000000000 --- a/components/HotelReservation/SelectRate/RoomSelection/FlexibilityOption/Popover/index.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { Button, Dialog, OverlayArrow, Popover } from "react-aria-components" - -import { CloseIcon } from "@/components/Icons" - -import styles from "./popover.module.css" - -import { PricePopoverProps } from "@/types/components/hotelReservation/selectRate/pricePopover" - -export default function PricePopover({ - children, - ...props -}: PricePopoverProps) { - return ( - <Popover {...props}> - <OverlayArrow className={styles.arrow}> - <svg - width="12" - height="12" - viewBox="0 0 12 12" - style={{ display: "block", transform: "rotate(180deg)" }} - > - <path d="M0 0L6 6L12 0" fill="white" /> - </svg> - </OverlayArrow> - <Dialog> - <Button - onPress={() => props.onOpenChange?.(false)} - className={styles.closeButton} - > - <CloseIcon className={styles.closeIcon} /> - </Button> - {children} - </Dialog> - </Popover> - ) -} diff --git a/components/HotelReservation/SelectRate/RoomSelection/FlexibilityOption/Popover/popover.module.css b/components/HotelReservation/SelectRate/RoomSelection/FlexibilityOption/Popover/popover.module.css deleted file mode 100644 index bb60ba100..000000000 --- a/components/HotelReservation/SelectRate/RoomSelection/FlexibilityOption/Popover/popover.module.css +++ /dev/null @@ -1,12 +0,0 @@ -.arrow { - top: -6px; -} - -.closeButton { - position: absolute; - top: 5px; - right: 5px; - background: none; - border: none; - cursor: pointer; -} diff --git a/components/TempDesignSystem/Popover/index.tsx b/components/TempDesignSystem/Popover/index.tsx index 1d389c648..b1b03f340 100644 --- a/components/TempDesignSystem/Popover/index.tsx +++ b/components/TempDesignSystem/Popover/index.tsx @@ -1,4 +1,3 @@ -import { useRef } from "react" import { Button, Dialog, @@ -8,6 +7,7 @@ import { } from "react-aria-components" import { CloseLargeIcon } from "@/components/Icons" +import useSetOverFlowVisibleOnRA from "@/hooks/useSetOverflowVisibleOnRA" import { Arrow } from "./Arrow" import { PopoverProps } from "./popover" @@ -19,10 +19,10 @@ export default function Popover({ children, ...props }: PopoverProps) { - let triggerRef = useRef(null) + const setOverflowVisible = useSetOverFlowVisibleOnRA() return ( - <DialogTrigger> + <DialogTrigger onOpenChange={setOverflowVisible}> <Button className={styles.trigger}>{triggerContent}</Button> <RAPopover diff --git a/types/components/hotelReservation/selectRate/pricePopover.ts b/types/components/hotelReservation/selectRate/pricePopover.ts deleted file mode 100644 index 2a7c84d56..000000000 --- a/types/components/hotelReservation/selectRate/pricePopover.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { PopoverProps } from "react-aria-components" - -export interface PricePopoverProps extends Omit<PopoverProps, "children"> { - children: React.ReactNode -}