diff --git a/next/assets/icons-top-services/elektronicke-sluzby.svg b/next/assets/icons-top-services/elektronicke-sluzby.svg index 48e573410..799e36f23 100644 --- a/next/assets/icons-top-services/elektronicke-sluzby.svg +++ b/next/assets/icons-top-services/elektronicke-sluzby.svg @@ -1,10 +1,8 @@ - - diff --git a/next/assets/icons-top-services/kampane-a-projekty.svg b/next/assets/icons-top-services/kampane-a-projekty.svg index 874310306..58441f4a8 100644 --- a/next/assets/icons-top-services/kampane-a-projekty.svg +++ b/next/assets/icons-top-services/kampane-a-projekty.svg @@ -1,10 +1,8 @@ - - diff --git a/next/assets/icons-top-services/kontakty-a-uradne-hodiny.svg b/next/assets/icons-top-services/kontakty-a-uradne-hodiny.svg index c9b274108..2df8dee92 100644 --- a/next/assets/icons-top-services/kontakty-a-uradne-hodiny.svg +++ b/next/assets/icons-top-services/kontakty-a-uradne-hodiny.svg @@ -1,10 +1,8 @@ - - diff --git a/next/assets/icons-top-services/miestne-dane-a-poplatky.svg b/next/assets/icons-top-services/miestne-dane-a-poplatky.svg index d39618a78..0255a2276 100644 --- a/next/assets/icons-top-services/miestne-dane-a-poplatky.svg +++ b/next/assets/icons-top-services/miestne-dane-a-poplatky.svg @@ -1,5 +1,4 @@ - @@ -7,7 +6,6 @@ d="M17.22 15.29h-4.59v33.55h20.58M24.29 15.3h-4.18m4.18 23.05h-4.18M35.5 24.33H20.11m13.45 3.63H20.11M44.76 39.4a3.819 3.819 0 0 0-2.1-.67c-2.41 0-4.37 2.33-4.37 5.21s2 5.21 4.37 5.21a3.82 3.82 0 0 0 2.1-.67m-8.91-6.01h7.31m-7.31 2.94h6.74"/> - diff --git a/next/assets/icons-top-services/nahlasenie-podnetov.svg b/next/assets/icons-top-services/nahlasenie-podnetov.svg index a2651f693..4d64f810c 100644 --- a/next/assets/icons-top-services/nahlasenie-podnetov.svg +++ b/next/assets/icons-top-services/nahlasenie-podnetov.svg @@ -1,12 +1,10 @@ - - diff --git a/next/assets/icons-top-services/organizacna-struktura.svg b/next/assets/icons-top-services/organizacna-struktura.svg index 578b7ca67..0c0aa419c 100644 --- a/next/assets/icons-top-services/organizacna-struktura.svg +++ b/next/assets/icons-top-services/organizacna-struktura.svg @@ -1,5 +1,4 @@ - - diff --git a/next/assets/icons-top-services/parky-a-zahrady.svg b/next/assets/icons-top-services/parky-a-zahrady.svg index 3d9fb9110..9191a9436 100644 --- a/next/assets/icons-top-services/parky-a-zahrady.svg +++ b/next/assets/icons-top-services/parky-a-zahrady.svg @@ -1,10 +1,8 @@ - - diff --git a/next/assets/icons-top-services/pracovne-prilezitosti.svg b/next/assets/icons-top-services/pracovne-prilezitosti.svg index 261b1f39b..dbce3bdaa 100644 --- a/next/assets/icons-top-services/pracovne-prilezitosti.svg +++ b/next/assets/icons-top-services/pracovne-prilezitosti.svg @@ -1,12 +1,10 @@ - - diff --git a/next/assets/icons-top-services/prenajom-priestorov.svg b/next/assets/icons-top-services/prenajom-priestorov.svg index b5973e95e..929d2f79f 100644 --- a/next/assets/icons-top-services/prenajom-priestorov.svg +++ b/next/assets/icons-top-services/prenajom-priestorov.svg @@ -1,10 +1,8 @@ - - diff --git a/next/assets/icons-top-services/turistom-v-hlavnom-meste.svg b/next/assets/icons-top-services/turistom-v-hlavnom-meste.svg index d2844cb8d..bfdbb1d6a 100644 --- a/next/assets/icons-top-services/turistom-v-hlavnom-meste.svg +++ b/next/assets/icons-top-services/turistom-v-hlavnom-meste.svg @@ -1,10 +1,8 @@ - - diff --git a/next/assets/images/dokumenty.svg b/next/assets/images/dokumenty.svg new file mode 100644 index 000000000..1fd89bf05 --- /dev/null +++ b/next/assets/images/dokumenty.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/next/backend/meili/fetchers/blogPostsFetcher.ts b/next/backend/meili/fetchers/blogPostsFetcher.ts index d07a0ca33..a1b6b1161 100644 --- a/next/backend/meili/fetchers/blogPostsFetcher.ts +++ b/next/backend/meili/fetchers/blogPostsFetcher.ts @@ -9,12 +9,14 @@ export type BlogPostsFilters = { search: string pageSize: number page: number + tagIds: string[] } export const blogPostsDefaultFilters: BlogPostsFilters = { search: '', pageSize: 10, page: 1, + tagIds: [], } export const getBlogPostsSwrKey = (filters: BlogPostsFilters, locale: string) => diff --git a/next/backend/meili/fetchers/pagesFetcher.ts b/next/backend/meili/fetchers/pagesFetcher.ts index 0ef43e4fe..01d01a837 100644 --- a/next/backend/meili/fetchers/pagesFetcher.ts +++ b/next/backend/meili/fetchers/pagesFetcher.ts @@ -1,24 +1,47 @@ +import { PageEntityFragment } from '@backend/graphql' import { Key } from 'swr' import { meiliClient } from '../meiliClient' import { PageMeili, SearchIndexWrapped } from '../types' -import { unwrapFromSearchIndex } from '../utils' +import { getMeilisearchPageOptions, unwrapFromSearchIndex } from '../utils' export type PagesFilters = { search: string + page: number + pageSize: number } export const pagesDefaultFilters: PagesFilters = { search: '', + page: 1, + pageSize: 5, } export const getPagesSwrKey = (filters: PagesFilters, locale: string) => ['Pages', filters, locale] as Key +export const getPagesQueryKey = (filters: PagesFilters, locale: string) => [ + 'Pages', + filters, + locale, +] + +export const pagesFetcherUseQuery = (filters: PagesFilters, locale: string) => { + return meiliClient + .index('search_index') + .search>(filters.search, { + ...getMeilisearchPageOptions({ page: filters.page ?? 1, pageSize: filters.pageSize ?? 5 }), + filter: ['type = "page"', `locale = ${locale}`], + sort: [], + }) + .then(unwrapFromSearchIndex('page')) +} + export const pagesFetcher = (filters: PagesFilters, locale: string) => () => { return meiliClient .index('search_index') .search>(filters.search, { + ...getMeilisearchPageOptions({ page: filters.page ?? 1, pageSize: filters.pageSize ?? 5 }), filter: ['type = "page"', `locale = ${locale}`], sort: [], }) diff --git a/next/components/atoms/icon/IconService.ts b/next/components/atoms/icon/IconService.ts index 131043b26..9b8f1c3a0 100644 --- a/next/components/atoms/icon/IconService.ts +++ b/next/components/atoms/icon/IconService.ts @@ -1,3 +1,4 @@ +import Documents64pxIcon from '@assets/images/dokumenty.svg' import EServices48pxIcon from '@assets/images/EServices.svg' import Accommodation48pxIcon from '@assets/images/menu-icons/48px/accommodation_48px.svg' import Bike48pxIcon from '@assets/images/menu-icons/48px/bike_48px.svg' @@ -87,6 +88,7 @@ import Travel64pxFilledIcon from '@assets/images/menu-icons/64px/travel_64px_fil import Travel64pxStrokeIcon from '@assets/images/menu-icons/64px/travel_64px_stroke.svg' import Tree64pxIcon from '@assets/images/menu-icons/64px/tree_64px.svg' import Trolleybus64pxIcon from '@assets/images/menu-icons/64px/trolleybus_64px.svg' +import Ostatne48pxIcon from '@assets/images/ostatne.svg' import Phone48pxIcon from '@assets/images/phone-medium.svg' import TouristSign48pxIcon from '@assets/images/Tourist-icon.svg' @@ -181,6 +183,8 @@ export const ICON_URL_MAP: IconUrlMap = { pracovne_prilezitosti: TopServices_PracovnePrilezitosti, turistom_v_hlavnom_meste: TopServices_TuristomVHlavnomMeste, prenajom_priestorov: TopServices_PrenajomPriestorov, + // Others + ostatne: Documents64pxIcon, }, size_64: { mesto_01: Castle64pxStrokeIcon, diff --git a/next/components/forms/simple-components/Chip.tsx b/next/components/forms/simple-components/Chip.tsx index 438369c05..94fe15dbd 100644 --- a/next/components/forms/simple-components/Chip.tsx +++ b/next/components/forms/simple-components/Chip.tsx @@ -15,7 +15,7 @@ const Chip = ({ variant = 'large', ...props }: ChipProps) => { > + setSearchQuery: Dispatch> +} + +const AdvancedSearchNew = forwardRef( + ({ placeholder, input, setInput, setSearchQuery }, forwardedRef) => { + const t = useTranslations() + + const handleSearch = () => { + setSearchQuery(input) + } + + return ( + + +
+ {/* 3.75rem = 60px, 0.75rem = 12px */} + + + {input ? ( +
+ + ) + }, +) + +export default AdvancedSearchNew diff --git a/next/components/molecules/SearchPageNew/GeneralSearchResults.tsx b/next/components/molecules/SearchPageNew/GeneralSearchResults.tsx new file mode 100644 index 000000000..dd8b963be --- /dev/null +++ b/next/components/molecules/SearchPageNew/GeneralSearchResults.tsx @@ -0,0 +1,112 @@ +import { Typography } from '@bratislava/component-library' +import Pagination from '@bratislava/ui-bratislava/Pagination/Pagination' +import SearchCardComposed from '@components/molecules/SearchPageNew/SearchCardComposed' +import SearchResultsHeader from '@components/molecules/SearchPageNew/SearchResultsHeader' +import { + SearchFilters, + useQueryBySearchOption, +} from '@components/molecules/SearchPageNew/useQueryBySearchOption' +import { SearchOption } from '@components/pages/searchPageContentNew' +import { useTranslations } from 'next-intl' +import { Dispatch, SetStateAction, useEffect } from 'react' + +type GeneralSearchResultsProps = { + filters: SearchFilters + variant: 'allResults' | 'specificResults' + searchOption: SearchOption + onSetResultsCount?: (searchOptionId: SearchOption['id'], count: number) => void + onShowMore?: Dispatch>> + onPageChange?: Dispatch> +} + +const GeneralSearchResults = ({ + filters, + onShowMore, + onPageChange, + onSetResultsCount, + searchOption, + variant, +}: GeneralSearchResultsProps) => { + const t = useTranslations() + + const searchQuery = useQueryBySearchOption(searchOption.id, filters) + + // FIXME types - ts doesn't know that this can be undefined or null, I added "?? {}" manually + const { data } = searchQuery ?? {} + const { searchResultsData, searchResultsCount } = data ?? {} + + const GENERAL_RESULTS_COUNT = 5 + + useEffect(() => { + onSetResultsCount(searchOption?.id, searchResultsCount ?? 0) + }, [searchResultsCount]) + + return ( +
+
+ {variant === 'allResults' && ( + 0} + handleShowMore={() => { + onShowMore(new Set([searchOption.id])) + }} + /> + )} + {searchResultsData?.length > 0 ? ( +
+ {variant === 'allResults' + ? searchResultsData.slice(0, GENERAL_RESULTS_COUNT).map((item) => { + return ( + + ) + }) + : null} + {variant === 'specificResults' + ? searchResultsData.map((item) => { + return ( + + ) + }) + : null} +
+ ) : filters.search ? ( + {t('SearchPage.noResults')} + ) : ( + /* Contacts show only for non-empty search query */ + // TODO keep this also during the first loading + {t('SearchPage.enterSearchQuery')} + )} +
+ {variant === 'specificResults' && onPageChange ? ( +
+ 0 ? Math.ceil(searchResultsCount / filters.pageSize) : 1 + } + onPageChange={onPageChange} + /> +
+ ) : null} +
+ ) +} + +export default GeneralSearchResults diff --git a/next/components/molecules/SearchPageNew/SearchCardComposed.tsx b/next/components/molecules/SearchPageNew/SearchCardComposed.tsx new file mode 100644 index 000000000..82e5d1f3e --- /dev/null +++ b/next/components/molecules/SearchPageNew/SearchCardComposed.tsx @@ -0,0 +1,243 @@ +import { ChevronRightIcon } from '@assets/ui-icons' +import { Enum_Page_Pagecolor, Enum_Pagecategory_Color } from '@backend/graphql' +import { Typography } from '@bratislava/component-library' +import { Icon } from '@components/atoms/icon/Icon' +import ImagePlaceholder from '@components/atoms/ImagePlaceholder' +import MLink from '@components/forms/simple-components/MLink' +import Tag from '@components/forms/simple-components/Tag' +import { SearchResult } from '@components/molecules/SearchPageNew/useQueryBySearchOption' +import { getCategoryColorLocalStyle } from '@utils/colors' +import { generateImageSizes } from '@utils/generateImageSizes' +import { isDefined } from '@utils/isDefined' +import { findIconByPageColor } from '@utils/pageIcons' +import cx from 'classnames' +import Image from 'next/image' +import React, { Fragment, ReactNode } from 'react' +import { twMerge } from 'tailwind-merge' + +type SearchCardComposedProps = { + data: SearchResult + tagText?: string + variant: 'default' | 'withPicture' +} + +const SearchCardComposed = ({ data, variant = 'default', tagText }: SearchCardComposedProps) => { + return ( + <> + {variant === 'default' && ( +
+
+ +
+ + +
+ +
+ {data.linkHref && } +
+
+ )} + {variant === 'withPicture' && ( +
+ {data.coverImageSrc ? ( + + ) : data.customIconName ? ( + + ) : data.pageColor ? ( + + ) : ( + + )} +
+ +
+ + +
+ +
+ {data.linkHref && } +
+
+ )} + + ) +} + +SearchCardComposed.ImageFromIcon = function ({ + iconName, + className, +}: { + iconName?: string + className?: string +}) { + return ( +
+ {iconName ? : null} +
+ ) +} + +SearchCardComposed.ImageFromPageColor = function ({ + pageColor, + className, +}: { + pageColor: Enum_Page_Pagecolor | Enum_Pagecategory_Color + className?: string +}) { + const colorStyle = getCategoryColorLocalStyle({ color: pageColor as Enum_Pagecategory_Color }) + const { default: PageIcon } = findIconByPageColor(pageColor as Enum_Pagecategory_Color) + + return ( + + ) +} + +SearchCardComposed.ImageFromUrl = function ({ + imgUrl, + className, +}: { + imgUrl: string + className?: string +}) { + return ( + + ) +} + +SearchCardComposed.InfoContainer = function ({ + children, + className, +}: { + children: React.PropsWithChildren + className?: string +}) { + return
{children}
+} + +SearchCardComposed.TitleWithLink = function ({ + title, + href, + className, +}: { + title: string + href?: string + className?: string +}) { + return ( + <> + {href ? ( + + + {title} + + + ) : ( + + {title} + + )} + + ) +} + +SearchCardComposed.Tag = function ({ text, className }: { text: string; className?: string }) { + return ( +
+ +
+ ) +} + +SearchCardComposed.Metadata = function ({ + metadata, + className, +}: { + metadata: string[] + className?: string +}) { + const cleanedMetadata = metadata?.filter(isDefined).filter((item: string) => item !== '') + const metaDataRow = + cleanedMetadata?.map((item: string, index: number) => { + return ( + + {index > 0 && ( + + • + + )} + + {item} + + + ) + }) ?? null + return ( +
+ {metaDataRow} +
+ ) +} + +SearchCardComposed.Button = function ({ className }: { className?: string }) { + return ( +
+ +
+ ) +} + +export default SearchCardComposed diff --git a/next/components/molecules/SearchPageNew/SearchResultsHeader.tsx b/next/components/molecules/SearchPageNew/SearchResultsHeader.tsx new file mode 100644 index 000000000..f5f330035 --- /dev/null +++ b/next/components/molecules/SearchPageNew/SearchResultsHeader.tsx @@ -0,0 +1,29 @@ +import { ArrowRightIcon } from '@assets/ui-icons' +import { Typography } from '@bratislava/component-library' +import Button from '@components/forms/simple-components/Button' +import { useTranslations } from 'next-intl' + +type SearchResultsHeaderProps = { + title: string + showButton?: boolean + handleShowMore: () => void +} + +const SearchResultsHeader = ({ title, showButton, handleShowMore }: SearchResultsHeaderProps) => { + const t = useTranslations() + + return ( +
+ + {title} + + {showButton ? ( + + ) : null} +
+ ) +} + +export default SearchResultsHeader diff --git a/next/components/molecules/SearchPageNew/useQueryBySearchOption.ts b/next/components/molecules/SearchPageNew/useQueryBySearchOption.ts new file mode 100644 index 000000000..8dc4c6e55 --- /dev/null +++ b/next/components/molecules/SearchPageNew/useQueryBySearchOption.ts @@ -0,0 +1,174 @@ +import { + getGinisOfficialBoardQueryKey, + ginisOfficialBoardFetcher, +} from '@backend/ginis/fetchers/ginisOfficialBoard.fetcher' +import { + Enum_Componentblockstopservicesitem_Icon, + Enum_Page_Pagecolor, + Enum_Pagecategory_Color, + LatestBlogPostEntityFragment, +} from '@backend/graphql' +import { + blogPostsFetcher, + BlogPostsFilters, + getBlogPostsQueryKey, +} from '@backend/meili/fetchers/blogPostsFetcherReactQuery' +import { + getInbaArticlesQueryKey, + inbaArticlesFetcher, + InbaArticlesFilters, +} from '@backend/meili/fetchers/inbaArticlesFetcher' +import { + getPagesQueryKey, + pagesFetcherUseQuery, + PagesFilters, +} from '@backend/meili/fetchers/pagesFetcher' +import { PageMeili } from '@backend/meili/types' +import { + getMsGraphSearchQueryKey, + msGraphSearchFetcher, +} from '@backend/ms-graph/fetchers/msGraphSearch.fetcher' +import { SearchOption } from '@components/pages/searchPageContentNew' +import { useQuery } from '@tanstack/react-query' +import { formatDate } from '@utils/local-date' +import { useLocale } from 'next-intl' + +export type SearchFilters = PagesFilters | BlogPostsFilters | InbaArticlesFilters + +export type SearchResult = { + title: string | null | undefined + linkHref?: string | null | undefined + metadata?: (string | null | undefined)[] + coverImageSrc?: string | null | undefined + pageColor?: Enum_Page_Pagecolor | Enum_Pagecategory_Color + customIconName?: string +} + +export const useQueryBySearchOption = (optionKey: SearchOption['id'], filters: SearchFilters) => { + const locale = useLocale() + + const pagesQuery = useQuery({ + queryKey: getPagesQueryKey(filters, locale), + queryFn: () => pagesFetcherUseQuery(filters, locale), + keepPreviousData: true, + select: (data) => { + const formattedData: SearchResult[] = + data?.hits.map((page: PageMeili): SearchResult => { + return { + title: page.title, + linkHref: `/${page.slug}`, + metadata: [page.pageCategory?.title, formatDate(page.publishedAt)], + pageColor: page.pageColor ?? page.pageCategory?.color, + } + }) ?? [] + + return { searchResultsData: formattedData, searchResultsCount: data?.estimatedTotalHits ?? 0 } + }, + }) + + const blogPostsQuery = useQuery({ + // TODO filters type + queryKey: getBlogPostsQueryKey(filters as BlogPostsFilters, locale), + queryFn: () => blogPostsFetcher(filters as BlogPostsFilters, locale), + keepPreviousData: true, + select: (data) => { + const formattedData: SearchResult[] = + data?.hits?.map( + (blogPostData: Pick): SearchResult => { + return { + title: blogPostData.attributes?.title, + linkHref: `/blog/${blogPostData.attributes?.slug}`, + metadata: [ + blogPostData.attributes?.tag?.data?.attributes?.title, + formatDate(blogPostData.attributes?.publishedAt), + ], + coverImageSrc: blogPostData.attributes?.coverImage?.data?.attributes?.url, + } + }, + ) ?? [] + + return { searchResultsData: formattedData, searchResultsCount: data?.estimatedTotalHits ?? 0 } + }, + }) + + const inbaArticlesQuery = useQuery({ + // TODO filters type + queryKey: getInbaArticlesQueryKey(filters as InbaArticlesFilters, locale), + queryFn: () => inbaArticlesFetcher(filters as InbaArticlesFilters, locale), + keepPreviousData: true, + select: (data) => { + const formattedData: SearchResult[] = + data?.hits?.map((inbaArticle): SearchResult => { + return { + title: inbaArticle.attributes.title, + linkHref: `/inba/text/${inbaArticle.attributes.slug}`, + metadata: [ + inbaArticle.attributes?.inbaTag?.data?.attributes?.title, + formatDate(inbaArticle.attributes.publishedAt), + ], + coverImageSrc: inbaArticle.attributes.coverImage.data.attributes.url, + } + }) ?? [] + + return { searchResultsData: formattedData, searchResultsCount: data?.estimatedTotalHits ?? 0 } + }, + }) + + const usersQuery = useQuery({ + queryKey: getMsGraphSearchQueryKey(filters.search), + queryFn: () => msGraphSearchFetcher(filters.search), + keepPreviousData: true, + select: (axiosResponse) => { + const formattedData: SearchResult[] = + axiosResponse?.data.map((user) => { + const mail = user.otherMails?.length ? user.otherMails[0] : user.mail + + return { + title: user.displayName, + metadata: [user.jobTitle, mail, user.businessPhones?.join(', ')], + customIconName: Enum_Componentblockstopservicesitem_Icon.UradneHodiny, + } + }) ?? [] + + return { searchResultsData: formattedData, searchResultsCount: formattedData.length } + }, + }) + + const officialBoardQuery = useQuery({ + queryKey: getGinisOfficialBoardQueryKey(filters.search), + queryFn: () => ginisOfficialBoardFetcher(filters.search), + keepPreviousData: true, + select: (axiosResponse) => { + const formattedData: SearchResult[] = + axiosResponse.data?.map((boardItem) => { + return { + title: boardItem.title, + metadata: [boardItem.createdAt], + customIconName: 'ostatne', + } + }) ?? [] + + return { searchResultsData: formattedData, searchResultsCount: formattedData.length } + }, + }) + + switch (optionKey) { + case 'pages': + return pagesQuery + + case 'articles': + return blogPostsQuery + + case 'inbaArticles': + return inbaArticlesQuery + + case 'users': + return usersQuery + + case 'officialBoard': + return officialBoardQuery + + default: + return null + } +} diff --git a/next/components/pages/searchPageContent.tsx b/next/components/pages/searchPageContent.tsx index 692a8b4b1..3ffee8afa 100644 --- a/next/components/pages/searchPageContent.tsx +++ b/next/components/pages/searchPageContent.tsx @@ -35,8 +35,8 @@ const SearchPageContent = () => { const pagesSelected = checkedOptions.some(({ key }) => key === 'pages') const usersSelected = checkedOptions.some(({ key }) => key === 'users') - const pagesFilters = { search: searchValue } - const blogPostsFilters = { search: searchValue, page: 1, pageSize: 6 } + const pagesFilters = { search: searchValue, page: 1, pageSize: 6 } + const blogPostsFilters = { search: searchValue, page: 1, pageSize: 6, tagIds: [] } const usersFilters = { search: searchValue } return ( diff --git a/next/components/pages/searchPageContentNew.tsx b/next/components/pages/searchPageContentNew.tsx new file mode 100644 index 000000000..e32b2c60c --- /dev/null +++ b/next/components/pages/searchPageContentNew.tsx @@ -0,0 +1,218 @@ +import { Typography } from '@bratislava/component-library' +import Chip from '@components/forms/simple-components/Chip' +import AdvancedSearchNew from '@components/molecules/SearchPageNew/AdvancedSearchNew' +import GeneralSearchResults from '@components/molecules/SearchPageNew/GeneralSearchResults' +import { SearchFilters } from '@components/molecules/SearchPageNew/useQueryBySearchOption' +import { SectionContainer } from '@components/ui/SectionContainer/SectionContainer' +import { getCategoryColorLocalStyle } from '@utils/colors' +import { useTranslations } from 'next-intl' +import React, { useEffect, useRef, useState } from 'react' +import { Selection, TagGroup, TagList } from 'react-aria-components' +import { StringParam, useQueryParam, withDefault } from 'use-query-params' +import { useDebounce } from 'usehooks-ts' + +/* + * RAC library recommends Selection as type for selection state, which is of type `'all' | Set`. + * To use standard operations on Set, you have to check if selection is not 'all' to satisfy Typescript. + * Even though we never use 'all' for selection, because it acts differently than we want. + */ + +export type SearchOption = { + id: 'allResults' | 'pages' | 'articles' | 'inbaArticles' | 'users' | 'officialBoard' + displayName?: string + displayNamePlural: string +} + +const SearchPageContentNew = () => { + const t = useTranslations() + + const [routerQueryValue] = useQueryParam('keyword', withDefault(StringParam, '')) + const [input, setInput] = useState('') + const debouncedInput = useDebounce(input, 300) + const [searchValue, setSearchValue] = useState(debouncedInput) + + useEffect(() => { + setInput(routerQueryValue) + }, [routerQueryValue]) + + useEffect(() => { + setSearchValue(debouncedInput) + }, [debouncedInput]) + + const defaultSearchOption: SearchOption = { + id: 'allResults', + displayNamePlural: t('SearchPage.allResults'), + } + + const searchOptions: SearchOption[] = [ + { id: 'pages', displayName: t('SearchPage.page'), displayNamePlural: t('SearchPage.pages') }, + { + id: 'articles', + displayName: t('SearchPage.article'), + displayNamePlural: t('SearchPage.articles'), + }, + // { + // id: 'inbaArticles', + // displayName: t('SearchPage.inbaArticle'), + // displayNamePlural: t('SearchPage.inbaArticles'), + // }, + { + id: 'users', + displayName: t('SearchPage.contact'), + displayNamePlural: t('SearchPage.contacts'), + }, + // { + // id: 'officialBoard', + // displayName: t('SearchPage.document'), + // displayNamePlural: t('officialBoard'), + // }, + ] + + const getSearchOptionByKeyValue = (key: string) => { + return searchOptions.find((option) => option.id === key) ?? defaultSearchOption + } + + const defaultSelection = new Set([defaultSearchOption.id]) + + const [selection, setSelection] = useState(defaultSelection) + // This is how you get first element from Set (we can do it because we use selectionMode="single" on TagGroup) + const selectedKey: SearchOption['id'] = + selection !== 'all' && selection.size === 1 + ? selection.values().next().value + : defaultSearchOption.id + + const [currentPage, setCurrentPage] = useState(1) + + useEffect(() => { + setCurrentPage(1) + }, [searchValue, selection]) + + const [resultsCount, setResultsCount] = useState( + Object.fromEntries(searchOptions.map((option): [string, number] => [option.id, 0])), + ) + + const setResultsCountById = (optionId: SearchOption['id'], count: number) => { + setResultsCount((prevResultsCount) => { + return { + ...prevResultsCount, + [optionId]: count, + } + }) + } + + const getResultsCountById = (optionId: SearchOption['id']): number => { + if (optionId === defaultSearchOption.id) { + return Object.values(resultsCount).reduce((a, b) => a + b, 0) + } + if (optionId in resultsCount) { + return resultsCount[optionId] + } + return 0 + } + + /** + * If the user clicks other Chip than the selected one, the new selection size will be 1. We can safely set the selected option to the new selection. + * If the user clicks the selected Chip, the new selection size will be 0, and therefore we set the default selection. + * + * Prerequisites are selectionMode="single" (and disallowEmptySelection={false} but it's default) on TagGroup. + * + * @param newSelection + */ + const handleSelection = (newSelection: Selection) => { + // Checking 'all' just to get pure Set type, 'all' is not used in our case + if (newSelection !== 'all' && newSelection.size === 1) { + /** If user click other chip than the selected one, and */ + setSelection(newSelection) + } else { + setSelection(defaultSelection) + } + } + + const searchFilters: SearchFilters = { + search: searchValue, + page: currentPage, + pageSize: 12, + // tagIds need to be here for now, because BlogPost and InbaArticle fetchers filter by tagIds + tagIds: [], + } + + const searchRef = useRef(null) + + useEffect(() => { + searchRef.current?.scrollIntoView({ behavior: 'smooth' }) + }, [searchFilters.page, searchFilters.pageSize]) + + return ( + +
+ {t('searching')} +
+ + + + + {[defaultSearchOption, ...searchOptions].map((option) => { + return ( + + {option.displayNamePlural} + + ) + })} + + +
+ {getResultsCountById(selectedKey) > 0 ? ( + + {t('SearchPage.showingResults', { + count: getResultsCountById(selectedKey), + })} + + ) : null} + {selectedKey === defaultSearchOption.id ? ( +
+ {searchOptions.map((option) => { + return ( + + ) + })} +
+ ) : ( + + )} +
+
+ ) +} + +export default SearchPageContentNew diff --git a/next/messages/en.json b/next/messages/en.json index d0114198e..e4473f702 100644 --- a/next/messages/en.json +++ b/next/messages/en.json @@ -252,5 +252,24 @@ "articleFilter": "Article filter", "allArticles": "All articles", "subcategories": "Subcategories" + }, + "SearchPage": { + "whatAreYouLookingFor": "What are you looking for?", + "searchOptions": "Search options", + "showingResults": "Showing {count} {count, plural, one {result} other {results}}", + "allResults": "All results", + "moreResults": "More results", + "noResults": "No results found", + "enterSearchQuery": "Enter a search query", + "article": "Article", + "articles": "Articles", + "inbaArticle": "in.ba article", + "inbaArticles": "in.ba articles", + "page": "Page", + "pages": "Pages", + "contact": "Contact", + "contacts": "Contacts", + "document": "Document", + "documents": "Documents" } } diff --git a/next/messages/sk.json b/next/messages/sk.json index 70b9b6525..b63731cad 100644 --- a/next/messages/sk.json +++ b/next/messages/sk.json @@ -252,5 +252,24 @@ "articleFilter": "Filter článkov", "allArticles": "Všetky články", "subcategories": "Podkategórie" + }, + "SearchPage": { + "whatAreYouLookingFor": "Čo hľadáte?", + "searchOptions": "Možnosti vyhľadávania", + "showingResults": "Zobrazujeme {count} {count, plural, one {výsledok} few {výsledky} other {výsledkov}}", + "allResults": "Všetky výsledky", + "moreResults": "Viac výsledkov", + "noResults": "Žiadne výsledky", + "enterSearchQuery": "Zadajte hľadaný výraz", + "article": "Článok", + "articles": "Články", + "inbaArticle": "in.ba článok", + "inbaArticles": "in.ba články", + "page": "Stránka", + "pages": "Stránky", + "contact": "Kontakt", + "contacts": "Kontakty", + "document": "Dokument", + "documents": "Dokumenty" } } diff --git a/next/pages/vyhladavanie.tsx b/next/pages/vyhladavanie.tsx index 73903e55d..770db2a1c 100644 --- a/next/pages/vyhladavanie.tsx +++ b/next/pages/vyhladavanie.tsx @@ -1,6 +1,8 @@ import { GeneralQuery } from '@backend/graphql' import { client } from '@backend/graphql/gql' +import PageHeader from '@bratislava/ui-bratislava/PageHeader/PageHeader' import SearchPageContent from '@components/pages/searchPageContent' +import SearchPageContentNew from '@components/pages/searchPageContentNew' import { LocalizationsProvider } from '@components/providers/LocalizationsProvider' import { GeneralContextProvider } from '@utils/generalContext' import { useTitle } from '@utils/useTitle' @@ -46,7 +48,8 @@ const Page = ({ general }: PageProps) => { {title} - + + diff --git a/next/tailwind.config.js b/next/tailwind.config.js index 9f4d78d0f..ab464e9db 100644 --- a/next/tailwind.config.js +++ b/next/tailwind.config.js @@ -7,6 +7,23 @@ const customVariants = plugin(function ({ addVariant }) { addVariant('not-first', '&:not(:first-child)') }) +/** + * This plugin remove X button and decorations in native search input. + * https://github.com/tailwindlabs/tailwindcss/discussions/10190#discussioncomment-4994363 + * + * Similar styles are used also in RAC example styling https://react-spectrum.adobe.com/react-aria/SearchField.html#example + * + * @type {{handler: PluginCreator, config?: Partial}} + */ +const removeNativeSearchInputStyling = plugin(function ({ addBase }) { + addBase({ + '[type="search"]::-webkit-search-decoration': { display: 'none' }, + '[type="search"]::-webkit-search-cancel-button': { display: 'none' }, + '[type="search"]::-webkit-search-results-button': { display: 'none' }, + '[type="search"]::-webkit-search-results-decoration': { display: 'none' }, + }) +}) + const toRem = (px) => `${px / 16}rem` const getFontSize = (size) => [toRem(size[0]), toRem(size[1])] @@ -17,6 +34,7 @@ module.exports = { ], plugins: [ customVariants, + removeNativeSearchInputStyling, require('tailwind-scrollbar-hide'), require('tailwindcss-react-aria-components'), require('tailwindcss-animate'), diff --git a/next/utils/colors.tsx b/next/utils/colors.tsx index 98a54f243..0d07162d0 100644 --- a/next/utils/colors.tsx +++ b/next/utils/colors.tsx @@ -8,6 +8,7 @@ export type ColorCategory = | 'social' | 'education' | 'culture' + | 'gray' const colorCategoryMap = { red: 'main', diff --git a/next/utils/pageIcons.ts b/next/utils/pageIcons.ts new file mode 100644 index 000000000..ddd5e1159 --- /dev/null +++ b/next/utils/pageIcons.ts @@ -0,0 +1,26 @@ +import PageBlueIcon from '@assets/images/page-blue-icon.svg' +import PageBlueIconSmall from '@assets/images/page-blue-icon-small.svg' +import PageBrownIcon from '@assets/images/page-brown-icon.svg' +import PageBrownIconSmall from '@assets/images/page-brown-icon-small.svg' +import PageGreenIcon from '@assets/images/page-green-icon.svg' +import PageGreenIconSmall from '@assets/images/page-green-icon-small.svg' +import PagePurpleIcon from '@assets/images/page-purple-icon.svg' +import PagePurpleIconSmall from '@assets/images/page-purple-icon-small.svg' +import PageRedIcon from '@assets/images/page-red-icon.svg' +import PageRedIconSmall from '@assets/images/page-red-icon-small.svg' +import PageYellowIcon from '@assets/images/page-yellow-icon.svg' +import PageYellowIconSmall from '@assets/images/page-yellow-icon-small.svg' +import { Enum_Pagecategory_Color } from '@backend/graphql' + +export const findIconByPageColor = (pageColor: Enum_Pagecategory_Color) => { + const icons = { + red: { default: PageRedIcon, small: PageRedIconSmall }, + blue: { default: PageBlueIcon, small: PageBlueIconSmall }, + green: { default: PageGreenIcon, small: PageGreenIconSmall }, + yellow: { default: PageYellowIcon, small: PageYellowIconSmall }, + purple: { default: PagePurpleIcon, small: PagePurpleIconSmall }, + brown: { default: PageBrownIcon, small: PageBrownIconSmall }, + } + + return icons[pageColor] ?? icons.red +}