diff --git a/apps/climatemappedafrica/src/components/DropdownSearch/DownloadSearch.js b/apps/climatemappedafrica/src/components/DropdownSearch/DownloadSearch.js index 5fd43ea1e..4c2488f6f 100644 --- a/apps/climatemappedafrica/src/components/DropdownSearch/DownloadSearch.js +++ b/apps/climatemappedafrica/src/components/DropdownSearch/DownloadSearch.js @@ -116,12 +116,12 @@ function DropdownSearch({ value={query} sx={({ typography, palette }) => ({ borderRadius: typography.pxToRem(10), - color: palette.primary.main, + color: palette.text.primary, border: `2px solid ${palette.text.hint}`, width: typography.pxToRem(278), backgroundColor: "inherit", height: typography.pxToRem(48), - padding: 0, + padding: `0 0 0 ${typography.pxToRem(20)}`, "&.MuiInputBase-input": { backgroundColor: "inherit", height: typography.pxToRem(48), diff --git a/apps/climatemappedafrica/src/components/DropdownSearch/DropdownSearch.snap.js b/apps/climatemappedafrica/src/components/DropdownSearch/DropdownSearch.snap.js index 06576c3fb..c4897ddd9 100644 --- a/apps/climatemappedafrica/src/components/DropdownSearch/DropdownSearch.snap.js +++ b/apps/climatemappedafrica/src/components/DropdownSearch/DropdownSearch.snap.js @@ -12,7 +12,7 @@ exports[` renders unchanged 1`] = ` Search for a location

{ if (data) { dispatch({ @@ -235,6 +241,8 @@ ExplorePage.propTypes = { }), ), ]), + hurumapUrl: PropTypes.string, + profileId: PropTypes.number, }; export default ExplorePage; diff --git a/apps/climatemappedafrica/src/components/ExplorePage/useProfileGeography.js b/apps/climatemappedafrica/src/components/ExplorePage/useProfileGeography.js index 108fa7542..0c7b4b786 100644 --- a/apps/climatemappedafrica/src/components/ExplorePage/useProfileGeography.js +++ b/apps/climatemappedafrica/src/components/ExplorePage/useProfileGeography.js @@ -2,9 +2,11 @@ import useSWR from "swr"; import fetchJson from "@/climatemappedafrica/utils/fetchJson"; -function useProfileGeography(shouldFetch) { +function useProfileGeography(shouldFetch, hurumapUrl, profileId) { const fetcher = (code) => { - return fetchJson(`/api/hurumap/geographies/${code}`); + return fetchJson( + `/api/hurumap/geographies/${code}?profileId=${profileId}&baseUrl=${hurumapUrl}`, + ); }; const { data, error } = useSWR(shouldFetch, fetcher); diff --git a/apps/climatemappedafrica/src/components/Hero/Hero.snap.js b/apps/climatemappedafrica/src/components/Hero/Hero.snap.js index e8ba6f322..458b3e886 100644 --- a/apps/climatemappedafrica/src/components/Hero/Hero.snap.js +++ b/apps/climatemappedafrica/src/components/Hero/Hero.snap.js @@ -61,7 +61,7 @@ exports[` renders unchanged 1`] = ` Search for a location

({ }, })); -function ExploreNavigation({ explorePagePath, locations, logo, variant }) { +function ExploreNavigation({ + explorePagePath, + locations, + logo, + tutorialEnabled, + variant, +}) { const classes = useStyles(); const { setIsOpen } = useTour(); @@ -83,24 +89,25 @@ function ExploreNavigation({ explorePagePath, locations, logo, variant }) { menuItem: classes.searchMenuItem, }} /> - ({ - color: "#666666", - textAlign: "center", - backgroundColor: "#EBEBEB", - borderRadius: theme.typography.pxToRem(60), - marginLeft: theme.typography.pxToRem(20), - width: theme.typography.pxToRem(48), - height: theme.typography.pxToRem(48), - cursor: "pointer", - })} - > - ? - + {tutorialEnabled && ( + + )} diff --git a/apps/climatemappedafrica/src/lib/data/blockify/explore-page.js b/apps/climatemappedafrica/src/lib/data/blockify/explore-page.js index 2f2179445..ddeb740a2 100644 --- a/apps/climatemappedafrica/src/lib/data/blockify/explore-page.js +++ b/apps/climatemappedafrica/src/lib/data/blockify/explore-page.js @@ -6,9 +6,11 @@ import { fetchProfileGeography } from "@/climatemappedafrica/lib/hurumap"; */ async function explorePage(block, _api, _context, { hurumap }) { const { + hurumapUrl, items: panelItems, labels: { dataNotAvailable, scrollToTop: scrollToTopLabel }, profile: hurumapProfile, + profileId, profilePage, rootGeography, } = hurumap; @@ -30,10 +32,16 @@ async function explorePage(block, _api, _context, { hurumap }) { } const [primaryCode, secondaryCode] = geoCodes; - const primaryProfile = await fetchProfileGeography(primaryCode); + const primaryProfile = await fetchProfileGeography(primaryCode, { + baseUrl: hurumapUrl, + profileId, + }); const profile = [primaryProfile]; if (secondaryCode) { - const secondaryProfile = await fetchProfileGeography(secondaryCode); + const secondaryProfile = await fetchProfileGeography(secondaryCode, { + baseUrl: hurumapUrl, + profileId, + }); profile.push(secondaryProfile); } @@ -46,6 +54,8 @@ async function explorePage(block, _api, _context, { hurumap }) { id: "explore-page", blockType: "explore-page", choropleth, + hurumapUrl, + profileId, rootGeography, explorePagePath: profilePage.slug, locations, diff --git a/apps/climatemappedafrica/src/lib/data/blockify/hero.js b/apps/climatemappedafrica/src/lib/data/blockify/hero.js index 580ba8032..d4142c684 100644 --- a/apps/climatemappedafrica/src/lib/data/blockify/hero.js +++ b/apps/climatemappedafrica/src/lib/data/blockify/hero.js @@ -12,18 +12,26 @@ import { */ export default async function hero(block, _api, _context, { hurumap }) { const { + hurumapUrl, + profileId, profilePage, rootGeography: { center, code, hasData: pinRootGeography, zoom }, profile: hurumapProfile, } = hurumap ?? { rootGeography: {} }; - const { geometries } = await fetchProfileGeography(code.toLowerCase()); + const { geometries } = await fetchProfileGeography(code.toLowerCase(), { + baseUrl: hurumapUrl, + profileId, + }); const { level } = geometries.boundary?.properties ?? {}; const childLevelMaps = { continent: "country", country: "region", }; const childLevel = childLevelMaps[level]; - const { locations, preferredChildren } = await fetchProfile(); + const { locations, preferredChildren } = await fetchProfile({ + baseUrl: hurumapUrl, + profileId, + }); const chloropleth = hurumapProfile?.choropleth ?? null; const { choropleth, legend } = generateChoropleth( chloropleth, diff --git a/apps/climatemappedafrica/src/lib/data/common/index.js b/apps/climatemappedafrica/src/lib/data/common/index.js index b784c3552..e909cc913 100644 --- a/apps/climatemappedafrica/src/lib/data/common/index.js +++ b/apps/climatemappedafrica/src/lib/data/common/index.js @@ -50,11 +50,13 @@ async function getNavBar(variant, settings) { const socialLinks = links?.filter((link) => connect.includes(link.platform)); let explorePagePath = null; let locations = null; + let tutorialEnabled; if (hurumap?.enabled) { explorePagePath = hurumap.profilePage.slug; if (hurumap.profile) { locations = hurumap.profile.locations; } + tutorialEnabled = hurumap.tutorial?.enabled; } return { @@ -64,6 +66,7 @@ async function getNavBar(variant, settings) { logo: imageFromMedia(title, primaryLogo.url), menus, socialLinks, + tutorialEnabled, variant, }; } @@ -118,8 +121,13 @@ export async function getPageProps(api, context) { const hurumapSettings = await api.findGlobal("settings-hurumap"); if (hurumapSettings?.enabled) { // TODO(koech): Handle cases when fetching profile fails? - const profile = await fetchProfile(); - const { page: hurumapPage, ...otherHurumapSettings } = hurumapSettings; + const { + url: hurumapUrl, + page: hurumapPage, + profile: profileId, + ...otherHurumapSettings + } = hurumapSettings; + const profile = await fetchProfile({ baseUrl: hurumapUrl, profileId }); const { value: profilePage } = hurumapPage; if (slug === profilePage.slug) { variant = "explore"; @@ -135,7 +143,9 @@ export async function getPageProps(api, context) { } settings.hurumap = { ...otherHurumapSettings, + hurumapUrl, profile, + profileId, profilePage, }; } diff --git a/apps/climatemappedafrica/src/lib/hurumap/index.js b/apps/climatemappedafrica/src/lib/hurumap/index.js index 550c5cb56..6ce4ec6f8 100644 --- a/apps/climatemappedafrica/src/lib/hurumap/index.js +++ b/apps/climatemappedafrica/src/lib/hurumap/index.js @@ -1,13 +1,10 @@ import defaultIcon from "@/climatemappedafrica/assets/icons/eye-white.svg"; -import { hurumap } from "@/climatemappedafrica/config"; import fetchJson from "@/climatemappedafrica/utils/fetchJson"; import formatNumericalValue from "@/climatemappedafrica/utils/formatNumericalValue"; -const apiUrl = process.env.HURUMAP_API_URL || hurumap?.api?.url; - -export async function fetchProfile() { +export async function fetchProfile({ baseUrl, profileId }) { const { configuration } = await fetchJson( - new URL("/api/v1/profiles/1/?format=json", apiUrl), + new URL(`/api/v1/profiles/${profileId}/?format=json`, baseUrl), ); const locations = configuration?.featured_locations?.map( @@ -24,6 +21,12 @@ export async function fetchProfile() { }; } +export async function fetchProfiles(baseUrl) { + const { results } = await fetchJson(new URL("/api/v1/profiles", baseUrl)); + const profiles = results.map(({ name, id }) => ({ name, id })); + return profiles; +} + function formatProfileGeographyData(data, parent) { if (!data) { return null; @@ -85,12 +88,15 @@ function formatProfileGeographyData(data, parent) { .filter((category) => category.children.length); } -export async function fetchProfileGeography(geoCode) { +export async function fetchProfileGeography( + geoCode, + { baseUrl, profileId, version = "Climate" }, +) { // HURUmap codes are uppercased in the API const json = await fetchJson( new URL( - `/api/v1/all_details/profile/1/geography/${geoCode.toUpperCase()}/?version=Climate`, - apiUrl, + `/api/v1/all_details/profile/${profileId}/geography/${geoCode.toUpperCase()}/?version=${version}`, + baseUrl, ), ); const { boundary, children, parent_layers: parents } = json; @@ -135,8 +141,8 @@ export async function fetchProfileGeography(geoCode) { if (parentCode) { const parentJson = await fetchJson( new URL( - `/api/v1/all_details/profile/1/geography/${parentCode.toUpperCase()}/?version=Climate`, - apiUrl, + `/api/v1/all_details/profile/${profileId}/geography/${parentCode.toUpperCase()}/?version=${version}`, + baseUrl, ), ); parent.data = parentJson.profile.profile_data; diff --git a/apps/climatemappedafrica/src/pages/api/hurumap/geographies/[geoCode].js b/apps/climatemappedafrica/src/pages/api/hurumap/geographies/[geoCode].js index fe7619246..34e41689f 100644 --- a/apps/climatemappedafrica/src/pages/api/hurumap/geographies/[geoCode].js +++ b/apps/climatemappedafrica/src/pages/api/hurumap/geographies/[geoCode].js @@ -1,10 +1,14 @@ import { fetchProfileGeography } from "@/climatemappedafrica/lib/hurumap"; export default async function index(req, res) { + const { profileId, baseUrl } = req.query; if (req.method === "GET") { try { const { geoCode } = req.query; - const result = await fetchProfileGeography(geoCode); + const result = await fetchProfileGeography(geoCode, { + baseUrl, + profileId, + }); return res.status(200).json(result); } catch (err) { return res.status(500).json(err.message); diff --git a/apps/climatemappedafrica/src/pages/api/hurumap/profiles.js b/apps/climatemappedafrica/src/pages/api/hurumap/profiles/[id].js similarity index 83% rename from apps/climatemappedafrica/src/pages/api/hurumap/profiles.js rename to apps/climatemappedafrica/src/pages/api/hurumap/profiles/[id].js index ae3ff17a5..e8c246ae4 100644 --- a/apps/climatemappedafrica/src/pages/api/hurumap/profiles.js +++ b/apps/climatemappedafrica/src/pages/api/hurumap/profiles/[id].js @@ -4,6 +4,7 @@ let cache = null; let cacheExpiry = 0; export default async function handler(req, res) { + const { id, baseUrl } = req.query; if (req.method === "GET") { const now = Date.now(); @@ -12,7 +13,7 @@ export default async function handler(req, res) { } try { - const result = await fetchProfile(); + const result = await fetchProfile({ baseUrl, profileId: id }); cache = result; cacheExpiry = now + 5 * 60 * 1000; return res.status(200).json(result); diff --git a/apps/climatemappedafrica/src/pages/api/hurumap/profiles/index.js b/apps/climatemappedafrica/src/pages/api/hurumap/profiles/index.js new file mode 100644 index 000000000..54f044379 --- /dev/null +++ b/apps/climatemappedafrica/src/pages/api/hurumap/profiles/index.js @@ -0,0 +1,26 @@ +import { fetchProfiles } from "@/climatemappedafrica/lib/hurumap"; + +let cache = null; +let cacheExpiry = 0; + +export default async function handler(req, res) { + const { baseUrl } = req.query; + if (req.method === "GET") { + const now = Date.now(); + + if (cache && now < cacheExpiry) { + return res.status(200).json(cache); + } + + try { + const result = await fetchProfiles(baseUrl); + 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/fields/HURUmapURL.js b/apps/climatemappedafrica/src/payload/fields/HURUmapURL.js new file mode 100644 index 000000000..666b9a20a --- /dev/null +++ b/apps/climatemappedafrica/src/payload/fields/HURUmapURL.js @@ -0,0 +1,85 @@ +import { Button } from "payload/components/elements"; +import { + TextInput, + reduceFieldsToValues, + useField, + useFormFields, +} from "payload/components/forms"; +import { createElement, useState } from "react"; + +// TODO: @kelvinkipruto Handle i18n +function HURUmapURL(props) { + const { + admin: { description }, + path, + } = props; + const { value, setValue } = useField({ path }); + const [loading, setLoading] = useState(false); + const [formFields, updateFormField] = useFormFields(([fields, dispatch]) => [ + fields, + dispatch, + ]); + const { urlValid } = reduceFieldsToValues(formFields, true); + + const validateURL = async () => { + if (!value) return; + setLoading(true); + try { + // For now we can use the profiles endpoint to check if the URL is valid + // Ideally we should have a dedicated endpoint for this, like /api/v1/validate or /api/v1/health + const response = await fetch(`${value}/profiles`); + updateFormField({ + type: "UPDATE", + path: "urlValid", + value: response.ok, + }); + } catch (error) { + updateFormField({ + type: "UPDATE", + path: "urlValid", + value: false, + }); + } finally { + setLoading(false); + } + }; + + const handleInputChange = (e) => { + const newUrl = e.target.value; + setValue(newUrl); + }; + + return createElement( + "div", + { + id: "hurumap-url-wrapper", + style: { + display: "flex", + alignItems: "center", + gap: "10px", + }, + }, + createElement(TextInput, { + ...props, + value, + onChange: handleInputChange, + description, + errorMessage: + !urlValid && + "Invalid URL. Please enter a valid URL to continue configuration.", + showError: !urlValid, + }), + createElement( + Button, + { + type: "button", + onClick: () => validateURL(), + className: "btn btn--style-primary", + disabled: loading, + }, + loading ? "Checking..." : "Validate URL", + ), + ); +} + +export default HURUmapURL; diff --git a/apps/climatemappedafrica/src/payload/fields/LocationSelect.js b/apps/climatemappedafrica/src/payload/fields/LocationSelect.js index 293f4b762..9f6c24c66 100644 --- a/apps/climatemappedafrica/src/payload/fields/LocationSelect.js +++ b/apps/climatemappedafrica/src/payload/fields/LocationSelect.js @@ -1,5 +1,8 @@ -import { Select } from "payload/components/forms"; -import { select } from "payload/dist/fields/validations"; +import { + Select, + useAllFormFields, + reduceFieldsToValues, +} from "payload/components/forms"; import { createElement, useMemo } from "react"; import useSWR from "swr"; @@ -14,17 +17,17 @@ const getOptions = (locations) => value: location.code, })) || []; -export async function validateLocation(value, { hasMany, required, t }) { - const data = await fetcher(`${apiUrl}/api/hurumap/profiles`); - const options = getOptions(data.locations); - return select(value, { hasMany, options, required, t }); -} - function LocationSelect(props) { - const { data } = useSWR(`${apiUrl}/api/hurumap/profiles`, fetcher, { - dedupingInterval: 60000, - revalidateOnFocus: false, - }); + const [fields] = useAllFormFields(); + const { profile, url } = reduceFieldsToValues(fields, true); + const { data } = useSWR( + `${apiUrl}/api/hurumap/profiles/${profile}?baseUrl=${url}`, + fetcher, + { + dedupingInterval: 60000, + revalidateOnFocus: false, + }, + ); const options = useMemo( () => diff --git a/apps/climatemappedafrica/src/payload/fields/ProfileSelect.js b/apps/climatemappedafrica/src/payload/fields/ProfileSelect.js new file mode 100644 index 000000000..843b5507d --- /dev/null +++ b/apps/climatemappedafrica/src/payload/fields/ProfileSelect.js @@ -0,0 +1,31 @@ +import { + Select, + useAllFormFields, + reduceFieldsToValues, +} from "payload/components/forms"; +import { createElement, useMemo } from "react"; +import useSWR from "swr"; + +const apiUrl = process.env.PAYLOAD_PUBLIC_APP_URL; +const fetcher = (url) => fetch(url).then((res) => res.json()); + +function ProfileSelect(props) { + const [fields] = useAllFormFields(); + const { url } = reduceFieldsToValues(fields, true); + const { data } = useSWR( + `${apiUrl}/api/hurumap/profiles?baseUrl=${url}`, + fetcher, + { + dedupingInterval: 60000, + revalidateOnFocus: false, + }, + ); + + const options = useMemo( + () => data?.map(({ name, id }) => ({ label: name, value: id })) || [], + [data], + ); + return createElement(Select, { ...props, options }); +} + +export default ProfileSelect; diff --git a/apps/climatemappedafrica/src/payload/globals/HURUMap/DataPanels.js b/apps/climatemappedafrica/src/payload/globals/HURUMap/DataPanels.js index cf60493c5..450ef677e 100644 --- a/apps/climatemappedafrica/src/payload/globals/HURUMap/DataPanels.js +++ b/apps/climatemappedafrica/src/payload/globals/HURUMap/DataPanels.js @@ -8,6 +8,11 @@ const DataPanels = { type: "array", label: "Panel Items", required: true, + admin: { + components: { + RowLabel: ({ data }) => data?.value, + }, + }, fields: [ { type: "select", diff --git a/apps/climatemappedafrica/src/payload/globals/HURUMap/Profile.js b/apps/climatemappedafrica/src/payload/globals/HURUMap/Profile.js index 7f33ea355..3f00e390e 100644 --- a/apps/climatemappedafrica/src/payload/globals/HURUMap/Profile.js +++ b/apps/climatemappedafrica/src/payload/globals/HURUMap/Profile.js @@ -1,6 +1,99 @@ +import HURUmapURL from "../../fields/HURUmapURL"; +import LocationSelect from "../../fields/LocationSelect"; +import ProfileSelect from "../../fields/ProfileSelect"; + const Profile = { label: "Profile", fields: [ + { + name: "url", + label: "HURUMap NG URL", + type: "text", + admin: { + condition: (_, siblingData) => !!siblingData?.enabled, + components: { + Field: HURUmapURL, + }, + description: + "The base URL for the HURUmap API. For example, https://hurumap.org/api/v1", + }, + required: true, + }, + { + name: "urlValid", + type: "checkbox", + admin: { + hidden: true, + readOnly: true, + condition: (_, siblingData) => !!siblingData?.enabled, + }, + }, + { + name: "profile", + type: "number", + label: { + en: "Profile to Use", + }, + required: true, + hasMany: false, + admin: { + components: { + Field: ProfileSelect, + }, + condition: (_, siblingData) => !!siblingData?.urlValid, + width: "50%", + }, + }, + { + name: "rootGeography", + label: { + en: "Root Geography", + }, + type: "group", + admin: { + condition: (_, siblingData) => !!siblingData?.urlValid, + }, + fields: [ + { + type: "row", + fields: [ + { + name: "code", + type: "text", + label: { + en: "Location Code", + }, + required: true, + hasMany: false, + defaultValue: "af", + admin: { + components: { + Field: LocationSelect, + }, + }, + }, + { + name: "center", + label: "Center Point", + type: "point", + defaultValue: [20.0, 4.25], + }, + ], + }, + { + name: "hasData", + type: "checkbox", + label: { + en: "Root geography has data", + }, + defaultValue: false, + admin: { + description: + "Indicates whether the root geography itself has data that can be used for comparison with its children", + }, + }, + ], + }, { name: "page", label: { @@ -13,6 +106,7 @@ const Profile = { required: true, admin: { description: "The page to show the HURUmap profile on.", + width: "50%", }, }, ], diff --git a/apps/climatemappedafrica/src/payload/globals/HURUMap/RootGeography.js b/apps/climatemappedafrica/src/payload/globals/HURUMap/RootGeography.js deleted file mode 100644 index 5e5fe4e47..000000000 --- a/apps/climatemappedafrica/src/payload/globals/HURUMap/RootGeography.js +++ /dev/null @@ -1,88 +0,0 @@ -import LocationSelect, { validateLocation } from "../../fields/LocationSelect"; - -const RootGeography = { - label: "Root Geography", - fields: [ - { - name: "rootGeography", - label: { - en: "Root Geography", - }, - type: "group", - fields: [ - { - name: "code", - type: "text", - label: { - en: "Location Code", - }, - required: true, - hasMany: false, - defaultValue: "af", - validate: validateLocation, - admin: { - components: { - Field: LocationSelect, - }, - }, - }, - { - name: "center", - label: "Center Point", - type: "point", - defaultValue: [20.0, 4.25], - }, - { - name: "hasData", - type: "checkbox", - label: { - en: "Root geography has data", - }, - defaultValue: false, - admin: { - description: - "Indicates whether the root geography itself has data that can be used for comparison with its children", - }, - }, - { - name: "zoom", - type: "group", - fields: [ - { - type: "row", - fields: [ - { - name: "desktop", - label: "Zoom Level for Desktop", - type: "number", - defaultValue: 3.05, - required: true, - admin: { - description: - "Indicates how the map should appear on desktop devices", - }, - }, - { - name: "mobile", - label: "Zoom Level for Mobile", - type: "number", - required: true, - defaultValue: 2.7, - admin: { - description: - "Indicates how the map should appear on small devices", - }, - }, - ], - }, - ], - admin: { - hideGutter: true, - }, - }, - ], - }, - ], -}; - -export default RootGeography; diff --git a/apps/climatemappedafrica/src/payload/globals/HURUMap/index.js b/apps/climatemappedafrica/src/payload/globals/HURUMap/index.js index b91ebb6a1..2314d90f3 100644 --- a/apps/climatemappedafrica/src/payload/globals/HURUMap/index.js +++ b/apps/climatemappedafrica/src/payload/globals/HURUMap/index.js @@ -1,6 +1,5 @@ import DataPanels from "./DataPanels"; import Profile from "./Profile"; -import RootGeography from "./RootGeography"; import Tutorial from "./Tutorial"; const HURUMap = { @@ -16,16 +15,16 @@ const HURUMap = { }, fields: [ { - name: "enableHURUMap", + name: "enabled", label: "Enable HURUMap", type: "checkbox", defaultValue: false, }, { type: "tabs", - tabs: [Profile, DataPanels, RootGeography, Tutorial], + tabs: [Profile, DataPanels, Tutorial], admin: { - condition: (_, siblingData) => !!siblingData?.enableHURUMap, + condition: (_, siblingData) => !!siblingData?.enabled, }, }, ],