diff --git a/app/[lang]/(live)/(protected)/my-pages/layout.tsx b/app/[lang]/(live)/(protected)/my-pages/layout.tsx index 43268adca..80a038b9a 100644 --- a/app/[lang]/(live)/(protected)/my-pages/layout.tsx +++ b/app/[lang]/(live)/(protected)/my-pages/layout.tsx @@ -2,8 +2,8 @@ import { Suspense } from "react" import LoadingSpinner from "@/components/LoadingSpinner" import Sidebar from "@/components/MyPages/Sidebar" +import Surprises from "@/components/MyPages/Surprises" -// import Surprises from "@/components/MyPages/Surprises" import styles from "./layout.module.css" export default async function MyPagesLayout({ @@ -24,9 +24,7 @@ export default async function MyPagesLayout({ - {/* TODO: Waiting on new API stuff - - */} + ) } diff --git a/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-hotel/@modal/(.)map/page.tsx b/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-hotel/@modal/(.)map/page.tsx index 7c5573536..cdf236184 100644 --- a/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-hotel/@modal/(.)map/page.tsx +++ b/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-hotel/@modal/(.)map/page.tsx @@ -1,7 +1,7 @@ import { notFound } from "next/navigation" import { env } from "@/env/server" -import { getLocations } from "@/lib/trpc/memoizedRequests" +import { getCityCoordinates, getLocations } from "@/lib/trpc/memoizedRequests" import { getHotelPins } from "@/components/HotelReservation/HotelCardDialogListing/utils" import SelectHotelMap from "@/components/HotelReservation/SelectHotel/SelectHotelMap" @@ -58,6 +58,7 @@ export default async function SelectHotelMapPage({ const hotelPins = getHotelPins(hotels) const filterList = getFiltersFromHotels(hotels) + const cityCoordinates = await getCityCoordinates({ city: city.name }) return ( @@ -67,6 +68,7 @@ export default async function SelectHotelMapPage({ mapId={googleMapId} hotels={hotels} filterList={filterList} + cityCoordinates={cityCoordinates} /> ) diff --git a/app/[lang]/(live)/layout.tsx b/app/[lang]/(live)/layout.tsx index e1c63bbbc..037f2d714 100644 --- a/app/[lang]/(live)/layout.tsx +++ b/app/[lang]/(live)/layout.tsx @@ -9,6 +9,7 @@ import TrpcProvider from "@/lib/trpc/Provider" import TokenRefresher from "@/components/Auth/TokenRefresher" import AdobeSDKScript from "@/components/Current/AdobeSDKScript" import VwoScript from "@/components/Current/VwoScript" +import StorageCleaner from "@/components/HotelReservation/EnterDetails/StorageCleaner" import { ToastHandler } from "@/components/TempDesignSystem/Toasts" import { preloadUserTracking } from "@/components/TrackingSDK" import { getIntl } from "@/i18n" @@ -64,6 +65,7 @@ export default async function RootLayout({ {footer} + diff --git a/components/ContentType/HotelPage/IntroSection/index.tsx b/components/ContentType/HotelPage/IntroSection/index.tsx index fe2db96c0..453f1e192 100644 --- a/components/ContentType/HotelPage/IntroSection/index.tsx +++ b/components/ContentType/HotelPage/IntroSection/index.tsx @@ -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" diff --git a/components/ContentType/HotelPage/Map/DynamicMap/Sidebar/index.tsx b/components/ContentType/HotelPage/Map/DynamicMap/Sidebar/index.tsx index db979bf47..d58cb10fc 100644 --- a/components/ContentType/HotelPage/Map/DynamicMap/Sidebar/index.tsx +++ b/components/ContentType/HotelPage/Map/DynamicMap/Sidebar/index.tsx @@ -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) diff --git a/components/ContentType/HotelPage/Map/DynamicMap/index.tsx b/components/ContentType/HotelPage/Map/DynamicMap/index.tsx index 969b24588..6af68db43 100644 --- a/components/ContentType/HotelPage/Map/DynamicMap/index.tsx +++ b/components/ContentType/HotelPage/Map/DynamicMap/index.tsx @@ -113,7 +113,7 @@ export default function DynamicMap({ activePoi={activePoi} hotelName={hotelName} pointsOfInterest={pointsOfInterest} - onActivePoiChange={setActivePoi} + onActivePoiChange={(poi) => setActivePoi(poi ?? null)} coordinates={coordinates} /> setActivePoi(poi ?? null)} mapId={mapId} /> diff --git a/components/ContentType/HotelPage/SidePeeks/AboutTheHotel/ContactInformation/contactInformation.module.css b/components/ContentType/HotelPage/SidePeeks/AboutTheHotel/ContactInformation/contactInformation.module.css new file mode 100644 index 000000000..b616382ab --- /dev/null +++ b/components/ContentType/HotelPage/SidePeeks/AboutTheHotel/ContactInformation/contactInformation.module.css @@ -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); +} diff --git a/components/ContentType/HotelPage/SidePeeks/AboutTheHotel/ContactInformation/index.tsx b/components/ContentType/HotelPage/SidePeeks/AboutTheHotel/ContactInformation/index.tsx new file mode 100644 index 000000000..f4bf137a4 --- /dev/null +++ b/components/ContentType/HotelPage/SidePeeks/AboutTheHotel/ContactInformation/index.tsx @@ -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 ( +
+ + + {intl.formatMessage({ id: "Practical information" })} + + +
+
+ + {intl.formatMessage({ id: "Address" })} + + {hotelAddress.streetAddress} + {hotelAddress.city} +
+
+ + {intl.formatMessage({ id: "Driving directions" })} + + + Google Maps + +
+
+ + {intl.formatMessage({ id: "Contact us" })} + + + + {contact.phoneNumber} + + +
+
+ + {intl.formatMessage({ id: "Follow us" })} + +
+ {socials.instagram && ( + + + + )} + {socials.facebook && ( + + + + )} +
+
+
+ + {intl.formatMessage({ id: "Email" })} + + + {contact.email} + +
+ {ecoLabels.nordicEcoLabel && ( +
+ {intl.formatMessage({ +
+ + {intl.formatMessage({ id: "Nordic Swan Ecolabel" })} + + + {ecoLabels.svanenEcoLabelCertificateNumber} + +
+
+ )} +
+
+ ) +} diff --git a/components/ContentType/HotelPage/SidePeeks/AboutTheHotel/aboutTheHotel.module.css b/components/ContentType/HotelPage/SidePeeks/AboutTheHotel/aboutTheHotel.module.css new file mode 100644 index 000000000..00ab8aebe --- /dev/null +++ b/components/ContentType/HotelPage/SidePeeks/AboutTheHotel/aboutTheHotel.module.css @@ -0,0 +1,5 @@ +.wrapper { + display: flex; + flex-direction: column; + gap: var(--Spacing-x3); +} diff --git a/components/ContentType/HotelPage/SidePeeks/AboutTheHotel/index.tsx b/components/ContentType/HotelPage/SidePeeks/AboutTheHotel/index.tsx new file mode 100644 index 000000000..436147a50 --- /dev/null +++ b/components/ContentType/HotelPage/SidePeeks/AboutTheHotel/index.tsx @@ -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 ( + +
+ + + {descriptions.descriptions.medium} + {descriptions.facilityInformation} +
+
+ ) +} diff --git a/components/ContentType/HotelPage/SidePeeks/WellnessAndExercise/Facility/index.tsx b/components/ContentType/HotelPage/SidePeeks/WellnessAndExercise/Facility/index.tsx index e00ed5964..3313a109b 100644 --- a/components/ContentType/HotelPage/SidePeeks/WellnessAndExercise/Facility/index.tsx +++ b/components/ContentType/HotelPage/SidePeeks/WellnessAndExercise/Facility/index.tsx @@ -16,7 +16,7 @@ export default async function Facility({ data }: FacilityProps) { return (
- {image.imageSizes.medium && ( + {image?.imageSizes.medium && ( {image.metaData.altText - - Some additional information about the hotel - + ) })} diff --git a/components/HotelReservation/EnterDetails/Breakfast/index.tsx b/components/HotelReservation/EnterDetails/Breakfast/index.tsx index fdaec3a84..a3be32d65 100644 --- a/components/HotelReservation/EnterDetails/Breakfast/index.tsx +++ b/components/HotelReservation/EnterDetails/Breakfast/index.tsx @@ -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 + } /> ))} diff --git a/components/HotelReservation/EnterDetails/StorageCleaner.tsx b/components/HotelReservation/EnterDetails/StorageCleaner.tsx new file mode 100644 index 000000000..96fdf3105 --- /dev/null +++ b/components/HotelReservation/EnterDetails/StorageCleaner.tsx @@ -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 +} diff --git a/components/HotelReservation/HotelCardDialog/index.tsx b/components/HotelReservation/HotelCardDialog/index.tsx index d444a1083..32f745620 100644 --- a/components/HotelReservation/HotelCardDialog/index.tsx +++ b/components/HotelReservation/HotelCardDialog/index.tsx @@ -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" diff --git a/components/HotelReservation/HotelCardDialogListing/index.tsx b/components/HotelReservation/HotelCardDialogListing/index.tsx index fe75c9b33..f13ff0433 100644 --- a/components/HotelReservation/HotelCardDialogListing/index.tsx +++ b/components/HotelReservation/HotelCardDialogListing/index.tsx @@ -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]) diff --git a/components/HotelReservation/SelectHotel/SelectHotelMap/index.tsx b/components/HotelReservation/SelectHotel/SelectHotelMap/index.tsx index 4fe186988..0d1d58eee 100644 --- a/components/HotelReservation/SelectHotel/SelectHotelMap/index.tsx +++ b/components/HotelReservation/SelectHotel/SelectHotelMap/index.tsx @@ -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(null) const [showBackToTop, setShowBackToTop] = useState(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) diff --git a/components/HotelReservation/SelectHotel/SelectHotelMap/utils.ts b/components/HotelReservation/SelectHotel/SelectHotelMap/utils.ts deleted file mode 100644 index 7a4c32ecc..000000000 --- a/components/HotelReservation/SelectHotel/SelectHotelMap/utils.ts +++ /dev/null @@ -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 -} diff --git a/components/HotelReservation/TripAdvisorChip/index.tsx b/components/HotelReservation/TripAdvisorChip/index.tsx index 96005bd60..05e03cd02 100644 --- a/components/HotelReservation/TripAdvisorChip/index.tsx +++ b/components/HotelReservation/TripAdvisorChip/index.tsx @@ -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" diff --git a/components/Icons/get-icon-by-icon-name.ts b/components/Icons/get-icon-by-icon-name.ts index 989941db7..7ba60f321 100644 --- a/components/Icons/get-icon-by-icon-name.ts +++ b/components/Icons/get-icon-by-icon-name.ts @@ -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, diff --git a/components/Icons/index.tsx b/components/Icons/index.tsx index 7ec502ca0..d252c8755 100644 --- a/components/Icons/index.tsx +++ b/components/Icons/index.tsx @@ -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" diff --git a/components/Maps/InteractiveMap/HotelListingMapContent/index.tsx b/components/Maps/InteractiveMap/HotelListingMapContent/index.tsx index ce9e95020..c1a3e8c01 100644 --- a/components/Maps/InteractiveMap/HotelListingMapContent/index.tsx +++ b/components/Maps/InteractiveMap/HotelListingMapContent/index.tsx @@ -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(null) + const dialogRef = useRef(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 (
{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 ) - } + }} > -
+
void }) => { diff --git a/components/Maps/InteractiveMap/HotelMapContent/index.tsx b/components/Maps/InteractiveMap/HotelMapContent/index.tsx index 04c796c2c..662c66667 100644 --- a/components/Maps/InteractiveMap/HotelMapContent/index.tsx +++ b/components/Maps/InteractiveMap/HotelMapContent/index.tsx @@ -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 ?? "")} > + {intl.formatMessage({ +
+ + {title} + +
+ + {children} +
+ ) +} diff --git a/components/MyPages/Surprises/Client.tsx b/components/MyPages/Surprises/Client.tsx new file mode 100644 index 000000000..f2adf4186 --- /dev/null +++ b/components/MyPages/Surprises/Client.tsx @@ -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 } + )} +
+ + {intl.formatMessage({ id: "Go to My Benefits" })} + + + ) + } + }, + 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 ( + + + + {open && ( + + + {({ close }) => { + return ( + <> +
{ + viewRewards() + close() + }} + > + {showSurprises && totalSurprises > 1 && ( + + {intl.formatMessage( + { id: "{amount} out of {total}" }, + { + amount: selectedSurprise + 1, + total: totalSurprises, + } + )} + + )} +
+ + {showSurprises ? ( + <> + + + + + + + {totalSurprises > 1 && ( + + )} + + ) : ( + { + setShowSurprises(true) + }} + /> + )} + + ) + }} +
+
+ )} +
+
+ ) +} + +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, + } + }, +} diff --git a/components/MyPages/Surprises/Header.tsx b/components/MyPages/Surprises/Header.tsx new file mode 100644 index 000000000..3a25400a9 --- /dev/null +++ b/components/MyPages/Surprises/Header.tsx @@ -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 ( +
+ {children} + +
+ ) +} diff --git a/components/MyPages/Surprises/Initial.tsx b/components/MyPages/Surprises/Initial.tsx new file mode 100644 index 000000000..8e3a60b9c --- /dev/null +++ b/components/MyPages/Surprises/Initial.tsx @@ -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 ( + + + {totalSurprises > 1 ? ( + <> + {intl.formatMessage( + { + id: "You have # gifts waiting for you!", + }, + { + amount: totalSurprises, + b: (str) => {str}, + } + )} +
+ {intl.formatMessage({ + id: "Hurry up and use them before they expire!", + })} + + ) : ( + intl.formatMessage({ + id: "We have a special gift waiting for you!", + }) + )} + + + {intl.formatMessage({ + id: "You'll find all your gifts in 'My benefits'", + })} + + + +
+ ) +} diff --git a/components/MyPages/Surprises/Navigation.tsx b/components/MyPages/Surprises/Navigation.tsx new file mode 100644 index 000000000..2a1296f01 --- /dev/null +++ b/components/MyPages/Surprises/Navigation.tsx @@ -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 ( + + ) +} diff --git a/components/MyPages/Surprises/Slide.tsx b/components/MyPages/Surprises/Slide.tsx new file mode 100644 index 000000000..2a4173fca --- /dev/null +++ b/components/MyPages/Surprises/Slide.tsx @@ -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 ( + + {surprise.description} +
+ + {intl.formatMessage( + { id: "Expires at the earliest" }, + { + date: dt(earliestExpirationDate) + .locale(lang) + .format("D MMM YYYY"), + } + )} + + + {intl.formatMessage({ + id: "Membership ID", + })}{" "} + {membershipNumber} + +
+
+ ) +} diff --git a/components/MyPages/Surprises/SurprisesNotification.tsx b/components/MyPages/Surprises/SurprisesNotification.tsx deleted file mode 100644 index 6b71c6703..000000000 --- a/components/MyPages/Surprises/SurprisesNotification.tsx +++ /dev/null @@ -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 } - )} -
- - {intl.formatMessage({ id: "Go to My Benefits" })} - - - ) - } - } - - return ( - - - - {({ close }) => { - return ( - <> -
- {surprises.length > 1 && showSurprises && ( - - {intl.formatMessage( - { id: "{amount} out of {total}" }, - { - amount: selectedSurprise + 1, - total: surprises.length, - } - )} - - )} - -
- {showSurprises ? ( - <> -
- - {surprise.description} -
- - {intl.formatMessage({ id: "Valid through" })}{" "} - {dt(surprise.endsAt) - .locale(lang) - .format("DD MMM YYYY")} - - - {intl.formatMessage({ id: "Membership ID" })}{" "} - {membershipNumber} - -
-
-
- {surprises.length > 1 && ( - <> - - - )} - - ) : ( -
- {surprises.length > 1 ? ( - - - {intl.formatMessage( - { - id: "You have # gifts waiting for you!", - }, - { - amount: surprises.length, - b: (str) => {str}, - } - )} -
- {intl.formatMessage({ - id: "Hurry up and use them before they expire!", - })} - - - {intl.formatMessage({ - id: "You'll find all your gifts in 'My benefits'", - })} - -
- ) : ( - - - {intl.formatMessage({ - id: "We have a special gift waiting for you!", - })} - - - {intl.formatMessage({ - id: "You'll find all your gifts in 'My benefits'", - })} - - - )} - - -
- )} - - ) - }} -
-
-
- ) -} - -function Surprise({ - title, - children, -}: { - title?: string - children?: React.ReactNode -}) { - return ( - <> - Gift - - {title} - - - {children} - - ) -} diff --git a/components/MyPages/Surprises/confetti.ts b/components/MyPages/Surprises/confetti.ts new file mode 100644 index 000000000..2990fade4 --- /dev/null +++ b/components/MyPages/Surprises/confetti.ts @@ -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"], + }) +} diff --git a/components/MyPages/Surprises/index.tsx b/components/MyPages/Surprises/index.tsx index 131ae2b28..2ae414a3a 100644 --- a/components/MyPages/Surprises/index.tsx +++ b/components/MyPages/Surprises/index.tsx @@ -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 ( - diff --git a/components/MyPages/Surprises/surprises.module.css b/components/MyPages/Surprises/surprises.module.css index 4bde7c46d..9a80761f4 100644 --- a/components/MyPages/Surprises/surprises.module.css +++ b/components/MyPages/Surprises/surprises.module.css @@ -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; +} diff --git a/components/TempDesignSystem/Divider/divider.module.css b/components/TempDesignSystem/Divider/divider.module.css index 6753e7533..c992bc096 100644 --- a/components/TempDesignSystem/Divider/divider.module.css +++ b/components/TempDesignSystem/Divider/divider.module.css @@ -51,3 +51,7 @@ .opacity8 { opacity: 0.08; } + +.baseSurfaceSubtleHover { + background-color: var(--Base-Surface-Subtle-Hover); +} diff --git a/components/TempDesignSystem/Divider/variants.ts b/components/TempDesignSystem/Divider/variants.ts index 43f7899e0..a9f25b044 100644 --- a/components/TempDesignSystem/Divider/variants.ts +++ b/components/TempDesignSystem/Divider/variants.ts @@ -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, diff --git a/components/TempDesignSystem/Form/ChoiceCard/_Card/card.ts b/components/TempDesignSystem/Form/ChoiceCard/_Card/card.ts index 7d24e46d7..c733dac50 100644 --- a/components/TempDesignSystem/Form/ChoiceCard/_Card/card.ts +++ b/components/TempDesignSystem/Form/ChoiceCard/_Card/card.ts @@ -12,6 +12,7 @@ interface BaseCardProps title: React.ReactNode type: "checkbox" | "radio" value?: string + handleSelectedOnClick?: () => void } interface ListCardProps extends BaseCardProps { diff --git a/components/TempDesignSystem/Form/ChoiceCard/_Card/index.tsx b/components/TempDesignSystem/Form/ChoiceCard/_Card/index.tsx index 7d6ef8105..29ce8e531 100644 --- a/components/TempDesignSystem/Form/ChoiceCard/_Card/index.tsx +++ b/components/TempDesignSystem/Form/ChoiceCard/_Card/index.tsx @@ -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 ( -