diff --git a/apps/climatemappedafrica/package.json b/apps/climatemappedafrica/package.json index 308612472..3bdfe54a1 100644 --- a/apps/climatemappedafrica/package.json +++ b/apps/climatemappedafrica/package.json @@ -78,6 +78,7 @@ "react-vega": "catalog:", "sharp": "catalog:", "simplebar-react": "catalog:", + "slate": "catalog:", "swr": "catalog:", "vega": "catalog:", "vega-embed": "catalog:", diff --git a/apps/climatemappedafrica/public/images/cms/blocks/hero.png b/apps/climatemappedafrica/public/images/cms/blocks/hero.png new file mode 100644 index 000000000..8cc7eccb5 Binary files /dev/null and b/apps/climatemappedafrica/public/images/cms/blocks/hero.png differ diff --git a/apps/climatemappedafrica/src/assets/images/bg-map-white.jpg b/apps/climatemappedafrica/src/assets/images/bg-map-white.jpg new file mode 100644 index 000000000..5873d7630 Binary files /dev/null and b/apps/climatemappedafrica/src/assets/images/bg-map-white.jpg differ diff --git a/apps/climatemappedafrica/src/components/Hero/Hero.js b/apps/climatemappedafrica/src/components/Hero/Hero.js new file mode 100644 index 000000000..7f375673c --- /dev/null +++ b/apps/climatemappedafrica/src/components/Hero/Hero.js @@ -0,0 +1,163 @@ +import { RichTypography } from "@commons-ui/core"; +import { Box, Grid, useMediaQuery } from "@mui/material"; +import dynamic from "next/dynamic"; +import PropTypes from "prop-types"; +import React, { useState } from "react"; + +import heroBg from "@/climatemappedafrica/assets/images/bg-map-white.jpg"; +import DropdownSearch from "@/climatemappedafrica/components/DropdownSearch"; +import Image from "@/climatemappedafrica/components/Image"; +import RichHeader from "@/climatemappedafrica/components/RichHeader"; +import Section from "@/climatemappedafrica/components/Section"; + +const Map = dynamic(() => import("./Map"), { ssr: false }); + +function Hero({ + comment, + title, + subtitle, + searchLabel, + featuredLocations, + searchPlaceholder, + properties, + location: { center }, + level, + ...props +}) { + const isUpLg = useMediaQuery((theme) => theme.breakpoints.up("lg")); + const [hoverGeo, setHoverGeo] = useState(null); + const continentLevelZoom = isUpLg ? 2.4 : 2.1; + const countryLevelZoom = isUpLg ? 6 : 5.25; + const zoom = level === "continent" ? continentLevelZoom : countryLevelZoom; + + return ( + + + + +
+ + + + + {title} + + + + {comment} + + + {/* Since map is dynamic-ally loaded, no need for implementation="css" */} + + + {center ? ( + + ) : null} + + + {hoverGeo} + + + + + +
+
+ ); +} + +Hero.propTypes = { + comment: PropTypes.string, + subtitle: PropTypes.arrayOf(PropTypes.shape({})), + searchLabel: PropTypes.string, + title: PropTypes.string, + featuredLocations: PropTypes.arrayOf(PropTypes.shape({})), + properties: PropTypes.shape({}), + level: PropTypes.string, +}; + +export default Hero; diff --git a/apps/climatemappedafrica/src/components/Hero/Hero.snap.js b/apps/climatemappedafrica/src/components/Hero/Hero.snap.js new file mode 100644 index 000000000..4fc9fb1e1 --- /dev/null +++ b/apps/climatemappedafrica/src/components/Hero/Hero.snap.js @@ -0,0 +1,113 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` renders unchanged 1`] = ` +
+
+
+ +
+
+
+
+
+
+
+

+ Data to hold your government accountable +

+
+
+
+ PesaYetu helps journalists, researchers and activists transform their work with in-depth county-specific information. Get started now with datasets from Kenya. + +
+
+
+ +
+
+
+
+
+
+
+
+
+`; diff --git a/apps/climatemappedafrica/src/components/Hero/Hero.test.js b/apps/climatemappedafrica/src/components/Hero/Hero.test.js new file mode 100644 index 000000000..566723acb --- /dev/null +++ b/apps/climatemappedafrica/src/components/Hero/Hero.test.js @@ -0,0 +1,58 @@ +import { createRender } from "@commons-ui/testing-library"; +import React from "react"; + +import Hero from "./Hero"; + +import theme from "@/climatemappedafrica/theme"; + +// eslint-disable-next-line testing-library/render-result-naming-convention +const render = createRender({ theme }); + +const defaultProps = { + title: [ + { + children: [ + { text: "Data to hold your government accountable", children: null }, + ], + }, + ], + subtitle: [ + { + children: [ + { + text: "PesaYetu helps journalists, researchers and activists transform their work with in-depth county-specific information. Get started now with datasets from Kenya.\n", + children: null, + }, + ], + }, + ], + searchLabel: "Search for a location", + featuredLocations: [], + searchPlaceholder: "Search for a location", + properties: { + code: "KE", + name: "Kenya", + area: 586002.515, + parent: "AF", + level: "country", + version: "Climate", + }, + location: {}, + level: "country", + id: "670e3996766697e7feb349d5", + blockType: "hero", + slug: "hero", + boundary: { + type: "FeatureCollection", + features: [], + }, + variant: "explore", + icon: null, +}; + +describe("", () => { + it("renders unchanged", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); +}); diff --git a/apps/climatemappedafrica/src/components/Hero/Map.js b/apps/climatemappedafrica/src/components/Hero/Map.js new file mode 100644 index 000000000..8b34ab9f2 --- /dev/null +++ b/apps/climatemappedafrica/src/components/Hero/Map.js @@ -0,0 +1,119 @@ +import { Box } from "@mui/material"; +import { useRouter } from "next/router"; +import PropTypes from "prop-types"; +import React from "react"; +import { MapContainer, GeoJSON } from "react-leaflet"; + +import "leaflet/dist/leaflet.css"; +import theme from "@/climatemappedafrica/theme"; + +function Map({ + center, + zoom, + boundary, + styles = { + height: "100%", + width: "100%", + }, + geoJSONStyles = { + color: "#2A2A2C", + weight: 1, + opacity: 1, + fillColor: "#fff", + dashArray: "2", + }, + onLayerMouseOver, + featuredLocations, +}) { + const router = useRouter(); + + const countyCodes = featuredLocations?.map(({ code }) => code); + + const onEachFeature = (feature, layer) => { + layer.setStyle({ + fillColor: theme.palette.background.default, + fillOpacity: 1, + }); + + if (countyCodes.includes(feature.properties.code?.toLowerCase())) { + layer.setStyle({ + weight: 1.5, + dashArray: 0, + }); + layer.on("mouseover", () => { + onLayerMouseOver(feature.properties.name.toLowerCase()); + layer.setStyle({ + fillColor: theme.palette.primary.main, + fillOpacity: 0.5, + }); + }); + layer.on("mouseout", () => { + onLayerMouseOver(null); + layer.setStyle({ + fillOpacity: 1, + fillColor: theme.palette.background.default, + }); + }); + layer.on("click", () => { + router.push(`/explore/${feature.properties.code.toLowerCase()}`); + }); + } + }; + + return ( + ({ + position: "relative", + height: { sm: "299px", lg: "471px" }, + width: { sm: "236px", lg: "371px" }, + marginTop: { sm: "55px", lg: "42px" }, + "& .leaflet-container": { + background: "transparent", + }, + })} + > + + + + + ); +} + +Map.propTypes = { + center: (props, propName, componentName) => { + const { [propName]: prop } = props; + if (!Array.isArray(prop) || prop.length !== 2 || prop.some(Number.isNaN)) { + return new Error( + `Invalid prop \`${propName}\` supplied to` + + ` \`${componentName}\`. Validation failed.`, + ); + } + return null; + }, + zoom: PropTypes.number, + styles: PropTypes.shape({}), + boundary: PropTypes.shape({}), + geoJSONStyles: PropTypes.shape({}), + onLayerMouseOver: PropTypes.func, + featuredLocations: PropTypes.arrayOf( + PropTypes.shape({ code: PropTypes.string }), + ), +}; + +export default Map; diff --git a/apps/climatemappedafrica/src/components/Hero/index.js b/apps/climatemappedafrica/src/components/Hero/index.js new file mode 100644 index 000000000..4ca8fd8ad --- /dev/null +++ b/apps/climatemappedafrica/src/components/Hero/index.js @@ -0,0 +1,3 @@ +import Hero from "./Hero"; + +export default Hero; diff --git a/apps/climatemappedafrica/src/components/LineClampedRichTypography/LineClampedRichTypography.js b/apps/climatemappedafrica/src/components/LineClampedRichTypography/LineClampedRichTypography.js new file mode 100644 index 000000000..134af62dd --- /dev/null +++ b/apps/climatemappedafrica/src/components/LineClampedRichTypography/LineClampedRichTypography.js @@ -0,0 +1,54 @@ +import { RichTypography } from "@commons-ui/next"; +import { styled } from "@mui/material/styles"; +import PropTypes from "prop-types"; +import React from "react"; + +const LineClampedRichTypographyRoot = styled(RichTypography, { + shouldForwardProp: (prop) => !["lineClamp"].includes(prop), +})(({ lineClamp, theme }) => ({ + ...(lineClamp && { + display: "-webkit-box", + overflow: "hidden", + textOverflow: "ellipsis", + WebkitBoxOrient: "vertical", + ...Object.keys(theme.breakpoints.values).reduce((acc, cur) => { + let accBreakpoint = acc; + if (theme.breakpoints.values[cur]) { + accBreakpoint = {}; + acc[theme.breakpoints.up(cur)] = accBreakpoint; + } + let lineClampValue; + if (typeof lineClamp !== "object") { + lineClampValue = lineClamp?.toString(); + } else if (typeof lineClamp?.[cur] !== "object") { + lineClampValue = lineClamp[cur]?.toString(); + } + if (lineClampValue) { + accBreakpoint.WebkitLineClamp = lineClampValue; + accBreakpoint.lineClamp = lineClampValue; + } + + return acc; + }, {}), + }), +})); + +const LineClampedRichTypography = React.forwardRef( + function LineClampedRichTypography(props, ref) { + return ; + }, +); + +LineClampedRichTypography.propTypes = { + lineClamp: PropTypes.oneOfType([ + PropTypes.shape({}), + PropTypes.number, + PropTypes.string, + ]), +}; + +LineClampedRichTypography.defaultProps = { + lineClamp: undefined, +}; + +export default LineClampedRichTypography; diff --git a/apps/climatemappedafrica/src/components/LineClampedRichTypography/index.js b/apps/climatemappedafrica/src/components/LineClampedRichTypography/index.js new file mode 100644 index 000000000..71636634f --- /dev/null +++ b/apps/climatemappedafrica/src/components/LineClampedRichTypography/index.js @@ -0,0 +1,3 @@ +import LineClampedRichTypography from "./LineClampedRichTypography"; + +export default LineClampedRichTypography; diff --git a/apps/climatemappedafrica/src/lib/data/blockify/hero.js b/apps/climatemappedafrica/src/lib/data/blockify/hero.js new file mode 100644 index 000000000..69eaccaf0 --- /dev/null +++ b/apps/climatemappedafrica/src/lib/data/blockify/hero.js @@ -0,0 +1,33 @@ +import { + fetchProfile, + fetchProfileGeography, +} from "@/climatemappedafrica/lib/hurumap"; + +export default async function hero(block) { + const { geometries } = await fetchProfileGeography( + block.location?.name?.toLowerCase(), + ); + const { level } = geometries.boundary?.properties ?? {}; + const childLevelMaps = { + continent: "country", + country: "region", + }; + const childLevel = childLevelMaps[level]; + const { locations, preferredChildren } = await fetchProfile(); + const preferredChildrenPerLevel = preferredChildren[level]; + const { children } = geometries; + const preferredLevel = + preferredChildrenPerLevel?.find((l) => children[l]) ?? null; + const featuredLocations = locations.filter( + (location) => location.level === childLevel, + ); + const boundary = children[preferredLevel]; + return { + ...block, + slug: "hero", + boundary, + featuredLocations, + level, + properties: geometries.boundary?.properties, + }; +} diff --git a/apps/climatemappedafrica/src/lib/data/blockify/index.js b/apps/climatemappedafrica/src/lib/data/blockify/index.js index 07d61107f..3aed58b79 100644 --- a/apps/climatemappedafrica/src/lib/data/blockify/index.js +++ b/apps/climatemappedafrica/src/lib/data/blockify/index.js @@ -1,7 +1,9 @@ +import hero from "./hero"; import pageHero from "./page-hero"; import team from "./team"; const propsifyBlockBySlug = { + hero, "page-hero": pageHero, team, }; diff --git a/apps/climatemappedafrica/src/pages/[[...slug]].js b/apps/climatemappedafrica/src/pages/[[...slugs]].js similarity index 96% rename from apps/climatemappedafrica/src/pages/[[...slug]].js rename to apps/climatemappedafrica/src/pages/[[...slugs]].js index d5b79793e..24a6d30f9 100644 --- a/apps/climatemappedafrica/src/pages/[[...slug]].js +++ b/apps/climatemappedafrica/src/pages/[[...slugs]].js @@ -4,12 +4,14 @@ import { SWRConfig } from "swr"; import AboutTeam from "@/climatemappedafrica/components/AboutTeam"; import Footer from "@/climatemappedafrica/components/Footer"; +import Hero from "@/climatemappedafrica/components/Hero"; import Navigation from "@/climatemappedafrica/components/Navigation"; import PageHero from "@/climatemappedafrica/components/PageHero"; import Summary from "@/climatemappedafrica/components/Summary"; import { getPageServerSideProps } from "@/climatemappedafrica/lib/data"; const componentsBySlugs = { + hero: Hero, "page-hero": PageHero, summary: Summary, team: AboutTeam, diff --git a/apps/climatemappedafrica/src/pages/api/hurumap/profiles.js b/apps/climatemappedafrica/src/pages/api/hurumap/profiles.js new file mode 100644 index 000000000..ae3ff17a5 --- /dev/null +++ b/apps/climatemappedafrica/src/pages/api/hurumap/profiles.js @@ -0,0 +1,25 @@ +import { fetchProfile } from "@/climatemappedafrica/lib/hurumap"; + +let cache = null; +let cacheExpiry = 0; + +export default async function handler(req, res) { + if (req.method === "GET") { + const now = Date.now(); + + if (cache && now < cacheExpiry) { + return res.status(200).json(cache); + } + + try { + const result = await fetchProfile(); + cache = result; + cacheExpiry = now + 5 * 60 * 1000; + return res.status(200).json(result); + } catch (err) { + return res.status(500).json(err.message); + } + } + + return res.status(405).end(); +} diff --git a/apps/climatemappedafrica/src/payload/blocks/Hero.js b/apps/climatemappedafrica/src/payload/blocks/Hero.js new file mode 100644 index 000000000..ba1b08a90 --- /dev/null +++ b/apps/climatemappedafrica/src/payload/blocks/Hero.js @@ -0,0 +1,72 @@ +import LocationSelect, { validateLocation } from "../fields/LocationSelect"; +import richText from "../fields/richText"; + +const Hero = { + slug: "hero", + imageURL: "/images/cms/blocks/hero.png", + imageAltText: "Used in homepage", + labels: { + singular: "Hero", + plural: "Hero", + }, + fields: [ + richText({ + name: "title", + required: true, + label: "Title", + localized: true, + }), + richText({ + name: "subtitle", + required: true, + localized: true, + }), + { + name: "searchLabel", + type: "text", + label: "Search Label", + localized: true, + required: true, + }, + { + name: "searchPlaceholder", + type: "text", + label: "Search Placeholder", + localized: true, + }, + { + name: "location", + label: "Featured Location", + type: "group", + fields: [ + { + name: "name", + type: "text", + required: true, + hasMany: false, + defaultValue: "af", + validate: validateLocation, + localized: true, + admin: { + components: { + Field: LocationSelect, + }, + }, + }, + { + name: "center", + label: "Center Point", + type: "point", + }, + ], + }, + { + name: "comment", + type: "text", + label: "Comment", + localized: true, + }, + ], +}; + +export default Hero; diff --git a/apps/climatemappedafrica/src/payload/collections/Pages.js b/apps/climatemappedafrica/src/payload/collections/Pages.js index b12ef87f3..c98d41908 100644 --- a/apps/climatemappedafrica/src/payload/collections/Pages.js +++ b/apps/climatemappedafrica/src/payload/collections/Pages.js @@ -1,3 +1,4 @@ +import Hero from "../blocks/Hero"; import PageHero from "../blocks/PageHero"; import Summary from "../blocks/Summary"; import Team from "../blocks/Team"; @@ -30,7 +31,7 @@ const Pages = { { name: "blocks", type: "blocks", - blocks: [PageHero, Summary, Team], + blocks: [Hero, PageHero, Summary, Team], localized: true, admin: { initCollapsed: true, diff --git a/apps/climatemappedafrica/src/payload/fields/LocationSelect.js b/apps/climatemappedafrica/src/payload/fields/LocationSelect.js new file mode 100644 index 000000000..7c11e4b49 --- /dev/null +++ b/apps/climatemappedafrica/src/payload/fields/LocationSelect.js @@ -0,0 +1,45 @@ +import { Select } from "payload/components/forms"; +import { select } from "payload/dist/fields/validations"; +import { createElement, useMemo } from "react"; +import useSWR from "swr"; + +const fetcher = (url) => fetch(url).then((res) => res.json()); +const apiUrl = process.env.PAYLOAD_PUBLIC_APP_URL; + +export async function validateLocation(value, { hasMany, required, t }) { + const response = await fetch(`${apiUrl}/api/hurumap/profiles`); + const data = await response.json(); + const { locations } = data ?? {}; + const options = + locations + ?.filter(({ level }) => level !== "region") + ?.map((location) => ({ + label: location.name, + value: location.code, + })) || []; + return select(value, { hasMany, options, required, t }); +} + +function LocationSelect(props) { + const url = `${apiUrl}/api/hurumap/profiles`; + const { data } = useSWR(url, fetcher, { + dedupingInterval: 60000, + revalidateOnFocus: false, + }); + const { locations } = data ?? {}; + const options = useMemo( + () => + ( + locations + ?.filter(({ level }) => level !== "region") + ?.map((location) => ({ + label: location.name, + value: location.code, + })) || [] + ).sort((a, b) => a.label.localeCompare(b.label)), + [locations], + ); + return createElement(Select, { ...props, options }); +} + +export default LocationSelect; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5fb2fa8d8..15d2511ab 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1175,6 +1175,9 @@ importers: simplebar-react: specifier: 'catalog:' version: 3.2.6(react@18.3.1) + slate: + specifier: 'catalog:' + version: 0.103.0 swr: specifier: 'catalog:' version: 2.2.5(react@18.3.1) @@ -22808,7 +22811,7 @@ snapshots: dependencies: debug: 3.2.7 enhanced-resolve: 0.9.1 - eslint-plugin-import: 2.30.0(eslint-import-resolver-typescript@3.6.3)(eslint-import-resolver-webpack@0.13.9)(eslint@8.57.1) + eslint-plugin-import: 2.30.0(eslint-import-resolver-webpack@0.13.9)(eslint@8.57.1) find-root: 1.1.0 hasown: 2.0.2 interpret: 1.4.0