web/src/routes/CategoriesPage.tsx
import { DateTime } from 'luxon'
import React, { ReactElement, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { Link, Navigate, useParams } from 'react-router-dom'
import { CATEGORIES_ROUTE, cityContentPath } from 'shared'
import {
CategoriesMapModel,
CategoryModel,
createCategoryChildrenEndpoint,
createCategoryParentsEndpoint,
NotFoundError,
ResponseError,
useLoadAsync,
useLoadFromEndpoint,
} from 'shared/api'
import { CityRouteProps } from '../CityContentSwitcher'
import Breadcrumbs from '../components/Breadcrumbs'
import CategoriesContent from '../components/CategoriesContent'
import CategoriesToolbar from '../components/CategoriesToolbar'
import CityContentLayout, { CityContentLayoutProps } from '../components/CityContentLayout'
import FailureSwitcher from '../components/FailureSwitcher'
import Helmet from '../components/Helmet'
import LoadingSpinner from '../components/LoadingSpinner'
import buildConfig from '../constants/buildConfig'
import { cmsApiBaseUrl } from '../constants/urls'
import usePreviousProp from '../hooks/usePreviousProp'
import BreadcrumbModel from '../models/BreadcrumbModel'
const CATEGORY_NOT_FOUND_STATUS_CODE = 400
const getBreadcrumb = (category: CategoryModel, cityName: string) => {
const title = category.isRoot() ? cityName : category.title
return new BreadcrumbModel({
title,
pathname: category.path,
node: (
<Link to={category.path} key={category.path}>
{title}
</Link>
),
})
}
const CategoriesPage = ({ city, pathname, cityCode, languageCode }: CityRouteProps): ReactElement | null => {
const previousPathname = usePreviousProp({ prop: pathname })
const categoryId = useParams()['*']
const { t } = useTranslation('layout')
const {
data: categories,
loading: categoriesLoading,
error: categoriesError,
} = useLoadFromEndpoint(createCategoryChildrenEndpoint, cmsApiBaseUrl, {
city: cityCode,
language: languageCode,
// We show tiles for the root category so only first level children are needed
depth: categoryId ? 2 : 1,
cityContentPath: pathname,
})
const requestParents = useCallback(async () => {
if (!categoryId) {
// The endpoint does not work for the root category, just return an empty array
return []
}
const { data } = await createCategoryParentsEndpoint(cmsApiBaseUrl).request({
city: cityCode,
language: languageCode,
cityContentPath: pathname,
})
if (!data) {
throw new Error('Data missing!')
}
return data
}, [cityCode, languageCode, pathname, categoryId])
const { data: parents, loading: parentsLoading, error: parentsError } = useLoadAsync(requestParents)
if (!city) {
return null
}
if (!categoryId && categories) {
// The root category is not delivered via our endpoints
categories.push(
new CategoryModel({
root: true,
path: pathname,
title: city.name,
parentPath: '',
content: '',
thumbnail: '',
order: -1,
availableLanguages: {},
lastUpdate: DateTime.fromMillis(0),
organization: null,
embeddedOffers: [],
}),
)
}
const category = categories?.find(it => it.path === pathname)
const languageChangePaths = city.languages.map(({ code, name }) => {
const isCurrentLanguage = code === languageCode
const path = category?.isRoot()
? cityContentPath({ cityCode, languageCode: code })
: category?.availableLanguages[code] || null
return {
path: isCurrentLanguage ? pathname : path,
name,
code,
}
})
const pageTitle = `${category && !category.isRoot() ? category.title : t('dashboard:localInformation')} - ${
city.name
}`
const locationLayoutParams: Omit<CityContentLayoutProps, 'isLoading'> = {
city,
languageChangePaths,
route: CATEGORIES_ROUTE,
languageCode,
Toolbar: (
<CategoriesToolbar category={category} cityCode={cityCode} languageCode={languageCode} pageTitle={pageTitle} />
),
}
if (categoriesLoading || parentsLoading || pathname !== previousPathname) {
return (
<CityContentLayout isLoading {...locationLayoutParams}>
<LoadingSpinner />
</CityContentLayout>
)
}
if (!category || !parents || !categories) {
// This adds support for the old paths of categories by redirecting to the new path
// The children endpoint always returns the category with the new path at the first position in the response
const newSlugCategory = categories?.[0]
if (newSlugCategory) {
return <Navigate to={newSlugCategory.path} replace />
}
const notFoundError = new NotFoundError({ type: 'category', id: pathname, city: cityCode, language: languageCode })
const error =
// The cms returns a 400 BAD REQUEST if the path is not a valid categories path
categoriesError instanceof ResponseError && categoriesError.response.status === CATEGORY_NOT_FOUND_STATUS_CODE
? notFoundError
: categoriesError || parentsError || notFoundError
return (
<CityContentLayout isLoading={false} {...locationLayoutParams}>
<FailureSwitcher error={error} />
</CityContentLayout>
)
}
const ancestorBreadcrumbs = parents
.sort((a, b) => a.parentPath.length - b.parentPath.length)
.map((categoryModel: CategoryModel) => getBreadcrumb(categoryModel, city.name))
const metaDescription = t('categories:metaDescription', { appName: buildConfig().appName })
return (
<CityContentLayout isLoading={false} {...locationLayoutParams}>
<Helmet
pageTitle={pageTitle}
metaDescription={metaDescription}
languageChangePaths={languageChangePaths}
cityModel={city}
/>
<Breadcrumbs ancestorBreadcrumbs={ancestorBreadcrumbs} currentBreadcrumb={getBreadcrumb(category, city.name)} />
<CategoriesContent
city={city}
cityCode={cityCode}
pathname={pathname}
languageCode={languageCode}
categories={new CategoriesMapModel(categories)}
categoryModel={category}
/>
</CityContentLayout>
)
}
export default CategoriesPage