Merge branch 'master' of bitbucket.org:scandic-swap/web into fix/loading-rooms-separately

This commit is contained in:
Joakim Jäderberg
2024-11-22 10:30:53 +01:00
64 changed files with 1294 additions and 413 deletions

View File

@@ -1,7 +1,6 @@
import { about } from "@/constants/routes/hotelPageParams"
import { ChevronRightSmallIcon } from "@/components/Icons"
import TripAdvisorIcon from "@/components/Icons/TripAdvisor"
import { ChevronRightSmallIcon, TripAdvisorIcon } from "@/components/Icons"
import Link from "@/components/TempDesignSystem/Link"
import BiroScript from "@/components/TempDesignSystem/Text/BiroScript"
import Body from "@/components/TempDesignSystem/Text/Body"

View File

@@ -60,13 +60,20 @@ export default function Sidebar({
}
}
function handleMouseEnter(poiName: string) {
function handleMouseEnter(poiName: string | undefined) {
if (!poiName) return
if (!isClicking) {
onActivePoiChange(poiName)
}
}
function handlePoiClick(poiName: string, poiCoordinates: Coordinates) {
function handlePoiClick(
poiName: string | undefined,
poiCoordinates: Coordinates
) {
if (!poiName || !poiCoordinates) return
setIsClicking(true)
toggleFullScreenSidebar()
onActivePoiChange(poiName)

View File

@@ -113,7 +113,7 @@ export default function DynamicMap({
activePoi={activePoi}
hotelName={hotelName}
pointsOfInterest={pointsOfInterest}
onActivePoiChange={setActivePoi}
onActivePoiChange={(poi) => setActivePoi(poi ?? null)}
coordinates={coordinates}
/>
<InteractiveMap
@@ -121,7 +121,7 @@ export default function DynamicMap({
coordinates={coordinates}
pointsOfInterest={pointsOfInterest}
activePoi={activePoi}
onActivePoiChange={setActivePoi}
onActivePoiChange={(poi) => setActivePoi(poi ?? null)}
mapId={mapId}
/>
</Dialog>

View File

@@ -0,0 +1,48 @@
.wrapper {
display: flex;
flex-direction: column;
gap: var(--Spacing-x-one-and-half);
}
.information {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--Spacing-x2);
grid-template-areas:
"address drivingDirections"
"contact socials"
"email email"
"ecoLabel ecoLabel";
}
.address {
grid-area: address;
}
.drivingDirections {
grid-area: drivingDirections;
}
.contact {
grid-area: contact;
}
.socials {
grid-area: socials;
}
.socialIcons {
display: flex;
gap: var(--Spacing-x1);
align-items: center;
}
.email {
grid-area: email;
}
.ecoLabel {
grid-area: ecoLabel;
display: flex;
gap: var(--Spacing-x-one-and-half);
}

View File

@@ -0,0 +1,120 @@
import { FacebookIcon, InstagramIcon } from "@/components/Icons"
import Image from "@/components/Image"
import Link from "@/components/TempDesignSystem/Link"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
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 "./contactInformation.module.css"
import type { ContactInformationProps } from "@/types/components/hotelPage/sidepeek/contactInformation"
export default async function ContactInformation({
hotelAddress,
coordinates,
contact,
socials,
ecoLabels,
}: ContactInformationProps) {
const intl = await getIntl()
const lang = getLang()
const { latitude, longitude } = coordinates
const directionsUrl = `https://www.google.com/maps/dir/?api=1&destination=${latitude},${longitude}`
return (
<div className={styles.wrapper}>
<Subtitle color="burgundy" asChild>
<Title level="h3">
{intl.formatMessage({ id: "Practical information" })}
</Title>
</Subtitle>
<div className={styles.information}>
<div className={styles.address}>
<Body textTransform="bold">
{intl.formatMessage({ id: "Address" })}
</Body>
<Body color="uiTextHighContrast">{hotelAddress.streetAddress}</Body>
<Body color="uiTextHighContrast">{hotelAddress.city}</Body>
</div>
<div className={styles.drivingDirections}>
<Body textTransform="bold">
{intl.formatMessage({ id: "Driving directions" })}
</Body>
<Link
href={directionsUrl}
target="_blank"
color="peach80"
textDecoration="underline"
>
Google Maps
</Link>
</div>
<div className={styles.contact}>
<Body textTransform="bold">
{intl.formatMessage({ id: "Contact us" })}
</Body>
<Body>
<Link
href={`tel:+${contact.phoneNumber}`}
color="peach80"
textDecoration="underline"
>
{contact.phoneNumber}
</Link>
</Body>
</div>
<div className={styles.socials}>
<Body textTransform="bold">
{intl.formatMessage({ id: "Follow us" })}
</Body>
<div className={styles.socialIcons}>
{socials.instagram && (
<Link href={socials.instagram}>
<InstagramIcon color="burgundy" />
</Link>
)}
{socials.facebook && (
<Link href={socials.facebook}>
<FacebookIcon color="burgundy" />
</Link>
)}
</div>
</div>
<div className={styles.email}>
<Body textTransform="bold">
{intl.formatMessage({ id: "Email" })}
</Body>
<Link
href={`mailto:${contact.email}`}
color="peach80"
textDecoration="underline"
>
{contact.email}
</Link>
</div>
{ecoLabels.nordicEcoLabel && (
<div className={styles.ecoLabel}>
<Image
height={38}
width={38}
alt={intl.formatMessage({ id: "Nordic Swan Ecolabel" })}
src={`/_static/img/icons/swan-eco/swan_eco_dark_${lang}.png`}
/>
<div>
<Caption color="uiTextPlaceholder">
{intl.formatMessage({ id: "Nordic Swan Ecolabel" })}
</Caption>
<Caption color="uiTextPlaceholder">
{ecoLabels.svanenEcoLabelCertificateNumber}
</Caption>
</div>
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,5 @@
.wrapper {
display: flex;
flex-direction: column;
gap: var(--Spacing-x3);
}

View File

@@ -0,0 +1,46 @@
import { about } from "@/constants/routes/hotelPageParams"
import Divider from "@/components/TempDesignSystem/Divider"
import SidePeek from "@/components/TempDesignSystem/SidePeek"
import Body from "@/components/TempDesignSystem/Text/Body"
import Preamble from "@/components/TempDesignSystem/Text/Preamble"
import { getIntl } from "@/i18n"
import { getLang } from "@/i18n/serverContext"
import ContactInformation from "./ContactInformation"
import styles from "./aboutTheHotel.module.css"
import type { AboutTheHotelSidePeekProps } from "@/types/components/hotelPage/sidepeek/aboutTheHotel"
export default async function AboutTheHotelSidePeek({
hotelAddress,
coordinates,
contact,
socials,
ecoLabels,
descriptions,
}: AboutTheHotelSidePeekProps) {
const lang = getLang()
const intl = await getIntl()
return (
<SidePeek
contentKey={about[lang]}
title={intl.formatMessage({ id: "About the hotel" })}
>
<section className={styles.wrapper}>
<ContactInformation
hotelAddress={hotelAddress}
coordinates={coordinates}
contact={contact}
socials={socials}
ecoLabels={ecoLabels}
/>
<Divider color="baseSurfaceSutbleHover" />
<Preamble>{descriptions.descriptions.medium}</Preamble>
<Body>{descriptions.facilityInformation}</Body>
</section>
</SidePeek>
)
}

View File

@@ -16,7 +16,7 @@ export default async function Facility({ data }: FacilityProps) {
return (
<div className={styles.content}>
{image.imageSizes.medium && (
{image?.imageSizes.medium && (
<Image
src={image.imageSizes.medium}
alt={image.metaData.altText || ""}

View File

@@ -0,0 +1,2 @@
export { default as AboutTheHotelSidePeek } from "./AboutTheHotel"
export { default as WellnessAndExerciseSidePeek } from "./WellnessAndExercise"

View File

@@ -16,12 +16,12 @@ 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"
import PreviewImages from "./PreviewImages"
import { Rooms } from "./Rooms"
import { AboutTheHotelSidePeek, WellnessAndExerciseSidePeek } from "./SidePeeks"
import TabNavigation from "./TabNavigation"
import styles from "./hotelPage.module.css"
@@ -41,7 +41,7 @@ export default async function HotelPage() {
const {
hotelId,
hotelName,
hotelDescription,
hotelDescriptions,
hotelLocation,
hotelAddress,
hotelRatings,
@@ -54,6 +54,9 @@ export default async function HotelPage() {
faq,
alerts,
healthFacilities,
contact,
socials,
ecoLabels,
} = hotelData
const topThreePois = pointsOfInterest.slice(0, 3)
@@ -80,7 +83,7 @@ export default async function HotelPage() {
<div className={styles.introContainer}>
<IntroSection
hotelName={hotelName}
hotelDescription={hotelDescription}
hotelDescription={hotelDescriptions.descriptions.short}
location={hotelLocation}
address={hotelAddress}
tripAdvisor={hotelRatings?.tripAdvisor}
@@ -134,12 +137,14 @@ export default async function HotelPage() {
{/* TODO: Render amenities as per the design. */}
Read more about the amenities here
</SidePeek>
<SidePeek
contentKey={hotelPageParams.about[lang]}
title={intl.formatMessage({ id: "Read more about the hotel" })}
>
Some additional information about the hotel
</SidePeek>
<AboutTheHotelSidePeek
hotelAddress={hotelAddress}
coordinates={hotelLocation}
contact={contact}
socials={socials}
ecoLabels={ecoLabels}
descriptions={hotelDescriptions}
/>
<SidePeek
contentKey={hotelPageParams.restaurantAndBar[lang]}
title={intl.formatMessage({ id: "Restaurant & Bar" })}

View File

@@ -2,8 +2,7 @@
import { useIntl } from "react-intl"
import FacebookIcon from "@/components/Icons/Facebook"
import InstagramIcon from "@/components/Icons/Instagram"
import { FacebookIcon, InstagramIcon } from "@/components/Icons"
import Image from "@/components/Image"
import Link from "@/components/TempDesignSystem/Link"
import useLang from "@/hooks/useLang"

View File

@@ -76,6 +76,9 @@ export default function BedType({ bedTypes }: BedTypeProps) {
subtitle={width}
title={roomType.description}
value={roomType.value}
handleSelectedOnClick={
bedType === roomType.value ? completeStep : undefined
}
/>
)
})}

View File

@@ -97,6 +97,9 @@ export default function Breakfast({ packages }: BreakfastProps) {
})}
title={intl.formatMessage({ id: "Breakfast buffet" })}
value={pkg.code}
handleSelectedOnClick={
breakfast === pkg.code ? completeStep : undefined
}
/>
))}
<RadioCard
@@ -113,6 +116,9 @@ export default function Breakfast({ packages }: BreakfastProps) {
})}
title={intl.formatMessage({ id: "No breakfast" })}
value="false"
handleSelectedOnClick={
breakfast === "false" ? completeStep : undefined
}
/>
</form>
</FormProvider>

View File

@@ -0,0 +1,26 @@
"use client"
import { usePathname } from "next/navigation"
import { useEffect } from "react"
import { hotelreservation } from "@/constants/routes/hotelReservation"
import { detailsStorageName } from "@/stores/details"
import useLang from "@/hooks/useLang"
/**
* Cleanup component to make sure no stale data is left
* from previous booking when user is not in the booking
* flow anymore
*/
export default function StorageCleaner() {
const lang = useLang()
const pathname = usePathname()
useEffect(() => {
if (!pathname.startsWith(hotelreservation(lang))) {
sessionStorage.removeItem(detailsStorageName)
}
}, [lang, pathname])
return null
}

View File

@@ -7,8 +7,7 @@ import { Lang } from "@/constants/languages"
import { selectRate } from "@/constants/routes/hotelReservation"
import { mapFacilityToIcon } from "@/components/ContentType/HotelPage/data"
import { CloseLargeIcon } from "@/components/Icons"
import TripAdvisorIcon from "@/components/Icons/TripAdvisor"
import { CloseLargeIcon, TripAdvisorIcon } from "@/components/Icons"
import Image from "@/components/Image"
import Button from "@/components/TempDesignSystem/Button"
import Chip from "@/components/TempDesignSystem/Chip"

View File

@@ -60,7 +60,7 @@ export default function HotelCardDialogListing({
const elements = document.querySelectorAll("[data-name]")
setTimeout(() => {
elements.forEach((el) => observerRef.current?.observe(el))
}, 500)
}, 1000)
}
}, [activeCard])

View File

@@ -7,7 +7,7 @@ import { useMediaQuery } from "usehooks-ts"
import { selectHotel } from "@/constants/routes/hotelReservation"
import { ArrowUpIcon, CloseIcon, CloseLargeIcon } from "@/components/Icons"
import { CloseIcon, CloseLargeIcon } from "@/components/Icons"
import InteractiveMap from "@/components/Maps/InteractiveMap"
import { BackToTopButton } from "@/components/TempDesignSystem/BackToTopButton"
import Button from "@/components/TempDesignSystem/Button"
@@ -15,7 +15,6 @@ import useLang from "@/hooks/useLang"
import FilterAndSortModal from "../FilterAndSortModal"
import HotelListing from "./HotelListing"
import { getCentralCoordinates } from "./utils"
import styles from "./selectHotelMap.module.css"
@@ -27,6 +26,7 @@ export default function SelectHotelMap({
mapId,
hotels,
filterList,
cityCoordinates,
}: SelectHotelMapProps) {
const searchParams = useSearchParams()
const router = useRouter()
@@ -36,15 +36,13 @@ export default function SelectHotelMap({
const [activeHotelPin, setActiveHotelPin] = useState<string | null>(null)
const [showBackToTop, setShowBackToTop] = useState<boolean>(false)
const centralCoordinates = getCentralCoordinates(hotelPins)
const coordinates = isAboveMobile
? centralCoordinates
: { ...centralCoordinates, lat: centralCoordinates.lat - 0.006 }
const selectHotelParams = new URLSearchParams(searchParams.toString())
const selectedHotel = selectHotelParams.get("selectedHotel")
const coordinates = isAboveMobile
? cityCoordinates
: { ...cityCoordinates, lat: cityCoordinates.lat - 0.006 }
useEffect(() => {
if (selectedHotel) {
setActiveHotelPin(selectedHotel)

View File

@@ -1,17 +0,0 @@
import { HotelPin } from "@/types/components/hotelReservation/selectHotel/map"
export function getCentralCoordinates(hotels: HotelPin[]) {
const centralCoordinates = hotels.reduce(
(acc, pin) => {
acc.lat += pin.coordinates.lat
acc.lng += pin.coordinates.lng
return acc
},
{ lat: 0, lng: 0 }
)
centralCoordinates.lat /= hotels.length
centralCoordinates.lng /= hotels.length
return centralCoordinates
}

View File

@@ -1,4 +1,4 @@
import TripAdvisorIcon from "@/components/Icons/TripAdvisor"
import { TripAdvisorIcon } from "@/components/Icons"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import styles from "./tripAdvisorChip.module.css"

View File

@@ -1,8 +1,5 @@
import { FC } from "react"
import FacebookIcon from "./Facebook"
import InstagramIcon from "./Instagram"
import TripAdvisorIcon from "./TripAdvisor"
import {
AccesoriesIcon,
AccessibilityIcon,
@@ -41,6 +38,7 @@ import {
EmailIcon,
EyeHideIcon,
EyeShowIcon,
FacebookIcon,
FanIcon,
FitnessIcon,
FootstoolIcon,
@@ -56,6 +54,7 @@ import {
HouseIcon,
ImageIcon,
InfoCircleIcon,
InstagramIcon,
KayakingIcon,
KettleIcon,
LampIcon,
@@ -93,6 +92,7 @@ import {
SwimIcon,
ThermostatIcon,
TrainIcon,
TripAdvisorIcon,
TshirtIcon,
TshirtWashIcon,
TvCastingIcon,

View File

@@ -55,6 +55,7 @@ export { default as EmailIcon } from "./Email"
export { default as ErrorCircleIcon } from "./ErrorCircle"
export { default as EyeHideIcon } from "./EyeHide"
export { default as EyeShowIcon } from "./EyeShow"
export { default as FacebookIcon } from "./Facebook"
export { default as FanIcon } from "./Fan"
export { default as FilterIcon } from "./Filter"
export { default as FitnessIcon } from "./Fitness"
@@ -127,6 +128,7 @@ export { default as StreetIcon } from "./Street"
export { default as SwimIcon } from "./Swim"
export { default as ThermostatIcon } from "./Thermostat"
export { default as TrainIcon } from "./Train"
export { default as TripAdvisorIcon } from "./TripAdvisor"
export { default as TshirtIcon } from "./Tshirt"
export { default as TshirtWashIcon } from "./TshirtWash"
export { default as TvCastingIcon } from "./TvCasting"

View File

@@ -2,10 +2,11 @@ import {
AdvancedMarker,
AdvancedMarkerAnchorPoint,
} from "@vis.gl/react-google-maps"
import { useState } from "react"
import { useRef, useState } from "react"
import HotelCardDialog from "@/components/HotelReservation/HotelCardDialog"
import Body from "@/components/TempDesignSystem/Text/Body"
import useClickOutside from "@/hooks/useClickOutside"
import HotelMarker from "../../Markers/HotelMarker"
@@ -19,6 +20,7 @@ export default function HotelListingMapContent({
onActiveHotelPinChange,
}: HotelListingMapContentProps) {
const [hoveredHotelPin, setHoveredHotelPin] = useState<string | null>(null)
const dialogRef = useRef<HTMLDivElement>(null)
function toggleActiveHotelPin(pinName: string | null) {
if (onActiveHotelPinChange) {
@@ -31,6 +33,10 @@ export default function HotelListingMapContent({
return activeHotelPin === pinName || hoveredHotelPin === pinName
}
useClickOutside(dialogRef, isPinActiveOrHovered(activeHotelPin ?? ""), () => {
toggleActiveHotelPin(null)
})
return (
<div>
{hotelPins.map((pin) => {
@@ -44,13 +50,13 @@ export default function HotelListingMapContent({
zIndex={isActiveOrHovered ? 2 : 0}
onMouseEnter={() => setHoveredHotelPin(pin.name)}
onMouseLeave={() => setHoveredHotelPin(null)}
onClick={() =>
onClick={() => {
toggleActiveHotelPin(
activeHotelPin === pin.name ? null : pin.name
)
}
}}
>
<div className={styles.dialogContainer}>
<div className={styles.dialogContainer} ref={dialogRef}>
<HotelCardDialog
isOpen={isActiveOrHovered}
handleClose={(event: { stopPropagation: () => void }) => {

View File

@@ -35,9 +35,9 @@ export default function HotelMapContent({
position={poi.coordinates}
anchorPoint={AdvancedMarkerAnchorPoint.CENTER}
zIndex={activePoi === poi.name ? 2 : 0}
onMouseEnter={() => onActivePoiChange?.(poi.name)}
onMouseEnter={() => onActivePoiChange?.(poi.name ?? null)}
onMouseLeave={() => onActivePoiChange?.(null)}
onClick={() => toggleActivePoi(poi.name)}
onClick={() => toggleActivePoi(poi.name ?? "")}
>
<span
className={`${styles.poi} ${activePoi === poi.name ? styles.active : ""}`}

View File

@@ -0,0 +1,30 @@
import { useIntl } from "react-intl"
import Image from "@/components/Image"
import Title from "@/components/TempDesignSystem/Text/Title"
import styles from "./surprises.module.css"
import type { CardProps } from "@/types/components/blocks/surprises"
export default function Card({ title, children }: CardProps) {
const intl = useIntl()
return (
<div className={styles.content}>
<Image
src="/_static/img/loyalty-award.png"
width={113}
height={125}
alt={intl.formatMessage({ id: "Surprise!" })}
/>
<header>
<Title textAlign="center" level="h4">
{title}
</Title>
</header>
{children}
</div>
)
}

View File

@@ -0,0 +1,214 @@
"use client"
import { AnimatePresence, motion } from "framer-motion"
import { usePathname } from "next/navigation"
import React, { useState } from "react"
import { Dialog, Modal, ModalOverlay } from "react-aria-components"
import { useIntl } from "react-intl"
import { benefits } from "@/constants/routes/myPages"
import { trpc } from "@/lib/trpc/client"
import Link from "@/components/TempDesignSystem/Link"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import { toast } from "@/components/TempDesignSystem/Toasts"
import useLang from "@/hooks/useLang"
import confetti from "./confetti"
import Header from "./Header"
import Initial from "./Initial"
import Navigation from "./Navigation"
import Slide from "./Slide"
import styles from "./surprises.module.css"
import type { SurprisesProps } from "@/types/components/blocks/surprises"
const MotionModal = motion(Modal)
export default function SurprisesNotification({
surprises,
membershipNumber,
}: SurprisesProps) {
const lang = useLang()
const intl = useIntl()
const pathname = usePathname()
const [open, setOpen] = useState(true)
const [[selectedSurprise, direction], setSelectedSurprise] = useState([0, 0])
const [showSurprises, setShowSurprises] = useState(false)
const unwrap = trpc.contentstack.rewards.unwrap.useMutation({
onSuccess: () => {
if (pathname.indexOf(benefits[lang]) !== 0) {
toast.success(
<>
{intl.formatMessage(
{ id: "Gift(s) added to your benefits" },
{ amount: surprises.length }
)}
<br />
<Link href={benefits[lang]} variant="underscored" color="burgundy">
{intl.formatMessage({ id: "Go to My Benefits" })}
</Link>
</>
)
}
},
onError: (error) => {
console.error("Failed to unwrap surprise", error)
},
})
const totalSurprises = surprises.length
if (!totalSurprises) {
return null
}
const surprise = surprises[selectedSurprise]
function showSurprise(newDirection: number) {
setSelectedSurprise(([currentIndex]) => [
currentIndex + newDirection,
newDirection,
])
}
async function viewRewards() {
const updates = surprises
.map((surprise) => {
const coupons = surprise.coupons
?.map((coupon) => {
if (coupon?.couponCode) {
return {
rewardId: surprise.id,
couponCode: coupon.couponCode,
}
}
})
.filter(
(coupon): coupon is { rewardId: string; couponCode: string } =>
!!coupon
)
return coupons
})
.flat()
unwrap.mutate(updates)
}
return (
<ModalOverlay
className={styles.overlay}
isOpen={open}
onOpenChange={setOpen}
isKeyboardDismissDisabled
>
<canvas id="surprise-confetti" className={styles.confetti} />
<AnimatePresence mode="wait">
{open && (
<MotionModal
className={styles.modal}
initial={{ y: 32, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: 32, opacity: 0 }}
transition={{
y: { duration: 0.4, ease: "easeInOut" },
opacity: { duration: 0.4, ease: "easeInOut" },
}}
onAnimationComplete={confetti}
>
<Dialog aria-label="Surprises" className={styles.dialog}>
{({ close }) => {
return (
<>
<Header
onClose={() => {
viewRewards()
close()
}}
>
{showSurprises && totalSurprises > 1 && (
<Caption type="label" uppercase>
{intl.formatMessage(
{ id: "{amount} out of {total}" },
{
amount: selectedSurprise + 1,
total: totalSurprises,
}
)}
</Caption>
)}
</Header>
{showSurprises ? (
<>
<AnimatePresence
mode="popLayout"
initial={false}
custom={direction}
>
<motion.div
key={selectedSurprise}
custom={direction}
variants={variants}
initial="enter"
animate="center"
exit="exit"
transition={{
x: { type: "ease", duration: 0.5 },
opacity: { duration: 0.2 },
}}
layout
>
<Slide
surprise={surprise}
membershipNumber={membershipNumber}
/>
</motion.div>
</AnimatePresence>
{totalSurprises > 1 && (
<Navigation
selectedSurprise={selectedSurprise}
totalSurprises={totalSurprises}
showSurprise={showSurprise}
/>
)}
</>
) : (
<Initial
totalSurprises={totalSurprises}
onOpen={() => {
setShowSurprises(true)
}}
/>
)}
</>
)
}}
</Dialog>
</MotionModal>
)}
</AnimatePresence>
</ModalOverlay>
)
}
const variants = {
enter: (direction: number) => {
return {
x: direction > 0 ? 1000 : -1000,
opacity: 0,
}
},
center: {
x: 0,
opacity: 1,
},
exit: (direction: number) => {
return {
x: direction < 0 ? 1000 : -1000,
opacity: 0,
}
},
}

View File

@@ -0,0 +1,16 @@
import { CloseLargeIcon } from "@/components/Icons"
import styles from "./surprises.module.css"
import type { HeaderProps } from "@/types/components/blocks/surprises"
export default function Header({ onClose, children }: HeaderProps) {
return (
<div className={styles.top}>
{children}
<button onClick={onClose} type="button" className={styles.close}>
<CloseLargeIcon />
</button>
</div>
)
}

View File

@@ -0,0 +1,62 @@
import { useIntl } from "react-intl"
import Button from "@/components/TempDesignSystem/Button"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Card from "./Card"
import type { InitialProps } from "@/types/components/blocks/surprises"
export default function Initial({ totalSurprises, onOpen }: InitialProps) {
const intl = useIntl()
return (
<Card title={intl.formatMessage({ id: "Surprise!" })}>
<Body textAlign="center">
{totalSurprises > 1 ? (
<>
{intl.formatMessage<React.ReactNode>(
{
id: "You have <b>#</b> gifts waiting for you!",
},
{
amount: totalSurprises,
b: (str) => <b>{str}</b>,
}
)}
<br />
{intl.formatMessage({
id: "Hurry up and use them before they expire!",
})}
</>
) : (
intl.formatMessage({
id: "We have a special gift waiting for you!",
})
)}
</Body>
<Caption>
{intl.formatMessage({
id: "You'll find all your gifts in 'My benefits'",
})}
</Caption>
<Button
intent="primary"
onPress={onOpen}
size="medium"
theme="base"
fullWidth
autoFocus
>
{intl.formatMessage(
{
id: "Open gift(s)",
},
{ amount: totalSurprises }
)}
</Button>
</Card>
)
}

View File

@@ -0,0 +1,45 @@
import { useIntl } from "react-intl"
import { ChevronRightSmallIcon } from "@/components/Icons"
import Button from "@/components/TempDesignSystem/Button"
import styles from "./surprises.module.css"
import type { NavigationProps } from "@/types/components/blocks/surprises"
export default function Navigation({
selectedSurprise,
totalSurprises,
showSurprise,
}: NavigationProps) {
const intl = useIntl()
return (
<nav className={styles.nav}>
<Button
variant="icon"
intent="tertiary"
disabled={selectedSurprise === 0}
onPress={() => showSurprise(-1)}
size="small"
>
<ChevronRightSmallIcon
className={styles.chevron}
width={20}
height={20}
/>
{intl.formatMessage({ id: "Previous" })}
</Button>
<Button
variant="icon"
intent="tertiary"
disabled={selectedSurprise === totalSurprises - 1}
onPress={() => showSurprise(1)}
size="small"
>
{intl.formatMessage({ id: "Next" })}
<ChevronRightSmallIcon width={20} height={20} />
</Button>
</nav>
)
}

View File

@@ -0,0 +1,49 @@
import { useIntl } from "react-intl"
import { dt } from "@/lib/dt"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import useLang from "@/hooks/useLang"
import Card from "./Card"
import styles from "./surprises.module.css"
import type { SlideProps } from "@/types/components/blocks/surprises"
export default function Slide({ surprise, membershipNumber }: SlideProps) {
const lang = useLang()
const intl = useIntl()
const earliestExpirationDate = surprise.coupons?.reduce(
(earliestDate, coupon) => {
const expiresAt = dt(coupon.expiresAt)
return earliestDate.isBefore(expiresAt) ? earliestDate : expiresAt
},
dt()
)
return (
<Card title={surprise.label}>
<Body textAlign="center">{surprise.description}</Body>
<div className={styles.badge}>
<Caption>
{intl.formatMessage(
{ id: "Expires at the earliest" },
{
date: dt(earliestExpirationDate)
.locale(lang)
.format("D MMM YYYY"),
}
)}
</Caption>
<Caption>
{intl.formatMessage({
id: "Membership ID",
})}{" "}
{membershipNumber}
</Caption>
</div>
</Card>
)
}

View File

@@ -1,247 +0,0 @@
"use client"
import { usePathname } from "next/navigation"
import React, { useState } from "react"
import { Dialog, Modal, ModalOverlay } from "react-aria-components"
import { useIntl } from "react-intl"
import { benefits } from "@/constants/routes/myPages"
import { dt } from "@/lib/dt"
import { trpc } from "@/lib/trpc/client"
import { ChevronRightSmallIcon, CloseLargeIcon } from "@/components/Icons"
import Image from "@/components/Image"
import Button from "@/components/TempDesignSystem/Button"
import Link from "@/components/TempDesignSystem/Link"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Title from "@/components/TempDesignSystem/Text/Title"
import { toast } from "@/components/TempDesignSystem/Toasts"
import useLang from "@/hooks/useLang"
import styles from "./surprises.module.css"
import type { SurprisesProps } from "@/types/components/blocks/surprises"
export default function SurprisesNotification({
surprises,
membershipNumber,
}: SurprisesProps) {
const lang = useLang()
const pathname = usePathname()
const [open, setOpen] = useState(true)
const [selectedSurprise, setSelectedSurprise] = useState(0)
const [showSurprises, setShowSurprises] = useState(false)
const update = trpc.contentstack.rewards.update.useMutation()
const intl = useIntl()
if (!surprises.length) {
return null
}
const surprise = surprises[selectedSurprise]
function showSurprise(n: number) {
setSelectedSurprise((surprise) => surprise + n)
}
function viewRewards() {
if (surprise.reward_id) {
update.mutate({ id: surprise.reward_id })
}
}
function closeModal(close: VoidFunction) {
viewRewards()
close()
if (pathname.indexOf(benefits[lang]) !== 0) {
toast.success(
<>
{intl.formatMessage(
{ id: "Gift(s) added to your benefits" },
{ amount: surprises.length }
)}
<br />
<Link href={benefits[lang]} variant="underscored" color="burgundy">
{intl.formatMessage({ id: "Go to My Benefits" })}
</Link>
</>
)
}
}
return (
<ModalOverlay
className={styles.overlay}
isOpen={open}
onOpenChange={setOpen}
isKeyboardDismissDisabled
>
<Modal className={styles.modal}>
<Dialog aria-label="Surprises" className={styles.dialog}>
{({ close }) => {
return (
<>
<div className={styles.top}>
{surprises.length > 1 && showSurprises && (
<Caption type="label" uppercase>
{intl.formatMessage(
{ id: "{amount} out of {total}" },
{
amount: selectedSurprise + 1,
total: surprises.length,
}
)}
</Caption>
)}
<button
onClick={() => closeModal(close)}
type="button"
className={styles.close}
>
<CloseLargeIcon />
</button>
</div>
{showSurprises ? (
<>
<div className={styles.content}>
<Surprise title={surprise.label}>
<Body textAlign="center">{surprise.description}</Body>
<div className={styles.badge}>
<Caption>
{intl.formatMessage({ id: "Valid through" })}{" "}
{dt(surprise.endsAt)
.locale(lang)
.format("DD MMM YYYY")}
</Caption>
<Caption>
{intl.formatMessage({ id: "Membership ID" })}{" "}
{membershipNumber}
</Caption>
</div>
</Surprise>
</div>
{surprises.length > 1 && (
<>
<nav className={styles.nav}>
<Button
variant="icon"
intent="tertiary"
disabled={selectedSurprise === 0}
onPress={() => showSurprise(-1)}
size="small"
>
<ChevronRightSmallIcon
className={styles.chevron}
width={20}
height={20}
/>
{intl.formatMessage({ id: "Previous" })}
</Button>
<Button
variant="icon"
intent="tertiary"
disabled={selectedSurprise === surprises.length - 1}
onPress={() => showSurprise(1)}
size="small"
>
{intl.formatMessage({ id: "Next" })}
<ChevronRightSmallIcon width={20} height={20} />
</Button>
</nav>
</>
)}
</>
) : (
<div className={styles.content}>
{surprises.length > 1 ? (
<Surprise title={intl.formatMessage({ id: "Surprise!" })}>
<Body textAlign="center">
{intl.formatMessage<React.ReactNode>(
{
id: "You have <b>#</b> gifts waiting for you!",
},
{
amount: surprises.length,
b: (str) => <b>{str}</b>,
}
)}
<br />
{intl.formatMessage({
id: "Hurry up and use them before they expire!",
})}
</Body>
<Caption>
{intl.formatMessage({
id: "You'll find all your gifts in 'My benefits'",
})}
</Caption>
</Surprise>
) : (
<Surprise title={intl.formatMessage({ id: "Surprise!" })}>
<Body textAlign="center">
{intl.formatMessage({
id: "We have a special gift waiting for you!",
})}
</Body>
<Caption>
{intl.formatMessage({
id: "You'll find all your gifts in 'My benefits'",
})}
</Caption>
</Surprise>
)}
<Button
intent="primary"
onPress={() => {
viewRewards()
setShowSurprises(true)
}}
size="medium"
theme="base"
fullWidth
autoFocus
>
{intl.formatMessage(
{
id: "Open gift(s)",
},
{ amount: surprises.length }
)}
</Button>
</div>
)}
</>
)
}}
</Dialog>
</Modal>
</ModalOverlay>
)
}
function Surprise({
title,
children,
}: {
title?: string
children?: React.ReactNode
}) {
return (
<>
<Image
src="/_static/img/loyalty-award.png"
width={113}
height={125}
alt="Gift"
/>
<Title textAlign="center" level="h4">
{title}
</Title>
{children}
</>
)
}

View File

@@ -0,0 +1,13 @@
import { confetti as particlesConfetti } from "@tsparticles/confetti"
export default function confetti() {
particlesConfetti("surprise-confetti", {
count: 300,
spread: 150,
position: {
y: 60,
},
colors: ["#cd0921", "#4d001b", "#fff"],
shapes: ["star", "square", "circle", "polygon"],
})
}

View File

@@ -1,9 +1,14 @@
import { env } from "@/env/server"
import { getProfile } from "@/lib/trpc/memoizedRequests"
import { serverClient } from "@/lib/trpc/server"
import SurprisesNotification from "./SurprisesNotification"
import SurprisesClient from "./Client"
export default async function Surprises() {
if (env.HIDE_FOR_NEXT_RELEASE) {
return null
}
const user = await getProfile()
if (!user || "error" in user) {
@@ -17,7 +22,7 @@ export default async function Surprises() {
}
return (
<SurprisesNotification
<SurprisesClient
surprises={surprises}
membershipNumber={user.membership?.membershipNumber}
/>

View File

@@ -1,4 +1,4 @@
@keyframes modal-fade {
@keyframes fade {
from {
opacity: 0;
}
@@ -8,16 +8,6 @@
}
}
@keyframes slide-up {
from {
transform: translateY(100%);
}
to {
transform: translateY(0);
}
}
.overlay {
background: rgba(0, 0, 0, 0.5);
height: var(--visual-viewport-height);
@@ -28,10 +18,10 @@
z-index: 100;
&[data-entering] {
animation: modal-fade 200ms;
animation: fade 400ms ease-in;
}
&[data-exiting] {
animation: modal-fade 200ms reverse ease-in;
animation: fade 400ms reverse ease-in;
}
}
@@ -43,6 +33,19 @@
}
}
@media screen and (min-width: 768px) and (prefers-reduced-motion) {
.overlay:before {
background-image: url("/_static/img/confetti.svg");
background-repeat: no-repeat;
background-position: center 40%;
content: "";
width: 100%;
height: 100%;
animation: fade 400ms ease-in;
display: block;
}
}
.modal {
background-color: var(--Base-Surface-Primary-light-Normal);
border-radius: var(--Corner-radius-Medium);
@@ -51,20 +54,7 @@
position: absolute;
left: 0;
bottom: 0;
&[data-entering] {
animation: slide-up 200ms;
}
&[data-exiting] {
animation: slide-up 200ms reverse ease-in-out;
}
}
.dialog {
display: flex;
flex-direction: column;
gap: var(--Spacing-x2);
padding-bottom: var(--Spacing-x2);
z-index: 102;
}
@media screen and (min-width: 768px) {
@@ -75,6 +65,17 @@
}
}
.dialog {
display: flex;
flex-direction: column;
gap: var(--Spacing-x2);
padding-bottom: var(--Spacing-x2);
/* to hide sliding cards */
position: relative;
overflow: hidden;
}
.top {
--button-height: 32px;
box-sizing: content-box;
@@ -90,8 +91,10 @@
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 0 var(--Spacing-x3);
gap: var(--Spacing-x2);
min-height: 350px;
}
.nav {
@@ -103,6 +106,8 @@
}
.nav button {
user-select: none;
&:nth-child(1) {
padding-left: 0;
}
@@ -141,3 +146,12 @@
display: flex;
align-items: center;
}
/*
* temporary fix until next version of tsparticles is released
* https://github.com/tsparticles/tsparticles/issues/5375
*/
.confetti {
position: relative;
z-index: 101;
}

View File

@@ -51,3 +51,7 @@
.opacity8 {
opacity: 0.08;
}
.baseSurfaceSubtleHover {
background-color: var(--Base-Surface-Subtle-Hover);
}

View File

@@ -13,6 +13,7 @@ export const dividerVariants = cva(styles.divider, {
primaryLightSubtle: styles.primaryLightSubtle,
subtle: styles.subtle,
white: styles.white,
baseSurfaceSutbleHover: styles.baseSurfaceSubtleHover,
},
opacity: {
100: styles.opacity100,

View File

@@ -12,6 +12,7 @@ interface BaseCardProps
title: React.ReactNode
type: "checkbox" | "radio"
value?: string
handleSelectedOnClick?: () => void
}
interface ListCardProps extends BaseCardProps {

View File

@@ -25,10 +25,22 @@ export default function Card({
title,
type,
value,
handleSelectedOnClick,
}: CardProps) {
const { register } = useFormContext()
function onLabelClick(event: React.MouseEvent) {
// Preventing click event on label elements firing twice: https://github.com/facebook/react/issues/14295
event.preventDefault()
handleSelectedOnClick?.()
}
return (
<label className={styles.label} data-declined={declined} tabIndex={0}>
<label
className={styles.label}
data-declined={declined}
tabIndex={0}
onClick={handleSelectedOnClick ? onLabelClick : undefined}
>
<Caption className={styles.title} color="burgundy" type="label" uppercase>
{title}
</Caption>