diff --git a/client/package.json b/client/package.json index 9d575e32..bee3f7c4 100644 --- a/client/package.json +++ b/client/package.json @@ -49,6 +49,7 @@ "class-variance-authority": "^0.6.0", "clsx": "^1.2.1", "cmdk": "0.2.1", + "country-iso-3-to-2": "^1.1.1", "d3-array": "3.2.4", "deck.gl": "8.9.19", "eslint": "8.42.0", @@ -67,7 +68,6 @@ "react-map-gl": "7.0.25", "react-markdown": "9.0.1", "react-share": "5.1.0", - "react-world-flags": "1.6.0", "recharts": "2.12.1", "remark-gfm": "4.0.0", "rooks": "7.14.1", diff --git a/client/src/components/flag/index.tsx b/client/src/components/flag/index.tsx new file mode 100644 index 00000000..cd321eb7 --- /dev/null +++ b/client/src/components/flag/index.tsx @@ -0,0 +1,44 @@ +import Image from 'next/image'; + +const DEFAULT_CDN_URL = 'https://cdn.jsdelivr.net/gh/lipis/flag-icons/flags/4x3/'; +const DEFAULT_CDN_SUFFIX = 'svg'; + +interface ImgProps extends React.ImgHTMLAttributes { + cdnSuffix?: string; + cdnUrl?: string; + countryCode: string; + width?: number; + height?: number; + svg?: true; +} + +export type CountryFlagProps = ImgProps; + +export const CountryFlag = ({ + cdnSuffix = DEFAULT_CDN_SUFFIX, + cdnUrl = DEFAULT_CDN_URL, + countryCode, + height = 26.66, + width = 40, + svg = true, + className, +}: CountryFlagProps) => { + if (typeof countryCode !== 'string') { + return null; + } + if (svg) { + const flagUrl = `${cdnUrl}${countryCode.toLowerCase()}.${cdnSuffix}`; + + return ( + {'Flag + ); + } +}; + +export default CountryFlag; diff --git a/client/src/containers/countries/detail/constants.tsx b/client/src/containers/countries/detail/constants.tsx index 5d8ce3b5..e0777591 100644 --- a/client/src/containers/countries/detail/constants.tsx +++ b/client/src/containers/countries/detail/constants.tsx @@ -87,6 +87,8 @@ export const COLUMNS = [ 'forest_area_pct', 'intervention_area_total', 'jobs', + 'total_trainings', + 'trainees', 'jobs_total', 'net_flux_co2e_year', 'production_ntfp_total', diff --git a/client/src/containers/countries/detail/panel.tsx b/client/src/containers/countries/detail/panel.tsx index 1bc73122..01284e8e 100644 --- a/client/src/containers/countries/detail/panel.tsx +++ b/client/src/containers/countries/detail/panel.tsx @@ -3,12 +3,12 @@ import { useEffect } from 'react'; import Markdown from 'react-markdown'; -import Flag from 'react-world-flags'; import Image from 'next/image'; import Link from 'next/link'; import { notFound, useParams } from 'next/navigation'; +import getCountryIso2 from 'country-iso-3-to-2'; import { useSetAtom } from 'jotai'; import { X } from 'lucide-react'; import { ArrowLeft, Download, ExternalLink, Info } from 'lucide-react'; @@ -37,6 +37,7 @@ import { usefulLinksCountry } from '@/containers/countries/detail/constants'; import { COLUMNS, CSV_COLUMNS_ORDER } from '@/containers/countries/detail/constants'; import Share from '@/containers/share'; +import CountryFlag from '@/components/flag'; import { Button } from '@/components/ui/button'; import { Dialog, DialogClose, DialogContent, DialogTrigger } from '@/components/ui/dialog'; import ContentLoader from '@/components/ui/loader'; @@ -195,11 +196,13 @@ export default function CountryDetailPanel() {
{data?.data?.attributes?.iso && ( - )}

@@ -220,11 +223,13 @@ export default function CountryDetailPanel() {
{data?.data?.attributes?.iso && ( - )}

{data?.data?.attributes?.name}

@@ -435,7 +440,7 @@ export default function CountryDetailPanel() {
-

Total jobs

+

Total trainings

@@ -444,8 +449,8 @@ export default function CountryDetailPanel() {

- The total number of short- and long-term jobs generated by the - project interventions in the AFoCO Member Countries. + The total number of training activities conducted and participants + in the AFoCO Member Countries.

@@ -453,15 +458,17 @@ export default function CountryDetailPanel() {

- {formatCompactNumber(indicators.jobs_total['value'])} + {formatCompactNumber(indicators.trainings_total['value'])}

- {indicators.jobs && ( + {indicators.trainees && (
({ - year, - uv, - }))} + data={Object.entries(indicators.trainees['value']).map( + ([year, uv]) => ({ + year, + uv, + }) + )} barDataKey="uv" barRadius={[20, 20, 20, 20]} fillBar="#70B6CC" diff --git a/client/src/containers/countries/item.tsx b/client/src/containers/countries/item.tsx index 76d32293..2ad552fc 100644 --- a/client/src/containers/countries/item.tsx +++ b/client/src/containers/countries/item.tsx @@ -1,14 +1,16 @@ 'use client'; -import Flag from 'react-world-flags'; - import Link from 'next/link'; +import getCountryIso2 from 'country-iso-3-to-2'; + import { useGetCountryIndicatorFields } from '@/types/generated/country-indicator-field'; import { CountryListResponseDataItem } from '@/types/generated/strapi.schemas'; import { useSyncQueryParams } from '@/hooks/datasets'; +import CountryFlag from '@/components/flag'; + export default function CountryItem({ data }: { data: CountryListResponseDataItem }) { const queryParams = useSyncQueryParams({ bbox: true }); @@ -35,7 +37,15 @@ export default function CountryItem({ data }: { data: CountryListResponseDataIte >
{data.attributes?.iso && ( - + )}

{data.attributes?.name}

diff --git a/client/src/containers/countries/list.tsx b/client/src/containers/countries/list.tsx index a0c0b8b4..5cf43193 100644 --- a/client/src/containers/countries/list.tsx +++ b/client/src/containers/countries/list.tsx @@ -3,16 +3,16 @@ // import { useState } from 'react'; // import { Search, X } from 'lucide-react'; +import { ExternalLink } from 'lucide-react'; import { useGetCountries } from '@/types/generated/country'; -import CountryItem from '@/containers/countries/item'; import { usefulLinksCountriesList } from '@/containers/countries/detail/constants'; +import CountryItem from '@/containers/countries/item'; // import { Input } from '@/components/ui/input'; import ContentLoader from '@/components/ui/loader'; import { ScrollArea } from '@/components/ui/scroll-area'; -import { ExternalLink } from 'lucide-react'; export default function CountriesList() { // const [searchValue, setSearchValue] = useState(null); diff --git a/client/src/containers/datasets/layers/projects/hooks.tsx b/client/src/containers/datasets/layers/projects/hooks.tsx index 98b229d2..89dcec20 100644 --- a/client/src/containers/datasets/layers/projects/hooks.tsx +++ b/client/src/containers/datasets/layers/projects/hooks.tsx @@ -142,13 +142,21 @@ export function useLayers({ // Reactivity to Hover Events in the Sidebar: // Similarly, When a user hovers over the a project in the sidebar, both point and geometry representations within the layer are programmed to change in color and/or size. + + const sanitizedProjects = filteredProjects.filter((project) => project !== null); + + const filter = + sanitizedProjects.length > 0 + ? ['in', ['get', 'project_code'], ['literal', sanitizedProjects]] + : ['!=', ['get', 'project_code'], null]; + return [ { id: 'projects_points_shadow', type: 'circle', - filter: ['in', ['get', 'project_code'], ['literal', filteredProjects]], + filter, source: 'projects', - 'source-layer': 'areas_centroids_c', + 'source-layer': 'areas_centroids_c_v202410', paint: { 'circle-radius': 16, 'circle-color': '#ccc', @@ -163,18 +171,18 @@ export function useLayers({ { id: 'projects_circle', type: 'circle', - filter: ['in', ['get', 'project_code'], ['literal', filteredProjects]], + filter, source: 'projects', - 'source-layer': 'areas_centroids_c', + 'source-layer': 'areas_centroids_c_v202410', paint: { 'circle-stroke-color': '#ffffff', 'circle-stroke-width': [ 'case', ['boolean', ['feature-state', 'hover'], false], 3, - ['==', ['get', 'project_code'], hoveredProject], + ['==', ['get', 'project_code'], hoveredProject?.[0] || null], 7, - ['!=', ['get', 'project_code'], hoveredProject], + ['!=', ['get', 'project_code'], hoveredProject?.[0] || null], 3, 7, ], @@ -193,9 +201,17 @@ export function useLayers({ 'case', ['boolean', ['feature-state', 'hover'], false], 1, - ['all', ['to-boolean', hoveredProject], ['!=', ['get', 'project_code'], hoveredProject]], + [ + 'all', + ['to-boolean', hoveredProject?.[0] || null], + ['!=', ['get', 'project_code'], hoveredProject?.[0] || null], + ], 0.2, - ['all', ['to-boolean', hoveredProject], ['==', ['get', 'project_code'], hoveredProject]], + [ + 'all', + ['to-boolean', hoveredProject?.[0] || null], + ['==', ['get', 'project_code'], hoveredProject?.[0] || null], + ], 1, opacity, ], @@ -203,9 +219,17 @@ export function useLayers({ 'case', ['boolean', ['feature-state', 'hover'], false], 1, - ['all', ['to-boolean', hoveredProject], ['!=', ['get', 'project_code'], hoveredProject]], + [ + 'all', + ['to-boolean', hoveredProject?.[0] || null], + ['!=', ['get', 'project_code'], hoveredProject?.[0] || null], + ], 0.2, - ['all', ['to-boolean', hoveredProject], ['==', ['get', 'project_code'], hoveredProject]], + [ + 'all', + ['to-boolean', hoveredProject?.[0] || null], + ['==', ['get', 'project_code'], hoveredProject?.[0] || null], + ], 1, opacity, ], @@ -218,18 +242,26 @@ export function useLayers({ { id: 'projects_fill', type: 'fill', - filter: ['in', ['get', 'project_code'], ['literal', filteredProjects]], + filter, source: 'projects', - 'source-layer': 'areas_centroids_l', + 'source-layer': 'areas_centroids_l_v202410', paint: { 'fill-color': '#176252', 'fill-opacity': [ 'case', ['boolean', ['feature-state', 'hover'], false], 0.7, - ['all', ['to-boolean', hoveredProject], ['!=', ['get', 'project_code'], hoveredProject]], + [ + 'all', + ['to-boolean', hoveredProject?.[0] || null], + ['!=', ['get', 'project_code'], hoveredProject?.[0] || null], + ], 0.2, - ['all', ['to-boolean', hoveredProject], ['==', ['get', 'project_code'], hoveredProject]], + [ + 'all', + ['to-boolean', hoveredProject?.[0] || null], + ['==', ['get', 'project_code'], hoveredProject?.[0] || null], + ], 0.7, opacity * 0.7, ], @@ -242,9 +274,9 @@ export function useLayers({ { id: 'projects_line', type: 'line', - filter: ['in', ['get', 'project_code'], ['literal', filteredProjects]], + filter, source: 'projects', - 'source-layer': 'areas_centroids_l', + 'source-layer': 'areas_centroids_l_v202410', paint: { 'line-color': '#B45F06', 'line-opacity': opacity, diff --git a/client/src/containers/datasets/layers/projects/layer.tsx b/client/src/containers/datasets/layers/projects/layer.tsx index 54eac774..268bf070 100644 --- a/client/src/containers/datasets/layers/projects/layer.tsx +++ b/client/src/containers/datasets/layers/projects/layer.tsx @@ -11,7 +11,7 @@ import { useLayers } from './hooks'; const SOURCE: SourceProps = { promoteId: 'project_code', type: 'vector', - url: 'mapbox://afoco.25x9bxct', + url: 'mapbox://afoco.06cjgob1', id: 'projects', }; diff --git a/client/src/containers/datasets/layers/tree-cover/settings.tsx b/client/src/containers/datasets/layers/tree-cover/settings.tsx index c571100a..4ca56d41 100644 --- a/client/src/containers/datasets/layers/tree-cover/settings.tsx +++ b/client/src/containers/datasets/layers/tree-cover/settings.tsx @@ -13,7 +13,6 @@ const TreeCoverLossSettings: React.FC = ({ description, startYear: startYearValue, endYear: endYearValue, - paramsConfig, onChangeSettings, }) => { // const startYear = useMemo( diff --git a/client/src/containers/map/index.tsx b/client/src/containers/map/index.tsx index 46aac44b..f8a74a37 100644 --- a/client/src/containers/map/index.tsx +++ b/client/src/containers/map/index.tsx @@ -55,7 +55,7 @@ const DEFAULT_PROPS: CustomMapProps = { const INITIAL_PROJECTS_POPUP = { position: null, popup: null, - info: null, + info: [], }; export default function MapContainer() { @@ -64,7 +64,7 @@ export default function MapContainer() { const [locationPopUp, setLocationPopUp] = useState<{ position: { x: number; y: number } | null; popup: number[] | null; - info: string | null; + info: string[] | null; }>(INITIAL_PROJECTS_POPUP); const { [id]: map } = useMap(); @@ -94,7 +94,7 @@ export default function MapContainer() { } }, [tmpBbox, sidebarOpen]); // eslint-disable-line react-hooks/exhaustive-deps - const { data: projectTitle } = useGetProjects( + const { data: ProjectInfo } = useGetProjects( { populate: 'name, project_code', }, @@ -103,7 +103,16 @@ export default function MapContainer() { select: (response) => response?.data ?.map((project) => project.attributes) - .find((project) => project?.project_code === locationPopUp.info)?.name, + .filter( + (project): project is { project_code: string; name: string } => + !!project?.project_code && + !!project?.name && + !!locationPopUp.info?.includes(project.project_code ?? '') + ) + ?.map((p) => ({ + id: p.project_code, + name: p.name, + })), }, } ); @@ -145,119 +154,110 @@ export default function MapContainer() { [map, push, queryParams, setTmpBbox] ); - let hoveredStateIdProjectsCircle: string | null = null; - let hoveredStateIdProjectsFill: string | null = null; - const handleMouseMove = useCallback( (e: MapLayerMouseEvent) => { + // Track hovered features as arrays + let hoveredProjectsCircle = []; + let hoveredProjectsFill = []; + + // Filter features by layer const ProjectsLayer = - e?.features && e?.features.find(({ layer }) => layer.id === 'projects_circle'); + e?.features && e?.features.filter(({ layer }) => layer.id === 'projects_circle'); const ProjectsFillLayer = - e?.features && e?.features.find(({ layer }) => layer.id === 'projects_fill'); - // *ON MOUSE ENTER - if (e.features && map && ProjectsLayer) { + e?.features && e?.features.filter(({ layer }) => layer.id === 'projects_fill'); + + // ON MOUSE ENTER: Handle Projects Circle + if (e.features && map && ProjectsLayer?.length) { + hoveredProjectsCircle = ProjectsLayer.map((p) => p?.properties?.project_code); + setCursor('pointer'); - setHoveredProjectMap(ProjectsLayer.properties?.project_code); + setHoveredProjectMap(hoveredProjectsCircle); setLocationPopUp({ popup: [e?.lngLat.lat, e?.lngLat.lng], position: { x: e.point.x, y: e.point.y, }, - info: ProjectsLayer.properties?.project_code, + info: hoveredProjectsCircle, + }); + + // Reset all previous hovered states + hoveredProjectsCircle.forEach((id) => { + map?.setFeatureState( + { + sourceLayer: 'areas_centroids_c_v202410', + source: 'projects', + id, + }, + { hover: true } + ); }); } - if (e.features && map && ProjectsFillLayer) { + // ON MOUSE ENTER: Handle Projects Fill + if (e.features && map && ProjectsFillLayer?.length) { + hoveredProjectsFill = ProjectsFillLayer.map((p) => p?.properties?.project_code); setCursor('pointer'); - setHoveredProjectMap(ProjectsFillLayer.properties?.project_code); + setHoveredProjectMap(hoveredProjectsFill); setLocationPopUp({ popup: [e?.lngLat.lat, e?.lngLat.lng], position: { x: e.point.x, y: e.point.y, }, - info: ProjectsFillLayer.properties?.project_code, + info: hoveredProjectsFill, + }); + + // Reset all previous hovered states + hoveredProjectsFill.forEach((id) => { + map?.setFeatureState( + { + sourceLayer: 'areas_centroids_l_v202410', + source: 'projects', + id, + }, + { hover: true } + ); }); } - if (ProjectsLayer && map) { - if (hoveredStateIdProjectsCircle !== null) { + // ON MOUSE LEAVE: Reset Feature States + if (!ProjectsLayer && map) { + hoveredProjectsCircle.forEach((id) => { map?.setFeatureState( { - sourceLayer: 'areas_centroids_c', + sourceLayer: 'areas_centroids_c_v202410', source: 'projects', - id: hoveredStateIdProjectsCircle, + id, }, { hover: false } ); - } - - hoveredStateIdProjectsCircle = ProjectsLayer?.properties?.project_code as string; - map?.setFeatureState( - { - sourceLayer: 'areas_centroids_c', - source: 'projects', - id: hoveredStateIdProjectsCircle, - }, - { hover: true } - ); + }); + hoveredProjectsCircle = []; } - if (ProjectsFillLayer && map) { - if (hoveredStateIdProjectsFill !== null) { + + if (!ProjectsFillLayer && map) { + hoveredProjectsFill.forEach((id) => { map?.setFeatureState( { - sourceLayer: 'areas_centroids_l', + sourceLayer: 'areas_centroids_l_v202410', source: 'projects', - id: hoveredStateIdProjectsFill, + id, }, { hover: false } ); - } - - hoveredStateIdProjectsFill = ProjectsFillLayer?.properties?.project_code as string; - map?.setFeatureState( - { - sourceLayer: 'areas_centroids_l', - source: 'projects', - id: hoveredStateIdProjectsFill, - }, - { hover: true } - ); + }); + hoveredProjectsFill = []; } - // *ON MOUSE LEAVE - + // Reset cursor and popup on empty features if (e.features?.length === 0) { setCursor('grab'); - setHoveredProjectMap(null); + setHoveredProjectMap([]); setLocationPopUp(INITIAL_PROJECTS_POPUP); } - - if (!ProjectsLayer && map && hoveredStateIdProjectsCircle) { - map?.setFeatureState( - { - sourceLayer: 'areas_centroids_c', - source: 'projects', - id: hoveredStateIdProjectsCircle, - }, - { hover: false } - ); - hoveredStateIdProjectsCircle = null; - } - if (!ProjectsFillLayer && map && hoveredStateIdProjectsFill) { - map?.setFeatureState( - { - sourceLayer: 'areas_centroids_l', - source: 'projects', - id: hoveredStateIdProjectsFill, - }, - { hover: false } - ); - hoveredStateIdProjectsFill = null; - } }, - [setCursor, map, hoveredStateIdProjectsCircle, hoveredStateIdProjectsFill] + [setCursor, setHoveredProjectMap, setLocationPopUp, map] ); return ( @@ -327,8 +327,8 @@ export default function MapContainer() { 1 ? 'Projects' : 'Project'} /> )} diff --git a/client/src/containers/map/popup/index.tsx b/client/src/containers/map/popup/index.tsx index 34c8a9c6..88fd2ed5 100644 --- a/client/src/containers/map/popup/index.tsx +++ b/client/src/containers/map/popup/index.tsx @@ -1,3 +1,9 @@ +import { useEffect, useState, MouseEvent, useCallback } from 'react'; + +import { useAtomValue } from 'jotai'; + +import { hoveredProjectMapAtom } from '@/store'; + import { Popup } from 'react-map-gl'; const PopupContainer = ({ @@ -8,9 +14,39 @@ const PopupContainer = ({ }: { longitude: number; latitude: number; - info?: string | null; + info: + | ( + | { + id: string; + name: string; + } + | undefined + )[] + | undefined; header?: string | null; }) => { + const hoveredProjectMap = useAtomValue(hoveredProjectMapAtom); + + const [scrollableArea, setScrollableArea] = useState(false); + + useEffect(() => { + if (hoveredProjectMap?.length !== 1) return; + }, [hoveredProjectMap, scrollableArea]); + + const handleScrollableArea = useCallback( + (e: MouseEvent) => { + const dataId = e.currentTarget.getAttribute('data-id'); + if (dataId) { + const element = document.getElementById(dataId); + if (!scrollableArea && element) { + element.scrollIntoView({ behavior: 'smooth' }); + } + setScrollableArea(true); + } + }, + [scrollableArea, setScrollableArea] + ); + return ( @@ -26,7 +63,19 @@ const PopupContainer = ({

{header}

-

{info}

+
+ {info?.map((i) => ( +

setScrollableArea(false)} + > + {i?.name} +

+ ))} +
); }; diff --git a/client/src/containers/panel/index.tsx b/client/src/containers/panel/index.tsx index 86139eed..ec44970d 100644 --- a/client/src/containers/panel/index.tsx +++ b/client/src/containers/panel/index.tsx @@ -22,8 +22,8 @@ export default function Panel({ children }: { children: React.ReactNode }) { const scrollRef = useRef(null); useEffect(() => { - if (!hoveredProjectMap) return; - const element = document.getElementById(hoveredProjectMap); + if (hoveredProjectMap?.length !== 1) return; + const element = document.getElementById(hoveredProjectMap[0]); !scrollableArea && element?.scrollIntoView({ behavior: 'smooth' }); }, [hoveredProjectMap, scrollableArea]); @@ -32,7 +32,7 @@ export default function Panel({ children }: { children: React.ReactNode }) { onMouseEnter={() => setScrollableArea(true)} onMouseLeave={() => setScrollableArea(false)} className={cn({ - 'animate-in slide-in-from-left fade-in rounded-4xl bg-background after:rounded-b-4xl before:rounded-t-4xl absolute bottom-0 top-0 z-10 my-2 flex w-full max-w-[342px] flex-col shadow-md transition-transform duration-700 before:absolute before:left-0 before:top-1 before:h-6 before:w-full before:bg-gradient-to-b before:from-white/100 before:to-white/0 before:content-[""] after:absolute after:bottom-1 after:left-0 after:h-6 after:w-full after:bg-gradient-to-t after:from-white/0 after:to-white/100 after:content-[""] xl:max-w-[400px]': + 'animate-in slide-in-from-left fade-in bg-background absolute bottom-0 top-0 z-10 my-2 flex w-full max-w-[342px] flex-col rounded-[24px] shadow-md transition-transform duration-700 before:absolute before:left-0 before:top-1 before:h-6 before:w-full before:rounded-t-[24px] before:bg-gradient-to-b before:from-white/100 before:to-white/0 before:content-[""] after:absolute after:bottom-1 after:left-0 after:h-6 after:w-full after:rounded-b-[24px] after:bg-gradient-to-t after:from-white/0 after:to-white/100 after:content-[""] xl:max-w-[400px]': true, 'left-20 translate-x-0 xl:left-[102px]': open, 'left-[70px] -translate-x-full rounded-[42px] bg-transparent duration-[2000ms] before:content-[] after:content-[] xl:left-[92px]': @@ -66,7 +66,7 @@ export default function Panel({ children }: { children: React.ReactNode }) {
-

Total jobs

+

Total trainings

@@ -220,20 +220,20 @@ export default function ProjectDashboard({ id }: { id: string }) {

- The total number of short- and long-term jobs generated by the project - interventions in the AFoCO Member Countries. + The total number of training activities conducted and participants in the + AFoCO Member Countries.

- {formatCompactNumber(data.jobs_total['value']) || 0} + {formatCompactNumber(data.trainings_total['value']) || 0}

- {data.jobs && ( + {data.trainees && ( ({ + data={Object.entries(data.trainees['value']).map(([year, uv]) => ({ year, uv, }))} diff --git a/client/src/containers/projects/detail/header-controls.tsx b/client/src/containers/projects/detail/header-controls.tsx new file mode 100644 index 00000000..344e9238 --- /dev/null +++ b/client/src/containers/projects/detail/header-controls.tsx @@ -0,0 +1,54 @@ +'use client'; + +import { useRouter } from 'next/navigation'; + +import { ArrowLeft, Share as Download, X } from 'lucide-react'; + +import Share from '@/containers/share'; + +import { Button } from '@/components/ui/button'; + +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import { Project, ProjectProjectIndicatorFields } from '@/types/generated/strapi.schemas'; + +const HeaderControls = ({ + data, + indicators, + downloadCSVProjectData, +}: { + data: Project; + indicators: ProjectProjectIndicatorFields; + downloadCSVProjectData: () => void; +}) => { + const router = useRouter(); + return ( +
+ + {data && indicators && ( +
+ + + + + +

Download project

+
+
+ + +
+ )} +
+ ); +}; + +export default HeaderControls; diff --git a/client/src/containers/projects/detail/header.tsx b/client/src/containers/projects/detail/header.tsx new file mode 100644 index 00000000..d5862cff --- /dev/null +++ b/client/src/containers/projects/detail/header.tsx @@ -0,0 +1,181 @@ +'use client'; + +import { useEffect, useLayoutEffect, useRef } from 'react'; + +import Image from 'next/image'; +import { notFound, useParams } from 'next/navigation'; + +import { useSetAtom } from 'jotai'; + +import { cn } from '@/lib/classnames'; + +import { tmpBboxAtom } from '@/store'; + +import { + InterventionTypeListResponseDataItem, + Project, + ProjectProjectIndicatorFields, +} from '@/types/generated/strapi.schemas'; + +import { useSyncBbox } from '@/hooks/datasets/sync-query'; + +import { COLUMNS, CSV_COLUMNS_ORDER } from '@/containers/projects/detail/constants'; + +import HeaderControls from './header-controls'; + +export default function Header({ + data, + indicators, + dynamicHeight, + setDynamicHeight, +}: { + data: Project | undefined; + indicators: ProjectProjectIndicatorFields; + dynamicHeight: number; + setDynamicHeight: (height: number) => void; +}) { + const params = useParams<{ id: string }>(); + const setTempBbox = useSetAtom(tmpBboxAtom); + const [URLBbox] = useSyncBbox(); + + const h2Ref = useRef(null); + + useEffect(() => { + if (data?.bbox && !URLBbox) { + setTempBbox(data?.bbox); + } + }, [data, setTempBbox, URLBbox]); + + const jsonToCsv = (json: ProjectProjectIndicatorFields & Project) => { + let csv = ''; + + const parsedJsonData = [ + Object.entries(json ?? {}) + .map((entry) => { + if (entry[0] === 'countries' || entry[0] === 'intervention_types') + return { + [entry[0]]: entry[1].data.map( + (el: InterventionTypeListResponseDataItem) => el.attributes?.name + ), + }; + return { [entry[0]]: entry[1] }; + }) + .reduce(function (result, current) { + return Object.assign(result, current); + }, []), + ]; + + const headers = Object.keys(json ?? {}).filter((el) => COLUMNS.includes(el)); + headers.sort( + (a: string, b: string) => + CSV_COLUMNS_ORDER[a as keyof typeof CSV_COLUMNS_ORDER] - + CSV_COLUMNS_ORDER[b as keyof typeof CSV_COLUMNS_ORDER] + ); + + csv += headers.join(',') + '\n'; + + parsedJsonData?.forEach(function (row: { [key: string]: number | object | string[] }) { + const data = headers + .map((header) => { + if (Array.isArray(row[header])) return `"${row[header].toString()}"`; + + if (typeof row[header] === 'object' && !Array.isArray(row[header])) { + return `"${JSON.stringify(row[header]).replace(/"/g, "'")}"`; + } + return JSON.stringify(row[header]); + }) + .join(','); + csv += data + '\n'; + }); + + return csv; + }; + + const downloadCSVProjectData = () => { + const dataToDownload = { ...indicators, ...data }; + + const csvData = jsonToCsv(dataToDownload || {}); // Provide a default value of an empty object if data is undefined + const blob = new Blob([csvData], { type: 'text/csv' }); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${data?.name}.csv`; + document.body.appendChild(a); + a.click(); + }; + + useLayoutEffect(() => { + if (h2Ref.current) { + const h2Height = h2Ref.current.offsetHeight; + const calculatedHeight = h2Height + 48 + 64; + setDynamicHeight(Math.max(calculatedHeight, 208)); + } + }, [data?.name, setDynamicHeight]); + + if (!params.id) { + return notFound(); + } + + return ( +
+
+
+ {data && indicators && ( +
+ {/* Header Controls */} + + + {/* Image Container */} +
+ {/* Gradient Background */} +
+ + {/* Main Image or Placeholder */} + {data.main_image?.data?.attributes?.url ? ( + AFOCO + ) : ( + AFOCO + )} +
+ + {/* Title */} +
+

120, + 'text-yellow-900': !data.main_image?.data?.attributes?.url, + })} + > + {data?.name || 'Default Title'} {/* Fallback for empty data */} +

+
+
+ )} +
+
+
+ ); +} diff --git a/client/src/containers/projects/detail/panel.tsx b/client/src/containers/projects/detail/panel.tsx index 76754946..0a9138f9 100644 --- a/client/src/containers/projects/detail/panel.tsx +++ b/client/src/containers/projects/detail/panel.tsx @@ -1,15 +1,13 @@ 'use client'; -import { useEffect } from 'react'; +import { useLayoutEffect, useState, useRef, useEffect } from 'react'; import Image from 'next/image'; import { notFound, useParams } from 'next/navigation'; -import { useRouter } from 'next/navigation'; import { useAtom, useSetAtom } from 'jotai'; -import { ArrowLeft, ChevronRight, Share as Download, X } from 'lucide-react'; +import { ChevronRight, X } from 'lucide-react'; -import { cn } from '@/lib/classnames'; import { formatCompactNumber } from '@/lib/utils/formats'; import { DescriptionWithoutMarkdown } from '@/lib/utils/markdown'; @@ -18,30 +16,24 @@ import { tmpBboxAtom } from '@/store'; import { useGetIndicatorFields } from '@/types/generated/indicator-field'; import { useGetProjects } from '@/types/generated/project'; -import { - InterventionTypeListResponseDataItem, - Project, - ProjectProjectIndicatorFields, -} from '@/types/generated/strapi.schemas'; import { useSyncBbox } from '@/hooks/datasets/sync-query'; -import { COLUMNS, CSV_COLUMNS_ORDER } from '@/containers/projects/detail/constants'; -import Share from '@/containers/share'; - import { Button } from '@/components/ui/button'; import { Drawer, DrawerClose, DrawerContent, DrawerTrigger } from '@/components/ui/drawer'; import ContentLoader from '@/components/ui/loader'; import { ScrollArea } from '@/components/ui/scroll-area'; -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import ProjectDashboard from './dashboard'; +import Header from './header'; + export default function ProjectDetailPanel() { const params = useParams<{ id: string }>(); - const router = useRouter(); const [dashboard, setDashboard] = useAtom(dashboardAtom); const setTempBbox = useSetAtom(tmpBboxAtom); + const [dynamicHeight, setDynamicHeight] = useState(208); + const [URLBbox] = useSyncBbox(); const { data, isFetching, isFetched, isError } = useGetProjects( @@ -89,139 +81,33 @@ export default function ProjectDetailPanel() { } ); - const jsonToCsv = (json: ProjectProjectIndicatorFields & Project) => { - let csv = ''; - - const parsedJsonData = [ - Object.entries(json ?? {}) - .map((entry) => { - if (entry[0] === 'countries' || entry[0] === 'intervention_types') - return { - [entry[0]]: entry[1].data.map( - (el: InterventionTypeListResponseDataItem) => el.attributes?.name - ), - }; - return { [entry[0]]: entry[1] }; - }) - .reduce(function (result, current) { - return Object.assign(result, current); - }, []), - ]; - - const headers = Object.keys(json ?? {}).filter((el) => COLUMNS.includes(el)); - headers.sort( - (a: string, b: string) => - CSV_COLUMNS_ORDER[a as keyof typeof CSV_COLUMNS_ORDER] - - CSV_COLUMNS_ORDER[b as keyof typeof CSV_COLUMNS_ORDER] - ); - - csv += headers.join(',') + '\n'; - - parsedJsonData?.forEach(function (row: { [key: string]: number | object | string[] }) { - const data = headers - .map((header) => { - if (Array.isArray(row[header])) return `"${row[header].toString()}"`; - - if (typeof row[header] === 'object' && !Array.isArray(row[header])) { - return `"${JSON.stringify(row[header]).replace(/"/g, "'")}"`; - } - return JSON.stringify(row[header]); - }) - .join(','); - csv += data + '\n'; - }); - - return csv; - }; + const headerRef = useRef(null); // Ref to measure the height of the Header + const [headerHeight, setHeaderHeight] = useState(0); // State to store the height of the Header - const downloadCSVProjectData = () => { - const dataToDownload = { ...indicators, ...data }; + useLayoutEffect(() => { + const refElement = headerRef.current; - const csvData = jsonToCsv(dataToDownload || {}); // Provide a default value of an empty object if data is undefined - const blob = new Blob([csvData], { type: 'text/csv' }); - const url = window.URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `${data?.name}.csv`; - document.body.appendChild(a); - a.click(); - }; + if (refElement) { + // Calculate height after the DOM updates + setTimeout(() => { + setHeaderHeight(refElement.offsetHeight); + }, 0); + } + }, []); // Run once after initial render if (!params.id) { return notFound(); } return ( -
-
- {data && indicators && ( -
- {data.main_image?.data?.attributes?.url && ( -
-
- - AFOCO -
- )} - {!data.main_image?.data?.attributes?.url && ( -
- AFOCO -
- )} - -

120, - 'text-yellow-900': !data.main_image?.data?.attributes?.url, - })} - > - {data?.name} -

-
- )} -
-
- - {data && indicators && ( -
- - - - - -

Download project

-
-
- - -
- )} +
+
+
-
-
- - {data?.description && ( -

- -

- )} + +
+ {data?.description && ( +

+ +

+ )} -
-
-

Status

-

{data?.status}

-
-
-

Location

-

- {data?.countries?.data?.map((d) => d?.attributes?.name).join(', ')} -

-
-
-

Duration

-

{data?.duration}

-
-
-

Donors

-

{data?.donors}

-
-
-

Investment

-

- {formatCompactNumber(indicators?.project_funding?.total_funding)} -

-
+
+
+

Project code

+

{data?.project_code}

+
+
+

Status

+

{data?.status}

+
+
+

Location

+

+ {data?.countries?.data?.map((d) => d?.attributes?.name).join(', ')} +

-
- {!!data?.gallery?.data?.length && ( - <> -

- Project Gallery -

-
-
- {data.gallery.data.map((img, index) => ( -
- {index >= 0 && index % 5 === 0 && ( -
- AFOCO -
- )} -
- ))} -
+
+

Duration

+

{data?.duration}

+
+
+

Donors

+

{data?.donors}

+
+
+

Investment

+

+ {formatCompactNumber(indicators?.project_funding?.total_funding)} +

+
+
+
+ {!!data?.gallery?.data?.length && ( + <> +

+ Project Gallery +

+
+
+ {data.gallery.data.map((img, index) => ( +
+ {index >= 0 && index % 5 === 0 && ( +
+ AFOCO +
+ )} +
+ ))} +
-
- {data.gallery.data.map( - (img, index) => - index > 0 && - index % 5 != 0 && ( -
- AFOCO -
- ) - )} -
+
+ {data.gallery.data.map( + (img, index) => + index > 0 && + index % 5 != 0 && ( +
+ AFOCO +
+ ) + )}
- - )} +
+ + )} -

- If you have pictures of this project to share, please sent them to{' '} - - contact@afocosec.org - -

-
- +

+ If you have pictures of this project to share, please sent them to{' '} + + contact@afocosec.org + +

+
-
+ +

-
+

{data?.attributes?.status}

+

{data?.attributes?.project_code}

diff --git a/client/src/containers/projects/list.tsx b/client/src/containers/projects/list.tsx index bd2e087b..56dbaceb 100644 --- a/client/src/containers/projects/list.tsx +++ b/client/src/containers/projects/list.tsx @@ -8,10 +8,8 @@ import { useSetAtom } from 'jotai'; import { Search, X } from 'lucide-react'; import { hoveredProjectMapAtom } from '@/store'; -import { tmpBboxAtom } from '@/store'; import { useGetProjects } from '@/types/generated/project'; -import { Bbox } from '@/types/map'; import { useSyncQueryParams } from '@/hooks/datasets'; import { useSyncFilters } from '@/hooks/datasets/sync-query'; @@ -27,7 +25,6 @@ import FiltersSelected from '../filters/selected'; export default function ProjectsList() { const [searchValue, setSearchValue] = useState(null); - const setTempBbox = useSetAtom(tmpBboxAtom); const [filtersSettings] = useSyncFilters(); const setHoveredProjectList = useSetAtom(hoveredProjectMapAtom); const queryParams = useSyncQueryParams({ bbox: true }); @@ -140,7 +137,9 @@ export default function ProjectsList() { const handleHover = useCallback( (e: MouseEvent) => { const currentValue = e.currentTarget.getAttribute('data-value'); - setHoveredProjectList(currentValue); + if (currentValue) { + setHoveredProjectList([currentValue]); + } }, [setHoveredProjectList] ); diff --git a/client/src/containers/projects/stats.tsx b/client/src/containers/projects/stats.tsx index 8ae373c9..cc9c6178 100644 --- a/client/src/containers/projects/stats.tsx +++ b/client/src/containers/projects/stats.tsx @@ -190,7 +190,7 @@ export default function Stats() {
-

Total jobs

+

Total trainings

@@ -198,20 +198,20 @@ export default function Stats() {

- The total number of short- and long-term jobs generated by the - project interventions in the AFoCO Member Countries. + The total number of training activities conducted and participants + in the AFoCO Member Countries.

- {formatCompactNumber(data.jobs_total['value'])} + {formatCompactNumber(data.trainings_total['value'])}

{' '}
- {data.jobs && ( + {data.trainees && ( ({ + data={Object.entries(data.trainees['value']).map(([year, uv]) => ({ year, uv, }))} diff --git a/client/src/hooks/datasets/index.ts b/client/src/hooks/datasets/index.ts index e7d8c5db..72225692 100644 --- a/client/src/hooks/datasets/index.ts +++ b/client/src/hooks/datasets/index.ts @@ -1,5 +1,5 @@ 'use client'; -import { useCallback, useMemo } from 'react'; +import { useMemo } from 'react'; import { serialize } from './query-parsers'; import { diff --git a/client/src/hooks/datasets/query-parsers.ts b/client/src/hooks/datasets/query-parsers.ts index f77b4033..aee4f906 100644 --- a/client/src/hooks/datasets/query-parsers.ts +++ b/client/src/hooks/datasets/query-parsers.ts @@ -5,8 +5,6 @@ import type { MapSettings } from '@/types/map'; import type { FilterSettings } from '@/containers/filters/types'; -import { DEFAULT_BBOX } from '@/components/map/constants'; - export type ProjectsTab = 'statistics' | 'list'; export const filtersParser = parseAsJson().withDefault({}); diff --git a/client/src/store/index.ts b/client/src/store/index.ts index c4d717a4..f894b859 100644 --- a/client/src/store/index.ts +++ b/client/src/store/index.ts @@ -18,7 +18,7 @@ export const layersInteractiveIdsAtom = atom([]); export const popupAtom = atom(null); // set project code when hovering a project in the map or over sidebar list -export const hoveredProjectMapAtom = atom(null); +export const hoveredProjectMapAtom = atom(null); export const DEFAULT_SETTINGS = { expand: true, diff --git a/client/src/types/flags.ts b/client/src/types/flags.ts new file mode 100644 index 00000000..14bd3fe7 --- /dev/null +++ b/client/src/types/flags.ts @@ -0,0 +1,3 @@ +declare module 'country-iso-3-to-2' { + export default function iso3ToIso2(iso3: string): string | undefined; +} diff --git a/client/tsconfig.json b/client/tsconfig.json index 53acf969..b2cfffc1 100644 --- a/client/tsconfig.json +++ b/client/tsconfig.json @@ -24,6 +24,12 @@ "@/*": ["./src/*"] } }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "src/app/not-found.js"], + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + "src/app/not-found.js" + ], "exclude": ["node_modules"] } diff --git a/cms/src/admin/tsconfig.json b/cms/src/admin/tsconfig.json index 9be50785..2894cac8 100644 --- a/cms/src/admin/tsconfig.json +++ b/cms/src/admin/tsconfig.json @@ -1,13 +1,5 @@ { "extends": "@strapi/typescript-utils/tsconfigs/admin", - "include": [ - "../plugins/**/admin/src/**/*", - "./" - ], - "exclude": [ - "node_modules/", - "build/", - "dist/", - "**/*.test.ts" - ] + "include": ["../plugins/**/admin/src/**/*", "./"], + "exclude": ["node_modules/", "build/", "dist/", "**/*.test.ts"] } diff --git a/package.json b/package.json index bf08584e..1b31a321 100644 --- a/package.json +++ b/package.json @@ -15,5 +15,8 @@ }, "engines": { "node": ">= 18.17" + }, + "dependencies": { + "country-iso-3-to-2": "^1.1.1" } } diff --git a/yarn.lock b/yarn.lock index 9fa82467..712ad6b1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -51,6 +51,7 @@ __metadata: class-variance-authority: ^0.6.0 clsx: ^1.2.1 cmdk: 0.2.1 + country-iso-3-to-2: ^1.1.1 cypress: 13.6.4 d3-array: 3.2.4 deck.gl: 8.9.19 @@ -75,7 +76,6 @@ __metadata: react-map-gl: 7.0.25 react-markdown: 9.0.1 react-share: 5.1.0 - react-world-flags: 1.6.0 recharts: 2.12.1 remark-gfm: 4.0.0 rooks: 7.14.1 @@ -6005,13 +6005,6 @@ __metadata: languageName: node linkType: hard -"@trysound/sax@npm:0.2.0": - version: 0.2.0 - resolution: "@trysound/sax@npm:0.2.0" - checksum: 11226c39b52b391719a2a92e10183e4260d9651f86edced166da1d95f39a0a1eaa470e44d14ac685ccd6d3df7e2002433782872c0feeb260d61e80f21250e65c - languageName: node - linkType: hard - "@turf/bbox@npm:6.5.0": version: 6.5.0 resolution: "@turf/bbox@npm:6.5.0" @@ -7306,6 +7299,8 @@ __metadata: "afoco@workspace:.": version: 0.0.0-use.local resolution: "afoco@workspace:." + dependencies: + country-iso-3-to-2: ^1.1.1 languageName: unknown linkType: soft @@ -9133,13 +9128,6 @@ __metadata: languageName: node linkType: hard -"commander@npm:^7.2.0": - version: 7.2.0 - resolution: "commander@npm:7.2.0" - checksum: 53501cbeee61d5157546c0bef0fedb6cdfc763a882136284bed9a07225f09a14b82d2a84e7637edfd1a679fb35ed9502fd58ef1d091e6287f60d790147f68ddc - languageName: node - linkType: hard - "common-path-prefix@npm:^3.0.0": version: 3.0.0 resolution: "common-path-prefix@npm:3.0.0" @@ -9388,6 +9376,13 @@ __metadata: languageName: node linkType: hard +"country-iso-3-to-2@npm:^1.1.1": + version: 1.1.1 + resolution: "country-iso-3-to-2@npm:1.1.1" + checksum: 2fca9f7beb384cbfa1eb68a8b7dabae03356456a33d15ba899475fb7f73e92e71cfa6907f57d552f47230bba25e053f4f0300d6fab9ccf2a95363f340b6bb925 + languageName: node + linkType: hard + "crc@npm:^3.8.0": version: 3.8.0 resolution: "crc@npm:3.8.0" @@ -9513,26 +9508,6 @@ __metadata: languageName: node linkType: hard -"css-tree@npm:^2.3.1": - version: 2.3.1 - resolution: "css-tree@npm:2.3.1" - dependencies: - mdn-data: 2.0.30 - source-map-js: ^1.0.1 - checksum: 493cc24b5c22b05ee5314b8a0d72d8a5869491c1458017ae5ed75aeb6c3596637dbe1b11dac2548974624adec9f7a1f3a6cf40593dc1f9185eb0e8279543fbc0 - languageName: node - linkType: hard - -"css-tree@npm:~2.2.0": - version: 2.2.1 - resolution: "css-tree@npm:2.2.1" - dependencies: - mdn-data: 2.0.28 - source-map-js: ^1.0.1 - checksum: b94aa8cc2f09e6f66c91548411fcf74badcbad3e150345074715012d16333ce573596ff5dfca03c2a87edf1924716db765120f94247e919d72753628ba3aba27 - languageName: node - linkType: hard - "css-what@npm:^6.0.1, css-what@npm:^6.1.0": version: 6.1.0 resolution: "css-what@npm:6.1.0" @@ -9556,15 +9531,6 @@ __metadata: languageName: node linkType: hard -"csso@npm:^5.0.5": - version: 5.0.5 - resolution: "csso@npm:5.0.5" - dependencies: - css-tree: ~2.2.0 - checksum: 0ad858d36bf5012ed243e9ec69962a867509061986d2ee07cc040a4b26e4d062c00d4c07e5ba8d430706ceb02dd87edd30a52b5937fd45b1b6f2119c4993d59a - languageName: node - linkType: hard - "csstype@npm:^3.0.2": version: 3.1.2 resolution: "csstype@npm:3.1.2" @@ -15795,20 +15761,6 @@ __metadata: languageName: node linkType: hard -"mdn-data@npm:2.0.28": - version: 2.0.28 - resolution: "mdn-data@npm:2.0.28" - checksum: f51d587a6ebe8e426c3376c74ea6df3e19ec8241ed8e2466c9c8a3904d5d04397199ea4f15b8d34d14524b5de926d8724ae85207984be47e165817c26e49e0aa - languageName: node - linkType: hard - -"mdn-data@npm:2.0.30": - version: 2.0.30 - resolution: "mdn-data@npm:2.0.30" - checksum: d6ac5ac7439a1607df44b22738ecf83f48e66a0874e4482d6424a61c52da5cde5750f1d1229b6f5fa1b80a492be89465390da685b11f97d62b8adcc6e88189aa - languageName: node - linkType: hard - "mdurl@npm:^1.0.1": version: 1.0.1 resolution: "mdurl@npm:1.0.1" @@ -19147,19 +19099,6 @@ __metadata: languageName: node linkType: hard -"react-world-flags@npm:1.6.0": - version: 1.6.0 - resolution: "react-world-flags@npm:1.6.0" - dependencies: - svg-country-flags: ^1.2.10 - svgo: ^3.0.2 - world-countries: ^5.0.0 - peerDependencies: - react: ">=0.14" - checksum: 29ea43e8ce58c402bac5fe8323bcbc23930c661ed620c050705274ea95227118b62bc8543c636465ea027218c5b3a9dacaf0a10abf795cd7db6eabf41b60bc54 - languageName: node - linkType: hard - "react@npm:18.2.0, react@npm:^18.2.0": version: 18.2.0 resolution: "react@npm:18.2.0" @@ -20432,13 +20371,6 @@ __metadata: languageName: node linkType: hard -"source-map-js@npm:^1.0.1": - version: 1.1.0 - resolution: "source-map-js@npm:1.1.0" - checksum: 6ef39381cdf5451c3db406e4b0fa95657be3c35db76fe6df3be430174b2e6af3c0b57d9728328dc62a211ae6209a0295d6a26442a55d5fccbf7cf1211fffa80e - languageName: node - linkType: hard - "source-map-js@npm:^1.0.2": version: 1.0.2 resolution: "source-map-js@npm:1.0.2" @@ -21050,30 +20982,6 @@ __metadata: languageName: node linkType: hard -"svg-country-flags@npm:^1.2.10": - version: 1.2.10 - resolution: "svg-country-flags@npm:1.2.10" - checksum: 52e8a946d5f9edb8f52b2b98754943604e82b465009a01774310b15be018b749ffa8b600b0c78ad18a4efd7c247e80c0cee33ef3930a60b18f751c587706cbdd - languageName: node - linkType: hard - -"svgo@npm:^3.0.2": - version: 3.2.0 - resolution: "svgo@npm:3.2.0" - dependencies: - "@trysound/sax": 0.2.0 - commander: ^7.2.0 - css-select: ^5.1.0 - css-tree: ^2.3.1 - css-what: ^6.1.0 - csso: ^5.0.5 - picocolors: ^1.0.0 - bin: - svgo: ./bin/svgo - checksum: 42168748a5586d85d447bec2867bc19814a4897f973ff023e6aad4ff19ba7408be37cf3736e982bb78e3f1e52df8785da5dca77a8ebc64c0ebd6fcf9915d2895 - languageName: node - linkType: hard - "swagger-ui-dist@npm:4.19.0": version: 4.19.0 resolution: "swagger-ui-dist@npm:4.19.0" @@ -22586,13 +22494,6 @@ __metadata: languageName: node linkType: hard -"world-countries@npm:^5.0.0": - version: 5.0.0 - resolution: "world-countries@npm:5.0.0" - checksum: 10c58f7fdc7fae180574866c1662defd63c04c828a682aeec13f69ccb9c08d0eb0e328b84f35dc6ebfc3bd5996815bc3cfb102857ef09404751aa29028016fe7 - languageName: node - linkType: hard - "wrap-ansi-cjs@npm:wrap-ansi@^7.0.0, wrap-ansi@npm:^7.0.0": version: 7.0.0 resolution: "wrap-ansi@npm:7.0.0"