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

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