Merged in feat/SW-1452-city-page-filter-2 (pull request #1392)
feat(SW-1452): Added filtering and sorting to destination city pages * feat(SW-1452): Added filtering and sorting to destination city pages * feat(SW-1452): Added temporary component for country pages to avoid Context issues Approved-by: Matilda Landström
This commit is contained in:
@@ -4,6 +4,9 @@ import { useMap, useMapsLibrary } from "@vis.gl/react-google-maps"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { useHotelDataStore } from "@/stores/hotel-data"
|
||||
|
||||
import HotelFilterAndSort from "@/components/HotelFilterAndSort"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import { debounce } from "@/utils/debounce"
|
||||
|
||||
@@ -14,22 +17,23 @@ import styles from "./hotelList.module.css"
|
||||
|
||||
import type { HotelDataWithUrl } from "@/types/hotel"
|
||||
|
||||
interface HotelListProps {
|
||||
hotels: HotelDataWithUrl[]
|
||||
}
|
||||
|
||||
export default function HotelList({ hotels }: HotelListProps) {
|
||||
export default function HotelList() {
|
||||
const intl = useIntl()
|
||||
const map = useMap()
|
||||
const coreLib = useMapsLibrary("core")
|
||||
const [visibleHotels, setVisibleHotels] = useState<HotelDataWithUrl[]>([])
|
||||
const { filters, sortItems, activeHotels } = useHotelDataStore((state) => ({
|
||||
filters: state.allFilters,
|
||||
sortItems: state.sortItems,
|
||||
activeHotels: state.activeHotels,
|
||||
}))
|
||||
|
||||
const debouncedUpdateVisibleHotels = useMemo(
|
||||
() =>
|
||||
debounce(() => {
|
||||
setVisibleHotels(getVisibleHotels(hotels, map))
|
||||
setVisibleHotels(getVisibleHotels(activeHotels, map))
|
||||
}, 500),
|
||||
[map, hotels]
|
||||
[map, activeHotels]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -56,6 +60,7 @@ export default function HotelList({ hotels }: HotelListProps) {
|
||||
{ count: visibleHotels.length }
|
||||
)}
|
||||
</Body>
|
||||
<HotelFilterAndSort filters={filters} sortItems={sortItems} />
|
||||
</div>
|
||||
<ul className={styles.hotelList}>
|
||||
{visibleHotels.map(({ hotel, url }) => (
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { env } from "@/env/server"
|
||||
import { getHotelsByCityIdentifier } from "@/lib/trpc/memoizedRequests"
|
||||
"use client"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { useHotelDataStore } from "@/stores/hotel-data"
|
||||
|
||||
import Title from "@/components/TempDesignSystem/Text/Title"
|
||||
import { getIntl } from "@/i18n"
|
||||
|
||||
import Map from "../../Map"
|
||||
import { getCityHeadingText } from "../../utils"
|
||||
import HotelList from "./HotelList"
|
||||
|
||||
import styles from "./cityMap.module.css"
|
||||
@@ -12,32 +14,32 @@ import styles from "./cityMap.module.css"
|
||||
import type { CityLocation } from "@/types/trpc/routers/hotel/locations"
|
||||
|
||||
interface CityMapProps {
|
||||
mapId: string
|
||||
apiKey: string
|
||||
city: CityLocation
|
||||
cityIdentifier: string
|
||||
}
|
||||
export function preloadHotels(cityIdentifier: string) {
|
||||
void getHotelsByCityIdentifier(cityIdentifier)
|
||||
}
|
||||
export default async function CityMap({ city, cityIdentifier }: CityMapProps) {
|
||||
const intl = await getIntl()
|
||||
const hotels = await getHotelsByCityIdentifier(cityIdentifier)
|
||||
|
||||
export default function CityMap({ mapId, apiKey, city }: CityMapProps) {
|
||||
const intl = useIntl()
|
||||
const { activeHotels, allFilters, activeFilters } = useHotelDataStore(
|
||||
(state) => ({
|
||||
activeHotels: state.activeHotels,
|
||||
allFilters: state.allFilters,
|
||||
activeFilters: state.activeFilters,
|
||||
})
|
||||
)
|
||||
|
||||
return (
|
||||
<Map
|
||||
hotels={hotels}
|
||||
mapId={env.GOOGLE_DYNAMIC_MAP_ID}
|
||||
apiKey={env.GOOGLE_STATIC_MAP_KEY}
|
||||
pageType="city"
|
||||
>
|
||||
<Map hotels={activeHotels} mapId={mapId} apiKey={apiKey} pageType="city">
|
||||
<Title
|
||||
level="h2"
|
||||
as="h3"
|
||||
textTransform="regular"
|
||||
className={styles.title}
|
||||
>
|
||||
{intl.formatMessage({ id: `Hotels in {city}` }, { city: city.name })}
|
||||
{getCityHeadingText(intl, city.name, allFilters, activeFilters[0])}
|
||||
</Title>
|
||||
<HotelList hotels={hotels} />
|
||||
<HotelList />
|
||||
</Map>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
"use client"
|
||||
|
||||
import SkeletonShimmer from "@/components/SkeletonShimmer"
|
||||
import BreadcrumbsSkeleton from "@/components/TempDesignSystem/Breadcrumbs/BreadcrumbsSkeleton"
|
||||
|
||||
import ExperienceListSkeleton from "../ExperienceList/ExperienceListSkeleton"
|
||||
import HotelListingSkeleton from "../HotelListing/HotelListingSkeleton"
|
||||
import SidebarContentWrapperSkeleton from "../SidebarContentWrapper/SidebarContentWrapperSkeleton"
|
||||
import TopImagesSkeleton from "../TopImages/TopImagesSkeleton"
|
||||
|
||||
import styles from "./destinationCityPage.module.css"
|
||||
|
||||
export default function DestinationCityPageSkeleton() {
|
||||
return (
|
||||
<div className={styles.pageContainer}>
|
||||
<header className={styles.header}>
|
||||
<BreadcrumbsSkeleton />
|
||||
<TopImagesSkeleton />
|
||||
</header>
|
||||
<main className={styles.mainContent}>
|
||||
<HotelListingSkeleton />
|
||||
</main>
|
||||
<aside className={styles.sidebar}>
|
||||
<SidebarContentWrapperSkeleton>
|
||||
<div>
|
||||
<SkeletonShimmer height="20px" width="90%" />
|
||||
<SkeletonShimmer height="20px" width="70%" />
|
||||
<SkeletonShimmer height="20px" width="100%" />
|
||||
<SkeletonShimmer height="20px" width="40%" />
|
||||
</div>
|
||||
<ExperienceListSkeleton />
|
||||
<div>
|
||||
<SkeletonShimmer height="200px" width="100%" />
|
||||
</div>
|
||||
</SidebarContentWrapperSkeleton>
|
||||
</aside>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,39 +1,45 @@
|
||||
import { notFound } from "next/navigation"
|
||||
import { Suspense } from "react"
|
||||
|
||||
import { env } from "@/env/server"
|
||||
import { getDestinationCityPage } from "@/lib/trpc/memoizedRequests"
|
||||
|
||||
import Blocks from "@/components/Blocks"
|
||||
import Breadcrumbs from "@/components/Breadcrumbs"
|
||||
import BreadcrumbsSkeleton from "@/components/TempDesignSystem/Breadcrumbs/BreadcrumbsSkeleton"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Title from "@/components/TempDesignSystem/Text/Title"
|
||||
import TrackingSDK from "@/components/TrackingSDK"
|
||||
|
||||
import ExperienceList from "../ExperienceList"
|
||||
import HotelDataContainer, { preload } from "../HotelDataContainer"
|
||||
import HotelListing from "../HotelListing"
|
||||
import HotelListingSkeleton from "../HotelListing/HotelListingSkeleton"
|
||||
import SidebarContentWrapper from "../SidebarContentWrapper"
|
||||
import DestinationPageSidePeek from "../Sidepeek"
|
||||
import StaticMap from "../StaticMap"
|
||||
import TopImages from "../TopImages"
|
||||
import CityMap, { preloadHotels } from "./CityMap"
|
||||
import CityMap from "./CityMap"
|
||||
import DestinationCityPageSkeleton from "./DestinationCityPageSkeleton"
|
||||
|
||||
import styles from "./destinationCityPage.module.css"
|
||||
|
||||
import { PageContentTypeEnum } from "@/types/requests/contentType"
|
||||
|
||||
export default async function DestinationCityPage() {
|
||||
interface DestinationCityPageProps {
|
||||
filterFromUrl: string | undefined
|
||||
}
|
||||
|
||||
export default async function DestinationCityPage({
|
||||
filterFromUrl,
|
||||
}: DestinationCityPageProps) {
|
||||
const pageData = await getDestinationCityPage()
|
||||
|
||||
if (!pageData) {
|
||||
return null
|
||||
notFound()
|
||||
}
|
||||
|
||||
const { tracking, destinationCityPage, cityIdentifier, city } = pageData
|
||||
const {
|
||||
blocks,
|
||||
images,
|
||||
heading,
|
||||
preamble,
|
||||
experiences,
|
||||
has_sidepeek,
|
||||
@@ -42,42 +48,48 @@ export default async function DestinationCityPage() {
|
||||
destination_settings,
|
||||
} = destinationCityPage
|
||||
|
||||
preloadHotels(cityIdentifier)
|
||||
preload(cityIdentifier)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.pageContainer}>
|
||||
<header className={styles.header}>
|
||||
<Suspense fallback={<BreadcrumbsSkeleton />}>
|
||||
<Breadcrumbs variant={PageContentTypeEnum.destinationCityPage} />
|
||||
</Suspense>
|
||||
<TopImages images={images} destinationName={city.name} />
|
||||
</header>
|
||||
<main className={styles.mainContent}>
|
||||
<Suspense fallback={<HotelListingSkeleton />}>
|
||||
<HotelListing cityIdentifier={cityIdentifier} />
|
||||
</Suspense>
|
||||
{blocks && <Blocks blocks={blocks} />}
|
||||
</main>
|
||||
<aside className={styles.sidebar}>
|
||||
<SidebarContentWrapper>
|
||||
<Title level="h2">{heading}</Title>
|
||||
<Body color="uiTextMediumContrast">{preamble}</Body>
|
||||
<ExperienceList experiences={experiences} />
|
||||
{has_sidepeek && (
|
||||
<DestinationPageSidePeek
|
||||
buttonText={sidepeek_button_text}
|
||||
sidePeekContent={sidepeek_content}
|
||||
/>
|
||||
)}
|
||||
<Suspense fallback={<DestinationCityPageSkeleton />}>
|
||||
<HotelDataContainer
|
||||
cityIdentifier={cityIdentifier}
|
||||
filterFromUrl={filterFromUrl}
|
||||
>
|
||||
<div className={styles.pageContainer}>
|
||||
<header className={styles.header}>
|
||||
<Breadcrumbs variant={PageContentTypeEnum.destinationCityPage} />
|
||||
<TopImages images={images} destinationName={city.name} />
|
||||
</header>
|
||||
<main className={styles.mainContent}>
|
||||
<HotelListing />
|
||||
{blocks && <Blocks blocks={blocks} />}
|
||||
</main>
|
||||
<aside className={styles.sidebar}>
|
||||
<SidebarContentWrapper cityName={city.name}>
|
||||
<Body color="uiTextMediumContrast">{preamble}</Body>
|
||||
<ExperienceList experiences={experiences} />
|
||||
{has_sidepeek && (
|
||||
<DestinationPageSidePeek
|
||||
buttonText={sidepeek_button_text}
|
||||
sidePeekContent={sidepeek_content}
|
||||
/>
|
||||
)}
|
||||
|
||||
{destination_settings.city && (
|
||||
<StaticMap city={destination_settings.city} />
|
||||
)}
|
||||
</SidebarContentWrapper>
|
||||
</aside>
|
||||
</div>
|
||||
<CityMap city={city} cityIdentifier={cityIdentifier} />
|
||||
{destination_settings.city && (
|
||||
<StaticMap city={destination_settings.city} />
|
||||
)}
|
||||
</SidebarContentWrapper>
|
||||
</aside>
|
||||
</div>
|
||||
<CityMap
|
||||
mapId={env.GOOGLE_DYNAMIC_MAP_ID}
|
||||
apiKey={env.GOOGLE_STATIC_MAP_KEY}
|
||||
city={city}
|
||||
/>
|
||||
</HotelDataContainer>
|
||||
</Suspense>
|
||||
<Suspense fallback={null}>
|
||||
<TrackingSDK pageData={tracking} />
|
||||
</Suspense>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
"use client"
|
||||
|
||||
import React from "react"
|
||||
|
||||
import SkeletonShimmer from "@/components/SkeletonShimmer"
|
||||
|
||||
import styles from "./sidebarContentWrapper.module.css"
|
||||
|
||||
export default function SidebarContentWrapperSkeleton({
|
||||
children,
|
||||
}: React.PropsWithChildren) {
|
||||
return (
|
||||
<div className={styles.sidebarContent}>
|
||||
<div>
|
||||
<SkeletonShimmer height="40px" width="100%" />
|
||||
<SkeletonShimmer height="40px" width="80%" />
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
"use client"
|
||||
|
||||
import { useRef } from "react"
|
||||
|
||||
import { StickyElementNameEnum } from "@/stores/sticky-position"
|
||||
|
||||
import useStickyPosition from "@/hooks/useStickyPosition"
|
||||
|
||||
import styles from "./sidebarContentWrapper.module.css"
|
||||
|
||||
export default function SidebarContentWrapper({
|
||||
children,
|
||||
}: React.PropsWithChildren) {
|
||||
const sidebarRef = useRef<HTMLDivElement>(null)
|
||||
useStickyPosition({
|
||||
ref: sidebarRef,
|
||||
name: StickyElementNameEnum.DESTINATION_SIDEBAR,
|
||||
})
|
||||
|
||||
return (
|
||||
<div ref={sidebarRef} className={styles.sidebarContent}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
.sidebarContent {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: var(--Spacing-x2);
|
||||
padding: var(--Spacing-x4);
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1367px) {
|
||||
.sidebarContent {
|
||||
position: sticky;
|
||||
}
|
||||
}
|
||||
@@ -12,11 +12,11 @@ import TrackingSDK from "@/components/TrackingSDK"
|
||||
import CityListing from "../CityListing"
|
||||
import CityListingSkeleton from "../CityListing/CityListingSkeleton"
|
||||
import ExperienceList from "../ExperienceList"
|
||||
import SidebarContentWrapper from "../SidebarContentWrapper"
|
||||
import DestinationPageSidePeek from "../Sidepeek"
|
||||
import StaticMap from "../StaticMap"
|
||||
import TopImages from "../TopImages"
|
||||
import CountryMap, { preload } from "./CountryMap"
|
||||
import SidebarContentWrapper from "./SidebarContentWrapper"
|
||||
|
||||
import styles from "./destinationCountryPage.module.css"
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"use client"
|
||||
|
||||
import SkeletonShimmer from "@/components/SkeletonShimmer"
|
||||
|
||||
import styles from "./experienceList.module.css"
|
||||
|
||||
export default async function ExperienceListSkeleton() {
|
||||
export default function ExperienceListSkeleton() {
|
||||
return (
|
||||
<ul className={styles.experienceList}>
|
||||
{Array.from({ length: 5 }).map((_, index) => (
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { getHotelsByCityIdentifier } from "@/lib/trpc/memoizedRequests"
|
||||
|
||||
import { getIntl } from "@/i18n"
|
||||
import HotelDataProvider from "@/providers/HotelDataProvider"
|
||||
|
||||
import type { SortItem } from "@/types/components/hotelFilterAndSort"
|
||||
import { SortOption } from "@/types/enums/hotelFilterAndSort"
|
||||
|
||||
interface HotelDataContainerProps extends React.PropsWithChildren {
|
||||
cityIdentifier: string
|
||||
filterFromUrl?: string
|
||||
}
|
||||
|
||||
export function preload(cityIdentifier: string) {
|
||||
void getHotelsByCityIdentifier(cityIdentifier)
|
||||
}
|
||||
|
||||
export default async function HotelDataContainer({
|
||||
cityIdentifier,
|
||||
filterFromUrl,
|
||||
children,
|
||||
}: HotelDataContainerProps) {
|
||||
const intl = await getIntl()
|
||||
const hotels = await getHotelsByCityIdentifier(cityIdentifier)
|
||||
|
||||
const sortItems: SortItem[] = [
|
||||
{
|
||||
label: intl.formatMessage({ id: "Distance to city center" }),
|
||||
value: SortOption.Distance,
|
||||
},
|
||||
{ label: intl.formatMessage({ id: "Name" }), value: SortOption.Name },
|
||||
{
|
||||
label: intl.formatMessage({ id: "TripAdvisor rating" }),
|
||||
value: SortOption.TripAdvisorRating,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<HotelDataProvider
|
||||
allHotels={hotels}
|
||||
filterFromUrl={filterFromUrl}
|
||||
sortItems={sortItems}
|
||||
>
|
||||
{children}
|
||||
</HotelDataProvider>
|
||||
)
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useRef, useState } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { BackToTopButton } from "@/components/TempDesignSystem/BackToTopButton"
|
||||
import ShowMoreButton from "@/components/TempDesignSystem/ShowMoreButton"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
import { useScrollToTop } from "@/hooks/useScrollToTop"
|
||||
|
||||
import HotelListingItem from "./HotelListingItem"
|
||||
|
||||
import styles from "./hotelListing.module.css"
|
||||
|
||||
import type { HotelData } from "@/types/hotel"
|
||||
|
||||
interface HotelListingClientProps {
|
||||
hotels: (HotelData & { url: string | null })[]
|
||||
}
|
||||
|
||||
export default function HotelListingClient({
|
||||
hotels,
|
||||
}: HotelListingClientProps) {
|
||||
const intl = useIntl()
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const showToggleButton = hotels.length > 5
|
||||
const [allHotelsVisible, setAllHotelsVisible] = useState(!showToggleButton)
|
||||
const { showBackToTop, scrollToTop } = useScrollToTop({
|
||||
threshold: 300,
|
||||
elementRef: scrollRef,
|
||||
})
|
||||
|
||||
function handleShowMore() {
|
||||
if (scrollRef.current && allHotelsVisible) {
|
||||
scrollRef.current.scrollIntoView({ behavior: "smooth" })
|
||||
}
|
||||
setAllHotelsVisible((state) => !state)
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={styles.container} ref={scrollRef}>
|
||||
<div className={styles.listHeader}>
|
||||
<Subtitle type="two">
|
||||
{intl.formatMessage(
|
||||
{
|
||||
id: `{count, plural, one {{count} Hotel} other {{count} Hotels}}`,
|
||||
},
|
||||
{ count: hotels.length }
|
||||
)}
|
||||
</Subtitle>
|
||||
</div>
|
||||
<ul
|
||||
className={`${styles.hotelList} ${allHotelsVisible ? styles.allVisible : ""}`}
|
||||
>
|
||||
{hotels.map(({ hotel, url }) => (
|
||||
<li key={hotel.name}>
|
||||
<HotelListingItem hotel={hotel} url={url} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{showToggleButton ? (
|
||||
<ShowMoreButton
|
||||
loadMoreData={handleShowMore}
|
||||
showLess={allHotelsVisible}
|
||||
/>
|
||||
) : null}
|
||||
{showBackToTop && (
|
||||
<BackToTopButton position="center" onClick={scrollToTop} />
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,19 +1,90 @@
|
||||
import { getHotelsByCityIdentifier } from "@/lib/trpc/memoizedRequests"
|
||||
"use client"
|
||||
|
||||
import HotelListingClient from "./Client"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
interface HotelListingProps {
|
||||
cityIdentifier: string
|
||||
}
|
||||
import { useHotelDataStore } from "@/stores/hotel-data"
|
||||
|
||||
export default async function HotelListing({
|
||||
cityIdentifier,
|
||||
}: HotelListingProps) {
|
||||
const hotels = await getHotelsByCityIdentifier(cityIdentifier)
|
||||
import HotelFilterAndSort from "@/components/HotelFilterAndSort"
|
||||
import { BackToTopButton } from "@/components/TempDesignSystem/BackToTopButton"
|
||||
import ShowMoreButton from "@/components/TempDesignSystem/ShowMoreButton"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
import useHash from "@/hooks/useHash"
|
||||
import { useScrollToTop } from "@/hooks/useScrollToTop"
|
||||
|
||||
if (!hotels.length) {
|
||||
return null
|
||||
import HotelListingItem from "./HotelListingItem"
|
||||
|
||||
import styles from "./hotelListing.module.css"
|
||||
|
||||
export default function HotelListing() {
|
||||
const intl = useIntl()
|
||||
const hash = useHash()
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const { showBackToTop, scrollToTop } = useScrollToTop({
|
||||
threshold: 300,
|
||||
elementRef: scrollRef,
|
||||
})
|
||||
const {
|
||||
activeHotels,
|
||||
filters,
|
||||
sortItems,
|
||||
initialHashFilterLoaded,
|
||||
loadInitialHashFilter,
|
||||
} = useHotelDataStore((state) => ({
|
||||
activeHotels: state.activeHotels,
|
||||
filters: state.allFilters,
|
||||
sortItems: state.sortItems,
|
||||
initialHashFilterLoaded: state.initialHashFilterLoaded,
|
||||
loadInitialHashFilter: state.actions.loadInitialHashFilter,
|
||||
}))
|
||||
const [allHotelsVisible, setAllHotelsVisible] = useState(
|
||||
activeHotels.length <= 5
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (hash !== undefined && !initialHashFilterLoaded) {
|
||||
loadInitialHashFilter(hash)
|
||||
}
|
||||
}, [hash, loadInitialHashFilter, initialHashFilterLoaded])
|
||||
|
||||
function handleShowMore() {
|
||||
if (scrollRef.current && allHotelsVisible) {
|
||||
scrollRef.current.scrollIntoView({ behavior: "smooth" })
|
||||
}
|
||||
setAllHotelsVisible((state) => !state)
|
||||
}
|
||||
|
||||
return <HotelListingClient hotels={hotels} />
|
||||
return (
|
||||
<section className={styles.container} ref={scrollRef}>
|
||||
<div className={styles.listHeader}>
|
||||
<Subtitle type="two">
|
||||
{intl.formatMessage(
|
||||
{
|
||||
id: `{count, plural, one {{count} Hotel} other {{count} Hotels}}`,
|
||||
},
|
||||
{ count: activeHotels.length }
|
||||
)}
|
||||
</Subtitle>
|
||||
<HotelFilterAndSort filters={filters} sortItems={sortItems} />
|
||||
</div>
|
||||
<ul
|
||||
className={`${styles.hotelList} ${allHotelsVisible ? styles.allVisible : ""}`}
|
||||
>
|
||||
{activeHotels.map(({ hotel, url }) => (
|
||||
<li key={hotel.name}>
|
||||
<HotelListingItem hotel={hotel} url={url} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{activeHotels.length > 5 ? (
|
||||
<ShowMoreButton
|
||||
loadMoreData={handleShowMore}
|
||||
showLess={allHotelsVisible}
|
||||
/>
|
||||
) : null}
|
||||
{showBackToTop && (
|
||||
<BackToTopButton position="center" onClick={scrollToTop} />
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { useSearchParams } from "next/navigation"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import {
|
||||
type PropsWithChildren,
|
||||
useCallback,
|
||||
@@ -36,6 +36,7 @@ export default function Map({
|
||||
children,
|
||||
pageType,
|
||||
}: PropsWithChildren<MapProps>) {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const isMapView = useMemo(
|
||||
() => searchParams.get("view") === "map",
|
||||
@@ -91,7 +92,9 @@ export default function Map({
|
||||
}, [rootDiv, isMapView, handleMapHeight])
|
||||
|
||||
function handleClose() {
|
||||
window.history.pushState({}, "", window.location.pathname)
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.delete("view")
|
||||
router.push(url.toString())
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
"use client"
|
||||
|
||||
import React from "react"
|
||||
|
||||
import SkeletonShimmer from "@/components/SkeletonShimmer"
|
||||
|
||||
import styles from "./sidebarContentWrapper.module.css"
|
||||
|
||||
export default function SidebarContentWrapperSkeleton({
|
||||
children,
|
||||
}: React.PropsWithChildren) {
|
||||
return (
|
||||
<div className={styles.sidebarContent}>
|
||||
<div>
|
||||
<SkeletonShimmer height="40px" width="100%" />
|
||||
<SkeletonShimmer height="40px" width="80%" />
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,24 +1,43 @@
|
||||
"use client"
|
||||
|
||||
import { useRef } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { useHotelDataStore } from "@/stores/hotel-data"
|
||||
import { StickyElementNameEnum } from "@/stores/sticky-position"
|
||||
|
||||
import Title from "@/components/TempDesignSystem/Text/Title"
|
||||
import useStickyPosition from "@/hooks/useStickyPosition"
|
||||
|
||||
import { getCityHeadingText } from "../utils"
|
||||
|
||||
import styles from "./sidebarContentWrapper.module.css"
|
||||
|
||||
interface SidebarContentWrapperProps extends React.PropsWithChildren {
|
||||
cityName?: string
|
||||
}
|
||||
|
||||
export default function SidebarContentWrapper({
|
||||
cityName,
|
||||
children,
|
||||
}: React.PropsWithChildren) {
|
||||
}: SidebarContentWrapperProps) {
|
||||
const intl = useIntl()
|
||||
const sidebarRef = useRef<HTMLDivElement>(null)
|
||||
useStickyPosition({
|
||||
ref: sidebarRef,
|
||||
name: StickyElementNameEnum.DESTINATION_SIDEBAR,
|
||||
})
|
||||
const { activeFilters, allFilters } = useHotelDataStore((state) => ({
|
||||
activeFilters: state.activeFilters,
|
||||
allFilters: state.allFilters,
|
||||
}))
|
||||
const headingText = cityName
|
||||
? getCityHeadingText(intl, cityName, allFilters, activeFilters[0])
|
||||
: null
|
||||
|
||||
return (
|
||||
<div ref={sidebarRef} className={styles.sidebarContent}>
|
||||
{headingText && <Title level="h2">{headingText}</Title>}
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client"
|
||||
|
||||
import Link from "next/link"
|
||||
import { useParams } from "next/navigation"
|
||||
import { useEffect, useState } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { MapIcon } from "@/components/Icons"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
|
||||
interface MapButtonProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function MapButton({ className = "" }: MapButtonProps) {
|
||||
const intl = useIntl()
|
||||
const params = useParams()
|
||||
const [mapUrl, setMapUrl] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.set("view", "map")
|
||||
setMapUrl(url.toString())
|
||||
}, [params])
|
||||
|
||||
if (!mapUrl) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
intent="inverted"
|
||||
variant="icon"
|
||||
size="small"
|
||||
theme="base"
|
||||
className={className}
|
||||
asChild
|
||||
>
|
||||
<Link href={mapUrl} scroll={true}>
|
||||
<MapIcon />
|
||||
{intl.formatMessage({ id: "See on map" })}
|
||||
</Link>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,8 @@
|
||||
import Link from "next/link"
|
||||
|
||||
import { MapIcon } from "@/components/Icons"
|
||||
import StaticMap from "@/components/Maps/StaticMap"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import { getIntl } from "@/i18n"
|
||||
|
||||
import MapButton from "./MapButton"
|
||||
|
||||
import styles from "./staticMap.module.css"
|
||||
|
||||
interface StaticMapProps {
|
||||
@@ -32,19 +30,7 @@ export default async function DestinationStaticMap({
|
||||
zoomLevel={zoomLevel}
|
||||
altText={altText}
|
||||
/>
|
||||
<Button
|
||||
intent="inverted"
|
||||
variant="icon"
|
||||
size="small"
|
||||
theme="base"
|
||||
className={styles.button}
|
||||
asChild
|
||||
>
|
||||
<Link href="?view=map">
|
||||
<MapIcon />
|
||||
{intl.formatMessage({ id: "See on map" })}
|
||||
</Link>
|
||||
</Button>
|
||||
<MapButton className={styles.button} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
"use client"
|
||||
|
||||
import SkeletonShimmer from "@/components/SkeletonShimmer"
|
||||
|
||||
import styles from "./topImages.module.css"
|
||||
|
||||
export default function TopImagesSkeleton() {
|
||||
return (
|
||||
<div className={styles.topImages}>
|
||||
<SkeletonShimmer height="400px" width="100%" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
32
components/ContentType/DestinationPage/utils.ts
Normal file
32
components/ContentType/DestinationPage/utils.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { IntlShape } from "react-intl"
|
||||
|
||||
import type { CategorizedFilters } from "@/types/components/hotelFilterAndSort"
|
||||
|
||||
export function getCityHeadingText(
|
||||
intl: IntlShape,
|
||||
cityName: string,
|
||||
allFilters: CategorizedFilters,
|
||||
filter?: string
|
||||
) {
|
||||
if (filter) {
|
||||
const facilityFilter = allFilters.facilityFilters.find(
|
||||
(f) => f.slug === filter
|
||||
)
|
||||
const surroudingsFilter = allFilters.surroundingsFilters.find(
|
||||
(f) => f.slug === filter
|
||||
)
|
||||
|
||||
if (facilityFilter) {
|
||||
return intl.formatMessage(
|
||||
{ id: "Hotels with {filter} in {cityName}" },
|
||||
{ cityName, filter: facilityFilter.name }
|
||||
)
|
||||
} else if (surroudingsFilter) {
|
||||
return intl.formatMessage(
|
||||
{ id: "Hotels near {filter} in {cityName}" },
|
||||
{ cityName, filter: surroudingsFilter.name }
|
||||
)
|
||||
}
|
||||
}
|
||||
return intl.formatMessage({ id: "Hotels in {city}" }, { city: cityName })
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
.checkboxWrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--Spacing-x-one-and-half);
|
||||
padding: var(--Spacing-x1) var(--Spacing-x-one-and-half);
|
||||
cursor: pointer;
|
||||
border-radius: var(--Corner-radius-Medium);
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
.checkboxWrapper:hover {
|
||||
background-color: var(--UI-Input-Controls-Surface-Hover);
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
min-width: 24px;
|
||||
border: 1px solid var(--UI-Input-Controls-Border-Normal);
|
||||
border-radius: var(--Corner-radius-Small);
|
||||
transition: all 0.3s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: var(--UI-Input-Controls-Surface-Normal);
|
||||
}
|
||||
|
||||
.checkboxWrapper[data-selected] .checkbox {
|
||||
border-color: var(--UI-Input-Controls-Fill-Selected);
|
||||
background-color: var(--UI-Input-Controls-Fill-Selected);
|
||||
}
|
||||
41
components/HotelFilterAndSort/Filter/Checkbox/index.tsx
Normal file
41
components/HotelFilterAndSort/Filter/Checkbox/index.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
"use client"
|
||||
|
||||
import { Checkbox as AriaCheckbox } from "react-aria-components"
|
||||
|
||||
import CheckIcon from "@/components/Icons/Check"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
|
||||
import styles from "./checkbox.module.css"
|
||||
|
||||
interface CheckboxProps {
|
||||
name: string
|
||||
value: string
|
||||
isSelected: boolean
|
||||
onChange: (filterId: string) => void
|
||||
}
|
||||
|
||||
export default function Checkbox({
|
||||
isSelected,
|
||||
name,
|
||||
value,
|
||||
onChange,
|
||||
}: CheckboxProps) {
|
||||
return (
|
||||
<AriaCheckbox
|
||||
className={styles.checkboxWrapper}
|
||||
isSelected={isSelected}
|
||||
onChange={() => onChange(value)}
|
||||
>
|
||||
{({ isSelected }) => (
|
||||
<>
|
||||
<span className={styles.checkbox}>
|
||||
{isSelected && <CheckIcon color="white" />}
|
||||
</span>
|
||||
<Body asChild>
|
||||
<span>{name}</span>
|
||||
</Body>
|
||||
</>
|
||||
)}
|
||||
</AriaCheckbox>
|
||||
)
|
||||
}
|
||||
27
components/HotelFilterAndSort/Filter/filter.module.css
Normal file
27
components/HotelFilterAndSort/Filter/filter.module.css
Normal file
@@ -0,0 +1,27 @@
|
||||
.container {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.form {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x4);
|
||||
}
|
||||
|
||||
.fieldset {
|
||||
border: none;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.fieldset:first-of-type {
|
||||
border-bottom: 1px solid var(--Base-Border-Subtle);
|
||||
}
|
||||
|
||||
.list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
list-style: none;
|
||||
gap: var(--Spacing-x1) var(--Spacing-x2);
|
||||
margin: var(--Spacing-x3) 0;
|
||||
}
|
||||
76
components/HotelFilterAndSort/Filter/index.tsx
Normal file
76
components/HotelFilterAndSort/Filter/index.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
"use client"
|
||||
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { useHotelDataStore } from "@/stores/hotel-data"
|
||||
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
import Title from "@/components/TempDesignSystem/Text/Title"
|
||||
|
||||
import Checkbox from "./Checkbox"
|
||||
|
||||
import styles from "./filter.module.css"
|
||||
|
||||
import type { CategorizedFilters } from "@/types/components/hotelFilterAndSort"
|
||||
|
||||
interface FilterProps {
|
||||
filters: CategorizedFilters
|
||||
}
|
||||
|
||||
export default function Filter({ filters }: FilterProps) {
|
||||
const intl = useIntl()
|
||||
const { facilityFilters, surroundingsFilters } = filters
|
||||
const { pendingFilters, togglePendingFilter } = useHotelDataStore(
|
||||
(state) => ({
|
||||
pendingFilters: state.pendingFilters,
|
||||
togglePendingFilter: state.actions.togglePendingFilter,
|
||||
})
|
||||
)
|
||||
|
||||
if (!facilityFilters.length && !surroundingsFilters.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<Title level="h4">{intl.formatMessage({ id: "Filter by" })}</Title>
|
||||
<form className={styles.form}>
|
||||
<fieldset className={styles.fieldset}>
|
||||
<Subtitle type="two" asChild>
|
||||
<legend>{intl.formatMessage({ id: "Hotel facilities" })}</legend>
|
||||
</Subtitle>
|
||||
<ul className={styles.list}>
|
||||
{facilityFilters.map((filter) => (
|
||||
<li key={`filter-${filter.slug}`} className={styles.item}>
|
||||
<Checkbox
|
||||
name={filter.name}
|
||||
value={filter.slug}
|
||||
onChange={() => togglePendingFilter(filter.slug)}
|
||||
isSelected={!!pendingFilters.find((f) => f === filter.slug)}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</fieldset>
|
||||
|
||||
<fieldset className={styles.fieldset}>
|
||||
<Subtitle type="two" asChild>
|
||||
<legend>{intl.formatMessage({ id: "Hotel surroundings" })}</legend>
|
||||
</Subtitle>
|
||||
<ul className={styles.list}>
|
||||
{surroundingsFilters.map((filter) => (
|
||||
<li key={`filter-${filter.slug}`} className={styles.item}>
|
||||
<Checkbox
|
||||
name={filter.name}
|
||||
value={filter.slug}
|
||||
onChange={() => togglePendingFilter(filter.slug)}
|
||||
isSelected={!!pendingFilters.find((f) => f === filter.slug)}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</fieldset>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
34
components/HotelFilterAndSort/Sort/index.tsx
Normal file
34
components/HotelFilterAndSort/Sort/index.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
"use client"
|
||||
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { useHotelDataStore } from "@/stores/hotel-data"
|
||||
|
||||
import Select from "@/components/TempDesignSystem/Select"
|
||||
|
||||
import type { SortItem } from "@/types/components/hotelFilterAndSort"
|
||||
import type { SortOption } from "@/types/enums/hotelFilterAndSort"
|
||||
|
||||
interface SortProps {
|
||||
sortItems: SortItem[]
|
||||
}
|
||||
|
||||
export default function Sort({ sortItems }: SortProps) {
|
||||
const intl = useIntl()
|
||||
const { pendingSort, setPendingSort } = useHotelDataStore((state) => ({
|
||||
pendingSort: state.pendingSort,
|
||||
setPendingSort: state.actions.setPendingSort,
|
||||
}))
|
||||
|
||||
return (
|
||||
<Select
|
||||
items={sortItems}
|
||||
defaultSelectedKey={pendingSort}
|
||||
label={intl.formatMessage({ id: "Sort by" })}
|
||||
aria-label={intl.formatMessage({ id: "Sort by" })}
|
||||
name="sort"
|
||||
showRadioButton
|
||||
onSelect={(sort) => setPendingSort(sort as SortOption)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
58
components/HotelFilterAndSort/hotelFilterAndSort.module.css
Normal file
58
components/HotelFilterAndSort/hotelFilterAndSort.module.css
Normal file
@@ -0,0 +1,58 @@
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
align-items: center;
|
||||
z-index: var(--default-modal-overlay-z-index);
|
||||
}
|
||||
.dialog {
|
||||
width: min(80dvw, 960px);
|
||||
border-radius: var(--Corner-radius-Large);
|
||||
background-color: var(--Base-Surface-Primary-light-Normal);
|
||||
box-shadow: 0px 4px 24px 0px rgba(38, 32, 30, 0.08);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
align-items: center;
|
||||
padding: var(--Spacing-x2) var(--Spacing-x3);
|
||||
border-bottom: 1px solid var(--Base-Border-Subtle);
|
||||
}
|
||||
|
||||
.buttonWrapper {
|
||||
display: flex;
|
||||
gap: var(--Spacing-x1);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.badge {
|
||||
background-color: var(--Base-Text-Accent);
|
||||
border-radius: var(--Corner-radius-xLarge);
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: var(--Base-Surface-Primary-light-Normal);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x4);
|
||||
align-content: start;
|
||||
padding: var(--Spacing-x4) var(--Spacing-x3);
|
||||
overflow-y: auto;
|
||||
height: min(calc(80dvh - 180px), 500px);
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: var(--Spacing-x2) var(--Spacing-x4);
|
||||
border-top: 1px solid var(--Base-Border-Subtle);
|
||||
}
|
||||
135
components/HotelFilterAndSort/index.tsx
Normal file
135
components/HotelFilterAndSort/index.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogTrigger,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
} from "react-aria-components"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { useHotelDataStore } from "@/stores/hotel-data"
|
||||
|
||||
import { CloseLargeIcon, FilterIcon } from "@/components/Icons"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Divider from "@/components/TempDesignSystem/Divider"
|
||||
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
|
||||
import Filter from "./Filter"
|
||||
import Sort from "./Sort"
|
||||
|
||||
import styles from "./hotelFilterAndSort.module.css"
|
||||
|
||||
import type {
|
||||
CategorizedFilters,
|
||||
SortItem,
|
||||
} from "@/types/components/hotelFilterAndSort"
|
||||
|
||||
interface HotelFilterAndSortProps {
|
||||
filters: CategorizedFilters
|
||||
sortItems: SortItem[]
|
||||
}
|
||||
|
||||
export default function HotelFilterAndSort({
|
||||
filters,
|
||||
sortItems,
|
||||
}: HotelFilterAndSortProps) {
|
||||
const intl = useIntl()
|
||||
const {
|
||||
pendingCount,
|
||||
activeFilters,
|
||||
clearPendingFilters,
|
||||
resetPendingValues,
|
||||
submitFiltersAndSort,
|
||||
} = useHotelDataStore((state) => ({
|
||||
pendingCount: state.pendingCount,
|
||||
activeFilters: state.activeFilters,
|
||||
clearPendingFilters: state.actions.clearPendingFilters,
|
||||
resetPendingValues: state.actions.resetPendingValues,
|
||||
submitFiltersAndSort: state.actions.submitFiltersAndSort,
|
||||
}))
|
||||
|
||||
function submitAndClose(close: () => void) {
|
||||
submitFiltersAndSort()
|
||||
close()
|
||||
}
|
||||
|
||||
function handleClose(isOpen: boolean) {
|
||||
if (isOpen) {
|
||||
resetPendingValues()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DialogTrigger onOpenChange={handleClose}>
|
||||
<div className={styles.buttonWrapper}>
|
||||
<Button intent="text" theme="base" variant="icon">
|
||||
<FilterIcon color="baseTextHighcontrast" />
|
||||
{intl.formatMessage({ id: "Filter and sort" })}
|
||||
</Button>
|
||||
{activeFilters.length > 0 && (
|
||||
<Footnote className={styles.badge} asChild>
|
||||
<span>{activeFilters.length}</span>
|
||||
</Footnote>
|
||||
)}
|
||||
</div>
|
||||
<ModalOverlay isDismissable className={styles.overlay}>
|
||||
<Modal>
|
||||
<Dialog
|
||||
className={styles.dialog}
|
||||
aria-label={intl.formatMessage({ id: "Filter and sort" })}
|
||||
>
|
||||
{({ close }) => (
|
||||
<>
|
||||
<header className={styles.header}>
|
||||
<Subtitle
|
||||
type="two"
|
||||
textAlign="center"
|
||||
className={styles.title}
|
||||
asChild
|
||||
>
|
||||
<h3>{intl.formatMessage({ id: "Filter and sort" })}</h3>
|
||||
</Subtitle>
|
||||
<Button onClick={close} variant="icon" intent="tertiary">
|
||||
<CloseLargeIcon />
|
||||
</Button>
|
||||
</header>
|
||||
<div className={styles.content}>
|
||||
<Sort sortItems={sortItems} />
|
||||
<Divider color="subtle" />
|
||||
<Filter filters={filters} />
|
||||
</div>
|
||||
<footer className={styles.footer}>
|
||||
<Button
|
||||
onClick={clearPendingFilters}
|
||||
intent="text"
|
||||
size="medium"
|
||||
theme="base"
|
||||
>
|
||||
{intl.formatMessage({ id: "Clear all filters" })}
|
||||
</Button>
|
||||
<Button
|
||||
intent="tertiary"
|
||||
size="large"
|
||||
theme="base"
|
||||
onClick={() => submitAndClose(close)}
|
||||
>
|
||||
{intl.formatMessage(
|
||||
{ id: "See results ({ count })" },
|
||||
{
|
||||
count: pendingCount,
|
||||
}
|
||||
)}
|
||||
</Button>
|
||||
</footer>
|
||||
</>
|
||||
)}
|
||||
</Dialog>
|
||||
</Modal>
|
||||
</ModalOverlay>
|
||||
</DialogTrigger>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user