diff --git a/package.json b/package.json index 3e6f1a63..adc6ca2d 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "dependencies": { "@internationalized/date": "^3.6.0", "@nextui-org/accordion": "^2.0.40", + "@nextui-org/alert": "^2.2.7", "@nextui-org/button": "^2.0.38", "@nextui-org/card": "^2.0.34", "@nextui-org/chip": "^2.0.33", @@ -37,7 +38,7 @@ "@nextui-org/switch": "^2.0.34", "@nextui-org/system": "2.2.6", "@nextui-org/table": "^2.0.40", - "@nextui-org/theme": "2.2.11", + "@nextui-org/theme": "^2.3.0", "@nextui-org/tooltip": "^2.0.41", "@react-aria/ssr": "3.9.4", "@react-aria/visually-hidden": "3.8.12", diff --git a/src/app/comparison-portal/layout.tsx b/src/app/comparison-portal/layout.tsx new file mode 100644 index 00000000..7443535c --- /dev/null +++ b/src/app/comparison-portal/layout.tsx @@ -0,0 +1,20 @@ +import { Metadata } from 'next'; + +import HungerMapChatbot from '@/components/Chatbot/Chatbot'; +import { Topbar } from '@/components/Topbar/Topbar'; + +export const metadata: Metadata = { + title: 'Comparison Portal', +}; + +export default function Layout({ children }: { children: React.ReactNode }) { + return ( +
+
+ + +
+
{children}
+
+ ); +} diff --git a/src/app/comparison-portal/page.tsx b/src/app/comparison-portal/page.tsx new file mode 100644 index 00000000..00d5e41b --- /dev/null +++ b/src/app/comparison-portal/page.tsx @@ -0,0 +1,28 @@ +import { Suspense } from 'react'; + +import ComparisonAccordionSkeleton from '@/components/ComparisonPortal/ComparisonAccordionSkeleton'; +import CountryComparison from '@/components/ComparisonPortal/CountryComparison'; +import CountrySelectionSkeleton from '@/components/ComparisonPortal/CountrySelectSkeleton'; +import container from '@/container'; +import { GlobalDataRepository } from '@/domain/repositories/GlobalDataRepository'; + +export default async function ComparisonPortal() { + const globalRepo = container.resolve('GlobalDataRepository'); + const countryMapData = await globalRepo.getMapDataForCountries(); + const globalFcsData = await globalRepo.getFcsData(); + return ( +
+

Comparison Portal

+ + + + + } + > + + +
+ ); +} diff --git a/src/components/Charts/CategoricalChart.tsx b/src/components/Charts/CategoricalChart.tsx index 24fe166a..aa76e876 100644 --- a/src/components/Charts/CategoricalChart.tsx +++ b/src/components/Charts/CategoricalChart.tsx @@ -49,7 +49,7 @@ export function CategoricalChart({ // handling the bar and pie chart switch and the theme switch; useEffect(() => { setChartOptions(CategoricalChartOperations.getHighChartOptions(data, showPieChart)); - }, [showPieChart, theme]); + }, [showPieChart, theme, data]); const alternativeSwitchButtonProps = disablePieChartSwitch ? undefined diff --git a/src/components/Charts/LineChart.tsx b/src/components/Charts/LineChart.tsx index 52891746..b8f31153 100644 --- a/src/components/Charts/LineChart.tsx +++ b/src/components/Charts/LineChart.tsx @@ -2,7 +2,7 @@ import Highcharts from 'highcharts'; import { useTheme } from 'next-themes'; -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { ChartContainer } from '@/components/Charts/helpers/ChartContainer'; import { LineChartData } from '@/domain/entities/charts/LineChartData'; @@ -54,9 +54,15 @@ export function LineChart({ const lineChartOptions: Highcharts.Options = LineChartOperations.getHighChartOptions(lineChartData); // the `selectedXAxisRange` saves the to be rendered x-axis range of the chart - // can be changed using the `LinkeChartXAxisSlider` if the param `xAxisSlider==true` - const xAxisLength: number = LineChartOperations.getDistinctXAxisValues(lineChartData).length; - const [selectedXAxisRange, setSelectedXAxisRange] = useState([0, xAxisLength - 1]); + // can be changed using the `LineChartXAxisSlider` if the param `xAxisSlider==true` + const [selectedXAxisRange, setSelectedXAxisRange] = useState([0, 0]); + const xAxisLength = useMemo(() => { + return LineChartOperations.getDistinctXAxisValues(lineChartData).length; + }, [lineChartData]); + + useEffect(() => { + setSelectedXAxisRange([0, xAxisLength - 1]); + }, [xAxisLength]); // controlling if a line or bar chart is rendered; line chart is the default const [showBarChart, setShowBarChart] = useState(false); @@ -75,7 +81,7 @@ export function LineChart({ LineChartOperations.getHighChartOptions(lineChartData, selectedXAxisRange[0], selectedXAxisRange[1]) ); } - }, [showBarChart, theme, selectedXAxisRange]); + }, [showBarChart, theme, selectedXAxisRange, lineChartData]); // chart slider props - to manipulate the shown x-axis range const sliderProps = disableXAxisSlider diff --git a/src/components/ComparisonPortal/ComparisonAccordionSkeleton.tsx b/src/components/ComparisonPortal/ComparisonAccordionSkeleton.tsx new file mode 100644 index 00000000..0e370ade --- /dev/null +++ b/src/components/ComparisonPortal/ComparisonAccordionSkeleton.tsx @@ -0,0 +1,23 @@ +import { Skeleton } from '@nextui-org/skeleton'; +import React from 'react'; +import { v4 as uuid } from 'uuid'; + +export default function ComparisonAccordionSkeleton() { + const N_ITEMS = 5; + return ( +
+
+ {[...Array(N_ITEMS)].map(() => ( +
+ +
+ +
+ ))} +
+
+ ); +} diff --git a/src/components/ComparisonPortal/CountryComparison.tsx b/src/components/ComparisonPortal/CountryComparison.tsx new file mode 100644 index 00000000..af1dd857 --- /dev/null +++ b/src/components/ComparisonPortal/CountryComparison.tsx @@ -0,0 +1,31 @@ +'use client'; + +import { useState } from 'react'; + +import { useSelectedCountries } from '@/domain/hooks/queryParamsHooks.ts'; +import CountryComparisonProps from '@/domain/props/CountryComparisonProps'; + +import CountryComparisonAccordion from './CountryComparisonAccordion'; +import CountrySelection from './CountrySelection'; + +export default function CountryComparison({ countryMapData, globalFcsData }: CountryComparisonProps) { + const [selectedCountries, setSelectedCountries] = useSelectedCountries(countryMapData); + const [disabledCountryIds, setDisabledCountryIds] = useState([]); + + return ( +
+ + +
+ ); +} diff --git a/src/components/ComparisonPortal/CountryComparisonAccordion.tsx b/src/components/ComparisonPortal/CountryComparisonAccordion.tsx new file mode 100644 index 00000000..c3438fdc --- /dev/null +++ b/src/components/ComparisonPortal/CountryComparisonAccordion.tsx @@ -0,0 +1,74 @@ +'use client'; + +import { useMemo } from 'react'; + +import ComparisonAccordionSkeleton from '@/components/ComparisonPortal/ComparisonAccordionSkeleton'; +import { useSnackbar } from '@/domain/contexts/SnackbarContext'; +import { useCountryDataListQuery, useCountryIso3DataListQuery } from '@/domain/hooks/countryHooks'; +import CountryComparisonAccordionProps from '@/domain/props/CountryComparisonAccordionProps'; +import { CountryComparisonOperations } from '@/operations/comparison-portal/CountryComparisonOperations'; + +import AccordionContainer from '../Accordions/AccordionContainer'; + +export default function CountryComparisonAccordion({ + selectedCountries, + setSelectedCountries, + setDisabledCountryIds, +}: CountryComparisonAccordionProps) { + const { showSnackBar } = useSnackbar(); + + const countryDataQuery = useCountryDataListQuery( + // selected country ids + selectedCountries?.map((country) => country.properties.adm0_id), + // callback to show snackbar if data not found + (invalidCountryId) => { + if (!selectedCountries) return; + const invalidCountryName = CountryComparisonOperations.getCountryNameById(invalidCountryId, selectedCountries); + CountryComparisonOperations.showDataNotFoundSnackBar(showSnackBar, invalidCountryName); + setSelectedCountries(selectedCountries.filter((country) => country.properties.adm0_id !== invalidCountryId)); + setDisabledCountryIds((prevDisabledCountryIds) => [...prevDisabledCountryIds, invalidCountryId.toString()]); + } + ); + + const countryIso3DataQuery = useCountryIso3DataListQuery( + // selected country iso3 codes + selectedCountries?.map((country) => country.properties.iso3), + // callback to show snackbar if data not found + (countryCode) => { + if (!selectedCountries) return; + const countryName = CountryComparisonOperations.getCountryNameByIso3(countryCode, selectedCountries); + CountryComparisonOperations.showDataNotFoundSnackBar(showSnackBar, countryName); + } + ); + + const isLoading = useMemo(() => { + return ( + countryDataQuery.some((result) => result.isLoading) || countryIso3DataQuery.some((result) => result.isLoading) + ); + }, [countryDataQuery, countryIso3DataQuery]); + + const { countryDataList, countryIso3DataList } = useMemo( + () => CountryComparisonOperations.getFilteredCountryData(countryDataQuery, countryIso3DataQuery), + [countryDataQuery, countryIso3DataQuery] + ); + + const accordionItems = useMemo(() => { + if (!selectedCountries) return undefined; + const chartData = CountryComparisonOperations.getChartData(countryDataList, countryIso3DataList, selectedCountries); + const selectedCountryNames = selectedCountries.map((country) => country.properties.adm0_name); + return CountryComparisonOperations.getComparisonAccordionItems(chartData, selectedCountryNames, isLoading); + }, [countryDataList, countryIso3DataList, selectedCountries]); + + if (!accordionItems || (countryDataList.length < 2 && isLoading)) return ; + + if (countryDataList.length < 2) { + return ( +

+ Select {countryDataList.length === 1 ? 'one additional country' : 'two or more countries'} to start a + comparison. +

+ ); + } + + return ; +} diff --git a/src/components/ComparisonPortal/CountrySelectSkeleton.tsx b/src/components/ComparisonPortal/CountrySelectSkeleton.tsx new file mode 100644 index 00000000..cd46c64b --- /dev/null +++ b/src/components/ComparisonPortal/CountrySelectSkeleton.tsx @@ -0,0 +1,16 @@ +import { Skeleton } from '@nextui-org/skeleton'; +import React from 'react'; + +export default function CountrySelectionSkeleton() { + return ( +
+
+
+ +
+ +
+
+
+ ); +} diff --git a/src/components/ComparisonPortal/CountrySelection.tsx b/src/components/ComparisonPortal/CountrySelection.tsx new file mode 100644 index 00000000..ea3f558b --- /dev/null +++ b/src/components/ComparisonPortal/CountrySelection.tsx @@ -0,0 +1,80 @@ +'use client'; + +import { Select, SelectItem } from '@nextui-org/react'; +import { useMemo } from 'react'; + +import { CountrySelectionProps } from '@/domain/props/CountrySelectionProps'; +import { CountrySelectionOperations } from '@/operations/comparison-portal/CountrySelectionOperations'; +import FcsChoroplethOperations from '@/operations/map/FcsChoroplethOperations'; + +import { CustomButton } from '../Buttons/CustomButton'; + +export default function CountrySelection({ + countryMapData, + globalFcsData, + selectedCountries, + setSelectedCountries, + disabledCountryIds, +}: CountrySelectionProps) { + const selectedKeys = useMemo( + () => selectedCountries?.map((country) => country.properties.adm0_id.toString()), + [selectedCountries] + ); + + const availableCountries = useMemo(() => { + return countryMapData.features.filter((country) => FcsChoroplethOperations.checkIfActive(country, globalFcsData)); + }, [countryMapData, globalFcsData]); + + const disabledKeys = useMemo(() => { + if (!selectedCountries) return []; + return availableCountries + .filter( + (country) => + // if there are already 5 selected countries, disable the rest + selectedCountries.length >= 5 && + !selectedCountries.find( + (selectedCountry) => selectedCountry.properties.adm0_id === country.properties.adm0_id + ) + ) + .map((country) => country.properties.adm0_id.toString()) + .concat(disabledCountryIds); + }, [selectedCountries, availableCountries, disabledCountryIds]); + + return ( +
+ + setSelectedCountries([])} + isDisabled={selectedCountries === undefined || selectedCountries.length === 0} + > + Clear + +
+ ); +} diff --git a/src/components/ComparisonPortal/NoDataHint.tsx b/src/components/ComparisonPortal/NoDataHint.tsx new file mode 100644 index 00000000..23e22676 --- /dev/null +++ b/src/components/ComparisonPortal/NoDataHint.tsx @@ -0,0 +1,39 @@ +import { Alert } from '@nextui-org/alert'; +import { useEffect, useState } from 'react'; + +import { isLineChartData } from '@/domain/entities/charts/LineChartData'; +import { NoDataHintProps } from '@/domain/props/NoDataHintProps.ts'; + +export default function NoDataHint({ chartData, selectedCountryNames, isLoading }: NoDataHintProps) { + const [formattedMissingCountryNames, setFormattedMissingCountryNames] = useState(null); + + useEffect(() => { + if (isLoading) return; + + const countryNamesInChart = isLineChartData(chartData) + ? chartData.lines.map((line) => line.name) + : chartData.categories.map((category) => category.name); + const missingCountryNames = selectedCountryNames.filter( + (countryName) => !countryNamesInChart.includes(countryName) + ); + switch (missingCountryNames.length) { + case 0: + setFormattedMissingCountryNames(null); + break; + case 1: + setFormattedMissingCountryNames(missingCountryNames[0]); + break; + default: + setFormattedMissingCountryNames( + `${missingCountryNames.slice(0, -1).join(', ')} and ${missingCountryNames.slice(-1)}` + ); + } + }, [isLoading, chartData, selectedCountryNames]); + + return formattedMissingCountryNames ? ( + + ) : null; +} diff --git a/src/domain/entities/ApiError.ts b/src/domain/entities/ApiError.ts new file mode 100644 index 00000000..27becb03 --- /dev/null +++ b/src/domain/entities/ApiError.ts @@ -0,0 +1,5 @@ +export type ApiError = [{ Error: string }, number]; + +export function isApiError(error: unknown): error is ApiError { + return error instanceof Array && error.length === 2 && typeof error[1] === 'number'; +} diff --git a/src/domain/entities/charts/InflationGraphs.ts b/src/domain/entities/charts/InflationGraphs.ts index 41c37e51..23123ace 100644 --- a/src/domain/entities/charts/InflationGraphs.ts +++ b/src/domain/entities/charts/InflationGraphs.ts @@ -5,9 +5,9 @@ import { ChartData } from '../common/ChartData'; export interface InflationGraphs { type: LineChartDataType.INFLATION_CHARTS; headline: { - data: ChartData[]; + data: ChartData[] | undefined; }; food: { - data: ChartData[]; + data: ChartData[] | undefined; }; } diff --git a/src/domain/entities/charts/LineChartData.ts b/src/domain/entities/charts/LineChartData.ts index 5066718b..a2f55b6d 100644 --- a/src/domain/entities/charts/LineChartData.ts +++ b/src/domain/entities/charts/LineChartData.ts @@ -51,3 +51,7 @@ export interface LineChartData { verticalLines?: ChartVerticalLine[]; verticalBands?: ChartVerticalBand[]; } + +export function isLineChartData(data: unknown): data is LineChartData { + return (data as LineChartData).type === LineChartDataType.LINE_CHART_DATA; +} diff --git a/src/domain/entities/charts/RcsiChartData.ts b/src/domain/entities/charts/RcsiChartData.ts index 891aa5a5..dfa69941 100644 --- a/src/domain/entities/charts/RcsiChartData.ts +++ b/src/domain/entities/charts/RcsiChartData.ts @@ -1,6 +1,6 @@ export interface RcsiChartData { x: string; - rcsi: number; - rcsiHigh: number; - rcsiLow: number; + rcsi: number | null; + rcsiHigh: number | null; + rcsiLow: number | null; } diff --git a/src/domain/entities/comparison/CountryComparisonChartdata.ts b/src/domain/entities/comparison/CountryComparisonChartdata.ts new file mode 100644 index 00000000..80650fd3 --- /dev/null +++ b/src/domain/entities/comparison/CountryComparisonChartdata.ts @@ -0,0 +1,13 @@ +import { CategoricalChartData } from '../charts/CategoricalChartData'; +import { LineChartData } from '../charts/LineChartData'; + +export interface CountryComparisonChartData { + fcsChartData?: LineChartData; + rcsiChartData?: LineChartData; + foodSecurityBarChartData?: CategoricalChartData; + populationBarChartData?: CategoricalChartData; + importDependencyBarChartData?: CategoricalChartData; + balanceOfTradeData?: LineChartData; + headlineInflationData?: LineChartData; + foodInflationData?: LineChartData; +} diff --git a/src/domain/entities/comparison/CountryComparisonData.ts b/src/domain/entities/comparison/CountryComparisonData.ts new file mode 100644 index 00000000..0c8146c0 --- /dev/null +++ b/src/domain/entities/comparison/CountryComparisonData.ts @@ -0,0 +1,7 @@ +import { CountryDataRecord } from '../country/CountryData'; +import { CountryIso3DataRecord } from '../country/CountryIso3Data'; + +export interface CountryComparisonData { + countryDataList: CountryDataRecord[]; + countryIso3DataList: CountryIso3DataRecord[]; +} diff --git a/src/domain/entities/country/CountryData.ts b/src/domain/entities/country/CountryData.ts index 451399c3..86d35ed4 100644 --- a/src/domain/entities/country/CountryData.ts +++ b/src/domain/entities/country/CountryData.ts @@ -5,9 +5,11 @@ export interface CountryData { fcs: number; fcsMinus1: number; fcsMinus3: number; - importDependency: number; + importDependency: number | null; population: number; populationSource: string; rcsiGraph: RcsiChartData[]; fcsGraph: FcsChartData[]; } + +export type CountryDataRecord = CountryData & { id: number }; diff --git a/src/domain/entities/country/CountryIso3Data.ts b/src/domain/entities/country/CountryIso3Data.ts index cc4cdd1b..39bf31f9 100644 --- a/src/domain/entities/country/CountryIso3Data.ts +++ b/src/domain/entities/country/CountryIso3Data.ts @@ -17,3 +17,5 @@ export interface CountryIso3Data { balanceOfTradeGraph: BalanceOfTradeGraph; inflationGraphs: InflationGraphs; } + +export type CountryIso3DataRecord = CountryIso3Data & { id: string }; diff --git a/src/domain/hooks/countryHooks.ts b/src/domain/hooks/countryHooks.ts index cde4a599..94e10aa3 100644 --- a/src/domain/hooks/countryHooks.ts +++ b/src/domain/hooks/countryHooks.ts @@ -1,11 +1,12 @@ -import { useQuery } from '@tanstack/react-query'; +import { useQueries, useQuery, UseQueryResult } from '@tanstack/react-query'; import { cachedQueryClient } from '@/config/queryClient'; import container from '@/container'; +import { isApiError } from '../entities/ApiError'; import { AdditionalCountryData } from '../entities/country/AdditionalCountryData'; -import { CountryData } from '../entities/country/CountryData'; -import { CountryIso3Data } from '../entities/country/CountryIso3Data'; +import { CountryData, CountryDataRecord } from '../entities/country/CountryData'; +import { CountryIso3Data, CountryIso3DataRecord } from '../entities/country/CountryIso3Data'; import { RegionIpc } from '../entities/region/RegionIpc'; import CountryRepository from '../repositories/CountryRepository'; @@ -20,6 +21,34 @@ export const useCountryDataQuery = (countryId: number) => cachedQueryClient ); +/** + * Fetches country data for a list of country IDs. + * If the data is not found for a country, the `onCountryDataNotFound` callback is called with the country ID. + * @param countryIds List of country IDs to fetch data for. + * @param onCountryDataNotFound Callback called when the data is not found for a country. + * @returns An array of query results for each country ID. The query result is `null` if the data is not found. + */ +export const useCountryDataListQuery = ( + countryIds: number[] | undefined, + onCountryDataNotFound: (countryId: number) => void +) => + useQueries[]>( + { + queries: (countryIds ?? []).map((countryId) => ({ + queryKey: ['fetchCountryData', countryId], + queryFn: async () => { + const countryData = await countryRepo.getCountryData(countryId); + if (isApiError(countryData)) { + onCountryDataNotFound(countryId); + return null; + } + return { ...countryData, id: countryId }; + }, + })), + }, + cachedQueryClient + ); + export const useRegionDataQuery = (countryId: number) => useQuery( { @@ -38,6 +67,34 @@ export const useCountryIso3DataQuery = (countryCode: string) => cachedQueryClient ); +/** + * Fetches country ISO3 data for a list of country codes. + * If the data is not found for a country, the `onCountryDataNotFound` callback is called with the country code. + * @param countryCodes List of country codes to fetch data for. + * @param onCountryDataNotFound Callback called when the data is not found for a country. + * @returns An array of query results for each country code. The query result is `null` if the data is not found. + */ +export const useCountryIso3DataListQuery = ( + countryCodes: string[] | undefined, + onCountryDataNotFound: (countryCode: string) => void +) => + useQueries[]>( + { + queries: (countryCodes ?? []).map((countryCode) => ({ + queryKey: ['fetchCountryIso3Data', countryCode], + queryFn: async () => { + const countryIso3Data = await countryRepo.getCountryIso3Data(countryCode); + if (isApiError(countryIso3Data)) { + onCountryDataNotFound(countryCode); + return null; + } + return { ...countryIso3Data, id: countryCode }; + }, + })), + }, + cachedQueryClient + ); + export const useRegionIpcDataQuery = (countryId: number) => useQuery( { diff --git a/src/domain/hooks/queryParamsHooks.ts b/src/domain/hooks/queryParamsHooks.ts new file mode 100644 index 00000000..0e020f22 --- /dev/null +++ b/src/domain/hooks/queryParamsHooks.ts @@ -0,0 +1,33 @@ +import { usePathname, useRouter, useSearchParams } from 'next/navigation'; +import { useEffect, useState } from 'react'; + +import { CountryMapData, CountryMapDataWrapper } from '@/domain/entities/country/CountryMapData.ts'; + +// returns a state value that is synchronized with a query param +// assumes there is one that single query param, all others will be erased +export const useSelectedCountries = (countryMapData: CountryMapDataWrapper) => { + const PARAM_NAME = 'countries'; + + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + const [selectedCountries, setSelectedCountries] = useState(undefined); + + // get state values from query params + useEffect(() => { + const searchParamCountryCodes = searchParams.get(PARAM_NAME)?.split(',') ?? []; + const newSelectedCountries = countryMapData.features.filter((availableCountry) => + searchParamCountryCodes.includes(availableCountry.properties.adm0_id.toString()) + ); + setSelectedCountries(newSelectedCountries); + }, [searchParams]); + + // update state and query params with new value + const setSelectedCountriesFn = (newValue: CountryMapData[] | undefined) => { + setSelectedCountries(newValue); + const selectedCountryIds = newValue?.map((country) => country.properties.adm0_id) ?? []; + router.push(`${pathname}?${PARAM_NAME}=${selectedCountryIds.join(',')}`); + }; + + return [selectedCountries, setSelectedCountriesFn] as const; +}; diff --git a/src/domain/props/CountryComparisonAccordionProps.ts b/src/domain/props/CountryComparisonAccordionProps.ts new file mode 100644 index 00000000..b7f29aa9 --- /dev/null +++ b/src/domain/props/CountryComparisonAccordionProps.ts @@ -0,0 +1,9 @@ +import { Dispatch, SetStateAction } from 'react'; + +import { CountryMapData } from '../entities/country/CountryMapData'; + +export default interface CountryComparisonAccordionProps { + selectedCountries: CountryMapData[] | undefined; + setSelectedCountries: (newCountries: CountryMapData[]) => void; + setDisabledCountryIds: Dispatch>; +} diff --git a/src/domain/props/CountryComparisonProps.ts b/src/domain/props/CountryComparisonProps.ts new file mode 100644 index 00000000..0bc02ce6 --- /dev/null +++ b/src/domain/props/CountryComparisonProps.ts @@ -0,0 +1,7 @@ +import { GlobalFcsData } from '../entities/country/CountryFcsData'; +import { CountryMapDataWrapper } from '../entities/country/CountryMapData'; + +export default interface CountryComparisonProps { + countryMapData: CountryMapDataWrapper; + globalFcsData: GlobalFcsData; +} diff --git a/src/domain/props/CountrySelectionProps.ts b/src/domain/props/CountrySelectionProps.ts new file mode 100644 index 00000000..ab8920fd --- /dev/null +++ b/src/domain/props/CountrySelectionProps.ts @@ -0,0 +1,10 @@ +import { GlobalFcsData } from '../entities/country/CountryFcsData'; +import { CountryMapData, CountryMapDataWrapper } from '../entities/country/CountryMapData'; + +export interface CountrySelectionProps { + countryMapData: CountryMapDataWrapper; + globalFcsData: GlobalFcsData; + selectedCountries?: CountryMapData[]; + setSelectedCountries: (countries: CountryMapData[]) => void; + disabledCountryIds: string[]; +} diff --git a/src/domain/props/NoDataHintProps.ts b/src/domain/props/NoDataHintProps.ts new file mode 100644 index 00000000..30b2965f --- /dev/null +++ b/src/domain/props/NoDataHintProps.ts @@ -0,0 +1,9 @@ +import { LineChartData } from '@/domain/entities/charts/LineChartData.ts'; + +import { CategoricalChartData } from '../entities/charts/CategoricalChartData'; + +export interface NoDataHintProps { + chartData: LineChartData | CategoricalChartData; + selectedCountryNames: string[]; + isLoading: boolean; +} diff --git a/src/operations/charts/LineChartOperations.ts b/src/operations/charts/LineChartOperations.ts index ea2269ee..dcbfaa6e 100644 --- a/src/operations/charts/LineChartOperations.ts +++ b/src/operations/charts/LineChartOperations.ts @@ -135,15 +135,17 @@ export default class LineChartOperations { lines: [ { name: 'Headline Inflation', - dataPoints: data.headline.data.map((p) => { - return { x: new Date(p.x).getTime(), y: p.y }; - }), + dataPoints: + data.headline.data?.map((p) => { + return { x: new Date(p.x).getTime(), y: p.y }; + }) ?? [], }, { name: 'Food Inflation', - dataPoints: data.food.data.map((p) => { - return { x: new Date(p.x).getTime(), y: p.y }; - }), + dataPoints: + data.food.data?.map((p) => { + return { x: new Date(p.x).getTime(), y: p.y }; + }) ?? [], }, ], }; @@ -164,7 +166,7 @@ export default class LineChartOperations { public static getDistinctXAxisValues(data: LineChartData): number[] { const uniqueXValues = new Set(); data.lines.forEach((line) => { - line.dataPoints.forEach((point) => { + line.dataPoints?.forEach((point) => { uniqueXValues.add(point.x); // Add x-value to the Set }); }); @@ -223,7 +225,7 @@ export default class LineChartOperations { // collect series data const seriesData: Highcharts.PointOptionsObject[] = []; - lineData.dataPoints.forEach((p) => { + lineData.dataPoints?.forEach((p) => { // check if datapoint x is in selected x-axis range if (xAxisSelectedMin !== undefined && xAxisSelectedMax !== undefined) { if (p.x < xAxisSelectedMin || xAxisSelectedMax < p.x) return; @@ -277,7 +279,7 @@ export default class LineChartOperations { if (lineData.showRange) { // collect series area range data const areaSeriesData: Highcharts.PointOptionsObject[] = []; - lineData.dataPoints.forEach((p) => { + lineData.dataPoints?.forEach((p) => { // check if datapoint x is in selected x-axis range if (xAxisSelectedMin !== undefined && xAxisSelectedMax !== undefined) { if (p.x < xAxisSelectedMin || xAxisSelectedMax < p.x) return; diff --git a/src/operations/comparison-portal/CountryComparisonOperations.tsx b/src/operations/comparison-portal/CountryComparisonOperations.tsx new file mode 100644 index 00000000..9b54633b --- /dev/null +++ b/src/operations/comparison-portal/CountryComparisonOperations.tsx @@ -0,0 +1,362 @@ +import { Spacer } from '@nextui-org/react'; +import { UseQueryResult } from '@tanstack/react-query'; + +import { CategoricalChart } from '@/components/Charts/CategoricalChart'; +import { LineChart } from '@/components/Charts/LineChart'; +import NoDataHint from '@/components/ComparisonPortal/NoDataHint'; +import CustomInfoCircle from '@/components/CustomInfoCircle/CustomInfoCircle'; +import { AccordionItemProps } from '@/domain/entities/accordions/Accordions'; +import { CategoricalChartData } from '@/domain/entities/charts/CategoricalChartData'; +import { LineChartData } from '@/domain/entities/charts/LineChartData'; +import { ChartData } from '@/domain/entities/common/ChartData.ts'; +import { CountryComparisonChartData } from '@/domain/entities/comparison/CountryComparisonChartdata'; +import { CountryComparisonData } from '@/domain/entities/comparison/CountryComparisonData'; +import { CountryDataRecord } from '@/domain/entities/country/CountryData'; +import { CountryIso3DataRecord } from '@/domain/entities/country/CountryIso3Data.ts'; +import { CountryMapData } from '@/domain/entities/country/CountryMapData'; +import { SNACKBAR_SHORT_DURATION } from '@/domain/entities/snackbar/Snackbar'; +import { LineChartDataType } from '@/domain/enums/LineChartDataType'; +import { SnackbarPosition, SnackbarStatus } from '@/domain/enums/Snackbar'; +import { SnackbarProps } from '@/domain/props/SnackbarProps'; +import { FcsAccordionOperations } from '@/operations/map/FcsAccordionOperations'; +import { formatToMillion } from '@/utils/formatting.ts'; + +export class CountryComparisonOperations { + static getFcsChartData(countryDataList: CountryDataRecord[], countryMapData: CountryMapData[]): LineChartData { + return this.chartWithoutEmptyLines({ + type: LineChartDataType.LINE_CHART_DATA, + xAxisType: 'datetime', + yAxisLabel: 'Mill', + lines: countryDataList.map((countryData) => ({ + name: this.getCountryNameById(countryData.id, countryMapData), + showRange: true, + dataPoints: countryData.fcsGraph.map((fcsChartData) => ({ + x: new Date(fcsChartData.x).getTime(), + y: formatToMillion(fcsChartData.fcs), + yRangeMin: formatToMillion(fcsChartData.fcsLow), + yRangeMax: formatToMillion(fcsChartData.fcsHigh), + })), + })), + }); + } + + static getRcsiChartData(countryDataList: CountryDataRecord[], countryMapData: CountryMapData[]): LineChartData { + return this.chartWithoutEmptyLines({ + type: LineChartDataType.LINE_CHART_DATA, + xAxisType: 'datetime', + yAxisLabel: 'Mill', + lines: countryDataList.map((countryData) => ({ + name: this.getCountryNameById(countryData.id, countryMapData), + showRange: true, + dataPoints: countryData.rcsiGraph + .filter((rcsiChartData) => rcsiChartData.rcsi !== null) + .map((rcsiChartData) => ({ + x: new Date(rcsiChartData.x).getTime(), + y: formatToMillion(rcsiChartData.rcsi), + yRangeMin: formatToMillion(rcsiChartData.rcsiLow), + yRangeMax: formatToMillion(rcsiChartData.rcsiHigh), + })), + })), + }); + } + + static getPopulationBarChartData( + countryDataList: CountryDataRecord[], + countryMapData: CountryMapData[] + ): CategoricalChartData { + return { + yAxisLabel: 'Mill', + categories: countryDataList.map((countryData) => ({ + name: this.getCountryNameById(countryData.id, countryMapData), + dataPoint: { + y: countryData.population, + }, + })), + }; + } + + static getFoodSecurityBarChartData( + countryDataList: CountryDataRecord[], + countryMapData: CountryMapData[] + ): CategoricalChartData { + return { + yAxisLabel: 'Mill', + categories: countryDataList.map((countryData) => ({ + name: this.getCountryNameById(countryData.id, countryMapData), + dataPoint: { + y: countryData.fcs, + }, + })), + }; + } + + static getImportDependencyBarChartData( + countryDataList: CountryDataRecord[], + selectedCountries: CountryMapData[] + ): CategoricalChartData { + return { + yAxisLabel: '% of Cereals', + categories: countryDataList + .filter((countryData) => countryData.importDependency !== null) + .map((countryData) => ({ + name: this.getCountryNameById(countryData.id, selectedCountries), + dataPoint: { + y: countryData.importDependency!, + }, + })), + }; + } + + static getBalanceOfTradeData( + countryIso3DataList: CountryIso3DataRecord[], + selectedCountries: CountryMapData[] + ): LineChartData { + return this.chartWithoutEmptyLines({ + type: LineChartDataType.LINE_CHART_DATA, + xAxisType: 'datetime', + yAxisLabel: 'Mill', + lines: countryIso3DataList.map((countryIso3Data) => ({ + name: this.getCountryNameByIso3(countryIso3Data.id, selectedCountries), + dataPoints: countryIso3Data.balanceOfTradeGraph.data.map((p) => { + return { x: new Date(p.x).getTime(), y: formatToMillion(p.y) }; + }), + })), + }); + } + + static getInflationData( + countryIso3DataList: CountryIso3DataRecord[], + selectedCountries: CountryMapData[], + type: 'headline' | 'food' + ): LineChartData { + return this.chartWithoutEmptyLines({ + type: LineChartDataType.LINE_CHART_DATA, + xAxisType: 'datetime', + yAxisLabel: 'Rate in %', + lines: countryIso3DataList + .filter((countryIso3Data) => countryIso3Data.inflationGraphs[type].data !== undefined) + .map((countryIso3Data) => ({ + name: this.getCountryNameByIso3(countryIso3Data.id, selectedCountries), + dataPoints: (countryIso3Data.inflationGraphs[type].data as ChartData[]).map((p) => { + return { x: new Date(p.x).getTime(), y: p.y }; + }), + })), + }); + } + + static getCountryNameById(id: number, countryMapData: CountryMapData[]): string { + return countryMapData.find((country) => country.properties.adm0_id === id)?.properties.adm0_name || ''; + } + + static getCountryNameByIso3(iso3: string, countryMapData: CountryMapData[]): string { + return countryMapData.find((country) => country.properties.iso3 === iso3)?.properties.adm0_name || ''; + } + + static chartWithoutEmptyLines(chart: LineChartData): LineChartData { + return { + ...chart, + lines: chart.lines.filter((line) => line.dataPoints.length > 0), + }; + } + + static getChartData( + countryDataList: CountryDataRecord[], + countryIso3DataList: CountryIso3DataRecord[], + selectedCountries: CountryMapData[] + ): CountryComparisonChartData { + return { + fcsChartData: countryDataList.length > 1 ? this.getFcsChartData(countryDataList, selectedCountries) : undefined, + rcsiChartData: countryDataList.length > 1 ? this.getRcsiChartData(countryDataList, selectedCountries) : undefined, + foodSecurityBarChartData: + countryDataList.length > 1 ? this.getFoodSecurityBarChartData(countryDataList, selectedCountries) : undefined, + populationBarChartData: + countryDataList.length > 1 ? this.getPopulationBarChartData(countryDataList, selectedCountries) : undefined, + importDependencyBarChartData: + countryDataList.length > 1 + ? this.getImportDependencyBarChartData(countryDataList, selectedCountries) + : undefined, + balanceOfTradeData: + countryIso3DataList.length > 1 ? this.getBalanceOfTradeData(countryIso3DataList, selectedCountries) : undefined, + headlineInflationData: + countryIso3DataList.length > 1 + ? this.getInflationData(countryIso3DataList, selectedCountries, 'headline') + : undefined, + foodInflationData: + countryIso3DataList.length > 1 + ? this.getInflationData(countryIso3DataList, selectedCountries, 'food') + : undefined, + }; + } + + static getFilteredCountryData( + countryDataQuery: UseQueryResult[], + countryIso3DataQuery: UseQueryResult[] + ): CountryComparisonData { + const countryDataList: CountryDataRecord[] = countryDataQuery + .map((result) => result.data) + .filter((data): data is CountryDataRecord => data !== null && data !== undefined); + + const countryIso3DataList: CountryIso3DataRecord[] = countryIso3DataQuery + .map((result) => result.data) + .filter((data): data is CountryIso3DataRecord => data !== null && data !== undefined); + + return { countryDataList, countryIso3DataList }; + } + + static showDataNotFoundSnackBar(showSnackBar: (props: SnackbarProps) => void, countryName: string): void { + showSnackBar({ + message: `Error fetching country data for ${countryName}`, + status: SnackbarStatus.Error, + position: SnackbarPosition.BottomMiddle, + duration: SNACKBAR_SHORT_DURATION, + }); + } + + static getComparisonAccordionItems( + { + fcsChartData, + rcsiChartData, + foodSecurityBarChartData, + populationBarChartData, + importDependencyBarChartData, + balanceOfTradeData, + headlineInflationData, + foodInflationData, + }: CountryComparisonChartData, + selectedCountryNames: string[], + isLoading: boolean + ): AccordionItemProps[] { + return [ + { + title: 'Food Security', + content: ( +
+ {foodSecurityBarChartData && ( + + )} + {populationBarChartData && ( + + )} +
+ ), + }, + { + title: 'Food Security Trends', + content: ( +
+ {fcsChartData && ( + <> + + + + )} + + {rcsiChartData && ( + <> + + + + )} +
+ ), + }, + { + title: 'Macro-economic', + infoIcon: , + popoverInfo: FcsAccordionOperations.getMacroEconomicPopoverInfo(), + content: ( +
+ {importDependencyBarChartData && ( + <> + + + + )} +
+ ), + }, + { + title: 'Balance of Trade', + infoIcon: , + popoverInfo: FcsAccordionOperations.getBalanceOfTradePopoverInfo(), + content: ( +
+ {balanceOfTradeData && ( + <> + + + + )} +
+ ), + }, + { + title: 'Food and Headline Inflation', + infoIcon: , + popoverInfo: FcsAccordionOperations.getHeadlineAndFoodInflationPopoverInfo(), + content: ( +
+ {headlineInflationData && ( + <> + + + + )} + {foodInflationData && ( + <> + + + + )} +
+ ), + }, + ]; + } +} diff --git a/src/operations/comparison-portal/CountrySelectionOperations.ts b/src/operations/comparison-portal/CountrySelectionOperations.ts new file mode 100644 index 00000000..c22aa502 --- /dev/null +++ b/src/operations/comparison-portal/CountrySelectionOperations.ts @@ -0,0 +1,16 @@ +import { SharedSelection } from '@nextui-org/react'; + +import { CountryMapData, CountryMapDataWrapper } from '@/domain/entities/country/CountryMapData'; + +export class CountrySelectionOperations { + static onSelectionChange( + keys: SharedSelection, + setSelectedCountries: (countries: CountryMapData[]) => void, + countryMapData: CountryMapDataWrapper + ) { + const keySet = new Set(keys as string); + setSelectedCountries( + countryMapData.features.filter((country) => keySet.has(country.properties.adm0_id.toString())) + ); + } +} diff --git a/tailwind.config.js b/tailwind.config.js index 4f24a79b..bcf690ce 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -49,7 +49,7 @@ const config = { './src/components/**/*.{js,ts,jsx,tsx,mdx}', './src/app/**/*.{js,ts,jsx,tsx,mdx}', './src/domain/constant/dataSourceTables/dataSourceAccordionItems.tsx', - './src/operations/tables/*.tsx', + './src/operations/**/*.tsx', './node_modules/@nextui-org/theme/dist/**/*.{js,ts,jsx,tsx}', ], theme: { diff --git a/yarn.lock b/yarn.lock index 17133265..4cf49f0f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1452,6 +1452,18 @@ "@react-types/accordion" "3.0.0-alpha.21" "@react-types/shared" "3.23.1" +"@nextui-org/alert@^2.2.7": + version "2.2.7" + resolved "https://registry.yarnpkg.com/@nextui-org/alert/-/alert-2.2.7.tgz#fc776a7858adc1d6d63623d197c2aa32c7f1aa63" + integrity sha512-HOIroKCa3z6hE58lAgF+JS8vsNFblOXcuMJwB+92t19q+jkoDCgo7pkPVqi4Hm4MRY2GpUQp+iAGZklA2P4n4Q== + dependencies: + "@nextui-org/button" "2.2.7" + "@nextui-org/react-utils" "2.1.1" + "@nextui-org/shared-icons" "2.1.1" + "@nextui-org/shared-utils" "2.1.1" + "@react-aria/utils" "3.26.0" + "@react-stately/utils" "3.10.5" + "@nextui-org/aria-utils@2.0.26": version "2.0.26" resolved "https://registry.yarnpkg.com/@nextui-org/aria-utils/-/aria-utils-2.0.26.tgz#0113247f80bc558aea650c15e38ee0ab7c7ac9f8" @@ -1544,6 +1556,23 @@ "@react-types/button" "3.9.4" "@react-types/shared" "3.23.1" +"@nextui-org/button@2.2.7": + version "2.2.7" + resolved "https://registry.yarnpkg.com/@nextui-org/button/-/button-2.2.7.tgz#032cd556870a3361be8acdddfe0e995ab4719ac5" + integrity sha512-3o7uAD7nFAUKJkg2PZ5ktJR8qqMrjQgzewXBWfz62RWya/uTFXzqwljy0JYGwq3tfvbmBzA1GGsAZdj5wUEymw== + dependencies: + "@nextui-org/react-utils" "2.1.1" + "@nextui-org/ripple" "2.2.5" + "@nextui-org/shared-utils" "2.1.1" + "@nextui-org/spinner" "2.2.4" + "@nextui-org/use-aria-button" "2.2.3" + "@react-aria/button" "3.11.0" + "@react-aria/focus" "3.19.0" + "@react-aria/interactions" "3.22.5" + "@react-aria/utils" "3.26.0" + "@react-types/button" "3.10.1" + "@react-types/shared" "3.26.0" + "@nextui-org/calendar@2.0.12": version "2.0.12" resolved "https://registry.yarnpkg.com/@nextui-org/calendar/-/calendar-2.0.12.tgz#e3e5df719cd08b710a51602d279a25d737b3c5b2" @@ -1675,6 +1704,11 @@ "@nextui-org/system-rsc" "2.1.6" "@react-types/shared" "3.23.1" +"@nextui-org/dom-animation@2.1.1": + version "2.1.1" + resolved "https://registry.yarnpkg.com/@nextui-org/dom-animation/-/dom-animation-2.1.1.tgz#42611dc7aff1a6ca11ff3d7683ea754a35ec8577" + integrity sha512-xLrVNf1EV9zyyZjk6j3RptOvnga1WUCbMpDgJLQHp+oYwxTfBy0SkXHuN5pRdcR0XpR/IqRBDIobMdZI0iyQyg== + "@nextui-org/dropdown@2.1.31", "@nextui-org/dropdown@^2.1.31": version "2.1.31" resolved "https://registry.yarnpkg.com/@nextui-org/dropdown/-/dropdown-2.1.31.tgz#4606509b00101f9a04a420400837485fe0390081" @@ -1898,6 +1932,11 @@ resolved "https://registry.yarnpkg.com/@nextui-org/react-rsc-utils/-/react-rsc-utils-2.0.14.tgz#277523854e594858c0b713df783f2d5228915f83" integrity sha512-s0GVgDhScyx+d9FtXd8BXf049REyaPvWsO4RRr7JDHrk91NlQ11Mqxka9o+8g5NX0rphI0rbe3/b1Dz+iQRx3w== +"@nextui-org/react-rsc-utils@2.1.1": + version "2.1.1" + resolved "https://registry.yarnpkg.com/@nextui-org/react-rsc-utils/-/react-rsc-utils-2.1.1.tgz#82a545ea952fa0f98769bb446ed448cab292e5bb" + integrity sha512-9uKH1XkeomTGaswqlGKt0V0ooUev8mPXtKJolR+6MnpvBUrkqngw1gUGF0bq/EcCCkks2+VOHXZqFT6x9hGkQQ== + "@nextui-org/react-utils@2.0.17": version "2.0.17" resolved "https://registry.yarnpkg.com/@nextui-org/react-utils/-/react-utils-2.0.17.tgz#3ed7903496ca8a9f5678ad816a2b24f1cd0581f9" @@ -1906,6 +1945,14 @@ "@nextui-org/react-rsc-utils" "2.0.14" "@nextui-org/shared-utils" "2.0.8" +"@nextui-org/react-utils@2.1.1": + version "2.1.1" + resolved "https://registry.yarnpkg.com/@nextui-org/react-utils/-/react-utils-2.1.1.tgz#36f9c216c539e22a4d68faccac4d5b01e98d7966" + integrity sha512-cN3Z0b2bV6Nf0CYD4imsGdXbHMQqad8KivltpBv1ItbI1/FSTAv9AHTKSzDE15hd/UwOGYt3Qm7I6tWzqov55w== + dependencies: + "@nextui-org/react-rsc-utils" "2.1.1" + "@nextui-org/shared-utils" "2.1.1" + "@nextui-org/react@^2.4.8": version "2.4.8" resolved "https://registry.yarnpkg.com/@nextui-org/react/-/react-2.4.8.tgz#2a430cca1d2e1dc812a55ca763d61e2ba5b0e195" @@ -1964,6 +2011,15 @@ "@nextui-org/react-utils" "2.0.17" "@nextui-org/shared-utils" "2.0.8" +"@nextui-org/ripple@2.2.5": + version "2.2.5" + resolved "https://registry.yarnpkg.com/@nextui-org/ripple/-/ripple-2.2.5.tgz#6ddedd46d043294a3ee06e088b2284b9ee14e2e7" + integrity sha512-GNYcRbVrUtdkbHKyFGRb0W8dyudH6hYY6YxYtlE5I82YwN1FovLvTRajDBbd3Bh2qNIcyUoFlmbt4h/6mM8uOQ== + dependencies: + "@nextui-org/dom-animation" "2.1.1" + "@nextui-org/react-utils" "2.1.1" + "@nextui-org/shared-utils" "2.1.1" + "@nextui-org/scroll-shadow@2.1.20": version "2.1.20" resolved "https://registry.yarnpkg.com/@nextui-org/scroll-shadow/-/scroll-shadow-2.1.20.tgz#9d6934cf3f8807f66e5b0280d61d484817ab7ec6" @@ -2001,11 +2057,26 @@ resolved "https://registry.yarnpkg.com/@nextui-org/shared-icons/-/shared-icons-2.0.9.tgz#ecc674ec51ba7f0570ee821aed317fba4cc70376" integrity sha512-WG3yinVY7Tk9VqJgcdF4V8Ok9+fcm5ey7S1els7kujrfqLYxtqoKywgiY/7QHwZlfQkzpykAfy+NAlHkTP5hMg== +"@nextui-org/shared-icons@2.1.1": + version "2.1.1" + resolved "https://registry.yarnpkg.com/@nextui-org/shared-icons/-/shared-icons-2.1.1.tgz#c2001523e96db8bcbdb46e4fe52d8c38f5e1018d" + integrity sha512-mkiTpFJnCzB2M8Dl7IwXVzDKKq9ZW2WC0DaQRs1eWgqboRCP8DDde+MJZq331hC7pfH8BC/4rxXsKECrOUUwCg== + "@nextui-org/shared-utils@2.0.8": version "2.0.8" resolved "https://registry.yarnpkg.com/@nextui-org/shared-utils/-/shared-utils-2.0.8.tgz#6e6e71a067c273581839c2226fd9fb4e1e3a3410" integrity sha512-ZEtoMPXS+IjT8GvpJTS9IWDnT1JNCKV+NDqqgysAf1niJmOFLyJgl6dh/9n4ufcGf1GbSEQN+VhJasEw7ajYGQ== +"@nextui-org/shared-utils@2.0.9": + version "2.0.9" + resolved "https://registry.yarnpkg.com/@nextui-org/shared-utils/-/shared-utils-2.0.9.tgz#08e06464b9e8f9b1e7456ea97895f360e952262d" + integrity sha512-tb+bkkRb8Yb2kaQYKQF6VvxOCuIjwP9UWHUL7zgq3mjOVtHlzz9iqrNlcc3xCrOtNWIBQuI15MAUYeo3PXXeqg== + +"@nextui-org/shared-utils@2.1.1": + version "2.1.1" + resolved "https://registry.yarnpkg.com/@nextui-org/shared-utils/-/shared-utils-2.1.1.tgz#e5a7edbdfe88c4b9595e413cfec958d95096f6f1" + integrity sha512-qE8gZO63GqUX1ljOi/4PlwGzE84dhUS3zFIq+10/N6ePAaNjM4DwtL4ocucG3abCz4iRUueYKLIxTO2+eYyAfw== + "@nextui-org/skeleton@2.0.32", "@nextui-org/skeleton@^2.0.32": version "2.0.32" resolved "https://registry.yarnpkg.com/@nextui-org/skeleton/-/skeleton-2.0.32.tgz#3815157bd137e5dd4004db22af0c56c6b216d7da" @@ -2062,6 +2133,15 @@ "@nextui-org/shared-utils" "2.0.8" "@nextui-org/system-rsc" "2.1.6" +"@nextui-org/spinner@2.2.4": + version "2.2.4" + resolved "https://registry.yarnpkg.com/@nextui-org/spinner/-/spinner-2.2.4.tgz#72b9cb53a661d53ec99dc521c8951ba665a6ff93" + integrity sha512-QdCRD64+fjWmi5byO9sR3D4xbkFNh9cB01KnoR1/u7+7m/WWNFOhUkYbdkB2e53oQlNXxh0Mw02U346tC8B+5g== + dependencies: + "@nextui-org/react-utils" "2.1.1" + "@nextui-org/shared-utils" "2.1.1" + "@nextui-org/system-rsc" "2.3.4" + "@nextui-org/switch@2.0.34", "@nextui-org/switch@^2.0.34": version "2.0.34" resolved "https://registry.yarnpkg.com/@nextui-org/switch/-/switch-2.0.34.tgz#69569063aa35561220177beb2d392b3cfe87cea1" @@ -2086,6 +2166,14 @@ "@react-types/shared" "3.23.1" clsx "^1.2.1" +"@nextui-org/system-rsc@2.3.4": + version "2.3.4" + resolved "https://registry.yarnpkg.com/@nextui-org/system-rsc/-/system-rsc-2.3.4.tgz#a9a4d4ba317a4b6c5f954b2c0d3c475191dbd695" + integrity sha512-Y6OLFO7diYnUMe5ffDPt6sIqCaah7FOqRaJ3ZQ/We8gE8AgHnyNQxWllLtRzBqaCiIheHLo7dTMed1FFmb775A== + dependencies: + "@react-types/shared" "3.26.0" + clsx "^1.2.1" + "@nextui-org/system@2.2.6": version "2.2.6" resolved "https://registry.yarnpkg.com/@nextui-org/system/-/system-2.2.6.tgz#837223d2acbfb6499fbd03d6345387897fa3d2a0" @@ -2157,6 +2245,20 @@ tailwind-merge "^1.14.0" tailwind-variants "^0.1.20" +"@nextui-org/theme@^2.3.0": + version "2.3.0" + resolved "https://registry.yarnpkg.com/@nextui-org/theme/-/theme-2.3.0.tgz#f33faa02684c32dfe2950563f98b43a2b0a5790e" + integrity sha512-MTWpjXeG+i9svlib8xMz17aK4GWijGoLZ4BRQv8JxvhZMUMuqPxkVeuMAXqg3dK9eF13KmtjCDf4K7d9/WkzYg== + dependencies: + "@nextui-org/shared-utils" "2.0.9" + clsx "^1.2.1" + color "^4.2.3" + color2k "^2.0.2" + deepmerge "4.3.1" + flat "^5.0.2" + tailwind-merge "^2.5.2" + tailwind-variants "^0.1.20" + "@nextui-org/tooltip@2.0.41", "@nextui-org/tooltip@^2.0.41": version "2.0.41" resolved "https://registry.yarnpkg.com/@nextui-org/tooltip/-/tooltip-2.0.41.tgz#a2027bf1e0f836c98bfd6bcc541cfbdda711f4be" @@ -2199,6 +2301,18 @@ "@react-types/button" "3.9.4" "@react-types/shared" "3.23.1" +"@nextui-org/use-aria-button@2.2.3": + version "2.2.3" + resolved "https://registry.yarnpkg.com/@nextui-org/use-aria-button/-/use-aria-button-2.2.3.tgz#4a84b68e6b9093f7cbc196b6e09836f0395b8fca" + integrity sha512-KG5A3tgSxmwq07zYwAocnulIoyDQZ8qjmE+m0Gx0tkb438gyN4VMIv7wCWhtrmhlJCfiNLvXdxCS4MjCWv7YCQ== + dependencies: + "@nextui-org/shared-utils" "2.1.1" + "@react-aria/focus" "3.19.0" + "@react-aria/interactions" "3.22.5" + "@react-aria/utils" "3.26.0" + "@react-types/button" "3.10.1" + "@react-types/shared" "3.26.0" + "@nextui-org/use-aria-link@2.0.19": version "2.0.19" resolved "https://registry.yarnpkg.com/@nextui-org/use-aria-link/-/use-aria-link-2.0.19.tgz#c0d63e65b5ecca362120b12df0fff770b2e19e06" @@ -2400,6 +2514,20 @@ "@react-types/shared" "^3.23.1" "@swc/helpers" "^0.5.0" +"@react-aria/button@3.11.0": + version "3.11.0" + resolved "https://registry.yarnpkg.com/@react-aria/button/-/button-3.11.0.tgz#cb7790db23949ec9c1e698fa531ee5471cf2b515" + integrity sha512-b37eIV6IW11KmNIAm65F3SEl2/mgj5BrHIysW6smZX3KoKWTGYsYfcQkmtNgY0GOSFfDxMCoolsZ6mxC00nSDA== + dependencies: + "@react-aria/focus" "^3.19.0" + "@react-aria/interactions" "^3.22.5" + "@react-aria/toolbar" "3.0.0-beta.11" + "@react-aria/utils" "^3.26.0" + "@react-stately/toggle" "^3.8.0" + "@react-types/button" "^3.10.1" + "@react-types/shared" "^3.26.0" + "@swc/helpers" "^0.5.0" + "@react-aria/button@3.9.5": version "3.9.5" resolved "https://registry.yarnpkg.com/@react-aria/button/-/button-3.9.5.tgz#f0082f58394394f3d16fdf45de57b382748f3345" @@ -2514,7 +2642,7 @@ "@swc/helpers" "^0.5.0" clsx "^2.0.0" -"@react-aria/focus@^3.17.1", "@react-aria/focus@^3.19.0": +"@react-aria/focus@3.19.0", "@react-aria/focus@^3.17.1", "@react-aria/focus@^3.19.0": version "3.19.0" resolved "https://registry.yarnpkg.com/@react-aria/focus/-/focus-3.19.0.tgz#82b9a5b83f023b943a7970df3d059f49d61df05d" integrity sha512-hPF9EXoUQeQl1Y21/rbV2H4FdUR2v+4/I0/vB+8U3bT1CJ+1AFj1hc/rqx2DqEwDlEwOHN+E4+mRahQmlybq0A== @@ -2604,7 +2732,7 @@ "@react-types/shared" "^3.23.1" "@swc/helpers" "^0.5.0" -"@react-aria/interactions@^3.21.3", "@react-aria/interactions@^3.22.5": +"@react-aria/interactions@3.22.5", "@react-aria/interactions@^3.21.3", "@react-aria/interactions@^3.22.5": version "3.22.5" resolved "https://registry.yarnpkg.com/@react-aria/interactions/-/interactions-3.22.5.tgz#9cd8c93b8b6988f1d315d3efb450119d1432bbb8" integrity sha512-kMwiAD9E0TQp+XNnOs13yVJghiy8ET8L0cbkeuTgNI96sOAp/63EJ1FSrDf17iD8sdjt41LafwX/dKXW9nCcLQ== @@ -2950,6 +3078,17 @@ "@react-types/shared" "^3.26.0" "@swc/helpers" "^0.5.0" +"@react-aria/toolbar@3.0.0-beta.11": + version "3.0.0-beta.11" + resolved "https://registry.yarnpkg.com/@react-aria/toolbar/-/toolbar-3.0.0-beta.11.tgz#019c9ff2a47ad207504a95afeb0f863cf71a114b" + integrity sha512-LM3jTRFNDgoEpoL568WaiuqiVM7eynSQLJis1hV0vlVnhTd7M7kzt7zoOjzxVb5Uapz02uCp1Fsm4wQMz09qwQ== + dependencies: + "@react-aria/focus" "^3.19.0" + "@react-aria/i18n" "^3.12.4" + "@react-aria/utils" "^3.26.0" + "@react-types/shared" "^3.26.0" + "@swc/helpers" "^0.5.0" + "@react-aria/tooltip@3.7.4": version "3.7.4" resolved "https://registry.yarnpkg.com/@react-aria/tooltip/-/tooltip-3.7.4.tgz#0efe8b4cc543a39395e99861ad6f0c64cd746026" @@ -2974,7 +3113,7 @@ "@swc/helpers" "^0.5.0" clsx "^2.0.0" -"@react-aria/utils@^3.24.1", "@react-aria/utils@^3.26.0": +"@react-aria/utils@3.26.0", "@react-aria/utils@^3.24.1", "@react-aria/utils@^3.26.0": version "3.26.0" resolved "https://registry.yarnpkg.com/@react-aria/utils/-/utils-3.26.0.tgz#833cbfa33e75d15835b757791b3f754432d2f948" integrity sha512-LkZouGSjjQ0rEqo4XJosS4L3YC/zzQkfRM3KoqK6fUOmUJ9t0jQ09WjiF+uOoG9u+p30AVg3TrZRUWmoTS+koQ== @@ -3402,7 +3541,7 @@ dependencies: "@swc/helpers" "^0.5.0" -"@react-stately/utils@^3.10.1", "@react-stately/utils@^3.10.5": +"@react-stately/utils@3.10.5", "@react-stately/utils@^3.10.1", "@react-stately/utils@^3.10.5": version "3.10.5" resolved "https://registry.yarnpkg.com/@react-stately/utils/-/utils-3.10.5.tgz#47bb91cd5afd1bafe39353614e5e281b818ebccc" integrity sha512-iMQSGcpaecghDIh3mZEpZfoFH3ExBwTtuBEcvZ2XnGzCgQjeYXcMdIUwAfVQLXFTdHUHGF6Gu6/dFrYsCzySBQ== @@ -3441,6 +3580,13 @@ "@react-types/link" "^3.5.9" "@react-types/shared" "^3.26.0" +"@react-types/button@3.10.1", "@react-types/button@^3.10.1", "@react-types/button@^3.9.4": + version "3.10.1" + resolved "https://registry.yarnpkg.com/@react-types/button/-/button-3.10.1.tgz#fc7ada2e83bc661b31c1473a82ec86dc11de057c" + integrity sha512-XTtap8o04+4QjPNAshFWOOAusUTxQlBjU2ai0BTVLShQEjHhRVDBIWsI2B2FKJ4KXT6AZ25llaxhNrreWGonmA== + dependencies: + "@react-types/shared" "^3.26.0" + "@react-types/button@3.9.4": version "3.9.4" resolved "https://registry.yarnpkg.com/@react-types/button/-/button-3.9.4.tgz#ec10452e870660d31db1994f6fe4abfe0c800814" @@ -3448,13 +3594,6 @@ dependencies: "@react-types/shared" "^3.23.1" -"@react-types/button@^3.10.1", "@react-types/button@^3.9.4": - version "3.10.1" - resolved "https://registry.yarnpkg.com/@react-types/button/-/button-3.10.1.tgz#fc7ada2e83bc661b31c1473a82ec86dc11de057c" - integrity sha512-XTtap8o04+4QjPNAshFWOOAusUTxQlBjU2ai0BTVLShQEjHhRVDBIWsI2B2FKJ4KXT6AZ25llaxhNrreWGonmA== - dependencies: - "@react-types/shared" "^3.26.0" - "@react-types/calendar@3.4.6": version "3.4.6" resolved "https://registry.yarnpkg.com/@react-types/calendar/-/calendar-3.4.6.tgz#66ddcefc3058492b3cce58a6e63b01558048b669" @@ -3639,7 +3778,7 @@ resolved "https://registry.yarnpkg.com/@react-types/shared/-/shared-3.23.1.tgz#2f23c81d819d0ef376df3cd4c944be4d6bce84c3" integrity sha512-5d+3HbFDxGZjhbMBeFHRQhexMFt4pUce3okyRtUVKbbedQFUrtXSBg9VszgF2RTeQDKDkMCIQDtz5ccP/Lk1gw== -"@react-types/shared@^3.23.1", "@react-types/shared@^3.26.0": +"@react-types/shared@3.26.0", "@react-types/shared@^3.23.1", "@react-types/shared@^3.26.0": version "3.26.0" resolved "https://registry.yarnpkg.com/@react-types/shared/-/shared-3.26.0.tgz#21a8b579f0097ee78de18e3e580421ced89e4c8c" integrity sha512-6FuPqvhmjjlpEDLTiYx29IJCbCNWPlsyO+ZUmCUXzhUv2ttShOXfw8CmeHWHftT/b2KweAWuzqSlfeXPR76jpw== @@ -10010,6 +10149,11 @@ tailwind-merge@^1.14.0: resolved "https://registry.yarnpkg.com/tailwind-merge/-/tailwind-merge-1.14.0.tgz#e677f55d864edc6794562c63f5001f45093cdb8b" integrity sha512-3mFKyCo/MBcgyOTlrY8T7odzZFx+w+qKSMAmdFzRvqBfLlSigU6TZnlFHK0lkMwj9Bj8OYU+9yW9lmGuS0QEnQ== +tailwind-merge@^2.5.2: + version "2.5.5" + resolved "https://registry.yarnpkg.com/tailwind-merge/-/tailwind-merge-2.5.5.tgz#98167859b856a2a6b8d2baf038ee171b9d814e39" + integrity sha512-0LXunzzAZzo0tEPxV3I297ffKZPlKDrjj7NXphC8V5ak9yHC5zRmxnOe2m/Rd/7ivsOMJe3JZ2JVocoDdQTRBA== + tailwind-variants@0.1.20, tailwind-variants@^0.1.20: version "0.1.20" resolved "https://registry.yarnpkg.com/tailwind-variants/-/tailwind-variants-0.1.20.tgz#8aaed9094be0379a438641a42d588943e44c5fcd"