diff --git a/index.html b/index.html index 69f47cd..6463442 100644 --- a/index.html +++ b/index.html @@ -2,7 +2,7 @@ - + =16.3.0" + } + }, + "node_modules/react-helmet/node_modules/react-fast-compare": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz", + "integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==" + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", @@ -7858,6 +7878,14 @@ "resolved": "https://registry.npmjs.org/react-property/-/react-property-2.0.2.tgz", "integrity": "sha512-+PbtI3VuDV0l6CleQMsx2gtK0JZbZKbpdu5ynr+lbsuvtmgbNcS3VM0tuY2QjFNOcWxvXeHjDpy42RO+4U2rug==" }, + "node_modules/react-side-effect": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/react-side-effect/-/react-side-effect-2.1.2.tgz", + "integrity": "sha512-PVjOcvVOyIILrYoyGEpDN3vmYNLdy1CajSFNt4TDsVQC5KpTijDvWVoR+/7Rz2xT978D8/ZtFceXxzsPwZEDvw==", + "peerDependencies": { + "react": "^16.3.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/react-transition-group": { "version": "4.4.5", "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", diff --git a/package.json b/package.json index b20976a..a8f3b1c 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "mapbox-gl": "^3.7.0", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-helmet": "^6.1.0", "yup": "^1.4.0" }, "devDependencies": { diff --git a/public/Logo_green_white.svg b/public/Logo_green_white.svg new file mode 100644 index 0000000..495c559 --- /dev/null +++ b/public/Logo_green_white.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/Poster_cs_1.svg b/public/Poster_cs_1.svg new file mode 100644 index 0000000..6089aae --- /dev/null +++ b/public/Poster_cs_1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/favicon.ico b/public/favicon.ico deleted file mode 100644 index cfc03f7..0000000 Binary files a/public/favicon.ico and /dev/null differ diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..ecbb4d4 --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/logo_green_transparent.svg b/public/logo_green_transparent.svg new file mode 100644 index 0000000..006a658 --- /dev/null +++ b/public/logo_green_transparent.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/logo_white_transparent.svg b/public/logo_white_transparent.svg new file mode 100644 index 0000000..2412023 --- /dev/null +++ b/public/logo_white_transparent.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/App.jsx b/src/App.jsx index cb49f2d..eb0c959 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -14,6 +14,10 @@ import ConfirmModal from "./utils/ConfirmationModal"; import SuccessModal from "./SuccessModal"; import BurgerMenu from "./BurgerMenu"; import SearchResults from "./SearchResultsList"; +import LegalNotice from "./utils/LegalNoticeModal"; +import PosterModal from "./utils/PosterModal"; +import { KaptaSVGIconWhite } from "./utils/icons"; +import WaitlistWidget from "./utils/WaitlistWidget"; export default function App() { const [isTaskFormVisible, setTaskFormVisible] = useState(false); @@ -22,6 +26,7 @@ export default function App() { const [isLoginFormVisible, setLoginFormVisible] = useState(false); const [signUpFormVisible, setSignUpFormVisible] = useState(false); + const [waitlistVisible, setWaitlistVisible] = useState(false); const [email, setEmail] = useState(""); const [confirmModalVisible, setConfirmModalVisible] = useState(false); const [cmRecipient, setCMRecipient] = useState(null); @@ -37,12 +42,15 @@ export default function App() { const [BMopen, setBMopen] = useState(false); const [boundsVisible, setBoundsVisible] = useState(false); - const [polygonStore, setPolygonStore] = useState(null); - const [focusTask, setFocusTask] = useState(null); - const [chosenTask, setChosenTask] = useState(null); + const [polygonStore, setPolygonStore] = useState(null); // for showing polygons + const [focusTask, setFocusTask] = useState(null); // for showing data points + const [chosenTaskId, setChosenTaskId] = useState(null); // for showing task in list from popup const [taskListName, setTaskListName] = useState("mine"); + const [noticeVisible, setNoticeVisible] = useState(false); + const [posterVisible, setPosterVisible] = useState(false); + const user = useUserStore(); const showTaskForm = (task) => { @@ -62,10 +70,10 @@ export default function App() { setConfirmModalVisible(true); }; const showLoginSuccessModal = (message) => { - setSuccessModalVisible(true); - setSuccessMsg(message); - setSuccessIsTask(false); - setEmail(""); + // setSuccessModalVisible(true); + // setSuccessMsg(message); + // setSuccessIsTask(false); + // setEmail(""); }; const showFilledLoginForm = (email) => { @@ -91,10 +99,12 @@ export default function App() { polygons.push(task); } }); + console.log("polygons", polygons); return polygons; }; const showSearchResults = (results) => { + setBoundsVisible(false); if (results !== searchResults) { setPolygonStore(null); // reset polygon store for each new search setSearchResults(results); @@ -109,156 +119,185 @@ export default function App() { }; const showTaskInList = (id) => { - setChosenTask(id); + console.log("show task in list", id); + setChosenTaskId(id); setTaskListVisible(true); }; const scrollFlashTask = (taskRefs) => { + // if (!isTaskListVisible) setTaskListVisible(true); + console.log("scroll flash", taskRefs, chosenTaskId); // Scroll to the chosen task - taskRefs.current[chosenTask].scrollIntoView({ + taskRefs.current[chosenTaskId].scrollIntoView({ behavior: "smooth", block: "center", }); // Make it flash - const taskElement = taskRefs.current[chosenTask]; + const taskElement = taskRefs.current[chosenTaskId]; taskElement.classList.add("flash"); // Remove the flash class after the animation duration setTimeout(() => { taskElement.classList.remove("flash"); - setChosenTask(null); - }, 1600); + setChosenTaskId(null); + }, 2600); }; return ( -
- {errorMsg && } - {!isLoginFormVisible && !user.loggedIn && !signUpFormVisible && ( -
- - + <> +
+
+ + Kapta
- )} - - - {confirmModalVisible && ( - } + {!isLoginFormVisible && !user.loggedIn && !signUpFormVisible && ( +
+ + +
+ )} + + + + {confirmModalVisible && ( + + )} + {successModalVisible && !successIsTask && ( + + )} + {successModalVisible && successIsTask && ( + + )} + - )} - {successModalVisible && !successIsTask && ( - - )} - {successModalVisible && successIsTask && ( - - )} - {user.loggedIn && ( - <> - -
-
- {/* this is where the bot responses will go */} -
- -
-
- - + {!user.loggedIn &&
} - +
+
+ {/* this is where the bot responses will go */}
- +
+
+ + + + + setTaskListVisible(true)} className="btn--view-tasks" + disabled={!user.loggedIn} > - TASKS + Task WhatsApp Mappers - - )} -
+
+ +
+ ); } diff --git a/src/BurgerMenu.jsx b/src/BurgerMenu.jsx index b7ba199..13e6e70 100644 --- a/src/BurgerMenu.jsx +++ b/src/BurgerMenu.jsx @@ -1,14 +1,10 @@ -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; import MenuIcon from "@mui/icons-material/Menu"; -import PersonIcon from "@mui/icons-material/Person"; -import SettingsIcon from "@mui/icons-material/Settings"; import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; +import TravelExploreIcon from "@mui/icons-material/TravelExplore"; import InfoIcon from "@mui/icons-material/Info"; -import PeopleIcon from "@mui/icons-material/People"; -import HelpIcon from "@mui/icons-material/Help"; -import NextPlanIcon from "@mui/icons-material/NextPlan"; +import GroupsIcon from "@mui/icons-material/Groups"; import LogoutIcon from "@mui/icons-material/Logout"; -import CampaignOutlinedIcon from "@mui/icons-material/CampaignOutlined"; import { Drawer, List, @@ -24,9 +20,43 @@ import parse from "html-react-parser"; import { CloseButton } from "./utils/Buttons"; import "./styles/burger-menu.css"; import { useUserStore, WA_CHAT_URL } from "./globals"; +import { KaptaSVGIconWhite } from "./utils/icons"; -export default function BurgerMenu({ isOpen, setIsOpen }) { +export default function BurgerMenu({ + isOpen, + setIsOpen, + setNoticeVisible, + setPosterVisible, +}) { const [expandedPanel, setExpandedPanel] = useState(false); + const [expandedSubPanel, setExpandedSubPanel] = useState(false); + const user = useUserStore(); + const handlePosterClick = () => { + setPosterVisible(true); + setIsOpen(false); + }; + const mailto = "mailto:info@kapta.earth?subject=Kapta Web Feedback"; + useEffect(() => { + const observer = new MutationObserver((mutationsList, observer) => { + for (let mutation of mutationsList) { + if (mutation.type === "childList") { + const imgElement = document.querySelector( + 'img[alt="Is this the first-ever WhatsApp Map?"]' + ); + if (imgElement) { + imgElement.addEventListener("click", handlePosterClick); + observer.disconnect(); // Stop observing once the element is found and event listener is attached + } + } + } + }); + + observer.observe(document.body, { childList: true, subtree: true }); + + return () => { + observer.disconnect(); // Cleanup observer on component unmount + }; + }, [isOpen]); const toggleDrawer = (open) => (event) => { if ( @@ -37,7 +67,7 @@ export default function BurgerMenu({ isOpen, setIsOpen }) { } setIsOpen(open); }; - const user = useUserStore(); + const handleLogout = () => { user.logout(); toggleDrawer(false); @@ -48,6 +78,7 @@ export default function BurgerMenu({ isOpen, setIsOpen }) { const viewSettings = () => { console.log("todo: view settings"); }; + const navItems = [ { text: "Logout", icon: , function: handleLogout }, // { text: "Profile", icon: , function: viewProfile }, @@ -74,47 +105,89 @@ export default function BurgerMenu({ isOpen, setIsOpen }) { "https://www.ucl.ac.uk/advanced-research-computing/people/amanda-ho-lyn", jedUrl: "https://www.durham.ac.uk/staff/jed-stevenson/", desUrl: "https://et.linkedin.com/in/dessalegn-tekle-02b848ba", + satoUrl: "https://www.linkedin.com/in/satoki-kawabata/", + gabrielUrl: "", + jeromeUrl: + "https://www.ucl.ac.uk/anthropology/people/academic-and-teaching-staff/jerome-lewis", }; - const menuSections = [ { - title: "About", - icon: , - subtitle: "Kapta Mobile is a Progressive Web App to create WhatsApp Maps", - content: "", - }, - { - title: "People", - icon: , + title: "Kapta", + icon: , subtitle: - "Kapta is being developed by the University College London (UCL) Extreme Citizen Science (ExCiteS) research group and the Advanced Research Computing Centre (UCL ARC).", - content: `Currently the core Kapta team consists of:
`, + "Kapta is a WhatsApp-based crowdsourcing platform to help solve local, national and global challenges through searching WhatsApp Maps and tasking WhatsApp Mappers", + content: "", }, { - title: "Why Kapta?", - icon: , + title: "Work with us", + icon: , subtitle: - "To popularise mapping and connect users and producers of ground information.", - content: `See our latest blog and where this started in 2010:
  • WhatsApp Maps? Connecting users and producers of ground information

  • Extreme Citizen Science in the Congo rainforest
  • `, + "We are open to building partnerships. Let's explore how Kapta can support your work.", + content: `Contact us at + info@kapta.earth`, }, { - title: "What's Next?", - icon: , - subtitle: - "Kapta:A (de)centralised crowdsourcing system to connect users and producers of ground information.", - content: "", + title: "Discover", + icon: , + subtitle: "", + hasSubTabs: true, + subTabs: [ + { + title: "Case Study", + content: `

    Is this the first-ever WhatsApp Map?

    Is this the first-ever WhatsApp Map? +

    The traditional method of assessing water infrastructure relies on field surveyors, a process that is often slow and costly. This can pose challenges for timely decision-making, especially in regions facing drought and hunger. In May 2024, pastoralists from various villages were engaged in the data collection process. Organised into WhatsApp groups and using Kapta, they facilitated faster and more efficient assessments by creating WhatsApp Maps on water infrastructure. Within just a few days, these WhatsApp mappers determined that 75% of the water infrastructure was non-functional, providing local authorities with accurate, ground-level information to take quicker and more informed action.

    More case studies coming soon.

    `, + }, + { + title: "Extreme Citizen Science", + content: `Kapta is inspired by Extreme Citizen Science, an inclusive approach to citizen science that enables people from all backgrounds, regardless of their literacy, technical, or scientific skills, to co-design and participate in scientific research addressing local challenges. It combines interdisciplinary methodologies and tailored technologies to empower communities, particularly in underrepresented or marginalised areas, to collect, visualise, analyse, and use data for informed decision-making and advocacy.`, + }, + ], }, { - title: "Disclaimer", - icon: , - subtitle: - "The Kapta team has made every effort to develop an app that parse WhatsApp chats to create WhatsApp Maps with the highest possible accuracy. However, we cannot accept responsibility for any errors, omissions, or inconsistencies that may occur.", - content: ` Please always make your own judgement about the accuracy of the maps and validate the information using other sources. If you encounter any issues or have feedback, please reach out to us at geog.excites@ucl.ac.uk or via WhatsApp at +34 678380944.`, + title: "About", + icon: , + subtitle: "", + hasSubTabs: true, + subTabs: [ + { + title: "Ethics", + content: `

    +We prioritise enhancing the capabilities of individuals and communities impacted by our work, ensuring that every action serves a meaningful purpose and aligns with the public interest. Ethics is at the core of our decisions, helping us build trust and foster collective intelligence. By focusing on fairness, transparency, and inclusivity, we develop solutions that empower people to make better decisions in an increasingly complex world shaped by global environmental changes. Our work is guided by a commitment to contribute to a more equitable, sustainable, and socially responsible future for all. +

    We embrace open-source principles to promote collective progress and serve the public interest. By sharing our tools and methods openly, we enable others to adapt them to diverse challenges and encourage broader participation in knowledge sharing. This approach fosters collaboration across geographies and cultures, driving solutions that benefit society as a whole.

    `, + }, + { + title: "Careers", + content: `

    Join our dynamic team! We combine the creativity of academic research with the agility of a company, guided by a shared commitment to ethics, citizen science technology and collective intelligence. If you are curious about our work and want to contribute to innovative solutions for real-world challenges, we would love to hear from you - reach out at info@kapta.earth.

    +`, + }, + { + title: "Team", + content: ``, + }, + ], }, ]; const handleAccordionChange = (panel) => (event, isExpanded) => { setExpandedPanel(isExpanded ? panel : false); }; + const handleSubAccordionChange = (subPanel) => (event, isExpanded) => { + setExpandedSubPanel(isExpanded ? subPanel : false); + }; return ( <> {section.subtitle} - {parse(section.content)} + + {section.hasSubTabs + ? section.subTabs.map((subTab, subIndex) => ( + + } + aria-controls={`subPanel${index}${subIndex}a-content`} + id={`subPanel${index}${subIndex}a-header`} + > + + {subTab.title} + + + + + {parse(subTab.content)} + + + + )) + : parse(section.content)} + ))}
    Have feedback or want to get in touch? - + + Contact us on{" "} + {" "} + or email us at{" "} + +
    - + {user.loggedIn && ( + + )} + + © 2024 Wisdom of the Crowd Labs, All rights reserved -{" "} + { + toggleDrawer(false)(e); + setNoticeVisible(true); + }} + > + Legal Notice + + diff --git a/src/LoginForm.jsx b/src/LoginForm.jsx index 05cb30c..b47d465 100644 --- a/src/LoginForm.jsx +++ b/src/LoginForm.jsx @@ -9,7 +9,7 @@ import { CloseButton } from "./utils/Buttons"; export default function LoginForm({ isVisible, setIsVisible, - setSignUpVisible, + setSignUpVisible, // actually waitlist setErrorMsg, showConfirmModal, showLoginSuccessModal, @@ -51,7 +51,7 @@ export default function LoginForm({ {({ isSubmitting }) => (
    - + Log in Log in diff --git a/src/Mapbox.jsx b/src/Mapbox.jsx index c6c1e9d..ed839b2 100644 --- a/src/Mapbox.jsx +++ b/src/Mapbox.jsx @@ -1,7 +1,7 @@ import mapboxgl from "mapbox-gl"; import { useEffect, useRef } from "react"; import { MAPBOX_TOKEN } from "./globals"; -import { centroid } from "@turf/turf"; +import { centroid, polygon, bbox } from "@turf/turf"; import "./styles/mapbox.css"; @@ -11,10 +11,10 @@ export function Map({ taskListOpen, focusTask, showTaskInList, + isBackground, }) { const map = useRef(null); const popupRef = useRef(null); - const longtAdjustment = 0.015; // used when splitscreen since transform mucks up the interaction const addMapClickListener = () => { // listen for click on a polygon @@ -44,14 +44,18 @@ export function Map({ popupRef.current = popup; } }); + }; - // Change cursor style on hover - map.current.on("mouseenter", "polygons-layer", () => { - map.current.getCanvas().style.cursor = "pointer"; - }); - - map.current.on("mouseleave", "polygons-layer", () => { - map.current.getCanvas().style.cursor = ""; + const getAndFitBounds = (focusTask) => { + let bounds; + if (focusTask.type === "FeatureCollection") { + bounds = bbox(focusTask); + } else { + const turfPoly = polygon([focusTask.geo_bounds.coordinates]); + bounds = bbox(turfPoly); + } + map.current.fitBounds(bounds, { + padding: 200, }); }; @@ -63,20 +67,29 @@ export function Map({ map.current = new mapboxgl.Map({ container: "map", style: "mapbox://styles/mapbox/dark-v11", - zoom: 1.2, - center: [30, 50], + zoom: 1.8, + center: [35, -25], projection: "globe", + attributionControl: true, }); map.current.on("load", () => { map.current.setFog({ color: "grey", "high-color": "#232222", - "horizon-blend": 0.02, + "horizon-blend": 0.01, "space-color": "#16161d", "star-intensity": 0, }); }); + const attributionControl = map.current._controls.find( + (control) => control instanceof mapboxgl.AttributionControl + ); + + if (attributionControl) { + attributionControl._container.innerHTML = + '© Mapbox, OpenStreetMap Contributors'; + } // make this function available from when the map initialises window.handlePopupDetailsClick = (id) => { showTaskInList(id); @@ -85,8 +98,15 @@ export function Map({ // resize the map when splitscreen useEffect(() => { + // todo: this needs some polishing if (map.current) { - map.current.resize(); + const center = map.current.getCenter(); + const zoom = map.current.getZoom(); + map.current.resize().flyTo({ + center: center, + zoom: zoom, + speed: 0.8, + }); } }, [taskListOpen]); @@ -94,103 +114,67 @@ export function Map({ useEffect(() => { if (!map.current || !map.current.isStyleLoaded() || !polygonStore) return; - // source does not exist - if (!map.current.getSource("polygon-source")) { - // not an array of polygons (not search results) - if (!Array.isArray(polygonStore)) { - map.current.addSource("polygon-source", { - type: "geojson", - data: { - type: "Feature", - geometry: { - type: "Polygon", - coordinates: [polygonStore.geo_bounds.coordinates], - }, - properties: { - id: polygonStore.task_id, - title: polygonStore.task_title, - description: polygonStore.task_description, - }, + var newData; + + // is search results + if (Array.isArray(polygonStore)) { + newData = { + type: "FeatureCollection", + features: polygonStore.map((polygon) => ({ + type: "Feature", + geometry: { + type: "Polygon", + coordinates: [polygon.geo_bounds.coordinates], }, - }); - } else { - const newData = { - type: "FeatureCollection", - features: polygonStore.map((polygon) => ({ - type: "Feature", - geometry: { - type: "Polygon", - coordinates: [polygon.geo_bounds.coordinates], - }, - properties: { - id: polygon.task_id, - title: polygon.task_title, - description: polygon.task_description, - }, - })), - }; + properties: { + id: polygon.task_id, + title: polygon.task_title, + description: polygon.task_description, + }, + })), + }; + } else { + // single polygon + const newFeature = { + type: "Feature", + geometry: { + type: "Polygon", + coordinates: [polygonStore.geo_bounds.coordinates], + }, + properties: { + id: polygonStore.task_id, + title: polygonStore.task_title, + description: polygonStore.task_description, + }, + }; - map.current.addSource("polygon-source", { - type: "geojson", - data: newData, - }); - } + newData = { + type: "FeatureCollection", + features: [newFeature], + }; + } + + // source does not exist + if (!map.current.getSource("polygon-source")) { + map.current.addSource("polygon-source", { + type: "geojson", + data: newData, + }); } else { + // source already exists, update the data + // removing data points since new task if (map.current.getLayer("datapoints-layer")) { map.current.removeLayer("datapoints-layer"); } + // removing popup since new task if (map.current && popupRef.current) { popupRef.current.remove(); popupRef.current = null; } - // source already exists, used when viewing task list and clicking between tasks + // getting and setting source let source = map.current.getSource("polygon-source"); - let existingData = source._data; - - if (!existingData || existingData.type !== "FeatureCollection") { - existingData = { - type: "FeatureCollection", - features: [], - }; - } - - // Ensure polygonStore is an array so we can use .map() even if it's one item (the initial one) - const polygons = Array.isArray(polygonStore) - ? polygonStore - : [polygonStore]; - // Set structure like this, particular attention to the [] around coordinates, otherwise polygon will not show - const newFeatures = polygons.map((polygon) => ({ - type: "Feature", - geometry: { - type: "Polygon", - coordinates: [polygon.geo_bounds.coordinates], - }, - properties: { - id: polygon.task_id, - title: polygon.task_title, - description: polygon.task_description, - }, - })); - - // if the polygon is already on the map, don't add it again - const filteredNewFeatures = newFeatures.filter((newFeature) => { - return !existingData.features.some( - (existingFeature) => - existingFeature.properties.id === newFeature.properties.id - ); - }); - - const updatedFeatures = [ - ...existingData.features, - ...filteredNewFeatures, - ]; - - const newData = { - type: "FeatureCollection", - features: updatedFeatures, - }; source.setData(newData); } @@ -204,7 +188,7 @@ export function Map({ source: "polygon-source", layout: {}, paint: { - "fill-color": "#ff6347", + "fill-color": "#087669", "fill-opacity": 0.6, }, }); @@ -215,7 +199,7 @@ export function Map({ type: "line", source: "polygon-source", paint: { - "line-color": "#e63621", + "line-color": "#075E54", "line-width": 4, }, }); @@ -235,48 +219,30 @@ export function Map({ } map.current.off("click"); } + if (!Array.isArray(polygonStore)) { + getAndFitBounds(polygonStore); + } else { + // fly to first task of results but not zoom in + const taskWithGeoBounds = polygonStore.find((task) => task.geo_bounds); - const adjustedBounds = (bounds) => { - const sw = bounds.getSouthWest(); - const ne = bounds.getNorthEast(); - - // Shift bounds by adjusting the longitude - const newSw = new mapboxgl.LngLat(sw.lng + longtAdjustment, sw.lat); - const newNe = new mapboxgl.LngLat(ne.lng + longtAdjustment, ne.lat); - - const adjustedBounds = new mapboxgl.LngLatBounds(newSw, newNe); - - return adjustedBounds; - }; - // fit to polygon and center it - const bounds = taskListOpen - ? adjustedBounds(getBounds(polygonStore)) - : getBounds(polygonStore); + const turfPoly = polygon([taskWithGeoBounds.geo_bounds.coordinates]); + const centroidPoint = centroid(turfPoly); - if (bounds) { - map.current.fitBounds(bounds, { - padding: 200, + map.current.flyTo({ + center: centroidPoint.geometry.coordinates, + essential: true, + zoom: 4, }); } }, [polygonStore, boundsVisible, taskListOpen]); // fly to focused task and show data points useEffect(() => { - // flying to task when multiple loaded if (!map.current || !map.current.isStyleLoaded() || !focusTask) return; + // flying to task polygon when multiple loaded if (focusTask && focusTask.geo_bounds) { - const centroidPoint = centroid(focusTask.geo_bounds); - let [longitude, latitude] = centroidPoint.geometry.coordinates; - if (taskListOpen) { - longitude += longtAdjustment; - } - - map.current.flyTo({ - center: [longitude, latitude], - essential: true, // not user-interruptible - padding: 200, - }); + getAndFitBounds(focusTask); } // if focusTask is a feature collection (used for showing data points) else if (focusTask && focusTask.type === "FeatureCollection") { @@ -298,58 +264,21 @@ export function Map({ source: "datapoints-source", paint: { "circle-radius": 5, - "circle-color": "#c8ff00", + "circle-color": "#25D366", }, }); } - // fly to it (in case they moved away) - const middleIndex = Math.floor(focusTask.features.length / 2); - const middleCoordinates = - focusTask.features[middleIndex].geometry.coordinates; - - if (taskListOpen) { - middleCoordinates[0] += longtAdjustment; - } - - map.current.flyTo({ - center: middleCoordinates, - essential: true, - padding: 200, - }); + getAndFitBounds(focusTask); } }, [focusTask]); - const getBounds = (polygonStore) => { - // expects either a single task object or an array of task objects with geo_bounds as the relevant part - var bounds = new mapboxgl.LngLatBounds(); - if (Array.isArray(polygonStore)) { - // only an array if multiple, unclear why there are slightly different structures - - polygonStore.forEach((object) => { - const geoBounds = object.geo_bounds; - if (geoBounds.type === "Polygon") { - geoBounds.coordinates.forEach((coord) => { - bounds.extend(coord); - }); - } else { - geoBounds.forEach((item) => { - if (item.type === "Polygon") { - item.coordinates.forEach((coord) => { - bounds.extend(coord); - }); - } - }); - } - }); - } else { - const coordinates = polygonStore.geo_bounds.coordinates; - coordinates.forEach((coord) => { - bounds.extend(coord); - }); - } - return bounds; - }; - - return
    ; + return ( +
    + ); } diff --git a/src/SearchForm.jsx b/src/SearchForm.jsx index 9c12713..c281ad1 100644 --- a/src/SearchForm.jsx +++ b/src/SearchForm.jsx @@ -8,7 +8,11 @@ import ArrowUpwardRoundedIcon from "@mui/icons-material/ArrowUpwardRounded"; import "./styles/search.css"; -export default function SearchForm({ showSearchResults, taskListOpen }) { +export default function SearchForm({ + showSearchResults, + taskListOpen, + isBackground, +}) { const user = useUserStore(); const [snackbarOpen, setSnackbarOpen] = useState(false); const [tasks, setTasks] = useState([]); @@ -20,10 +24,12 @@ export default function SearchForm({ showSearchResults, taskListOpen }) { return fetchedTasks; }; - fetchTasks().then((tasks) => { - const visibleTasks = tasks.filter((task) => task.visible === true); - setTasks(visibleTasks); - }); + if (user?.idToken) { + fetchTasks().then((tasks) => { + const visibleTasks = tasks.filter((task) => task.visible === true); + setTasks(visibleTasks); + }); + } }, [user]); const handleSubmit = async (values) => { @@ -46,6 +52,7 @@ export default function SearchForm({ showSearchResults, taskListOpen }) { } else showSearchResults(results); }; const handleRefresh = async () => { + // todo: get this to work try { const fetchTasks = async () => { var fetchedTasks = await fetchAllTasks({ user }); @@ -65,27 +72,35 @@ export default function SearchForm({ showSearchResults, taskListOpen }) { const chipSuggestions = [ { - label: "Show me all the sanity tasks", + label: "Show me citizens complaints in Camden, London", + icon: <>, + action: (setFieldValue) => { + setFieldValue("query", "citizen complaint"); + handleSubmit("citizen complaint"); + }, + }, + { + label: "Water points in Nyangatom, Ethiopia", icon: <>, action: (setFieldValue) => { - setFieldValue("query", "sanity"); - handleSubmit("sanity"); + setFieldValue("query", "water point"); + handleSubmit("water point"); }, }, { - label: "Display all tasks mentioning 'points'", + label: "Information about postboxes", icon: <>, action: (setFieldValue) => { - setFieldValue("query", "points"); - handleSubmit("points"); + setFieldValue("query", "postbox"); + handleSubmit("postbox"); }, }, { - label: "Where are the elves?", + label: "Bakery recommendations", icon: <>, action: (setFieldValue) => { - setFieldValue("query", "elf"); - handleSubmit("elf"); + setFieldValue("query", "bakeries"); + handleSubmit("bakeries"); }, }, ]; @@ -95,10 +110,12 @@ export default function SearchForm({ showSearchResults, taskListOpen }) { {({ isSubmitting, setFieldValue }) => ( @@ -131,7 +148,7 @@ export default function SearchForm({ showSearchResults, taskListOpen }) { diff --git a/src/SearchResultsList.jsx b/src/SearchResultsList.jsx index 71450d8..29d4eea 100644 --- a/src/SearchResultsList.jsx +++ b/src/SearchResultsList.jsx @@ -10,7 +10,7 @@ export default function SearchResults({ setIsVisible, results, setFocusTask, - chosenTask, + chosenTaskId, scrollFlashTask, }) { const [snackbarOpen, setSnackbarOpen] = useState(false); @@ -22,25 +22,26 @@ export default function SearchResults({ const taskRefs = useRef({}); useEffect(() => { - if (chosenTask && taskRefs.current[chosenTask]) { + if (chosenTaskId && taskRefs.current[chosenTaskId]) { scrollFlashTask(taskRefs); } }); - useEffect(() => { - // set pinned preference when component mounts - const storedPinnedPreference = localStorage.getItem( - "resultsPinnedPreference" - ); - if (storedPinnedPreference) { - setIsPinned(storedPinnedPreference); - } - }, []); + // useEffect(() => { + // // set pinned preference when component mounts + // const storedPinnedPreference = localStorage.getItem( + // "resultsPinnedPreference" + // ); + // if (storedPinnedPreference) { + // setIsPinned(...storedPinnedPreference); + // } + // }, []); // Store pinned task in localStorage whenever it changes useEffect(() => { - if (isPinned) { + if (isPinned !== undefined) { localStorage.setItem("resultsPinnedPreference", isPinned); + console.log(localStorage.getItem("resultsPinnedPreference")); } }, [isPinned]); @@ -84,8 +85,6 @@ export default function SearchResults({ taskRefs: taskRefs, }; - if (!isVisible) return null; - return ( {({ isSubmitting, setFieldValue }) => ( - + Create Account
    @@ -211,7 +211,7 @@ export default function SignUpForm({ + */} {/* Private to Org */} @@ -155,13 +154,6 @@ export default function TaskForm({
    */} - {/* Visible on Kapta Web */} -
    - -
    {/* Title */} Your campaign code: {initialValues.campaignCode}

    )} + {/* Visible on Kapta Web */} +
    + +
    {/* Submit Button */} diff --git a/src/TaskList.jsx b/src/TaskList.jsx index 081ec0f..1abff77 100644 --- a/src/TaskList.jsx +++ b/src/TaskList.jsx @@ -25,7 +25,7 @@ export default function TaskList({ showNewTaskForm, showBounds, setFocusTask, - chosenTask, + chosenTaskId, scrollFlashTask, taskListName, setTaskListName, @@ -41,46 +41,54 @@ export default function TaskList({ const [displayedTask, setDisplayedTask] = useState(null); useEffect(() => { - if (taskListName === "opendata") { - setIsLoading(true); - const fetchTasks = async () => { - var fetchedTasks = await fetchODTasks({ user }); - setTasks(fetchedTasks); - }; - fetchTasks(); - } else if (taskListName === "mine") { - setIsLoading(true); - const fetchTasks = async () => { - var fetchedTasks = await fetchMyTasks({ user }); - setTasks(fetchedTasks); - }; - fetchTasks(); + if (user?.idToken) { + if (taskListName === "opendata") { + setIsLoading(true); + const fetchTasks = async () => { + var fetchedTasks = await fetchODTasks({ user }); + setTasks(fetchedTasks); + }; + fetchTasks(); + } else if (taskListName === "mine") { + setIsLoading(true); + const fetchTasks = async () => { + var fetchedTasks = await fetchMyTasks({ user }); + setTasks(fetchedTasks); + }; + fetchTasks(); + } + setIsLoading(false); } - setIsLoading(false); }, [taskListName, user]); useEffect(() => { - if (chosenTask && chosenTask.includes("opendata")) { + if (chosenTaskId && chosenTaskId.includes("opendata")) { setTaskListName("opendata"); } - if (chosenTask && taskRefs.current[chosenTask]) { - scrollFlashTask(taskRefs); - } + const checkTaskRefs = setTimeout(() => { + if (taskRefs.current) { + if (chosenTaskId && taskRefs.current[chosenTaskId]) { + scrollFlashTask(taskRefs); + } + } + }, 100); + return () => clearTimeout(checkTaskRefs); }); - useEffect(() => { - // set pinned preference when component mounts - const storedPinnedPreference = localStorage.getItem( - "tasklistPinnedPreference" - ); - if (storedPinnedPreference) { - setIsPinned(storedPinnedPreference); - } - }, []); + // useEffect(() => { + // // set pinned preference when component mounts + // const storedPinnedPreference = localStorage.getItem( + // "tasklistPinnedPreference" + // ); + // console.log(isPinned, storedPinnedPreference); + // if (storedPinnedPreference) { + // setIsPinned(...storedPinnedPreference); + // } + // }, []); // Store pinned task in localStorage whenever it changes useEffect(() => { - if (isPinned) { + if (isPinned !== undefined) { localStorage.setItem("tasklistPinnedPreference", isPinned); } }, [isPinned]); @@ -141,7 +149,6 @@ export default function TaskList({ taskRefs: taskRefs, }; - if (!isVisible) return null; return ( My Tasks - Open Datasets + + Explore Others’ Tasks + +

    The mobile version of this site is currently under development.

    +

    Please visit on a computer to explore with KAPTA

    + + + + ); +} diff --git a/src/main.jsx b/src/main.jsx index 80b57e1..fdf1d44 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -4,50 +4,41 @@ import App from "./App.jsx"; import { UserProvider } from "./utils/UserContext.jsx"; import { createTheme, ThemeProvider } from "@mui/material/styles"; -import { - pink, - red, - amber, - orange, - deepOrange, - grey, -} from "@mui/material/colors"; +import { red, orange, grey, teal } from "@mui/material/colors"; +import { isMobileDevice } from "./utils/generalUtils.js"; +import TempMobileApp from "./TempMobileApp.jsx"; const theme = createTheme({ palette: { mode: "dark", - primary: deepOrange, - secondary: amber, - info: pink, - error: red, - warning: { - main: "#E3D026", - light: "#E9DB5D", - dark: "#A29415", + primary: { + main: "#25D366", + dark: "#00ba3c", + light: "#95e6a8", contrastText: "#16161d", }, + secondary: { main: grey[300], contrastText: "#075E54", dark: grey[600] }, + info: teal, + info2: { main: "#075E54", contrastText: grey[200] }, // WA dark teal + info3: { main: "#25d3bc", contrastText: grey[200] }, // lighter bluer teal + error: red, + warning: orange, white: { main: "#f5f5f5", contrastText: "#16161d", }, - orange: { main: orange[800] }, - tomato: { - main: "#ff6347", - light: "#ffa592", - dark: "#e63621", - contrastText: "#16161d", - }, muted: { main: grey[500] }, }, cssVariables: true, }); +const isMobile = isMobileDevice(); createRoot(document.getElementById("root")).render( {" "} - {" "} + {isMobile ? : }{" "} diff --git a/src/styles/App.css b/src/styles/App.css index d366f20..07d0d0b 100644 --- a/src/styles/App.css +++ b/src/styles/App.css @@ -5,10 +5,10 @@ --intrinsic-grey: #16161d; --intrinsic-grey--translucent: #16161dd5; + --intrinsic-grey--translucent-2: #16161d69; --off-white: #eae1e1; - --rose: var(--mui-palette-info-200); + --rose: var(--mui-palette-primary-light); --dark-grey--translucent: #2a282888; - --tomato: var(--mui-palette-tomato-main); } html, body { @@ -28,6 +28,7 @@ main { height: 100vh; .login-signup__wrapper { + z-index: 3; justify-self: center; display: flex; flex-direction: column; @@ -38,6 +39,42 @@ main { } } +.waitlist-widget { + z-index: 4; + align-self: center; + justify-self: center; + position: absolute; + background-color: var(--intrinsic-grey); + border: 1px solid var(--mui-palette-primary-main); + border-radius: 8px; +} + +.background { + pointer-events: none !important; +} +.shield { + position: absolute; + width: 100%; + height: 100%; + background-color: var(--intrinsic-grey--translucent-2); + z-index: 2; +} +.kapta-logo--main { + z-index: 3; + position: fixed; + height: fit-content; + margin-left: 4.5rem; + margin-top: 1.5rem; + justify-self: flex-start; + align-self: flex-start; + width: 10vw; + display: flex; + gap: 1rem; + align-items: center; + opacity: 0.8; + pointer-events: none; +} + .task-map-wrapper { display: grid; grid-template-columns: 1.35fr 1fr; @@ -65,5 +102,16 @@ main { width: fit-content !important; right: 4%; bottom: 9%; - z-index: 2 !important; + z-index: 1 !important; + text-transform: none !important; +} + +.temp-mobile-app { + height: 100vh; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + margin-inline: 4%; + text-align: center; } diff --git a/src/styles/burger-menu.css b/src/styles/burger-menu.css index 33b36a4..0206e33 100644 --- a/src/styles/burger-menu.css +++ b/src/styles/burger-menu.css @@ -1,11 +1,15 @@ .btn--burger-menu { + position: fixed !important; height: fit-content; margin-left: 1rem !important; margin-top: 1rem !important; - z-index: 1; + z-index: 3; justify-self: flex-start; align-self: flex-start; + border: 1px solid var(--mui-palette-secondary-main) !important; + border-radius: 8px !important; } + .bm__drawer { .btn--logout { margin-bottom: 4%; @@ -28,9 +32,9 @@ } a { - color: var(--tomato); + color: var(--mui-palette-secondary-main); &:hover { - color: salmon; + color: var(--mui-palette-primary-main); } } @@ -41,5 +45,16 @@ .bm__footer { text-align: center; margin-top: 2rem; + + button { + text-transform: none; + font-size: inherit; + } + } + + #legal-notice { + text-align: center; + margin-inline: 2%; + color: var(--mui-palette-secondary-main); } } diff --git a/src/styles/dialogs.css b/src/styles/dialogs.css index 73cfcd6..32495f4 100644 --- a/src/styles/dialogs.css +++ b/src/styles/dialogs.css @@ -6,12 +6,13 @@ text-align: center; border-radius: 8px; padding-bottom: 3rem; + width: 50vw; } #success-dialog { background-color: #2d5b39eb; top: 20%; - width: 60vw; + max-width: 50rem; color: white; h3 { @@ -20,11 +21,9 @@ font-size: 1.5rem; color: #c9ff79; } - .task-info__container { - border: 1px solid var(--rose); - } .task-info { font-size: 1.2rem; + margin-inline: 4%; small { display: block; font-size: 0.8rem; @@ -38,20 +37,19 @@ color: var(--rose); font-weight: 700; padding-bottom: 1rem; + cursor: pointer; } } #error-dialog { background-color: #531919d1; top: 24%; - width: 50vw; color: #f9e6e6; } #confirm-dialog { background-color: var(--dark-grey--translucent); top: 20%; - width: 50vw; color: #f9e6e6; padding-inline: 3%; @@ -59,3 +57,9 @@ margin-top: 1.5rem; } } + +#legal-dialog { + background-color: var(--dark-grey--translucent); + border: 1px solid var(--mui-palette-primary-main); + line-height: 1.3rem; +} diff --git a/src/styles/forms.css b/src/styles/forms.css index 41371c5..51974a1 100644 --- a/src/styles/forms.css +++ b/src/styles/forms.css @@ -1,16 +1,15 @@ .task-request-form { - width: 75%; + width: 55%; text-align: center; display: flex; flex-direction: column; justify-self: center; - align-self: start; background-color: var(--intrinsic-grey--translucent); z-index: 1; padding-bottom: 3rem; margin-top: -5rem; color: var(--off-white); - border: 1px solid var(--tomato); + border: 1px solid var(--mui-palette-primary-main); border-radius: 8px; .form__body { @@ -24,6 +23,7 @@ .login__form--container, .signup__form--container { + z-index: 3; display: flex; flex-direction: column; gap: 1rem; @@ -37,6 +37,10 @@ display: flex; flex-direction: column; gap: 0.8rem; + + div { + background-color: var(--intrinsic-grey--translucent); + } } .form__row { diff --git a/src/styles/mapbox.css b/src/styles/mapbox.css index 01e9829..592402b 100644 --- a/src/styles/mapbox.css +++ b/src/styles/mapbox.css @@ -8,6 +8,7 @@ #map { height: 100%; grid-column: span 2; + &.splitscreen { grid-column: span 1; width: 62vw !important; @@ -27,13 +28,16 @@ cursor: pointer; } } + .mapbox-improve-map { + display: none; + } } .mapboxgl-ctrl-attrib { background-color: transparent !important; font-size: 0.6rem; - - .mapboxgl-ctrl-attrib-inner > a { + a, + & { color: grey; } } diff --git a/src/styles/search.css b/src/styles/search.css index c7ca2ee..34b573f 100644 --- a/src/styles/search.css +++ b/src/styles/search.css @@ -6,23 +6,27 @@ } .search__form { - width: 75%; + width: 80%; display: flex; align-self: end; + align-items: center; justify-self: center; flex-direction: column; margin-bottom: 8vh; margin-inline: 4%; - transition: width ease 1.5s; grid-column: span 2; pointer-events: auto; + transition: all ease 1.5s; &.splitscreen { grid-column: 1; - width: 90%; - transition: width ease 1.5s; + width: 80%; justify-items: center; justify-self: center; + + .search__input__wrapper { + width: 80%; + } } .MuiInputBase-formControl { @@ -43,15 +47,18 @@ gap: 0.4rem; display: flex; justify-content: center; + flex-wrap: wrap; + transition: flex-wrap ease 1.5s; } .search__input__wrapper { display: flex; gap: 0.8rem; margin-inline: 4%; - width: 100%; + width: 60%; flex-grow: 1; flex-shrink: 1; flex-basis: 1rem; + transition: width ease 1.5s; } } diff --git a/src/styles/task-list.css b/src/styles/task-list.css index de42036..6e9dd10 100644 --- a/src/styles/task-list.css +++ b/src/styles/task-list.css @@ -1,9 +1,10 @@ .task-list__drawer { - z-index: 3; + z-index: 2; .MuiPaper-root { width: 100%; max-width: 42vw; + transition: transform 0.6s cubic-bezier(0, 0, 0.2, 1) !important; } } .task-list__header { @@ -82,19 +83,19 @@ display: flex; justify-content: space-evenly; .task__info-chip { - color: var(--mui-palette-tomato-main); - border-color: var(--mui-palette-tomato-main); + color: var(--mui-palette-secondary-main); + border-color: var(--mui-palette-secondary-main); margin-left: 0.2rem; svg { - color: var(--mui-palette-secondary-700); + color: var(--mui-palette-secondary-dark); padding-left: 0.1rem; } } } } .campaign-code { - color: var(--rose); - border-color: var(--rose); + color: var(--mui-palette-secondary-main); + border-color: var(--mui-palette-secondary-main); font-weight: 600; margin-left: 0.4rem; font-size: 1rem; @@ -116,5 +117,5 @@ } .flash { - animation: flash 1.3s ease 0.2s; + animation: flash 2.3s ease 0.2s; } diff --git a/src/utils/LegalNoticeModal.jsx b/src/utils/LegalNoticeModal.jsx new file mode 100644 index 0000000..db8892a --- /dev/null +++ b/src/utils/LegalNoticeModal.jsx @@ -0,0 +1,33 @@ +import { CloseButton } from "./Buttons"; + +export default function LegalNotice({ isVisible, setIsVisible }) { + return ( + <> + {isVisible && ( + + +

    Legal Notice

    +

    + This website is operated by{" "} + Wisdom of the Crowd Labs Ltd, a not-for-profit UCL + spinout company registered in England and Wales. +

    +

    + Registered Office: ExCiteS C/O WCL, Geography + Department, University College London, Gower St, London, United + Kingdom, WC1E 6BT

    + Company Registration Number: 15934186

    + Contact: info@kapta.earth +

    +

    + All content on this website is protected by copyright and other + applicable laws. Wisdom of the Crowd Labs Ltd accepts no liability + for external links or third-party content.

    + Governing Law: This website and its use are + governed by the laws of England and Wales +

    +
    + )} + + ); +} diff --git a/src/utils/PosterModal.jsx b/src/utils/PosterModal.jsx new file mode 100644 index 0000000..79861ca --- /dev/null +++ b/src/utils/PosterModal.jsx @@ -0,0 +1,17 @@ +import { CloseButton } from "./Buttons"; + +export default function PosterModal({ isVisible, setIsVisible }) { + return ( + <> + {isVisible && ( + + + Is this the first-ever WhatsApp Map? + + )} + + ); +} diff --git a/src/utils/TaskCard.jsx b/src/utils/TaskCard.jsx index ab9ec84..26cbe07 100644 --- a/src/utils/TaskCard.jsx +++ b/src/utils/TaskCard.jsx @@ -18,6 +18,7 @@ import PersonIcon from "@mui/icons-material/Person"; import { getDataFromBucket } from "./apiQueries"; import { useState } from "react"; import JSZip from "jszip"; +import { slugify } from "./generalUtils"; export default function TaskCard({ task, @@ -52,15 +53,17 @@ export default function TaskCard({ if (data.response !== 200) { // todo: show an error + console.error("Error downloading task data", data); return; } else { const txtContent = data.content.txtFileContent; const jsonContent = data.content.jsonFileContent; const zip = new JSZip(); + const downloadTitle = slugify(task.task_title); - zip.file(`${task.task_title}-${task.campaign_code}.txt`, txtContent); - zip.file(`${task.task_title}-${task.campaign_code}.geojson`, jsonContent); + zip.file(`${downloadTitle}-${task.campaign_code}.txt`, txtContent); + zip.file(`${downloadTitle}-${task.campaign_code}.geojson`, jsonContent); setIsLoading({ download: false }); @@ -104,10 +107,12 @@ export default function TaskCard({ const cardActionBtns = [ { text: - taskId === displayedTask?.task_id ? "Show data points" : "Show on Map", + taskId === displayedTask?.task_id && userID === task.created_by + ? "Show data points" + : "Show on Map", icon: , action: - taskId === displayedTask?.task_id + taskId === displayedTask?.task_id && userID === task.created_by ? (task) => showDataPoints(task) : (task) => { handleShowOnMap(task); @@ -120,13 +125,14 @@ export default function TaskCard({ }, { - text: "Download Data", + text: userID === task.created_by ? "Download Data" : "Request Data", icon: , action: (task) => handleDownload(task), variant: "outlined", - color: "orange", + color: "primary", loading: true, typeName: "download", + disabled: userID !== task.created_by, }, ]; @@ -185,7 +191,7 @@ export default function TaskCard({

    {task.task_description}

    - + {cardActionBtns.map((btn, index) => btn.loading === true ? ( + +
    + + + + + + ); +} diff --git a/src/utils/apiQueries.js b/src/utils/apiQueries.js index 063fdba..ebc78bd 100644 --- a/src/utils/apiQueries.js +++ b/src/utils/apiQueries.js @@ -121,7 +121,7 @@ export const updateTask = async ({ user, values }) => { }; export const getDataFromBucket = async ({ user, task }) => { - const taskId = task.task_id; + let taskId = task.task_id; const userID = user.idToken; try { const response = await fetch(`${REQUEST_URL}/requests/download/${taskId}`, { @@ -138,7 +138,6 @@ export const getDataFromBucket = async ({ user, task }) => { const result = await response.json(); const data = JSON.parse(result); - if (data.taskID === taskId) { return { response: 200, content: data }; } else { diff --git a/src/utils/generalUtils.js b/src/utils/generalUtils.js new file mode 100644 index 0000000..eb714dc --- /dev/null +++ b/src/utils/generalUtils.js @@ -0,0 +1,23 @@ +export function isMobileDevice() { + const userAgent = navigator.userAgent || navigator.vendor || window.opera; + + // Check for iPad separately since it might not be detected as mobile + if (/iPad/.test(userAgent) && !window.MSStream) { + return false; // Treat iPads as non-mobile devices + } + + // Check for other mobile devices + return /android|iPhone|iPod|avantgo|blackberry|bada|bb|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile|netfront|nokia|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|symbian|treo|up(\.browser|link)|vodafone|wap|windows (ce|phone)|xda|xiino/i.test( + userAgent.toLowerCase() + ); +} + +export const slugify = (str) => { + str = str.replace(/^\s+|\s+$/g, ""); // trim leading/trailing white space + str = str.toLowerCase(); + str = str + .replace(/[^a-z0-9 -]/g, "") // remove any non-alphanumeric characters + .replace(/\s+/g, "-") // replace spaces with hyphens + .replace(/-+/g, "-"); // remove consecutive hyphens + return str; +}; diff --git a/src/utils/icons.jsx b/src/utils/icons.jsx new file mode 100644 index 0000000..a1f5250 --- /dev/null +++ b/src/utils/icons.jsx @@ -0,0 +1,41 @@ +import { SvgIcon } from "@mui/material"; + +export const KaptaSVGIconGreen = (props) => ( + + + +); + +export const KaptaSVGIconWhite = (props) => ( + + + +);