diff --git a/app/globals.css b/app/globals.css index 6e2529da2..d7b5369aa 100644 --- a/app/globals.css +++ b/app/globals.css @@ -125,6 +125,8 @@ --dialog-z-index: 9; --sidepeek-z-index: 100; --lightbox-z-index: 150; + --default-modal-overlay-z-index: 100; + --default-modal-z-index: 101; } * { diff --git a/components/HotelReservation/EnterDetails/Details/MemberPriceModal/index.tsx b/components/HotelReservation/EnterDetails/Details/MemberPriceModal/index.tsx new file mode 100644 index 000000000..73c56664d --- /dev/null +++ b/components/HotelReservation/EnterDetails/Details/MemberPriceModal/index.tsx @@ -0,0 +1,64 @@ +"use client" + +import { useIntl } from "react-intl" + +import { useEnterDetailsStore } from "@/stores/enter-details" + +import { MagicWandIcon } from "@/components/Icons" +import Button from "@/components/TempDesignSystem/Button" +import Body from "@/components/TempDesignSystem/Text/Body" +import Subtitle from "@/components/TempDesignSystem/Text/Subtitle" +import Title from "@/components/TempDesignSystem/Text/Title" + +import Modal from "../../Modal" + +import styles from "./modal.module.css" + +import type { Dispatch, SetStateAction } from "react" + +export default function MemberPriceModal({ + isOpen, + setIsOpen, +}: { + isOpen: boolean + setIsOpen: Dispatch> +}) { + const memberRate = useEnterDetailsStore((state) => state.roomRate.memberRate) + const intl = useIntl() + + const memberPrice = memberRate?.localPrice ?? memberRate?.requestedPrice + + return ( + +
+
+ + + {intl.formatMessage({ + id: "Member price activated", + })} + + + {memberPrice && ( + + + {intl.formatMessage({ + id: "The new price is", + })} + + + {intl.formatNumber(memberPrice.pricePerStay, { + currency: memberPrice.currency, + style: "currency", + })} + + + )} +
+ +
+
+ ) +} diff --git a/components/HotelReservation/EnterDetails/Details/MemberPriceModal/modal.module.css b/components/HotelReservation/EnterDetails/Details/MemberPriceModal/modal.module.css new file mode 100644 index 000000000..43fdc5f70 --- /dev/null +++ b/components/HotelReservation/EnterDetails/Details/MemberPriceModal/modal.module.css @@ -0,0 +1,24 @@ +.modalContent { + display: grid; + gap: var(--Spacing-x3); + width: 100%; +} + +.innerModalContent { + display: grid; + gap: var(--Spacing-x2); + align-items: center; + justify-items: center; +} + +.newPrice { + display: flex; + gap: var(--Spacing-x1); + align-items: center; +} + +@media screen and (min-width: 768px) { + .modalContent { + width: 352px; + } +} diff --git a/components/HotelReservation/EnterDetails/Details/index.tsx b/components/HotelReservation/EnterDetails/Details/index.tsx index aee04241a..8fb059c87 100644 --- a/components/HotelReservation/EnterDetails/Details/index.tsx +++ b/components/HotelReservation/EnterDetails/Details/index.tsx @@ -1,6 +1,6 @@ "use client" import { zodResolver } from "@hookform/resolvers/zod" -import { useCallback } from "react" +import { useCallback, useState } from "react" import { FormProvider, useForm } from "react-hook-form" import { useIntl } from "react-intl" @@ -13,6 +13,7 @@ import Phone from "@/components/TempDesignSystem/Form/Phone" import Footnote from "@/components/TempDesignSystem/Text/Footnote" import JoinScandicFriendsCard from "./JoinScandicFriendsCard" +import MemberPriceModal from "./MemberPriceModal" import { guestDetailsSchema, signedInDetailsSchema } from "./schema" import Signup from "./Signup" import SpecialRequests from "./SpecialRequests" @@ -27,6 +28,8 @@ import type { const formID = "enter-details" export default function Details({ user, memberPrice }: DetailsProps) { const intl = useIntl() + const [isMemberPriceModalOpen, setIsMemberPriceModalOpen] = useState(false) + const initialData = useEnterDetailsStore((state) => state.guest) const updateDetails = useEnterDetailsStore( (state) => state.actions.updateDetails @@ -53,9 +56,12 @@ export default function Details({ user, memberPrice }: DetailsProps) { const onSubmit = useCallback( (values: DetailsSchema) => { + if ((values.join || values.membershipNo) && memberPrice) { + setIsMemberPriceModalOpen(true) + } updateDetails(values) }, - [updateDetails] + [updateDetails, setIsMemberPriceModalOpen, memberPrice] ) return ( @@ -130,6 +136,10 @@ export default function Details({ user, memberPrice }: DetailsProps) { {intl.formatMessage({ id: "Proceed to payment method" })} + ) diff --git a/components/HotelReservation/EnterDetails/Modal/index.tsx b/components/HotelReservation/EnterDetails/Modal/index.tsx new file mode 100644 index 000000000..50f8b838c --- /dev/null +++ b/components/HotelReservation/EnterDetails/Modal/index.tsx @@ -0,0 +1,150 @@ +"use client" + +import { motion } from "framer-motion" +import { type PropsWithChildren, useEffect, useState } from "react" +import { + Dialog, + DialogTrigger, + Modal as AriaModal, + ModalOverlay, +} from "react-aria-components" +import { useIntl } from "react-intl" + +import { CloseLargeIcon } from "@/components/Icons" +import Subtitle from "@/components/TempDesignSystem/Text/Subtitle" + +import { + type AnimationState, + AnimationStateEnum, + type InnerModalProps, + type ModalProps, +} from "./modal" +import { fade, slideInOut } from "./motionVariants" + +import styles from "./modal.module.css" + +const MotionOverlay = motion(ModalOverlay) +const MotionModal = motion(AriaModal) + +function InnerModal({ + animation, + onAnimationComplete = () => undefined, + setAnimation, + onToggle, + isOpen, + children, + title, +}: PropsWithChildren) { + const intl = useIntl() + function modalStateHandler(newAnimationState: AnimationState) { + setAnimation((currentAnimationState) => + newAnimationState === AnimationStateEnum.hidden && + currentAnimationState === AnimationStateEnum.hidden + ? AnimationStateEnum.unmounted + : currentAnimationState + ) + if (newAnimationState === AnimationStateEnum.visible) { + onAnimationComplete() + } + } + + function onOpenChange(state: boolean) { + onToggle!(state) + } + + return ( + + + + {({ close }) => ( + <> +
+ {title && ( + + {title} + + )} + + +
+
{children}
+ + )} +
+
+
+ ) +} + +export default function Modal({ + onAnimationComplete = () => undefined, + trigger, + isOpen, + onToggle, + title, + children, +}: PropsWithChildren) { + const [animation, setAnimation] = useState( + AnimationStateEnum.visible + ) + + useEffect(() => { + if (typeof isOpen === "boolean") { + setAnimation( + isOpen ? AnimationStateEnum.visible : AnimationStateEnum.hidden + ) + } + }, [isOpen]) + + if (!trigger) { + return ( + + {children} + + ) + } + + return ( + + setAnimation( + isOpen ? AnimationStateEnum.visible : AnimationStateEnum.hidden + ) + } + > + {trigger} + + {children} + + + ) +} diff --git a/components/HotelReservation/EnterDetails/Modal/modal.module.css b/components/HotelReservation/EnterDetails/Modal/modal.module.css new file mode 100644 index 000000000..31239b9ee --- /dev/null +++ b/components/HotelReservation/EnterDetails/Modal/modal.module.css @@ -0,0 +1,77 @@ +.overlay { + background: rgba(0, 0, 0, 0.5); + height: var(--visual-viewport-height); + position: fixed; + top: 0; + left: 0; + width: 100vw; + z-index: var(--default-modal-overlay-z-index); +} + +.modal { + background-color: var(--Base-Surface-Primary-light-Normal); + border-radius: var(--Corner-radius-Medium) var(--Corner-radius-Medium) 0 0; + box-shadow: 0px 4px 24px 0px rgba(38, 32, 30, 0.08); + width: 100%; + position: absolute; + left: 0; + bottom: 0; + z-index: var(--default-modal-z-index); +} + +.dialog { + display: flex; + flex-direction: column; + padding-bottom: var(--Spacing-x3); + + /* for supporting animations within content */ + position: relative; + overflow: hidden; +} + +.header { + --button-dimension: 32px; + + box-sizing: content-box; + display: flex; + align-items: center; + height: var(--button-dimension); + position: relative; + justify-content: center; + padding: var(--Spacing-x3) var(--Spacing-x2) 0; +} + +.content { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--Spacing-x2); + padding: 0 var(--Spacing-x3) var(--Spacing-x1); +} + +.close { + background: none; + border: none; + cursor: pointer; + position: absolute; + right: var(--Spacing-x2); + width: var(--button-dimension); + height: var(--button-dimension); + display: flex; + align-items: center; +} + +@media screen and (min-width: 768px) { + .overlay { + display: flex; + justify-content: center; + align-items: center; + } + + .modal { + left: auto; + bottom: auto; + width: auto; + border-radius: var(--Corner-radius-Medium); + } +} diff --git a/components/HotelReservation/EnterDetails/Modal/modal.ts b/components/HotelReservation/EnterDetails/Modal/modal.ts new file mode 100644 index 000000000..0f28f0432 --- /dev/null +++ b/components/HotelReservation/EnterDetails/Modal/modal.ts @@ -0,0 +1,26 @@ +import type { Dispatch, SetStateAction } from "react" + +export enum AnimationStateEnum { + unmounted = "unmounted", + hidden = "hidden", + visible = "visible", +} + +export type AnimationState = keyof typeof AnimationStateEnum + +export type ModalProps = { + onAnimationComplete?: VoidFunction + title?: string +} & ( + | { trigger: JSX.Element; isOpen?: never; onToggle?: never } + | { + trigger?: never + isOpen: boolean + onToggle: Dispatch> + } +) + +export type InnerModalProps = Omit & { + animation: AnimationState + setAnimation: Dispatch> +} diff --git a/components/HotelReservation/EnterDetails/Modal/motionVariants.ts b/components/HotelReservation/EnterDetails/Modal/motionVariants.ts new file mode 100644 index 000000000..e24932152 --- /dev/null +++ b/components/HotelReservation/EnterDetails/Modal/motionVariants.ts @@ -0,0 +1,23 @@ +export const fade = { + hidden: { + opacity: 0, + transition: { duration: 0.4, ease: "easeInOut" }, + }, + visible: { + opacity: 1, + transition: { duration: 0.4, ease: "easeInOut" }, + }, +} + +export const slideInOut = { + hidden: { + opacity: 0, + y: 32, + transition: { duration: 0.4, ease: "easeInOut" }, + }, + visible: { + opacity: 1, + y: 0, + transition: { duration: 0.4, ease: "easeInOut" }, + }, +} diff --git a/components/HotelReservation/EnterDetails/Summary/UI/index.tsx b/components/HotelReservation/EnterDetails/Summary/UI/index.tsx index 90dc0775d..2d1e02f77 100644 --- a/components/HotelReservation/EnterDetails/Summary/UI/index.tsx +++ b/components/HotelReservation/EnterDetails/Summary/UI/index.tsx @@ -5,16 +5,21 @@ import { dt } from "@/lib/dt" import { useEnterDetailsStore } from "@/stores/enter-details" import SignupPromoDesktop from "@/components/HotelReservation/SignupPromo/Desktop" -import { ArrowRightIcon, ChevronDownSmallIcon } from "@/components/Icons" +import { + ArrowRightIcon, + ChevronDownSmallIcon, + ChevronRightSmallIcon, +} 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" import useLang from "@/hooks/useLang" +import Modal from "../../Modal" + import styles from "./ui.module.css" import type { SummaryProps } from "@/types/components/hotelReservation/summary" @@ -31,6 +36,7 @@ export function storeSelector(state: DetailsState) { roomRate: state.roomRate, roomPrice: state.roomPrice, toggleSummaryOpen: state.actions.toggleSummaryOpen, + togglePriceDetailsModalOpen: state.actions.togglePriceDetailsModalOpen, totalPrice: state.totalPrice, } } @@ -54,6 +60,7 @@ export default function SummaryUI({ roomPrice, roomRate, toggleSummaryOpen, + togglePriceDetailsModalOpen, totalPrice, } = useEnterDetailsStore(storeSelector) @@ -82,6 +89,12 @@ export default function SummaryUI({ } } + function handleTogglePriceDetailsModal() { + if (togglePriceDetailsModalOpen) { + togglePriceDetailsModalOpen() + } + } + return (
@@ -243,9 +256,28 @@ export default function SummaryUI({ { b: (str) => {str} } )} - - {intl.formatMessage({ id: "Price details" })} - + + + {intl.formatMessage({ id: "Price details" })} + + + + } + > +
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do + eiusmod tempor incididunt ut labore et dolore magna aliqua. Arcu + risus quis varius quam quisque id diam vel. Rhoncus urna neque + viverra justo. Mattis aliquam faucibus purus in massa. Id cursus + metus aliquam eleifend mi in nulla posuere. +
+
diff --git a/components/HotelReservation/EnterDetails/Summary/UI/ui.module.css b/components/HotelReservation/EnterDetails/Summary/UI/ui.module.css index 9ab1f7ef3..b4d3e41ac 100644 --- a/components/HotelReservation/EnterDetails/Summary/UI/ui.module.css +++ b/components/HotelReservation/EnterDetails/Summary/UI/ui.module.css @@ -68,6 +68,10 @@ display: none; } +.modalContent { + width: 560px; +} + @media screen and (min-width: 1367px) { .bottomDivider { display: block; diff --git a/components/TempDesignSystem/Button/button.module.css b/components/TempDesignSystem/Button/button.module.css index f57a0ff8b..bdf299a03 100644 --- a/components/TempDesignSystem/Button/button.module.css +++ b/components/TempDesignSystem/Button/button.module.css @@ -55,7 +55,10 @@ a.text { border: none; outline: none; } - +/* TODO: The variants for combinations of size/text/wrapping should be looked at and iterated on */ +.text:not(.wrapping) { + padding: 0 !important; +} /* VARIANTS */ .default, a.default { diff --git a/components/TempDesignSystem/Select/select.module.css b/components/TempDesignSystem/Select/select.module.css index 60251bc41..519f97872 100644 --- a/components/TempDesignSystem/Select/select.module.css +++ b/components/TempDesignSystem/Select/select.module.css @@ -52,6 +52,7 @@ outline: none; padding: var(--Spacing-x-one-and-half) var(--Spacing-x2); text-align: left; + justify-content: space-between; } .input :global(.react-aria-SelectValue) { diff --git a/i18n/dictionaries/da.json b/i18n/dictionaries/da.json index cbfc5f089..f63e7f629 100644 --- a/i18n/dictionaries/da.json +++ b/i18n/dictionaries/da.json @@ -236,6 +236,7 @@ "Marketing city": "Marketing by", "Meetings & Conferences": "Møder & Konferencer", "Member price": "Medlemspris", + "Member price activated": "Medlemspris aktiveret", "Member price from": "Medlemspris fra", "Members": "Medlemmer", "Membership ID": "Medlems-id", @@ -403,6 +404,7 @@ "Tell us what information and updates you'd like to receive, and how, by clicking the link below.": "Fortæl os, hvilke oplysninger og opdateringer du gerne vil modtage, og hvordan, ved at klikke på linket nedenfor.", "Terms and conditions": "Vilkår og betingelser", "Thank you": "Tak", + "The new price is": "Nyprisen er", "The price has increased": "Prisen er steget", "The price has increased since you selected your room.": "Prisen er steget, efter at du har valgt dit værelse.", "Theatre": "Teater", diff --git a/i18n/dictionaries/de.json b/i18n/dictionaries/de.json index 2298fce4a..e0be005e6 100644 --- a/i18n/dictionaries/de.json +++ b/i18n/dictionaries/de.json @@ -236,6 +236,7 @@ "Marketing city": "Marketingstadt", "Meetings & Conferences": "Tagungen & Konferenzen", "Member price": "Mitgliederpreis", + "Member price activated": "Mitgliederpreis aktiviert", "Member price from": "Mitgliederpreis ab", "Members": "Mitglieder", "Membership ID": "Mitglieds-ID", @@ -403,6 +404,7 @@ "Tell us what information and updates you'd like to receive, and how, by clicking the link below.": "Teilen Sie uns mit, welche Informationen und Updates Sie wie erhalten möchten, indem Sie auf den unten stehenden Link klicken.", "Terms and conditions": "Geschäftsbedingungen", "Thank you": "Danke", + "The new price is": "Der neue Preis beträgt", "The price has increased": "Der Preis ist gestiegen", "The price has increased since you selected your room.": "Der Preis ist gestiegen, nachdem Sie Ihr Zimmer ausgewählt haben.", "Theatre": "Theater", diff --git a/i18n/dictionaries/en.json b/i18n/dictionaries/en.json index 947ae455d..6b6916adb 100644 --- a/i18n/dictionaries/en.json +++ b/i18n/dictionaries/en.json @@ -254,6 +254,7 @@ "Meetings & Conferences": "Meetings & Conferences", "Member discount": "Member discount", "Member price": "Member price", + "Member price activated": "Member price activated", "Member price from": "Member price from", "Members": "Members", "Membership ID": "Membership ID", @@ -440,6 +441,7 @@ "Tell us what information and updates you'd like to receive, and how, by clicking the link below.": "Tell us what information and updates you'd like to receive, and how, by clicking the link below.", "Terms and conditions": "Terms and conditions", "Thank you": "Thank you", + "The new price is": "The new price is", "The price has increased": "The price has increased", "The price has increased since you selected your room.": "The price has increased since you selected your room.", "Theatre": "Theatre", diff --git a/i18n/dictionaries/fi.json b/i18n/dictionaries/fi.json index 38bcd3dcb..0c06c9172 100644 --- a/i18n/dictionaries/fi.json +++ b/i18n/dictionaries/fi.json @@ -236,6 +236,7 @@ "Marketing city": "Markkinointikaupunki", "Meetings & Conferences": "Kokoukset & Konferenssit", "Member price": "Jäsenhinta", + "Member price activated": "Jäsenhinta aktivoitu", "Member price from": "Jäsenhinta alkaen", "Members": "Jäsenet", "Membership ID": "Jäsentunnus", @@ -404,6 +405,7 @@ "Tell us what information and updates you'd like to receive, and how, by clicking the link below.": "Kerro meille, mitä tietoja ja päivityksiä haluat saada ja miten, napsauttamalla alla olevaa linkkiä.", "Terms and conditions": "Käyttöehdot", "Thank you": "Kiitos", + "The new price is": "Uusi hinta on", "The price has increased": "Hinta on noussut", "The price has increased since you selected your room.": "Hinta on noussut, koska valitsit huoneen.", "Theatre": "Teatteri", diff --git a/i18n/dictionaries/no.json b/i18n/dictionaries/no.json index 7b57e8167..b318a181b 100644 --- a/i18n/dictionaries/no.json +++ b/i18n/dictionaries/no.json @@ -235,6 +235,7 @@ "Marketing city": "Markedsføringsby", "Meetings & Conferences": "Møter & Konferanser", "Member price": "Medlemspris", + "Member price activated": "Medlemspris aktivert", "Member price from": "Medlemspris fra", "Members": "Medlemmer", "Membership ID": "Medlems-ID", @@ -402,6 +403,7 @@ "Tell us what information and updates you'd like to receive, and how, by clicking the link below.": "Fortell oss hvilken informasjon og hvilke oppdateringer du ønsker å motta, og hvordan, ved å klikke på lenken nedenfor.", "Terms and conditions": "Vilkår og betingelser", "Thank you": "Takk", + "The new price is": "Den nye prisen er", "The price has increased": "Prisen er steget", "The price has increased since you selected your room.": "Prisen er steget, etter at du har valgt rommet.", "Theatre": "Teater", diff --git a/i18n/dictionaries/sv.json b/i18n/dictionaries/sv.json index d4d9a720f..2677c8250 100644 --- a/i18n/dictionaries/sv.json +++ b/i18n/dictionaries/sv.json @@ -235,6 +235,7 @@ "Marketing city": "Marknadsföringsstad", "Meetings & Conferences": "Möten & Konferenser", "Member price": "Medlemspris", + "Member price activated": "Medlemspris aktiverat", "Member price from": "Medlemspris från", "Members": "Medlemmar", "Membership ID": "Medlems-ID", @@ -402,6 +403,7 @@ "Tell us what information and updates you'd like to receive, and how, by clicking the link below.": "Berätta för oss vilken information och vilka uppdateringar du vill få och hur genom att klicka på länken nedan.", "Terms and conditions": "Allmänna villkor", "Thank you": "Tack", + "The new price is": "Det nya priset är", "The price has increased": "Priset har ökat", "The price has increased since you selected your room.": "Priset har ökat sedan du valde ditt rum.", "Theatre": "Teater", diff --git a/stores/enter-details/index.ts b/stores/enter-details/index.ts index b2480471b..ee0849b34 100644 --- a/stores/enter-details/index.ts +++ b/stores/enter-details/index.ts @@ -174,6 +174,13 @@ export function createDetailsStore( }) ) }, + togglePriceDetailsModalOpen() { + return set( + produce((state: DetailsState) => { + state.isPriceDetailsModalOpen = !state.isPriceDetailsModalOpen + }) + ) + }, updateBedType(bedType) { return set( produce((state: DetailsState) => { @@ -328,6 +335,7 @@ export function createDetailsStore( : defaultGuestState, isSubmittingDisabled: false, isSummaryOpen: false, + isPriceDetailsModalOpen: false, isValid: { [StepEnum.selectBed]: false, [StepEnum.breakfast]: false, diff --git a/types/stores/enter-details.ts b/types/stores/enter-details.ts index e507ab146..6589dc20c 100644 --- a/types/stores/enter-details.ts +++ b/types/stores/enter-details.ts @@ -34,6 +34,7 @@ export interface DetailsState { setStep: (step: StepEnum) => void setTotalPrice: (totalPrice: Price) => void toggleSummaryOpen: () => void + togglePriceDetailsModalOpen: () => void updateBedType: (data: BedTypeSchema) => void updateBreakfast: (data: BreakfastPackage | false) => void updateDetails: (data: DetailsSchema) => void @@ -47,6 +48,7 @@ export interface DetailsState { guest: DetailsSchema isSubmittingDisabled: boolean isSummaryOpen: boolean + isPriceDetailsModalOpen: boolean isValid: Record packages: Packages | null roomRate: DetailsProviderProps["roomRate"]