├── .gitignore ├── README.md ├── package-lock.json ├── package.json ├── public ├── event-map-social-preview.jpg ├── favicon.ico ├── index.html ├── logo192.png ├── logo512.png ├── manifest.json └── robots.txt ├── src ├── App.js ├── App.scss ├── App.test.js ├── EventList.js ├── History.js ├── Map.js ├── MobileList.js ├── SearchBar.js ├── Util.js ├── downArrow.svg ├── img │ ├── icon_512x512.png │ ├── marker-shadow.png │ ├── w-marker-icon-2x-highlighted.png │ └── w-marker-icon-2x.png ├── index.css ├── index.js ├── logo.svg └── serviceWorker.js └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | src/js/old.js 3 | todo.txt 4 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 5 | 6 | # dependencies 7 | /node_modules 8 | /.pnp 9 | .pnp.js 10 | 11 | # testing 12 | /coverage 13 | 14 | # production 15 | /build 16 | 17 | # misc 18 | .DS_Store 19 | .env.local 20 | .env.development.local 21 | .env.test.local 22 | .env.production.local 23 | 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | src/ScrapCode.js 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Event Map 2 | 3 | This project provides a map of all of the events specific to an organization's account in Mobilize America. It loads the events via the Mobilize API ([docs for /events endpoint](https://github.com/mobilizeamerica/api#request-2)). 4 | 5 | The project originated with the [Tech for Warren group](https://github.com/techforwarren/eventmap) in 2019. 6 | 7 | ## Working version 8 | 9 | The event map is now live at [https://hope-and-code-labs.github.io/eventmap/](https://hope-and-code-labs.github.io/eventmap/)! Future merges to the `gh-pages` branch will update this site. 10 | 11 | ## Getting Started - Cloning & Installation 12 | 13 | You can clone the GitHub repo or download it from the repo page. After it is on your local machine, be sure to run `npm install` to install all dependencies. 14 | 15 | Change the `mobilizeOrgId` variable in src/App.js to reflect your organization's id in Mobilize. The default is the organization id for the Warren for President campaign. 16 | 17 | ## Running App Locally - `npm start` 18 | 19 | Runs the app in the development mode.
20 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 21 | 22 | The page will reload if you make edits.
23 | You will also see any lint errors in the console. 24 | 25 | 26 | ## Deploying For Github Pages - `npm run deploy` 27 | 28 | Should ideally be done from the `main` branch. 29 | 30 | Follows the create-react-app [Github Pages deployment steps](https://facebook.github.io/create-react-app/docs/deployment). 31 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "eventmap", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@types/lodash.sortby": "^4.7.6", 7 | "history": "^4.10.1", 8 | "leaflet": "^1.5.1", 9 | "lodash.groupby": "^4.6.0", 10 | "lodash.sortby": "^4.7.0", 11 | "moment": "^2.24.0", 12 | "node-sass": "^7.0.0", 13 | "query-string": "^6.8.3", 14 | "react": "^16.9.0", 15 | "react-device-detect": "^1.9.10", 16 | "react-dom": "^16.9.0", 17 | "react-ga": "^2.7.0", 18 | "react-scripts": "3.1.1", 19 | "twix": "^1.3.0" 20 | }, 21 | "scripts": { 22 | "start": "react-scripts start", 23 | "build": "react-scripts build", 24 | "test": "jest --watchAll", 25 | "eject": "react-scripts eject", 26 | "predeploy": "npm run build", 27 | "deploy": "gh-pages -d build" 28 | }, 29 | "eslintConfig": { 30 | "extends": "react-app" 31 | }, 32 | "browserslist": { 33 | "production": [ 34 | ">0.2%", 35 | "not dead", 36 | "not op_mini all" 37 | ], 38 | "development": [ 39 | "last 1 chrome version", 40 | "last 1 firefox version", 41 | "last 1 safari version" 42 | ] 43 | }, 44 | "homepage": "https://hope-and-code-labs.github.io/eventmap/", 45 | "devDependencies": { 46 | "gh-pages": "^2.1.1" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /public/event-map-social-preview.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/techforwarren/eventmap/9a0a092ff8e774cbce013bcd9887ec1ca65689f5/public/event-map-social-preview.jpg -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/techforwarren/eventmap/9a0a092ff8e774cbce013bcd9887ec1ca65689f5/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 8 | 9 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | Events 21 | 22 | 23 | 24 |
25 | 26 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/techforwarren/eventmap/9a0a092ff8e774cbce013bcd9887ec1ca65689f5/public/logo192.png -------------------------------------------------------------------------------- /public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/techforwarren/eventmap/9a0a092ff8e774cbce013bcd9887ec1ca65689f5/public/logo512.png -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | -------------------------------------------------------------------------------- /src/App.js: -------------------------------------------------------------------------------- 1 | import React, {useState, useEffect } from 'react'; 2 | import {isMobile} from 'react-device-detect'; 3 | import History from './History'; 4 | import SearchBar from './SearchBar'; 5 | import Map from './Map'; 6 | import MobileList from './MobileList'; 7 | import './App.scss'; 8 | import gMark from './img/w-marker-icon-2x.png'; 9 | import { eventHasValidLocation } from './Util'; 10 | 11 | import ReactGA from 'react-ga'; 12 | ReactGA.initialize('UA-149839620-1'); 13 | ReactGA.pageview(window.location.pathname + window.location.search); 14 | 15 | const queryString = require('query-string'); 16 | 17 | /* 18 | * Overview of the entire app 19 | * 20 | * Global state is maintained in this file, App.js. Other components 21 | * can update that state (via functions passed to those components), but 22 | * for the most part, the other components are just expected to do what 23 | * they do and report back. An example is the way the SearchBar works. 24 | * It gathers user input and then sets global state (such as the current 25 | * zip code) using routines passed in by App.js - in this case, setCurrZip(). 26 | * The actual persistent global state is maintained in App.js. 27 | * 28 | * App.js does the API call to Mobilize to get events that match the current 29 | * search criteria - at the time of this writing (Dec. 2019), the criteria are 30 | * zip code and distance away from that zip code in miles. 31 | * 32 | * Client side filtering of that list of events is also done in this 33 | * code (App.js), such as filtering for a particular kind of event (like 34 | * canvassing or phone-banking). The rest of the code is fed the filtered 35 | * list of events to display to the user. 36 | * 37 | */ 38 | 39 | const deviceIsMobile = isMobile; // HACK to allow easy mocking of isMobile for testing/debugging 40 | 41 | function App() { 42 | 43 | //List of events returned from the Mobilize API 44 | const [events, setEvents] = useState(null); 45 | 46 | // List of events after client-side filtering of the list of events returned from the Mobilize API 47 | const [filteredEvents, setFilteredEvents] = useState(null); 48 | 49 | /* 50 | * URL parameters: 51 | * 52 | * At present, January 2020, there are three URL parameters: 53 | * zip : the current zip code (default is null) 54 | * eventkind : the current kind of event (default is ALLEVENTS) 55 | * distance : the current distance/radius from current zip code (default is 75 miles) 56 | * 57 | * We initialize state variables from the URL, and we update the URL every time 58 | * the user changes one of the filter parameters. Note that all of this logic 59 | * is contained in the App.js source. Note also that we wrap the setXXX() useState 60 | * functions in updateXXX functions - so do NOT use the setXXX functions directly. 61 | */ 62 | 63 | function updateURL(whichParam, newValue) { 64 | 65 | // Get the existing values 66 | const qs = queryString.parse(History.location.search); 67 | let mobilizeOrgId = getOrgId(); 68 | let zip = qs.zip; 69 | let eventKind = qs.eventkind; 70 | let distance = qs.distance; 71 | 72 | // Change the appropriate value 73 | switch(whichParam) { 74 | case 'zip': 75 | zip = newValue; 76 | break; 77 | case 'eventkind': 78 | eventKind = newValue; 79 | break; 80 | case 'distance': 81 | distance = newValue; 82 | break; 83 | default: 84 | // code block 85 | console.log("Warning: updateURL in App.js - bad value for whichParam: " + whichParam); 86 | } 87 | 88 | // Update the URL 89 | History.push( 90 | window.location.pathname 91 | + "?orgid=" + mobilizeOrgId 92 | + "&zip=" + (zip ? zip : "") 93 | + "&eventkind=" + (eventKind ? eventKind : "") 94 | + "&distance=" + (distance ? distance : "") 95 | ); 96 | } 97 | 98 | function getOrgId() { 99 | const qs = queryString.parse(History.location.search); 100 | let mobilizeOrgId = qs.orgid; 101 | return mobilizeOrgId; 102 | } 103 | 104 | // Current range - distance in miles from the target zip code 105 | const [currRange, setCurrRange] = useState(() => { 106 | // check URL parameter on initialization 107 | const qs = queryString.parse(History.location.search); 108 | let distance = qs.distance; 109 | if (!distance) { 110 | distance = 75; // default distance is 75 miles 111 | updateURL('distance', distance); 112 | } 113 | return distance; 114 | }); 115 | 116 | function updateCurrRange(newDistance) { 117 | setCurrRange(newDistance); // update useState global 118 | 119 | // Update URL 120 | updateURL('distance', newDistance); 121 | } 122 | 123 | // Current kinds of events to display 124 | const [currEventKind, setCurrEventKind] = useState(() => { 125 | // check URL parameter on initialization 126 | const qs = queryString.parse(History.location.search); 127 | let eventKind = qs.eventkind; 128 | if (!eventKind) { 129 | eventKind = 'ALLEVENTS'; // default is ALLEVENTS 130 | updateURL('eventkind', eventKind); 131 | } 132 | return eventKind; 133 | }); 134 | 135 | function updateCurrEventKind(newEventKind) { 136 | setCurrEventKind(newEventKind); 137 | updateURL('eventkind', newEventKind); 138 | } 139 | 140 | //Current zip code search 141 | const [currZip, setCurrZip] = useState(() => { 142 | // check URL parameter on initialization 143 | const qs = queryString.parse(History.location.search); 144 | return (qs.zip ? qs.zip : ""); 145 | }); 146 | 147 | function updateCurrZip(newZip) { 148 | setCurrZip(newZip); 149 | updateURL('zip', newZip); 150 | } 151 | 152 | //Current event being hovered over (in the event list) 153 | const [hoverEvent, setHoverEvent] = useState(null); 154 | 155 | //Current selected location location filter 156 | // When set, only events at the single location will be shown 157 | // Set by a user clicking on a marker in the map, unset by a user clicking elsewhere on the map 158 | const [locFilt, setLocFilt] = useState(null); 159 | 160 | //For mobile, the current card 161 | const [cardIndex, setCardIndex] = useState(null); 162 | 163 | //Makes API call when zipcode entered or the range is updated 164 | useEffect(() => { 165 | if(currZip != null){ 166 | var url = "https://api.mobilize.us/v1/organizations/" + getOrgId() + "/events?timeslot_start=gte_now&per_page=200&zipcode=" + currZip + "&max_dist=" + currRange; 167 | 168 | fetch(url) 169 | .then((res)=>res.json()) 170 | .then((data)=>setEvents(data['data'])); 171 | 172 | //Reset states on new zipcode 173 | setHoverEvent(null); 174 | setLocFilt(null); 175 | setCardIndex(0); 176 | 177 | //Tracks zip input 178 | 179 | ReactGA.event({ 180 | category: 'Search', 181 | action: 'User Searched', 182 | label: `${currZip}` 183 | }); 184 | 185 | } 186 | }, [currZip, currRange]); 187 | 188 | /* 189 | * Filters the events when there are new events from the API or 190 | * when the user changes filtering criteria 191 | */ 192 | useEffect(() => { 193 | 194 | if (!events){ 195 | setCardIndex(null); // no events, hence no valid cardIndex 196 | setFilteredEvents(null); // no events, hence no filtered events 197 | return; 198 | } 199 | 200 | setCardIndex(0); // reset to the first event in the list 201 | 202 | // if ALLEVENTS then return everything, otherwise filter on the current kind of event 203 | setFilteredEvents(events.filter((event) => { 204 | return ((currEventKind === 'ALLEVENTS') || (currEventKind === event['event_type'])); 205 | })); 206 | }, [events, currEventKind]); 207 | 208 | 209 | /* 210 | * If the cardIndex changes, then reset the event whose 211 | * marker is highlighted, by calling setHoverEvent() 212 | */ 213 | useEffect(() => { 214 | if (deviceIsMobile && filteredEvents != null) { 215 | setHoverEvent( 216 | eventHasValidLocation(filteredEvents[cardIndex]) 217 | ? "" + filteredEvents[cardIndex]['location']['location']['latitude'] 218 | + "&" + filteredEvents[cardIndex]['location']['location']['longitude'] 219 | : null); 220 | } 221 | }, [cardIndex]); 222 | 223 | // Note that the code passes {filteredEvents} to other components rather than {events}. 224 | // This shields the rest of the code from having to know and worry about client-side filtering - that 225 | // code just operates on the list of events passed to it. 226 | 227 | return ( 228 |
229 | updateCurrZip(newZip)} 234 | updateRange={(newRange) => updateCurrRange(newRange)} 235 | updateEventKind={(newEventKind) => updateCurrEventKind(newEventKind)} 236 | events={filteredEvents} 237 | updatedHover={(newHover) => setHoverEvent(newHover)} 238 | locFilt={locFilt} 239 | deviceIsMobile={deviceIsMobile} 240 | /> 241 | {events === null && currZip == null && 242 |
243 |

SHE HAS

EVENTS

FOR THAT

244 |

Enter your zipcode to find events near you!

245 |
246 | } 247 | setLocFilt(newLoc)} locFilt={locFilt}/> 248 | {filteredEvents !== null && deviceIsMobile && 249 | setHoverEvent(newHover)} 252 | locFilt={locFilt} 253 | selectLoc={(newLoc) => setLocFilt(newLoc)} 254 | cardIndex={cardIndex} 255 | updateCardIndex={(update) => setCardIndex(update)} 256 | /> 257 | } 258 |
259 | ); 260 | } 261 | 262 | export default App; 263 | -------------------------------------------------------------------------------- /src/App.scss: -------------------------------------------------------------------------------- 1 | /* 2 | * 3 | * The app uses a tiled layout that is different for desktop 4 | * vs. mobile. 5 | * 6 | * On desktop, there are two columns: the left 7 | * column contains the search criteria
above a 8 | * scrolling list of events that match the search criteria. 9 | * The width of the first column is constrained. The right 10 | * column contains the map and it grows/shrinks to use up 11 | * all of the remaining available space. 12 | * 13 | * On mobile, there is a single column that contains (in 14 | * order from top to bottom): the search criteria then 15 | * the map and then an area where the event details are 16 | * provided along with buttons (previous, Info/RSVP, next). 17 | * 18 | * As you will see, we use CSS grid to implement this layout. 19 | * 20 | * To distinguish between the desktop and mobile cases, there 21 | * is a class defined for each case: "appIsDesktop" and 22 | * "appIsMobile". 23 | * 24 | */ 25 | 26 | .appIsDesktop { 27 | display: grid; /* on desktop use a two column grid layout - right column grows/shrinks */ 28 | grid-template-columns: minmax(180px,300px) auto; 29 | 30 | #map{ 31 | height: 100vh; 32 | z-index: 9; 33 | } 34 | 35 | } 36 | 37 | .appIsMobile { 38 | display: grid; /* on mobile use a row grid with three rows: search, map, card & nav */ 39 | grid-template-rows: min-content auto min-content; 40 | 41 | #map{ 42 | z-index: 9; /* frm: not sure z-index does anything for mobile */ 43 | } 44 | 45 | } 46 | 47 | .app{ 48 | height: 100vh; 49 | 50 | #startLoad{ 51 | position: fixed; 52 | width: 100%; 53 | z-index: 10; 54 | text-align: center; 55 | padding-top: 20vh; 56 | background-color: rgba(183, 228, 207,0.85); 57 | height: 100%; 58 | color: #232444; 59 | margin-top: 0px; 60 | 61 | @media only screen and (max-width: 425px) { 62 | padding-top: 25vh; 63 | } 64 | 65 | 66 | #firstLine, #secondLine, #thirdLine{ 67 | display: block; 68 | margin-top: 0px; 69 | margin-bottom: 0px; 70 | 71 | } 72 | 73 | #firstLine, #thirdLine{ 74 | font-size: 10vh; 75 | img{ 76 | height: 10vh; 77 | } 78 | 79 | @media only screen and (max-width: 768px) { 80 | font-size: 10vw; 81 | img{ 82 | height: 10vw; 83 | } 84 | } 85 | } 86 | #secondLine{ 87 | font-size: 20vh; 88 | color: white; 89 | font-style: italic; 90 | text-shadow: -1px -1px 0 #232444, 1px -1px 0 #232444, -1px 1px 0 #232444, 1px 1px 0 #232444; 91 | @media only screen and (max-width: 768px) { 92 | font-size: 20vw; 93 | } 94 | } 95 | 96 | #searchCTA{ 97 | color: #b61b28; 98 | font-style: italic; 99 | font-size: 3vh; 100 | @media only screen and (max-width: 768px) { 101 | font-size: 3vw; 102 | } 103 | } 104 | 105 | 106 | } 107 | 108 | } 109 | 110 | html, body, #root, .app { 111 | height: 100%; 112 | } 113 | 114 | .searchBar{ 115 | background-color: #232444; 116 | z-index:11; /* frm: not sure we need z-index anymore */ 117 | box-shadow: 10px 0px 5px rgba(0, 0, 0, .3); 118 | } 119 | 120 | .desktopSearch { 121 | /* For desktop, we want two rows: 1) search criteria and 2) the scrolling list of events */ 122 | height: 100vh; 123 | display: grid; 124 | grid-template-rows: auto auto; 125 | grid-gap: 0; 126 | } 127 | 128 | .mobileSearch { 129 | width: 100%; 130 | top: 0; 131 | left: 0; 132 | box-shadow: 0px 5px 5px rgba(0, 0, 0, .3); 133 | touch-action: manipulation; 134 | 135 | #zipForm{ 136 | padding-left: 0%; 137 | padding-right: 5%; 138 | } 139 | 140 | #zipInput { 141 | -webkit-border-radius: 0; 142 | border-radius: 0; 143 | -webkit-box-shadow: none; 144 | box-shadow: none; 145 | width: 80%; 146 | } 147 | #locateMe { 148 | width: 25% !important; 149 | } 150 | 151 | .kindOfEvent, .searchRange{ 152 | padding-top: 1%; 153 | padding-bottom: 1%; 154 | } 155 | 156 | } 157 | 158 | .activeList{ 159 | padding-bottom: 0px; 160 | } 161 | 162 | .userInput{ 163 | 164 | display: flex; 165 | align-items: flex-end; 166 | justify-content: center; 167 | padding-left: 5%; 168 | padding-right: 5%; 169 | padding-top: 5%; 170 | padding-bottom: 5%; 171 | 172 | #zipForm{ 173 | display: flex; 174 | flex-flow:row nowrap; 175 | align-items: flex-end; 176 | justify-content: space-between; 177 | background-color:#232444; 178 | border-right: 1px solid white; 179 | padding-right: 9%; 180 | } 181 | 182 | label[for=zipInput] { 183 | color: white; 184 | position: fixed; 185 | font-size: 24px; 186 | } 187 | 188 | [data-has-input=true] label[for=zipInput] { 189 | color: transparent; 190 | } 191 | 192 | #zipInput{ 193 | height: 30%; 194 | width: 80%; 195 | font-size: 24px; 196 | margin-right: 13%; 197 | color: white; 198 | display: block; 199 | background: none; 200 | border: none; 201 | border-bottom: 1px solid white; 202 | z-index:10; 203 | } 204 | 205 | #rangeInput{ 206 | height: 30%; 207 | min-width:105px; 208 | font-size: 24px; 209 | border: none; 210 | background: none; 211 | 212 | } 213 | 214 | #submitZip{ 215 | height: 35px; 216 | padding: 0; 217 | font-size: 24px; 218 | color: white; 219 | background-color: #232444; 220 | border: none; 221 | } 222 | 223 | #submitZip{ 224 | height: 35px; 225 | padding: 0; 226 | font-size: 24px; 227 | color: white; 228 | background-color: #232444; 229 | border: none; 230 | } 231 | 232 | 233 | #locateMe { 234 | height: 35px; 235 | padding: 0; 236 | padding-left: 7%; 237 | color: white; 238 | background-color: #232444; 239 | border: none; 240 | img { 241 | width: 35px; 242 | z-index: 12; 243 | } 244 | } 245 | 246 | 247 | } 248 | 249 | .userOptions{ 250 | display: flex; 251 | flex-wrap: wrap; 252 | background-color: #f5f5f5; 253 | } 254 | 255 | .searchRange{ 256 | font-size: 16px; 257 | background-color: #f5f5f5; 258 | height:30px; 259 | line-height:30px; 260 | text-align: left; 261 | padding-left: 5px; 262 | } 263 | 264 | .searchRange select{ 265 | background: none; 266 | font-size: 16px; 267 | background-color: #f5f5f5; 268 | border: none; 269 | padding: 0px 5px 0px 5px; 270 | appearance: none; 271 | -moz-appearance: none; 272 | -webkit-appearance: none; 273 | background-image: url(./downArrow.svg); 274 | background-position: calc(100% - 8px) 85%; 275 | background-size: 12px 12px; 276 | background-repeat: no-repeat; 277 | 278 | } 279 | 280 | .kindOfEvent { 281 | font-size: 16px; 282 | background-color: #f5f5f5; 283 | height:30px; 284 | line-height:30px; 285 | text-align: left; 286 | padding-left: 5px; 287 | } 288 | 289 | .kindOfEvent > p { 290 | margin: 0; /* inhibit normal margins - so we can pack stuff together */ 291 | } 292 | 293 | .searchRange > p { 294 | margin: 0; /* inhibit normal margins - so we can pack stuff together */ 295 | } 296 | 297 | .kindOfEvent select{ 298 | background: none; 299 | font-size: 16px; 300 | background-color: #f5f5f5; 301 | border: none; 302 | padding: 0px 20px 0px 5px; 303 | appearance: none; 304 | -moz-appearance: none; 305 | -webkit-appearance: none; 306 | background-image: url(./downArrow.svg); 307 | background-position: calc(100% - 8px) 85%; 308 | background-size: 12px 12px; 309 | background-repeat: no-repeat; 310 | 311 | } 312 | 313 | 314 | 315 | .eventList{ 316 | background-color: white; 317 | overflow:hidden; 318 | overflow-y:scroll; 319 | margin-bottom: 0px; 320 | padding-left: 0px; 321 | margin-top: 0px; 322 | 323 | .eventCard{ 324 | text-decoration: none; 325 | color: #232444; 326 | 327 | h3{ 328 | text-transform: uppercase; 329 | } 330 | 331 | :hover{ 332 | color: white; 333 | } 334 | 335 | li{ 336 | list-style-type: none; 337 | padding-left: 10%; 338 | padding-right: 10%; 339 | padding-top: 5%; 340 | padding-bottom: 5%; 341 | border-top: 1px solid #E8E8E8; 342 | border-bottom: 1px solid #E8E8E8; 343 | position: relative; 344 | 345 | .eventRSVP{ 346 | visibility: hidden; 347 | text-align: right; 348 | } 349 | 350 | } 351 | 352 | li:hover{ 353 | background-color: #232444; 354 | 355 | .eventRSVP{ 356 | visibility: visible; 357 | } 358 | } 359 | 360 | } 361 | 362 | .kicker{ 363 | list-style-type: none; 364 | padding-left: 10%; 365 | padding-right: 10%; 366 | padding-top: 5%; 367 | padding-bottom: 5%; 368 | border-top: 1px solid #E8E8E8; 369 | border-bottom: 1px solid #E8E8E8; 370 | position: relative; 371 | } 372 | 373 | } 374 | 375 | .mobileList{ 376 | margin: none; 377 | font-size:calc(10px + 1vw); 378 | touch-action: manipulation; 379 | 380 | .eventCard{ 381 | 382 | text-decoration: none; 383 | color: #232444; 384 | z-index: 10; 385 | bottom: 10%; 386 | background-color: white; 387 | left: 5%; 388 | max-height: 30vh; 389 | min-height: 10vh; 390 | 391 | h3{ 392 | margin-top: 10px; 393 | margin-bottom: 10px; 394 | text-transform: uppercase; 395 | } 396 | 397 | p { 398 | margin: 2px; 399 | } 400 | 401 | .mobileInfo{ 402 | padding-left: 5%; 403 | padding-right: 5%; 404 | padding-bottom: 1%; 405 | 406 | .eventRSVP{ 407 | display: none; 408 | } 409 | 410 | } 411 | } 412 | 413 | .mobileNavWrapper { /* contains navigation buttons (next/prev) and Info/RSVP button */ 414 | display: flex; 415 | flex-direction: row; 416 | justify-content: space-around; 417 | } 418 | 419 | button{ 420 | bottom: 3%; 421 | z-index: 10; 422 | background-color: #232444; 423 | border: none; 424 | color: white; 425 | font-size: 16px; 426 | height: 30px; 427 | 428 | } 429 | #mobileRSVP{ 430 | flex: 1; /* Info/RSVP button stretches to use up all available space */ 431 | a{ 432 | text-decoration: none; 433 | color:white; 434 | } 435 | } 436 | #leftIndex{ 437 | width: 50px; 438 | border-right: 3px solid white; /* separate nav button from RSVP/Info button */ 439 | } 440 | #rightIndex{ 441 | width: 50px; 442 | border-left: 3px solid white; /* separate nav button from RSVP/Info button */ 443 | } 444 | } 445 | 446 | 447 | 448 | // Disables the up/down arrows on the number input 449 | input[type="number"]::-webkit-outer-spin-button, 450 | input[type="number"]::-webkit-inner-spin-button { 451 | -webkit-appearance: none; 452 | margin: 0; 453 | } 454 | input[type="number"] { 455 | -moz-appearance: textfield; 456 | } 457 | -------------------------------------------------------------------------------- /src/App.test.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/techforwarren/eventmap/9a0a092ff8e774cbce013bcd9887ec1ca65689f5/src/App.test.js -------------------------------------------------------------------------------- /src/EventList.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import moment from 'moment'; 3 | import groupBy from 'lodash.groupby'; 4 | import sortBy from 'lodash.sortby'; 5 | import { eventHasValidLocation } from './Util'; 6 | require('twix'); 7 | 8 | 9 | const MAX_DAYS_IN_LIST = 4; 10 | 11 | function EventTimes(props) { 12 | const { rawTimes } = props; 13 | let sortedTimesByDate = groupBy(sortBy(rawTimes, 14 | // Sort all of the ranges by when they start; 15 | // Unix returns the millisecond time, so all 16 | // events will be different 17 | (item) => { return item.start.unix() }), 18 | // Group the ranges by the day they happen on; 19 | // fully including the year, month, and day 20 | // in that order guarantees that normal sorting 21 | // will respect 9/31 v 10/1, and 2020/01 vs 2019/12 22 | (item) => { return item.start.format('YYYY-MM-DD') } 23 | ) 24 | 25 | let sortedDates = Object.keys(sortedTimesByDate).sort(); 26 | 27 | const dateRowFactory = (date) => { 28 | let times = sortedTimesByDate[date]; 29 | let dayStr = times[0].start.format('ddd M/D') 30 | let timeStrs = times.map((time) => time.range.format({ hideDate : true })).join(', ') 31 | return ( 32 |

33 | { dayStr }{' | '}{ timeStrs } 34 |

35 | ) 36 | } 37 | 38 | if (sortedDates.length <= MAX_DAYS_IN_LIST) { 39 | return sortedDates.map(dateRowFactory) 40 | } else { 41 | let nextDay = sortedDates[MAX_DAYS_IN_LIST - 1]; 42 | let lastDay = sortedDates[sortedDates.length - 1]; 43 | let nextStart = sortedTimesByDate[nextDay][0].start; 44 | let lastStart = sortedTimesByDate[lastDay][0].start; 45 | return sortedDates.slice(0,MAX_DAYS_IN_LIST - 1).map(dateRowFactory).concat( 46 |

47 | More Times from {nextStart.twix(lastStart, { allDay : true }).format()} 48 |

49 | ) 50 | } 51 | } 52 | 53 | export function EventList(props) { 54 | let listEvents; 55 | if(props.events.length > 0){ 56 | listEvents = props.events.map((event) => { 57 | 58 | // Normalize Mobilize's time formatting into 59 | // easy-to-use moments 60 | let rawTimes = event['timeslots'].map((timeslot) => { 61 | let start = moment(timeslot.start_date * 1000); 62 | let end = moment(timeslot.end_date * 1000); 63 | return { 64 | start, end, 65 | range: start.twix(end) 66 | } 67 | }) 68 | 69 | //Location filter: if user has clicked on a marker, then only show events at that location 70 | if(props.locFilt != null){ 71 | if(eventHasValidLocation(event)) { 72 | if(event['location']['location']['latitude'] !== props.locFilt['lat'] || event['location']['location']['longitude'] !== props.locFilt['lng']){ 73 | return(null); // event is not at the location filter's coordinates 74 | } 75 | } else { 76 | return(null); // event is private - no location, hence no marker 77 | } 78 | } 79 | 80 | return ( 81 | { props.updatedHover(event['currentTarget'].getAttribute('coord')) }} 91 | onMouseLeave={(event) => { props.updatedHover(null) }}> 92 |
  • 93 |
    94 |

    {event['title']}

    95 |

    {event['location']['venue']} {event['location']['locality'] ? "in" : ""} {event['location']['locality']}

    96 | 97 |

    Click to RSVP

    98 |
    99 |
  • 100 |
    101 | 102 | ) 103 | }); 104 | } else { 105 | listEvents = null; 106 | } 107 | 108 | // At this point listEvents is either null or the HTML for a list of each of the events 109 | 110 | return ( 111 | 116 | ); 117 | 118 | } 119 | 120 | export default EventList; 121 | -------------------------------------------------------------------------------- /src/History.js: -------------------------------------------------------------------------------- 1 | import { createBrowserHistory } from 'history'; 2 | const history = createBrowserHistory(); 3 | 4 | export default history; -------------------------------------------------------------------------------- /src/Map.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useRef } from 'react'; 2 | import L from 'leaflet'; 3 | import gMark from './img/w-marker-icon-2x.png'; 4 | import hMark from './img/w-marker-icon-2x-highlighted.png'; 5 | import sMark from './img/marker-shadow.png'; 6 | import { eventHasValidLocation } from './Util'; 7 | 8 | export function Map(props){ 9 | 10 | const [center, setCenter] = useState([39.8283, -98.5795]); // center of the USA 11 | const [locations, setLocations] = useState({}); // set of unique lat/longs for events 12 | const [newCenter, setNewCenter] = useState(false); 13 | const map = useRef(); 14 | const markers = useRef(); 15 | 16 | //Called to set/unset location filter 17 | function locationFilter(event, set){ 18 | 19 | if(set){ 20 | props.selectLoc({ 21 | 'lat': event['latlng']['lat'], 22 | 'lng': event['latlng']['lng'] 23 | }); 24 | } else { 25 | props.selectLoc(null); 26 | } 27 | } 28 | 29 | //First render 30 | useEffect(() => { 31 | var lastScroll = new Date().getTime(); 32 | var wheelDeltaList = []; 33 | 34 | // override the scrollwheelzoom 35 | L.Map.ScrollWheelZoomExtended = L.Map.ScrollWheelZoom.extend({ 36 | _performZoom: function() { 37 | var currentScrollTime = new Date().getTime(); 38 | var map = this._map, 39 | zoom = map.getZoom(), 40 | delta = this._delta, 41 | normalizedDelta = 0, 42 | snap = this._map.options.zoomSnap || 0; // ??? frm: why use this._map instead of just map 43 | 44 | wheelDeltaList.push(Math.abs(delta)); 45 | var average = 0; 46 | for(let i = 0; i< wheelDeltaList.length; i++){ 47 | average += wheelDeltaList[i]; 48 | } 49 | average = average / wheelDeltaList.length; 50 | 51 | var diffSquaredTotal= 0; 52 | for(let i = 0; i < wheelDeltaList.length; i++){ 53 | var diff = wheelDeltaList[i] - average; 54 | diffSquaredTotal += Math.pow(diff,2); 55 | } 56 | 57 | var standardDeviation = Math.sqrt(diffSquaredTotal/wheelDeltaList.length); 58 | map.stop(); // stop panning and fly animations if any 59 | 60 | var deltaTime = currentScrollTime - lastScroll; 61 | 62 | var d2 = this._delta / (this._map.options.wheelPxPerZoomLevel * 4), // ??? frm: why use this._map instead of just map 63 | d3 = 4 * Math.log(2 / (1 + Math.exp(-Math.abs(d2)))) / Math.LN2, 64 | d4 = snap ? Math.ceil(d3 / snap) * snap : d3, 65 | normalizedDelta = map._limitZoom(zoom + (this._delta > 0 ? d4 : -d4)) - zoom; 66 | 67 | this._delta = 0; 68 | this._startTime = null; 69 | lastScroll = currentScrollTime; 70 | if (!normalizedDelta) { 71 | return; 72 | } 73 | 74 | if(deltaTime < 1000 && ((average+standardDeviation) >= Math.abs(delta))){ 75 | return; 76 | } else if (map.options.scrollWheelZoom === 'center') { 77 | map.setZoom(zoom + normalizedDelta); 78 | } else { 79 | map.setZoomAround(this._lastMousePos, zoom + normalizedDelta); 80 | } 81 | wheelDeltaList = []; 82 | } 83 | }); 84 | 85 | L.Map.addInitHook('addHandler', 'scrollWheelZoomExtended', L.Map.ScrollWheelZoomExtended); 86 | 87 | // Create the map with US center 88 | map.current = L.map('map', { 89 | zoomControl: false, 90 | scrollWheelZoom: false, 91 | scrollWheelZoomExtended: true 92 | }).setView(center, (props.events != null) ? 8 : 4); 93 | 94 | //Initializes layergroup 95 | markers.current = L.featureGroup().addTo(map.current); 96 | markers.current.on("click", (event) => locationFilter(event, true)); 97 | map.current.on("click", (event) => locationFilter(event, false)); 98 | 99 | 100 | // Set up the OSM layer 101 | L.tileLayer( 102 | 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { 103 | attribution: 'Map data © OpenStreetMap contributors', 104 | maxZoom: 18 105 | }).addTo(map.current); 106 | 107 | L.control.zoom({ 108 | position: 'topright' 109 | }).addTo(map.current); 110 | 111 | }, []); 112 | 113 | 114 | //When locations are updated, generate new markers 115 | useEffect(() => { 116 | 117 | if(Object.keys(locations).length > 0){ 118 | markers.current.clearLayers(); 119 | 120 | if(newCenter){ 121 | map.current.setView(center, 8); 122 | setNewCenter(false); 123 | } 124 | 125 | 126 | var generalIcon = new L.Icon({ 127 | iconUrl: gMark, 128 | shadowUrl: sMark, 129 | iconSize: [25, 41], 130 | iconAnchor: [12, 41], 131 | popupAnchor: [1, -34], 132 | shadowSize: [41, 41], 133 | }); 134 | var highlightedIcon = new L.Icon({ 135 | iconUrl: hMark, 136 | shadowUrl: sMark, 137 | iconSize: [25, 41], 138 | iconAnchor: [12, 41], 139 | popupAnchor: [1, -34], 140 | shadowSize: [41, 41], 141 | }); 142 | 143 | for (var key in locations) { 144 | let highlighted = false; 145 | 146 | if(key === props.hoverMarker || (props.locFilt !== null && key === props.locFilt['lat'] + "&" + props.locFilt['lng'])){ 147 | console.log("matching"); 148 | highlighted = true; 149 | } 150 | 151 | 152 | 153 | let cord = key.split("&"); 154 | 155 | if(highlighted){ 156 | L.marker([parseFloat(cord[0]), parseFloat(cord[1])], {icon: highlightedIcon, zIndexOffset: 1000}).addTo(markers.current); 157 | } else { 158 | L.marker([parseFloat(cord[0]), parseFloat(cord[1])], {icon: generalIcon}).addTo(markers.current); 159 | } 160 | 161 | 162 | } 163 | 164 | /* ??? frm: The current code immediately below does not correctly resize and 165 | * rezoom on mobile. The problem is that the amount of space used 166 | * for each card varies, and hence the amount of space available 167 | * for the map can change every time the cardIndex changes. 168 | * 169 | * The code below tells the map to resize (by calling invlidatesize() ) 170 | * everytime the hoverMarker changes which is almost correct. Instead 171 | * it should resize every time the cardIndex changes. 172 | * 173 | * Unfortuately, the Map currently does not know about the cardIndex 174 | * It would be easy to pass it in the way the hoverMarker is currently 175 | * passed in, but I want to wait and make that change in a separate 176 | * pull request (there is another related issue/bug concerning hoverMarker 177 | * on mobile that I will change at the same time). 178 | * 179 | * I am also not going to create an issue for this on github yet 180 | * because this is not a problem in the old UI - so it doesn't make 181 | * sense to create an issue that is not yet a problem in production. 182 | * I will create an issue once the new tiled layout is merged... 183 | */ 184 | 185 | // zoom to marker bounds, plus padding to make sure entire marker is visible 186 | // ??? frm: probably only have to invalidateSize() on mobile... (but it is a cheap op) 187 | map.current.invalidateSize(); // make sure the map fits its allocated space (mobile issue) 188 | map.current.fitBounds(markers.current.getBounds().pad(0.1)); 189 | } 190 | }, [locations, props.hoverMarker, props.locFilt]); 191 | 192 | //Iterates through new events 193 | useEffect(() => { 194 | 195 | if(props.events != null){ 196 | 197 | if(props.events.length > 0){ 198 | 199 | //Initiates map's focus at the first event (typically the closest to the provided zipcode) with a valid lat & long position 200 | 201 | // Find out whether there are any events in the list that are not private 202 | let first = -1; 203 | for (let i=0; i
    283 | ); 284 | } 285 | 286 | export default Map; 287 | -------------------------------------------------------------------------------- /src/MobileList.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | import moment from 'moment'; 3 | import groupBy from 'lodash.groupby'; 4 | import sortBy from 'lodash.sortby'; 5 | import { eventHasValidLocation } from './Util'; 6 | require('twix'); 7 | 8 | const MAX_DAYS_IN_LIST = 1; 9 | 10 | function EventTimes(props) { 11 | const { rawTimes } = props; 12 | let sortedTimesByDate = groupBy(sortBy(rawTimes, 13 | // Sort all of the ranges by when they start; 14 | // Unix returns the millisecond time, so all 15 | // events will be different 16 | (item) => { return item.start.unix() }), 17 | // Group the ranges by the day they happen on; 18 | // fully including the year, month, and day 19 | // in that order guarantees that normal sorting 20 | // will respect 9/31 v 10/1, and 2020/01 vs 2019/12 21 | (item) => { return item.start.format('YYYY-MM-DD') } 22 | ) 23 | 24 | let sortedDates = Object.keys(sortedTimesByDate).sort(); 25 | 26 | const dateRowFactory = (date) => { 27 | let times = sortedTimesByDate[date]; 28 | let dayStr = times[0].start.format('ddd M/D') 29 | let timeStrs = times.map((time) => time.range.format({ hideDate : true })).join(', ') 30 | return ( 31 |

    32 | { dayStr }{' | '}{ timeStrs } 33 |

    34 | ) 35 | } 36 | 37 | if (sortedDates.length <= MAX_DAYS_IN_LIST) { 38 | return sortedDates.map(dateRowFactory) 39 | } else { 40 | let nextDay = sortedDates[MAX_DAYS_IN_LIST - 1]; 41 | let lastDay = sortedDates[sortedDates.length - 1]; 42 | let nextStart = sortedTimesByDate[nextDay][0].start; 43 | let lastStart = sortedTimesByDate[lastDay][0].start; 44 | return sortedDates.slice(0,MAX_DAYS_IN_LIST - 1).map(dateRowFactory).concat( 45 |

    46 | More Times from {nextStart.twix(lastStart, { allDay : true }).format()} 47 |

    48 | ) 49 | } 50 | } 51 | 52 | 53 | /* 54 | * Utility functions for the actions for the "previous" and "next" buttons. 55 | * In each case we want to decrement/increment the cardIndex so that the 56 | * app will show the previoius/next event. But we also want to make sure 57 | * that the location filter (locFilt in App.js) is reset to null since 58 | * if we change the current event, we may move to a new location (lat/long) 59 | * and we do not want the map to continue to highlight the old locFilt 60 | * location (lat/long). 61 | */ 62 | 63 | function clickPrevious(props) { 64 | if (!(props.cardIndex > 0)) { // verify that there is indeed a previous event 65 | console.warn("clickPrevious: cardIndex is not > 0"); 66 | return; 67 | } 68 | props.updateCardIndex(props.cardIndex-1); 69 | props.selectLoc(null); 70 | } 71 | 72 | function clickNext(props, listEvents) { 73 | if (!(props.cardIndex < listEvents.length-1)) { // verify that there is a next event 74 | console.warn("clickNext: cardIndex is too large"); 75 | return; 76 | } 77 | props.updateCardIndex(props.cardIndex+1); 78 | props.selectLoc(null); 79 | } 80 | 81 | export function MobileList(props){ 82 | 83 | //Mobile's location filter doesn't filter but moves the currentIndex to the location's first event 84 | useEffect(() => { 85 | 86 | /* 87 | * If the location filter (locFilt) has changed then reset the cardIndex global. 88 | * 89 | * The location filter changes when the user clicks on a marker on the map. In this case we 90 | * set the cardIndex to the first event in the list of events that has the same lat/long 91 | * as the event the user clicked on (the click stores that event's lat/long in locFilt). 92 | * 93 | */ 94 | 95 | if (props.locFilt !== null) { 96 | 97 | // Reset the cardIndex to the first event that matches the location of the locFilt location 98 | for(let x = 0; x < props.events.length; x++) { 99 | let event = props.events[x]; 100 | 101 | if (eventHasValidLocation(event) && 102 | (event['location']['location']['latitude'] === props.locFilt['lat'] || 103 | event['location']['location']['longitude'] === props.locFilt['lng'])) 104 | { 105 | // We have found the first event in the list that has the sae lat/long as the new location filter 106 | props.updateCardIndex(x); // set the cardIndex to the index of the matching event 107 | x = props.events.length; // fast forward to exit the loop 108 | } 109 | } 110 | } 111 | }, [props.locFilt]) 112 | 113 | if (!props.events) { // MobileList should only be invoked if there are events, but just to be safe... 114 | console.warn("MobileList: props.events is null"); 115 | return; 116 | } 117 | 118 | let listEvents = {}; 119 | if(props.events.length > 0){ 120 | listEvents = props.events.map((event, index) => { 121 | 122 | // Normalize Mobilize's time formatting into 123 | // easy-to-use moments 124 | let rawTimes = event['timeslots'].map((timeslot) => { 125 | let start = moment(timeslot.start_date * 1000); 126 | let end = moment(timeslot.end_date * 1000); 127 | return { 128 | start, end, 129 | range: start.twix(end) 130 | } 131 | }) 132 | 133 | return ( 134 | /* 135 | * frm: Original code that made the event text be an anchor tag. 136 | * 137 | * I changed this because with the new layout, the next and previous buttons 138 | * are right next to the event text, making it too easy to mistakenly 139 | * activate the anchor instead of just going to next/previous event. 140 | * 141 | { props.updatedHover(event['currentTarget'].getAttribute('coord')) }} 148 | onMouseLeave={(event) => { props.updatedHover(null) }}> 149 |
    150 |

    {event['title']}

    151 |

    {event['location']['venue']} in {event['location']['locality']}

    152 | 153 |

    Click to RSVP

    154 |
    155 |
    156 | * 157 | */ 158 | 159 |
    { props.updatedHover(event['currentTarget'].getAttribute('coord')) }} 164 | onMouseLeave={(event) => { props.updatedHover(null) }}> 165 |
    166 |

    {event['title']}

    167 |

    {event['location']['venue']} in {event['location']['locality']}

    168 | 169 |

    Click to RSVP

    170 |
    171 | 172 |
    173 | 174 | ) 175 | }).filter((arrItem) => { 176 | return arrItem != null; 177 | }); 178 | } else { 179 | return
    180 |
    181 |
    182 |

    Sorry! No events near you.

    183 |
    184 |
    185 |
    186 | } 187 | 188 | //Conditional rendering for buttons, depending on position in list 189 | if(props.events.length > 0){ 190 | return ( 191 |
    192 | {listEvents[props.cardIndex]} 193 |
    194 | { 195 | props.cardIndex > 0 && 196 | 197 | } 198 | 199 | { 200 | props.cardIndex < listEvents.length-1 && 201 | 202 | } 203 |
    204 | 205 |
    206 | ); 207 | } 208 | } 209 | 210 | export default MobileList; 211 | -------------------------------------------------------------------------------- /src/SearchBar.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import EventList from './EventList'; 3 | import locateImage from './img/icon_512x512.png'; 4 | 5 | 6 | export function SearchBar(props){ 7 | 8 | const [input, setInput] = useState(props.currZip || ''); 9 | const[rangeInput, setRangeInput] = useState(props.currRange); 10 | 11 | // filters what events are displayed according to the kind of event 12 | const [eventKindInput, setEventKindInput] = useState(props.currEventKind || 'ALLEVENTS'); 13 | 14 | function onlySetNumbers(event){ 15 | let baseValue = event.target.value; 16 | let replacedVal = baseValue.replace(/\D*/g, '') 17 | setInput(replacedVal) 18 | } 19 | 20 | function geolocate(event) { 21 | if (navigator.geolocation) { 22 | navigator.geolocation.getCurrentPosition((position) => { 23 | // limit accuracy to 3 decimial points (~100m), for user privacy 24 | fetch("https://nominatim.openstreetmap.org/reverse?"+ 25 | "lat="+position.coords.latitude.toFixed(3)+ 26 | "&lon="+position.coords.longitude.toFixed(3)+ 27 | '&format=jsonv2') 28 | .then((res)=>res.json()) 29 | .then((data)=>{ 30 | if(data.address && data.address.postcode) { 31 | setZip(data.address.postcode); 32 | } 33 | }); 34 | }, (error) => { 35 | console.error(error); 36 | 37 | }); 38 | } 39 | } 40 | 41 | function onSubmit(event){ 42 | event.preventDefault(); 43 | props.updateRange(rangeInput); // calls setCurrRange() in App.js triggering a Mobilize API call and a re-render 44 | setZip(input); 45 | /* ??? frm: Should I add a call to setEventKind() here? 46 | * I don't think it necessary until I put the event kind in the URL... 47 | */ 48 | } 49 | 50 | function setRange(input){ 51 | setRangeInput(input); // updates local global state 52 | props.updateRange(input); // calls setCurrRange() in App.js triggering a Mobilize API call and a re-render 53 | } 54 | 55 | function setEventKind(input) { 56 | setEventKindInput(input); // update local global 57 | props.updateEventKind(input); // update App.js global - triggering re-render of list of events 58 | } 59 | 60 | function setZip(input) { 61 | setInput(input); // updates local state 62 | props.updateZip(input); // calls setCurrZip() in App.js - triggering a Mobilize API call and a re-render 63 | } 64 | 65 | /* TODO: Decide on what kinds of events we should allow users to filter on. 66 | * 67 | * The list below is probably adequate, but it would make sense for some 68 | * product minded folks to think about what the right set of events 69 | * should be. 70 | * 71 | * Here is the list of events from the Mobilize API documentation: 72 | * 73 | * https://github.com/mobilizeamerica/api#event-object 74 | * 75 | * The type of the event, one of: 76 | * CANVASS, PHONE_BANK, TEXT_BANK, MEETING, COMMUNITY, FUNDRAISER, 77 | * MEET_GREET, HOUSE_PARTY, VOTER_REG, TRAINING, FRIEND_TO_FRIEND_OUTREACH, 78 | * DEBATE_WATCH_PARTY, ADVOCACY_CALL, RALLY, TOWN_HALL, OFFICE_OPENING, 79 | * BARNSTORM, SOLIDARITY_EVENT, COMMUNITY_CANVASS, SIGNATURE_GATHERING, 80 | * CARPOOL, OTHER. 81 | * This list may expand. 82 | * 83 | * The subset of these events that I (Fred Mueller) chose to put in the code are: 84 | * 85 | * CANVASS 86 | * PHONE_BANK 87 | * TEXT_BANK 88 | * FUNDRAISER 89 | * MEET_GREET 90 | * HOUSE_PARTY 91 | * TRAINING 92 | * FRIEND_TO_FRIEND_OUTREACH 93 | * DEBATE_WATCH_PARTY 94 | * RALLY 95 | * TOWN_HALL 96 | * COMMUNITY_CANVASS 97 | * CARPOOL 98 | * 99 | * Note that the events are not always categorized properly - for instance, 100 | * I found a couple of user generated events that were categorized as TRAINING 101 | * when they were actually for CANVASS, but I suppose there is nothing to be 102 | * done about that. 103 | * 104 | */ 105 | 106 | return( 107 |
    108 |
    109 |
    110 | 111 | 112 | 113 |
    114 | 115 |
    116 | 117 | 118 | 119 | { props.events !== null && 120 | 121 |
    122 |
    123 | 139 |
    140 |
    141 | 150 |
    151 |
    152 | } 153 | {props.events !== null && !props.deviceIsMobile && 154 | props.updatedHover(item)}/> 155 | } 156 |
    157 | ); 158 | } 159 | 160 | export default SearchBar; 161 | -------------------------------------------------------------------------------- /src/Util.js: -------------------------------------------------------------------------------- 1 | /* 2 | * A place to put stateless utility functions that we 3 | * want to be available generally 4 | */ 5 | 6 | export function eventHasValidLocation(event) { 7 | /* 8 | * Returns true iff the given event (returned by a call to the Mobilize API) 9 | * has a valid location - meaning that it has a value for latitude. We assume 10 | * that if it has a value for latitude, then it also has one for longitude. 11 | */ 12 | return ('location' in event && 'location' in event['location'] && 'latitude' in event['location']['location']); 13 | } 14 | 15 | -------------------------------------------------------------------------------- /src/downArrow.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/img/icon_512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/techforwarren/eventmap/9a0a092ff8e774cbce013bcd9887ec1ca65689f5/src/img/icon_512x512.png -------------------------------------------------------------------------------- /src/img/marker-shadow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/techforwarren/eventmap/9a0a092ff8e774cbce013bcd9887ec1ca65689f5/src/img/marker-shadow.png -------------------------------------------------------------------------------- /src/img/w-marker-icon-2x-highlighted.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/techforwarren/eventmap/9a0a092ff8e774cbce013bcd9887ec1ca65689f5/src/img/w-marker-icon-2x-highlighted.png -------------------------------------------------------------------------------- /src/img/w-marker-icon-2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/techforwarren/eventmap/9a0a092ff8e774cbce013bcd9887ec1ca65689f5/src/img/w-marker-icon-2x.png -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", 4 | "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", 12 | monospace; 13 | } 14 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import './index.css'; 4 | import App from './App'; 5 | import * as serviceWorker from './serviceWorker'; 6 | 7 | ReactDOM.render(, document.getElementById('root')); 8 | 9 | // If you want your app to work offline and load faster, you can change 10 | // unregister() to register() below. Note this comes with some pitfalls. 11 | // Learn more about service workers: https://bit.ly/CRA-PWA 12 | serviceWorker.unregister(); 13 | -------------------------------------------------------------------------------- /src/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/serviceWorker.js: -------------------------------------------------------------------------------- 1 | // This optional code is used to register a service worker. 2 | // register() is not called by default. 3 | 4 | // This lets the app load faster on subsequent visits in production, and gives 5 | // it offline capabilities. However, it also means that developers (and users) 6 | // will only see deployed updates on subsequent visits to a page, after all the 7 | // existing tabs open on the page have been closed, since previously cached 8 | // resources are updated in the background. 9 | 10 | // To learn more about the benefits of this model and instructions on how to 11 | // opt-in, read https://bit.ly/CRA-PWA 12 | 13 | const isLocalhost = Boolean( 14 | window.location.hostname === 'localhost' || 15 | // [::1] is the IPv6 localhost address. 16 | window.location.hostname === '[::1]' || 17 | // 127.0.0.1/8 is considered localhost for IPv4. 18 | window.location.hostname.match( 19 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ 20 | ) 21 | ); 22 | 23 | export function register(config) { 24 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { 25 | // The URL constructor is available in all browsers that support SW. 26 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href); 27 | if (publicUrl.origin !== window.location.origin) { 28 | // Our service worker won't work if PUBLIC_URL is on a different origin 29 | // from what our page is served on. This might happen if a CDN is used to 30 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374 31 | return; 32 | } 33 | 34 | window.addEventListener('load', () => { 35 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 36 | 37 | if (isLocalhost) { 38 | // This is running on localhost. Let's check if a service worker still exists or not. 39 | checkValidServiceWorker(swUrl, config); 40 | 41 | // Add some additional logging to localhost, pointing developers to the 42 | // service worker/PWA documentation. 43 | navigator.serviceWorker.ready.then(() => { 44 | console.log( 45 | 'This web app is being served cache-first by a service ' + 46 | 'worker. To learn more, visit https://bit.ly/CRA-PWA' 47 | ); 48 | }); 49 | } else { 50 | // Is not localhost. Just register service worker 51 | registerValidSW(swUrl, config); 52 | } 53 | }); 54 | } 55 | } 56 | 57 | function registerValidSW(swUrl, config) { 58 | navigator.serviceWorker 59 | .register(swUrl) 60 | .then(registration => { 61 | registration.onupdatefound = () => { 62 | const installingWorker = registration.installing; 63 | if (installingWorker == null) { 64 | return; 65 | } 66 | installingWorker.onstatechange = () => { 67 | if (installingWorker.state === 'installed') { 68 | if (navigator.serviceWorker.controller) { 69 | // At this point, the updated precached content has been fetched, 70 | // but the previous service worker will still serve the older 71 | // content until all client tabs are closed. 72 | console.log( 73 | 'New content is available and will be used when all ' + 74 | 'tabs for this page are closed. See https://bit.ly/CRA-PWA.' 75 | ); 76 | 77 | // Execute callback 78 | if (config && config.onUpdate) { 79 | config.onUpdate(registration); 80 | } 81 | } else { 82 | // At this point, everything has been precached. 83 | // It's the perfect time to display a 84 | // "Content is cached for offline use." message. 85 | console.log('Content is cached for offline use.'); 86 | 87 | // Execute callback 88 | if (config && config.onSuccess) { 89 | config.onSuccess(registration); 90 | } 91 | } 92 | } 93 | }; 94 | }; 95 | }) 96 | .catch(error => { 97 | console.error('Error during service worker registration:', error); 98 | }); 99 | } 100 | 101 | function checkValidServiceWorker(swUrl, config) { 102 | // Check if the service worker can be found. If it can't reload the page. 103 | fetch(swUrl) 104 | .then(response => { 105 | // Ensure service worker exists, and that we really are getting a JS file. 106 | const contentType = response.headers.get('content-type'); 107 | if ( 108 | response.status === 404 || 109 | (contentType != null && contentType.indexOf('javascript') === -1) 110 | ) { 111 | // No service worker found. Probably a different app. Reload the page. 112 | navigator.serviceWorker.ready.then(registration => { 113 | registration.unregister().then(() => { 114 | window.location.reload(); 115 | }); 116 | }); 117 | } else { 118 | // Service worker found. Proceed as normal. 119 | registerValidSW(swUrl, config); 120 | } 121 | }) 122 | .catch(() => { 123 | console.log( 124 | 'No internet connection found. App is running in offline mode.' 125 | ); 126 | }); 127 | } 128 | 129 | export function unregister() { 130 | if ('serviceWorker' in navigator) { 131 | navigator.serviceWorker.ready.then(registration => { 132 | registration.unregister(); 133 | }); 134 | } 135 | } 136 | --------------------------------------------------------------------------------