feat(SW-1063): add member price modal

This commit is contained in:
Christian Andolf
2024-12-04 16:40:45 +01:00
committed by Christel Westerberg
parent a1a36e80d5
commit 42bb71cf2f
20 changed files with 446 additions and 8 deletions

View File

@@ -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;
}
* {

View File

@@ -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<SetStateAction<boolean>>
}) {
const memberRate = useEnterDetailsStore((state) => state.roomRate.memberRate)
const intl = useIntl()
const memberPrice = memberRate?.localPrice ?? memberRate?.requestedPrice
return (
<Modal isOpen={isOpen} onToggle={setIsOpen}>
<div className={styles.modalContent}>
<div className={styles.innerModalContent}>
<MagicWandIcon width="265px" />
<Title as="h3" level="h1" textTransform="regular">
{intl.formatMessage({
id: "Member price activated",
})}
</Title>
{memberPrice && (
<span className={styles.newPrice}>
<Body>
{intl.formatMessage({
id: "The new price is",
})}
</Body>
<Subtitle type="two" color="red">
{intl.formatNumber(memberPrice.pricePerStay, {
currency: memberPrice.currency,
style: "currency",
})}
</Subtitle>
</span>
)}
</div>
<Button intent="primary" theme="base" onClick={() => setIsOpen(false)}>
OK
</Button>
</div>
</Modal>
)
}

View File

@@ -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;
}
}

View File

@@ -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" })}
</Button>
</footer>
<MemberPriceModal
isOpen={isMemberPriceModalOpen}
setIsOpen={setIsMemberPriceModalOpen}
/>
</form>
</FormProvider>
)

View File

@@ -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<InnerModalProps>) {
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 (
<MotionOverlay
animate={animation}
className={styles.overlay}
initial={"hidden"}
isDismissable
isExiting={animation === AnimationStateEnum.hidden}
onAnimationComplete={modalStateHandler}
variants={fade}
isOpen={isOpen}
onOpenChange={onOpenChange}
>
<MotionModal
className={styles.modal}
variants={slideInOut}
animate={animation}
initial={"hidden"}
>
<Dialog className={styles.dialog} aria-label="Dialog">
{({ close }) => (
<>
<header className={styles.header}>
{title && (
<Subtitle type="one" color="uiTextHighContrast">
{title}
</Subtitle>
)}
<button onClick={close} type="button" className={styles.close}>
<CloseLargeIcon color="uiTextMediumContrast" />
</button>
</header>
<section className={styles.content}>{children}</section>
</>
)}
</Dialog>
</MotionModal>
</MotionOverlay>
)
}
export default function Modal({
onAnimationComplete = () => undefined,
trigger,
isOpen,
onToggle,
title,
children,
}: PropsWithChildren<ModalProps>) {
const [animation, setAnimation] = useState<AnimationState>(
AnimationStateEnum.visible
)
useEffect(() => {
if (typeof isOpen === "boolean") {
setAnimation(
isOpen ? AnimationStateEnum.visible : AnimationStateEnum.hidden
)
}
}, [isOpen])
if (!trigger) {
return (
<InnerModal
onAnimationComplete={onAnimationComplete}
animation={animation}
setAnimation={setAnimation}
onToggle={onToggle}
isOpen={isOpen}
title={title}
>
{children}
</InnerModal>
)
}
return (
<DialogTrigger
onOpenChange={(isOpen) =>
setAnimation(
isOpen ? AnimationStateEnum.visible : AnimationStateEnum.hidden
)
}
>
{trigger}
<InnerModal
onAnimationComplete={onAnimationComplete}
animation={animation}
setAnimation={setAnimation}
title={title}
>
{children}
</InnerModal>
</DialogTrigger>
)
}

View File

@@ -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);
}
}

View File

@@ -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<SetStateAction<boolean>>
}
)
export type InnerModalProps = Omit<ModalProps, "trigger"> & {
animation: AnimationState
setAnimation: Dispatch<SetStateAction<AnimationState>>
}

View File

@@ -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" },
},
}

View File

@@ -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 (
<section className={styles.summary}>
<header className={styles.header}>
@@ -243,9 +256,28 @@ export default function SummaryUI({
{ b: (str) => <b>{str}</b> }
)}
</Body>
<Link color="burgundy" href="" variant="underscored" size="small">
{intl.formatMessage({ id: "Price details" })}
</Link>
<Modal
trigger={
<Button intent="text" onPress={handleTogglePriceDetailsModal}>
<Caption color="burgundy">
{intl.formatMessage({ id: "Price details" })}
</Caption>
<ChevronRightSmallIcon
color="burgundy"
height="20px"
width="20px"
/>
</Button>
}
>
<div className={styles.modalContent}>
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.
</div>
</Modal>
</div>
<div>
<Body textTransform="bold">

View File

@@ -68,6 +68,10 @@
display: none;
}
.modalContent {
width: 560px;
}
@media screen and (min-width: 1367px) {
.bottomDivider {
display: block;

View File

@@ -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 {

View File

@@ -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) {

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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,

View File

@@ -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<StepEnum, boolean>
packages: Packages | null
roomRate: DetailsProviderProps["roomRate"]