├── client ├── components │ ├── style │ │ ├── button.js │ │ ├── colors.js │ │ └── device.js │ ├── NotFound.jsx │ ├── Logo.jsx │ ├── Contest.jsx │ ├── Election.jsx │ ├── Candidate.jsx │ ├── Official.jsx │ ├── Finances.jsx │ └── OfficialDetails.jsx ├── assets │ └── noImage.png ├── index.js ├── util │ ├── ProtectedRoute.jsx │ └── index.js ├── App.jsx ├── models │ ├── election.js │ ├── finances.js │ └── officials.js ├── Session.js └── routes │ ├── Portal.jsx │ ├── Elections.jsx │ └── Officials.jsx ├── .travis.yml ├── .babelrc ├── .gitignore ├── README.md ├── jest-teardown.js ├── jest-setup.js ├── server ├── routes │ └── api.js ├── index.js └── controllers │ └── apiController.js ├── index.html ├── webpack.config.js ├── __tests__ ├── puppeteer.js ├── supertest.js └── enzyme.js └── package.json /client/components/style/button.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "node" 4 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-env", "@babel/preset-react"] 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build 3 | secret.js 4 | bundle.js 5 | TestComponent.jsx -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Congre$$ | Mobile-first, location based, political information data aggregator 2 | -------------------------------------------------------------------------------- /jest-teardown.js: -------------------------------------------------------------------------------- 1 | module.exports = async (globalConfig) => { 2 | testServer.close(); 3 | }; 4 | -------------------------------------------------------------------------------- /jest-setup.js: -------------------------------------------------------------------------------- 1 | module.exports = async () => { 2 | global.testServer = await require('./server'); 3 | }; 4 | -------------------------------------------------------------------------------- /client/assets/noImage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/followthemoeny/CongreSS/HEAD/client/assets/noImage.png -------------------------------------------------------------------------------- /client/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from 'react-dom'; 3 | import App from './App.jsx'; 4 | 5 | if (module && module.hot) { 6 | module.hot.accept(); 7 | } 8 | 9 | render(, document.getElementById('root')); 10 | -------------------------------------------------------------------------------- /client/components/style/colors.js: -------------------------------------------------------------------------------- 1 | const colors = { 2 | blue: '#0052a5', 3 | lightBlue: '#e5eef7', 4 | hoverBlue: '#0052bd', 5 | activeBlue: '#005276', 6 | red: '#e0162b', 7 | white: '#fffff0', 8 | black: '#1b1b1b' 9 | }; 10 | 11 | export default colors; -------------------------------------------------------------------------------- /client/util/ProtectedRoute.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Redirect, Route } from "react-router-dom"; 3 | 4 | const ProtectedRoute = ({ validate, children, ...rest }) => { 5 | const render = ({ location }) => { 6 | const redirect = validate(location); 7 | if (redirect) { 8 | return ; 9 | } 10 | return children; 11 | } 12 | 13 | return ; 14 | }; 15 | 16 | export default ProtectedRoute; -------------------------------------------------------------------------------- /client/components/NotFound.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const NotFound = () => { 4 | return ( 5 |
10 | {' '} 11 |

19 | 404 Not Found 20 |

21 |
22 | ); 23 | }; 24 | 25 | export default NotFound; 26 | -------------------------------------------------------------------------------- /client/components/style/device.js: -------------------------------------------------------------------------------- 1 | const size = { 2 | mobileS: '320px', 3 | mobileM: '375px', 4 | mobileL: '425px', 5 | tablet: '768px', 6 | laptop: '1024px', 7 | laptopL: '1440px', 8 | desktop: '2560px', 9 | }; 10 | 11 | export const device = { 12 | mobileS: `(min-width: ${size.mobileS})`, 13 | mobileM: `(min-width: ${size.mobileM})`, 14 | mobileL: `(min-width: ${size.mobileL})`, 15 | tablet: `(min-width: ${size.tablet})`, 16 | laptop: `(min-width: ${size.laptop})`, 17 | laptopL: `(min-width: ${size.laptopL})`, 18 | desktop: `(min-width: ${size.desktop})`, 19 | desktopL: `(min-width: ${size.desktop})`, 20 | }; 21 | -------------------------------------------------------------------------------- /server/routes/api.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const snippetController = require('../controllers/apiController'); 3 | const apiController = require('../controllers/apiController'); 4 | 5 | const router = express.Router(); 6 | 7 | router.get('/officials', apiController.getRepresentatives, (req, res) => { 8 | return res.status(200).json(res.locals.representatives); 9 | }); 10 | router.get('/election', apiController.getElectionInfo, (req, res) => { 11 | return res.status(200).json(res.locals.elections); 12 | }); 13 | router.get('/finances', apiController.getCandidateInfo, (req, res) => { 14 | return res.status(200).json(res.locals.finance); 15 | }); 16 | module.exports = router; 17 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | SHOW ME THE MONEY 7 | 11 | 12 | 20 |
21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /client/components/Logo.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import { device } from '../components/style/device'; 4 | import colors from './style/colors'; 5 | 6 | const LogoWrapper = styled.div` 7 | display: flex; 8 | justify-content: center; 9 | background-color: ${colors.red}; 10 | color: white; 11 | padding: 2vh 2vh; 12 | border-radius: 0px 0px 10px 10px; 13 | @media ${device.laptop} { 14 | justify-content: start; 15 | padding: 25px 25px; 16 | border-radius: 0px; 17 | } 18 | `; 19 | LogoWrapper.displayName = 'LogoWrapper' 20 | 21 | const LogoContent = styled.span` 22 | font-family: 'Rubik', sans-serif; 23 | font-size: 2.5em; 24 | @media ${device.laptop} { 25 | font-size: 2.5em; 26 | } 27 | `; 28 | LogoContent.displayName = 'LogoContent' 29 | 30 | const Logo = () => { 31 | return ( 32 | 33 | Congre$$ 34 | 35 | ); 36 | }; 37 | 38 | export default Logo; 39 | -------------------------------------------------------------------------------- /server/index.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const path = require('path'); 3 | const cookieparser = require('cookie-parser'); 4 | const apiRouter = require('./routes/api'); 5 | 6 | const app = express(); 7 | 8 | const PORT = 3000; 9 | 10 | app.use(express.json()); 11 | app.use(express.urlencoded({ extended: true })); 12 | app.use(cookieparser()); 13 | 14 | app.use('/api', apiRouter); 15 | 16 | if (process.env.NODE_ENV === 'production') { 17 | app.use('/build', express.static(path.join(__dirname, '../build'))); 18 | app.use('/', (req, res) => { 19 | res.sendFile(path.join(__dirname, '../index.html')); 20 | }); 21 | } 22 | 23 | // Error Handler 24 | app.use((err, req, res, next) => { 25 | const defaultErr = { 26 | log: err, 27 | status: 400, 28 | message: { err: 'An error occurred' }, 29 | }; 30 | const errorObj = { ...defaultErr, ...err }; 31 | console.log(`MIDDLEWARE ERROR: ${errorObj.log}`); 32 | res.status(errorObj.status).send(JSON.stringify(errorObj.log)); 33 | }); 34 | 35 | module.exports = app.listen(PORT, () => console.log(`listening on port ${PORT}`)); 36 | -------------------------------------------------------------------------------- /client/App.jsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react'; 2 | import styled from 'styled-components'; 3 | import { BrowserRouter as Router, Switch, Route } from 'react-router-dom'; 4 | import ProtectedRoute from './util/ProtectedRoute.jsx'; 5 | 6 | import Session from './Session.js'; 7 | 8 | import Portal from './routes/Portal.jsx'; 9 | import Officials from './routes/Officials.jsx'; 10 | import Elections from './routes/Elections.jsx'; 11 | import NotFound from './components/NotFound.jsx'; 12 | 13 | const App = () => { 14 | const validate = () => { 15 | if (!Session.address) { 16 | return '/'; 17 | } 18 | }; 19 | 20 | return ( 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | ); 38 | }; 39 | 40 | export default App; 41 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | const mode = process.env.NODE_ENV; 4 | const webpack = require('webpack'); 5 | 6 | module.exports = { 7 | devServer: { 8 | historyApiFallback: true, 9 | publicPath: '/build/', 10 | proxy: { 11 | '/api': 'http://localhost:3000', 12 | }, 13 | port: 8080, 14 | hot: true, 15 | }, 16 | watchOptions: { 17 | ignored: /node_modules/, 18 | }, 19 | entry: ['./client/index.js'], 20 | output: { 21 | path: path.resolve(__dirname, 'build'), 22 | filename: 'bundle.js', 23 | publicPath: '/build/', 24 | }, 25 | mode, 26 | plugins: [new webpack.HotModuleReplacementPlugin()], 27 | module: { 28 | rules: [ 29 | { 30 | test: /\.jsx?/, 31 | exclude: /node_modules/, 32 | use: { 33 | loader: 'babel-loader', 34 | options: { 35 | presets: ['@babel/preset-env', '@babel/preset-react'], 36 | }, 37 | }, 38 | }, 39 | { 40 | test: /\.(png|jpe?g|gif)$/i, 41 | use: [ 42 | { 43 | loader: 'file-loader', 44 | }, 45 | ], 46 | }, 47 | ], 48 | }, 49 | }; 50 | -------------------------------------------------------------------------------- /client/components/Contest.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | 4 | import Candidate from './Candidate.jsx'; 5 | 6 | const Contest = (props) => { 7 | const candidates = props.candidates || []; 8 | const { ballotTitle, type } = props; 9 | 10 | const children = candidates.length ? ( 11 | candidates.map((data, i) => ( 12 | 13 | )) 14 | ) : ( 15 |
No candidate information available.
16 | ); 17 | 18 | const ContestWrapper = styled.div` 19 | padding: 15px; 20 | display: flex; 21 | flex-direction: column; 22 | align-items: center; 23 | `; 24 | 25 | const BallotTitle = styled(ContestWrapper)` 26 | font-size: 1.2em; 27 | padding: 0; 28 | `; 29 | ContestWrapper.displayName = 'ContestWrapper'; 30 | 31 | const CandidatesWrapper = styled.div` 32 | font-weight: 1.4em; 33 | `; 34 | 35 | const Candidates = styled.div` 36 | font-size: 1.1em; 37 | `; 38 | return ( 39 | 40 | 41 |

{ballotTitle}

42 |
43 |
44 | 45 | Candidates: 46 | 47 | {children} 48 |
49 |
50 | ); 51 | }; 52 | 53 | export default Contest; 54 | -------------------------------------------------------------------------------- /client/util/index.js: -------------------------------------------------------------------------------- 1 | import * as fetch from 'node-fetch'; 2 | import * as querystring from 'querystring'; 3 | 4 | export const access = (value) => { 5 | const context = { value }; 6 | const proxy = new Proxy(function () {}, { 7 | get(target, prop) { 8 | if (context.value) { 9 | context.value = context.value[prop]; 10 | } 11 | return proxy; 12 | }, 13 | apply(target, thisArg, [defaultVal]) { 14 | if (context.value === undefined) { 15 | return defaultVal; 16 | } 17 | return context.value; 18 | }, 19 | }); 20 | return proxy; 21 | }; 22 | 23 | export const clientStore = (name, session) => { 24 | const store = session ? window.sessionStorage : window.localStorage; 25 | return (key, val = undefined) => { 26 | let result; 27 | 28 | if (key === null) { 29 | store.removeItem(name); 30 | return; 31 | } 32 | 33 | let data = JSON.parse(store.getItem(name) || '{}'); 34 | 35 | if (typeof key === 'object') { 36 | result = data = { ...data, ...key }; 37 | } 38 | 39 | if (val !== undefined) { 40 | data[key] = val; 41 | } 42 | 43 | store.setItem(name, JSON.stringify(data)); 44 | 45 | return result || data[key]; 46 | }; 47 | }; 48 | 49 | export const httpGet = (uri, query) => { 50 | return fetch(`${uri}?${querystring.encode(query)}`).then((response) => 51 | response.status === 200 ? response.json() : Promise.reject(response.status), 52 | ); 53 | }; 54 | -------------------------------------------------------------------------------- /__tests__/puppeteer.js: -------------------------------------------------------------------------------- 1 | const puppeteer = require('puppeteer'); 2 | import renderer from 'react-test-renderer'; 3 | 4 | const APP = `http://localhost:3000/`; 5 | jest.setTimeout(10000); 6 | describe('do we get to the candidates page?', () => { 7 | let browser; 8 | let page; 9 | 10 | beforeAll(async () => { 11 | browser = await puppeteer.launch({ 12 | args: ['--no-sandbox', '--disable-setuid-sandbox'], 13 | headless: false, 14 | }); 15 | page = await browser.newPage(); 16 | }); 17 | 18 | afterAll(() => { 19 | browser.close(); 20 | }); 21 | 22 | describe('Splash Page', () => { 23 | it('loads successfully', async () => { 24 | // We navigate to the page at the beginning of each case so we have a 25 | // fresh start 26 | await page.goto('http://localhost:3000'); 27 | await page.waitForSelector('head'); 28 | let title = await page.$eval('head > title', (el) => el.innerHTML); 29 | expect(title).toBe('SHOW ME THE MONEY'); 30 | title = await page.$eval('span', (el) => el.innerHTML); 31 | expect(title).toBe('Congre$$'); 32 | }); 33 | 34 | it('Can get to officials', async () => { 35 | await page.goto('http://localhost:3000'); 36 | await page.waitForSelector('input'); 37 | await page.focus('input'); 38 | await page.keyboard.type('411 Woodland Heights 28734'); 39 | await page.$eval('button', (el) => el.click()); 40 | await page.waitForSelector('h2'); 41 | let successfulSearch = await page.$eval('h2', (el) => el.innerHTML); 42 | expect(successfulSearch).toBe('Your Elected Officials'); 43 | }); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /client/models/election.js: -------------------------------------------------------------------------------- 1 | const elections = [ 2 | { 3 | "election": { 4 | "id": "4997", 5 | "name": "North Carolina 11th Congressional District Republican Primary Election", 6 | "electionDay": "2020-06-23", 7 | "ocdDivisionId": "ocd-division/country:us/state:nc", 8 | "pollingLocations": [ 9 | { 10 | "address": { 11 | "locationName": "FRANKLIN TOWN HALL", 12 | "line1": "95 Northeast Main Street", 13 | "city": "Franklin", 14 | "state": "NC", 15 | "zip": "28734" 16 | }, 17 | "pollingHours": "Tue, Jun 23: 6:30 am - 7:30 pm", 18 | "startDate": "2020-06-23", 19 | "endDate": "2020-06-23", 20 | "sources": [ 21 | { 22 | "name": "Voting Information Project", 23 | "official": true 24 | } 25 | ] 26 | } 27 | ] 28 | }, 29 | "contests": [ 30 | { 31 | "type": "General", 32 | "ballotTitle": "US HOUSE OF REPRESENTATIVES DISTRICT 11", 33 | "office": "US HOUSE OF REPRESENTATIVES DISTRICT 11", 34 | "level": ["country"], 35 | "district": { 36 | "name": "US HOUSE OF REPRESENTATIVES DISTRICT 11", 37 | "scope": "congressional", 38 | "id": "0" 39 | }, 40 | "numberElected": "1", 41 | "ballotPlacement": "3", 42 | "sources": [ 43 | { 44 | "name": "Voting Information Project", 45 | "official": true 46 | } 47 | ], 48 | "candidates": [ 49 | { 50 | "name": "Lynda Bennett", 51 | "party": "REPUBLICAN" 52 | }, 53 | { 54 | "name": "Madison Cawthorn", 55 | "party": "REPUBLICAN" 56 | } 57 | ] 58 | } 59 | ] 60 | } 61 | ]; 62 | 63 | export default elections; -------------------------------------------------------------------------------- /client/components/Election.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import Contest from './Contest.jsx'; 4 | import colors from '../components/style/colors'; 5 | import { access } from '../util'; 6 | 7 | const Election = (props) => { 8 | const contests = props.contests || []; 9 | const election = props.election || props; 10 | const address = access(election).pollingLocations[0].address({}); 11 | const { electionDay } = election; 12 | 13 | const children = contests.length ? ( 14 | contests.map((data, i) => ( 15 | 16 | )) 17 | ) : ( 18 |
No available contest information.
19 | ); 20 | 21 | const ElectionWrapper = styled.div` 22 | display: flex; 23 | flex-direction: column; 24 | align-items: center; 25 | `; 26 | const ElectionHeader = styled.div` 27 | padding-top: 20px; 28 | display: flex; 29 | justify-content: center; 30 | font-size: 2em; 31 | font-weight: bold; 32 | `; 33 | 34 | ElectionHeader.displayName = 'ElectionHeader'; 35 | 36 | const Date = styled.div` 37 | font-weight: bold; 38 | font-size: 1.4em; 39 | padding-bottom: 0.25em; 40 | color: ${colors.red}; 41 | `; 42 | 43 | const Address = styled(Date)` 44 | font-size: 1em; 45 | color: black; 46 | `; 47 | 48 | return ( 49 | 50 | {electionDay} 51 |
52 | Address: {address.line1} 53 |
54 | Contests: 55 |
{children}
56 |
57 | ); 58 | }; 59 | 60 | export default Election; 61 | -------------------------------------------------------------------------------- /client/Session.js: -------------------------------------------------------------------------------- 1 | import { clientStore, httpGet } from './util'; 2 | 3 | const value = clientStore('data', true); 4 | 5 | const Session = { 6 | ADDRESS: "address", 7 | OFFICIALS: "officials", 8 | ELECTIONS: "elections", 9 | FINANCES: "finances", 10 | 11 | get address() { 12 | return value(Session.ADDRESS); 13 | }, 14 | 15 | initialize(address) { 16 | value({ 17 | [Session.ADDRESS]: address, 18 | [Session.OFFICIALS]: null, 19 | [Session.ELECTIONS]: null, 20 | [Session.FINANCES]: {} 21 | }); 22 | 23 | return Session.getOfficals(address) 24 | .catch((err) => { 25 | value(Session.ADDRESS, null); 26 | return Promise.reject(err); 27 | }); 28 | }, 29 | 30 | getOfficals() { 31 | const officials = value(Session.OFFICIALS); 32 | 33 | if (officials) { 34 | return Promise.resolve(officials); 35 | } 36 | 37 | return httpGet('/api/officials', { address: Session.address }) 38 | .then((data) => value(Session.OFFICIALS, data)); 39 | }, 40 | 41 | getElections() { 42 | const elections = value(Session.ELECTIONS); 43 | 44 | if (elections) { 45 | return Promise.resolve(elections); 46 | } 47 | 48 | return httpGet('/api/election', { address: Session.address }) 49 | .then((data) => value(Session.ELECTIONS, Array.isArray(data) ? data : [data])); //fix server 50 | }, 51 | 52 | getFinances(name, state) { 53 | const finances = value(Session.FINANCES); 54 | 55 | const key = `${name}:${state}`; 56 | 57 | if (finances[key]) { 58 | return Promise.resolve(finances[key]); 59 | } 60 | 61 | return httpGet('/api/finances', { name }) 62 | .then((data) => { 63 | finances[key] = data; 64 | value(Session.FINANCES, finances); 65 | return data; 66 | }); 67 | } 68 | }; 69 | 70 | export default Session; -------------------------------------------------------------------------------- /client/components/Candidate.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { device } from '../components/style/device'; 3 | import colors from '../components/style/colors'; 4 | import styled from 'styled-components'; 5 | import Finances from './Finances.jsx'; 6 | 7 | const Candidate = (props) => { 8 | const CardWrapper = styled.div` 9 | display: flex; 10 | flex-direction: column; 11 | align-items: center; 12 | margin-top: 10px; 13 | // padding: 10px; 14 | // max-width: 80vw; 15 | // min-width: 80vw; 16 | // border-radius: 4px; 17 | // @media ${device.laptop} { 18 | // max-width: 100px; 19 | // min-width: 30vw; 20 | } 21 | `; 22 | 23 | const Name = styled.h2` 24 | font-weight: bold; 25 | margin: 0; 26 | margin-bottom: 5px; 27 | padding: 0; 28 | `; 29 | 30 | const Party = styled.h3` 31 | padding: 0px 0px 5px 0px; 32 | margin: 0; 33 | `; 34 | 35 | const MoreInfoButton = styled.button` 36 | width: 300px; 37 | padding: 20px 18px 20px 18px; 38 | margin-top: 0px; 39 | font-size: 1.1em; 40 | font-weight: bold; 41 | border: none; 42 | border-radius: ${(props) => (props.rounded ? '0 0 10px 10px;' : '10px;')} 43 | background-color: ${colors.blue}; 44 | color: white; 45 | `; 46 | CardWrapper.displayName = 'CardWrapper'; 47 | 48 | const [showFinances, setShowFinances] = useState(false); 49 | 50 | const { name, party, state } = props; 51 | 52 | return ( 53 | 54 | {name} 55 | {party} 56 |
57 | setShowFinances(!showFinances)}> 58 | {showFinances ? 'Hide' : 'Show'} Finances 59 | 60 | {showFinances ? : null} 61 |
62 |
63 | ); 64 | }; 65 | 66 | export default Candidate; 67 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "showmethemoney", 3 | "version": "1.0.0", 4 | "description": "money shall be shown", 5 | "main": "index.js", 6 | "scripts": { 7 | "testwindows": "set \"NODE_ENV=production\" && jest", 8 | "test": "NODE_ENV=production jest", 9 | "startwindows": "set \"NODE_ENV=production\" && node server/index.js", 10 | "buildwindows": "set \"NODE_ENV=production\" && webpack", 11 | "devwindows": "set \"NODE_ENV=development\" && concurrently \"webpack-dev-server --open\" \"nodemon server/index.js\"", 12 | "start": "NODE_ENV=production node server/index.js", 13 | "build": "cross-env NODE_ENV=production webpack", 14 | "dev": "nodemon server/index.js & cross-env NODE_ENV=development webpack-dev-server --open" 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "git+https://github.com/followthemoeny/showmethemoney.git" 19 | }, 20 | "keywords": [ 21 | "America" 22 | ], 23 | "author": "codesmith 18", 24 | "license": "ISC", 25 | "bugs": { 26 | "url": "https://github.com/followthemoeny/showmethemoney/issues" 27 | }, 28 | "homepage": "https://github.com/followthemoeny/showmethemoney#readme", 29 | "jest": { 30 | "globalSetup": "./jest-setup.js", 31 | "globalTeardown": "./jest-teardown.js" 32 | }, 33 | "dependencies": { 34 | "chai": "^4.2.0", 35 | "chart.js": "^2.9.3", 36 | "cookie-parser": "^1.4.5", 37 | "express": "^4.17.1", 38 | "jest-puppeteer": "^4.4.0", 39 | "jest-styled-components": "^7.0.2", 40 | "node-fetch": "^2.6.0", 41 | "path": "^0.12.7", 42 | "puppeteer": "^4.0.1", 43 | "puppeteer-core": "^4.0.1", 44 | "querystring": "^0.2.0", 45 | "react": "^16.13.1", 46 | "react-chartjs-2": "^2.9.0", 47 | "react-dom": "^16.13.1", 48 | "react-loadingg": "^1.7.2", 49 | "react-router-dom": "^5.2.0", 50 | "styled-components": "^5.1.1" 51 | }, 52 | "devDependencies": { 53 | "@babel/core": "^7.9.6", 54 | "@babel/plugin-transform-runtime": "^7.9.6", 55 | "@babel/preset-env": "^7.9.6", 56 | "@babel/preset-react": "^7.9.4", 57 | "babel-core": "^7.0.0-bridge.0", 58 | "babel-jest": "^23.6.0", 59 | "babel-loader": "^8.1.0", 60 | "concurrently": "^5.2.0", 61 | "cross-env": "^7.0.2", 62 | "enzyme": "^3.11.0", 63 | "enzyme-adapter-react-16": "^1.15.2", 64 | "enzyme-to-json": "^3.4.4", 65 | "file-loader": "^6.0.0", 66 | "jest": "^23.6.0", 67 | "jest-puppeteer": "^4.4.0", 68 | "nodemon": "^2.0.3", 69 | "puppeteer": "^4.0.1", 70 | "puppeteer-core": "^4.0.1", 71 | "react-addons-test-utils": "^15.6.2", 72 | "react-test-renderer": "^16.13.1", 73 | "supertest": "^3.3.0", 74 | "webpack": "^4.43.0", 75 | "webpack-bundle-analyzer": "^3.8.0", 76 | "webpack-cli": "^3.3.11", 77 | "webpack-dev-server": "^3.11.0", 78 | "zombie": "^6.1.3" 79 | }, 80 | "nodemonConfig": { 81 | "watch": [ 82 | "server" 83 | ] 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /client/routes/Portal.jsx: -------------------------------------------------------------------------------- 1 | import React, { useContext, useState } from 'react'; 2 | import { withRouter } from 'react-router-dom'; 3 | import styled from 'styled-components'; 4 | import { device } from '../components/style/device'; 5 | import Logo from '../components/Logo.jsx'; 6 | import Session from '../Session.js'; 7 | import { WaveLoading } from 'react-loadingg'; 8 | import colors from '../components/style/colors'; 9 | 10 | const StyledForm = styled.form` 11 | display: flex; 12 | flex-direction: column; 13 | align-items: center; 14 | margin-top: 7.5vh; 15 | `; 16 | 17 | const SearchInput = styled.input` 18 | outline: none; 19 | width: 75vw; 20 | height: 2em; 21 | font-size: 1.2em; 22 | padding: 10px 10px 10px 26px; 23 | border-radius: 10px; 24 | border: none; 25 | margin-top: 3vh; 26 | &:focus { 27 | border: 1px solid ${colors.blue}; 28 | padding: 10px 10px 8px 26px; 29 | } 30 | `; 31 | 32 | const SubmitButton = styled.button` 33 | outline: none; 34 | width: 75vw; 35 | box-sizing: content-box; 36 | margin-top: 10px; 37 | padding: 20px 18px 20px 18px; 38 | background-color: ${colors.blue}; 39 | color: white; 40 | font-size: 1.1em; 41 | font-weight: bold; 42 | border-radius: 10px; 43 | border: none; 44 | &:hover { 45 | background-color: ${colors.hoverBlue}; 46 | } 47 | &:active { 48 | box-shadow: inset 0px 0px 20px 0px ${colors.activeBlue}; 49 | } 50 | `; 51 | 52 | const Explanation = styled.p` 53 | font-family: 'Yeseva One', cursive; 54 | color: white; 55 | font-size: 2em; 56 | `; 57 | 58 | const Portal = (props) => { 59 | const [searching, setSearching] = useState(null); 60 | 61 | const search = (ev) => { 62 | ev.preventDefault(); 63 | 64 | const address = ev.target.elements.address.value; 65 | 66 | setSearching(true); 67 | Session.initialize(address) 68 | .then((data) => { 69 | // setTimeout(() => { 70 | // setSearching(false); 71 | // props.history.push('/officials'); 72 | // }, 650); 73 | setSearching(false); 74 | props.history.push('/officials'); 75 | }) 76 | .catch((err) => { 77 | setTimeout(() => { 78 | setSearching("Sorry, that address doesn't seem to be valid."); 79 | }, 500); 80 | }); 81 | }; 82 | 83 | return ( 84 |
85 | 86 | 87 |
{typeof searching === 'string' ? searching : null}
88 | {searching !== true && ( 89 | 94 | )} 95 | {searching === true ? ( 96 | 97 | ) : ( 98 | Search for my Representatives 99 | )} 100 |
101 |
102 | ); 103 | }; 104 | 105 | export default withRouter(Portal); 106 | -------------------------------------------------------------------------------- /client/components/Official.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { Link } from 'react-router-dom'; 3 | import styled from 'styled-components'; 4 | import { access } from '../util'; 5 | import { device } from '../components/style/device'; 6 | import Finances from './Finances.jsx'; 7 | import img from '../assets/noImage.png'; 8 | import colors from '../components/style/colors'; 9 | 10 | const CardWrapper = styled.div` 11 | display: flex; 12 | flex-direction: column; 13 | align-items: center; 14 | margin: 20px 30px 20px 30px; 15 | padding-top: 20px; 16 | padding: 10px; 17 | max-width: 80vw; 18 | min-width: 80vw; 19 | border-radius: 4px; 20 | @media ${device.laptop} { 21 | max-width: 100px; 22 | min-width: 25vw; 23 | } 24 | @media ${device.desktop} { 25 | min-width: 15vw; 26 | } 27 | `; 28 | 29 | CardWrapper.displayName = 'CardWrapper'; 30 | 31 | const InfoWrapper = styled.div` 32 | display: flex; 33 | flex-direction: column; 34 | align-items: center; 35 | `; 36 | InfoWrapper.displayName = 'InfoWrapper'; 37 | 38 | const Picture = styled.img` 39 | object-fit: cover; 40 | max-width: 300px; 41 | min-width: 300px; 42 | max-height: 300px; 43 | min-height: 300px; 44 | border-radius: 5% 5% 0 0; 45 | `; 46 | Picture.displayName = 'Picture'; 47 | 48 | const Name = styled.h2` 49 | font-weight: bold; 50 | margin: 0; 51 | margin-bottom: 5px; 52 | padding: 0; 53 | `; 54 | Name.displayName = 'Name'; 55 | 56 | const Position = styled.h3` 57 | padding: 0px 0px 5px 0px; 58 | margin: 0; 59 | display: flex; 60 | text-align: center; 61 | `; 62 | Position.displayName = 'Position'; 63 | 64 | const MoreInfoButton = styled.button` 65 | outline: none; 66 | width: 300px; 67 | padding: 20px 18px 20px 18px; 68 | margin-top: 0px; 69 | font-size: 1.1em; 70 | font-weight: bold; 71 | border: none; 72 | border-radius: 0 0 10px 10px; 73 | background-color: ${colors.blue}; 74 | color: white; 75 | &:hover { 76 | background-color: ${colors.hoverBlue}; 77 | } 78 | &:active { 79 | box-shadow: inset 0px 0px 20px 0px ${colors.activeBlue}; 80 | } 81 | `; 82 | MoreInfoButton.displayName = 'MoreInfoButton'; 83 | 84 | const Official = (props) => { 85 | const websiteUrl = access(props).urls[0](null); 86 | const phoneNumber = access(props).phones[0](null); 87 | const address = access(props).address[0].line1(null); 88 | const { name, party, photoUrl, position, details } = props; 89 | 90 | return ( 91 | 92 | 93 | {name} 94 | {position} 95 | { 98 | e.target.src = img; 99 | }} 100 | /> 101 |
102 | 103 | More Details 104 | 105 | {details} 106 |
107 |
108 |
109 | ); 110 | }; 111 | 112 | export default Official; 113 | -------------------------------------------------------------------------------- /__tests__/supertest.js: -------------------------------------------------------------------------------- 1 | const request = require('supertest'); 2 | const fs = require('fs'); 3 | const path = require('path'); 4 | const server = 'http://localhost:3000'; 5 | 6 | describe('Route Integration', () => { 7 | describe('/', () => { 8 | describe('GET /', () => { 9 | it('responds with a 200 status and text/html content type', () => { 10 | return request(server) 11 | .get('/') 12 | .expect('Content-Type', /text\/html/) 13 | .expect(200); 14 | }); 15 | }); 16 | }); 17 | 18 | describe('/api', () => { 19 | describe('GET api/officials', () => { 20 | it('gracefully handles an unregistered or malformed address with a 400', () => { 21 | return request(server).get('/api/officials?address=thisaintanaddress').expect(400); 22 | }); 23 | 24 | it('responds with a 200 status and a json object', () => { 25 | return request(server) 26 | .get('/api/officials?address=144 2nd ave 10003') 27 | .expect('Content-Type', /json/) 28 | .expect(200); 29 | }); 30 | 31 | it('response is an object with an array of objects key "officials" with minimum keys name, addess,party, position and photoURL', () => { 32 | return request(server) 33 | .get('/api/officials?address=144 2nd ave 10003') 34 | .expect((res) => { 35 | expect(Array.isArray(res.body.officials)).toEqual(true); 36 | res.body.officials.forEach((official) => { 37 | // expect(official.hasOwnProperty('address')).toEqual(true); had to disable because letitia jame has no address 38 | expect(official.hasOwnProperty('name')).toEqual(true); 39 | expect(official.hasOwnProperty('position')).toEqual(true); 40 | expect(official.hasOwnProperty('party')).toEqual(true); 41 | // expect(official.hasOwnProperty('photoUrl')).toEqual(true); had to disable because Cuomo gets no image. 42 | }); 43 | }); 44 | }); 45 | }); 46 | describe('GET api/election', () => { 47 | it('gracefully handles an unregistered or malformed address with a 400', () => { 48 | return request(server).get('/api/election?address=thisaintanaddress').expect(400); 49 | }); 50 | //if this test is commented out it is because the test address has no elections nearby and has become invalidated 51 | xit('responds with a 200 status and a json object, note this test can become invalid over time due to database purges', () => { 52 | return request(server) 53 | .get('/api/election?address=411 Woodland Heights 28734') 54 | .expect('Content-Type', /application\/json/) 55 | .expect(200); 56 | }); 57 | }); 58 | describe('GET api/finances', () => { 59 | it('responds with a 200 status and a json object if successful', () => { 60 | return request(server) 61 | .get('/api/finances?name=Lynda Bennett&state=NC') 62 | .expect(200) 63 | .expect('Content-Type', /application\/json/); 64 | }); 65 | 66 | it('responds with a 404 error when it cannot find anything about the candidate', () => { 67 | return request(server).get('/api/finances?name=Vermin Supreme&state=deep').expect(404); 68 | }); 69 | }); 70 | }); 71 | }); 72 | -------------------------------------------------------------------------------- /server/controllers/apiController.js: -------------------------------------------------------------------------------- 1 | const fetch = require('node-fetch'); 2 | 3 | const { google, proPublica, fec } = require('../../secret'); 4 | const { resource } = require('../index'); 5 | 6 | const apiController = {}; 7 | 8 | apiController.getElectionInfo = (req, res, next) => { 9 | const { address } = req.query; 10 | if (!address) return res.sendStatus(400); 11 | fetch(`https://www.googleapis.com/civicinfo/v2/voterinfo?key=${google}&address=${address}`) 12 | .then((response) => response.json()) 13 | .then((data) => { 14 | if (data.error) return next(data.error.message); 15 | const { election, pollingLocations, contests, normalizedInput } = data; 16 | const electionObj = { normalizedInput, ...election, pollingLocations }; 17 | if (contests) electionObj.contests = contests; 18 | res.locals.elections = electionObj; 19 | return next(); 20 | }) 21 | .catch((error) => { 22 | return next(error); 23 | }); 24 | }; 25 | 26 | apiController.getRepresentatives = (req, res, next) => { 27 | const { address } = req.query; 28 | if (!address) res.sendStatus(400); 29 | 30 | fetch(`https://www.googleapis.com/civicinfo/v2/representatives?key=${google}&address=${address}`) 31 | .then((response) => response.json()) 32 | .then((data) => { 33 | if (data.error) return next(data.error.message); 34 | const { offices, officials, normalizedInput } = data; 35 | const reps = []; 36 | offices.forEach((elem) => { 37 | elem.officialIndices.forEach((index) => { 38 | reps.push({ ...officials[index], position: elem.name }); 39 | }); 40 | }); 41 | res.locals.representatives = { normalizedInput, officials: reps }; 42 | return next(); 43 | }) 44 | .catch((error) => next(error)); 45 | }; 46 | 47 | apiController.getCandidateInfo = async (req, res, next) => { 48 | const { name, state } = req.query; 49 | let url = `https://api.open.fec.gov/v1/candidates/search/?sort_null_only=false&name=${name}&sort=name&page=1&sort_hide_null=false&sort_nulls_last=false&api_key=${fec}&per_page=20`; 50 | if (state) url += `&state=${state}`; 51 | if (!name) return res.sendStatus(400); 52 | const candidateResp = await fetch(url); 53 | const data = await candidateResp.json(); 54 | if (data.error) return next(data.error.message); 55 | if (!data.results.length) return res.sendStatus(404); 56 | const { candidate_id, principal_committees } = data.results[0]; 57 | const { committee_id } = principal_committees[0]; 58 | const financeResp = await fetch(`https://api.open.fec.gov/v1/candidate/${candidate_id}/totals/?sort=-cycle&api_key=${fec}&sort_nulls_last=false&page=1&election_full=true&sort_hide_null=false&sort_null_only=false&per_page=20 59 | `); 60 | const committeeResp = await fetch(`https://api.open.fec.gov/v1/committee/${committee_id}/totals/?sort=-cycle&api_key=${fec}&sort_nulls_last=false&page=1&sort_hide_null=false&sort_null_only=false&per_page=20 61 | `); 62 | const financeData = await financeResp.json(); 63 | const committeeData = await committeeResp.json(); 64 | if (financeData.error) return next(data.error.message); 65 | [res.locals.finance] = financeData.results; 66 | if (!committeeData.error) { 67 | res.locals.finance.committees = committeeData.results; 68 | } 69 | return next(); 70 | }; 71 | 72 | module.exports = apiController; 73 | -------------------------------------------------------------------------------- /client/routes/Elections.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { withRouter, Link } from 'react-router-dom'; 3 | import styled from 'styled-components'; 4 | import Session from '../Session.js'; 5 | import Election from '../components/Election.jsx'; 6 | import Logo from '../components/Logo.jsx'; 7 | import { device } from '../components/style/device'; 8 | import { WaveLoading } from 'react-loadingg'; 9 | import colors from '../components/style/colors'; 10 | 11 | const ElectionLink = (props) => { 12 | const ButtonsWrapepr = styled.div` 13 | display: flex; 14 | justify-content: space-between; 15 | margin: 15px 10px; 16 | `; 17 | const LinkWrapper = styled.div` 18 | a { 19 | display: flex; 20 | justify-content: flex-end; 21 | color: blue; 22 | text-decoration: none; 23 | } 24 | `; 25 | const ElectionsButton = styled.button` 26 | width: 100px; 27 | padding: 5px 0px 5px 0px; 28 | font-size: 1em; 29 | font-weight: bold; 30 | border: none; 31 | border-radius: 10px; 32 | background-color: ${colors.red}; 33 | color: white; 34 | `; 35 | const [elections, setElections] = useState(null); 36 | 37 | useEffect(() => { 38 | Session.getElections() 39 | .then((data) => setElections(data)) 40 | .catch((err) => setElections(undefined)); 41 | }, []); 42 | 43 | if (!elections) { 44 | return null; 45 | } 46 | 47 | const linkTo = { 48 | pathname: '/elections', 49 | state: elections, 50 | }; 51 | 52 | return ( 53 | 54 | 55 | 56 | Officials 57 | 58 | 59 | 60 | ); 61 | }; 62 | 63 | const Elections = (props) => { 64 | const [elections, setElections] = useState(null); 65 | 66 | useEffect(() => { 67 | Session.getElections() 68 | .then((data) => 69 | setTimeout(() => { 70 | setElections(data); 71 | }, 650) 72 | ) 73 | .catch((err) => setElections(undefined)); 74 | }, []); 75 | 76 | if (elections === null) { 77 | return ; 78 | } 79 | 80 | if (!elections.length) { 81 | return No upcoming elections near you; 82 | } 83 | 84 | if (elections === undefined) { 85 | return An error occurred.; 86 | } 87 | 88 | const ElectionsHeader = styled.h2` 89 | display: flex; 90 | justify-content: center; 91 | color: ${colors.blue}; 92 | font-size: 2em; 93 | margin-bottom: 0px; 94 | `; 95 | const ElectionsWrapper = styled.div` 96 | display: flex; 97 | flex-direction: column; 98 | align-items: center; 99 | flex-wrap: wrap; 100 | margin: 5vw; 101 | @media ${device.laptop} { 102 | flex-direction: row; 103 | justify-content: center; 104 | flex-wrap: wrap; 105 | } 106 | `; 107 | 108 | const children = elections.map((props, i) => ); 109 | 110 | return ( 111 |
112 | 113 | 114 | Elections near you: 115 | {children} 116 |
117 | ); 118 | }; 119 | 120 | export default withRouter(Elections); 121 | -------------------------------------------------------------------------------- /client/components/Finances.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import Session from '../Session.js'; 3 | import { Bar } from 'react-chartjs-2'; 4 | import styled from 'styled-components'; 5 | import { access } from '../util'; 6 | import { WaveLoading } from 'react-loadingg'; 7 | 8 | const NoFinances = styled.h1` 9 | text-align: center; 10 | `; 11 | 12 | const Finances = (props) => { 13 | const [data, setData] = useState(null); 14 | 15 | useEffect(() => { 16 | Session.getFinances(props.name, props.state) 17 | .then((data) => 18 | setTimeout(() => { 19 | setData(data); 20 | }, 650) 21 | ) 22 | .catch((err) => 23 | setTimeout(() => { 24 | setData(undefined); 25 | }, 650) 26 | ); 27 | }, []); 28 | 29 | if (data === null) { 30 | return ; 31 | } 32 | if (!data) { 33 | return No financial information available for this candidate.; 34 | } 35 | 36 | const personal = data; 37 | const committee = access(data).committees[0](null); 38 | 39 | const personalData = { 40 | height: 300, 41 | maintainAspectRatio: false, 42 | data: { 43 | datasets: [ 44 | { 45 | label: 'individual contributions', 46 | backgroundColor: 'rgba(75,192,192,1)', 47 | data: [personal.individual_contributions], 48 | }, 49 | { 50 | label: 'committee contributions', 51 | backgroundColor: 'rgba(75,0,192,1)', 52 | data: [personal.other_political_committee_contributions], 53 | }, 54 | { 55 | label: 'operating expenditures', 56 | backgroundColor: 'rgba(75,192,0,1)', 57 | data: [personal.operating_expenditures], 58 | }, 59 | ], 60 | }, 61 | options: { 62 | title: { 63 | display: true, 64 | text: 'Personal', 65 | fontSize: 20, 66 | }, 67 | legend: { 68 | display: true, 69 | position: 'bottom', 70 | align: 'start', 71 | }, 72 | scales: { 73 | yAxes: [ 74 | { 75 | ticks: { 76 | callback: function (value, index, values) { 77 | return '$' + value / 1000 + 'k'; 78 | }, 79 | }, 80 | }, 81 | ], 82 | }, 83 | }, 84 | }; 85 | const committeeData = committee 86 | ? { 87 | height: 300, 88 | maintainAspectRatio: false, 89 | data: { 90 | datasets: [ 91 | { 92 | label: 'individual contributions', 93 | backgroundColor: 'rgba(75,192,192,1)', 94 | data: [committee.individual_contributions], 95 | }, 96 | { 97 | label: 'transfers from affiliated committees', 98 | backgroundColor: 'rgba(75,0,192,1)', 99 | data: [committee.transfers_from_affiliated_committee], 100 | }, 101 | { 102 | label: 'operating expenditures', 103 | backgroundColor: 'rgba(75,192,0,1)', 104 | data: [committee.operating_expenditures], 105 | }, 106 | ], 107 | }, 108 | options: { 109 | title: { 110 | display: true, 111 | text: committee.committee_name, 112 | fontSize: 20, 113 | }, 114 | legend: { 115 | display: true, 116 | position: 'bottom', 117 | align: 'start', 118 | }, 119 | scales: { 120 | yAxes: [ 121 | { 122 | ticks: { 123 | callback: function (value, index, values) { 124 | return '$' + value / 1000 + 'k'; 125 | }, 126 | }, 127 | }, 128 | ], 129 | }, 130 | }, 131 | } 132 | : null; 133 | 134 | return ( 135 |
136 |
137 | 138 |
139 |
140 | {committeeData ? : null} 141 |
142 |
143 | ); 144 | }; 145 | 146 | export default Finances; 147 | -------------------------------------------------------------------------------- /client/models/finances.js: -------------------------------------------------------------------------------- 1 | const finances = { 2 | "api_version": "1.0", 3 | "results": [ 4 | { 5 | "candidate_id": "H0NC11191", 6 | "receipts": 447474.24, 7 | "coverage_start_date": "2019-12-19T00:00:00+00:00", 8 | "federal_funds": 0, 9 | "refunded_individual_contributions": 710, 10 | "other_receipts": 0, 11 | "net_operating_expenditures": 354500.14, 12 | "cycle": null, 13 | "disbursements": 381170.57, 14 | "loans": 80000, 15 | "all_other_loans": 0, 16 | "contributions": 366513.81, 17 | "last_beginning_image_number": "202006199240024035", 18 | "last_debts_owed_to_committee": 0, 19 | "loan_repayments": 25000, 20 | "last_debts_owed_by_committee": 55000, 21 | "refunded_other_political_committee_contributions": 0, 22 | "net_contributions": 365803.81, 23 | "loan_repayments_other_loans": 0, 24 | "loan_repayments_candidate_loans": 25000, 25 | "last_cash_on_hand_end_period": 66303.67, 26 | "election_full": true, 27 | "other_disbursements": 0, 28 | "coverage_end_date": "2020-06-03T00:00:00+00:00", 29 | "last_report_year": 2020, 30 | "candidate_election_year": 2020, 31 | "transfers_to_other_authorized_committee": 0, 32 | "last_net_contributions": 205593, 33 | "transaction_coverage_date": "2020-06-03T00:00:00+00:00", 34 | "exempt_legal_accounting_disbursement": 0, 35 | "other_political_committee_contributions": 72708.56, 36 | "contribution_refunds": 710, 37 | "offsets_to_fundraising_expenditures": 0, 38 | "individual_itemized_contributions": 195038, 39 | "candidate_contribution": 1740, 40 | "transfers_from_other_authorized_committee": 0, 41 | "refunded_political_party_committee_contributions": 0, 42 | "offsets_to_operating_expenditures": 960.43, 43 | "political_party_committee_contributions": 0, 44 | "individual_contributions": 292065.25, 45 | "total_offsets_to_operating_expenditures": 0, 46 | "operating_expenditures": 355460.57, 47 | "last_report_type_full": "PRE-RUN-OFF", 48 | "individual_unitemized_contributions": 97027.25, 49 | "last_net_operating_expenditures": 220694.43, 50 | "loans_made_by_candidate": 80000, 51 | "offsets_to_legal_accounting": 0, 52 | "fundraising_disbursements": 0 53 | }, 54 | { 55 | "candidate_id": "H0NC11191", 56 | "receipts": 447474.24, 57 | "coverage_start_date": "2019-12-19T00:00:00+00:00", 58 | "federal_funds": 0, 59 | "refunded_individual_contributions": 710, 60 | "other_receipts": 0, 61 | "net_operating_expenditures": 354500.14, 62 | "cycle": 2020, 63 | "disbursements": 381170.57, 64 | "loans": 80000, 65 | "all_other_loans": 0, 66 | "contributions": 366513.81, 67 | "last_beginning_image_number": "202006199240024035", 68 | "last_debts_owed_to_committee": 0, 69 | "loan_repayments": 25000, 70 | "last_debts_owed_by_committee": 55000, 71 | "refunded_other_political_committee_contributions": 0, 72 | "net_contributions": 365803.81, 73 | "loan_repayments_other_loans": 0, 74 | "loan_repayments_candidate_loans": 25000, 75 | "last_cash_on_hand_end_period": 66303.67, 76 | "election_full": false, 77 | "other_disbursements": 0, 78 | "coverage_end_date": "2020-06-03T00:00:00+00:00", 79 | "last_report_year": 2020, 80 | "candidate_election_year": 2020, 81 | "transfers_to_other_authorized_committee": 0, 82 | "last_net_contributions": 205593, 83 | "transaction_coverage_date": "2020-06-03T00:00:00+00:00", 84 | "exempt_legal_accounting_disbursement": 0, 85 | "other_political_committee_contributions": 72708.56, 86 | "contribution_refunds": 710, 87 | "offsets_to_fundraising_expenditures": 0, 88 | "individual_itemized_contributions": 195038, 89 | "candidate_contribution": 1740, 90 | "transfers_from_other_authorized_committee": 0, 91 | "refunded_political_party_committee_contributions": 0, 92 | "offsets_to_operating_expenditures": 960.43, 93 | "political_party_committee_contributions": 0, 94 | "individual_contributions": 292065.25, 95 | "total_offsets_to_operating_expenditures": 0, 96 | "operating_expenditures": 355460.57, 97 | "last_report_type_full": "PRE-RUN-OFF", 98 | "individual_unitemized_contributions": 97027.25, 99 | "last_net_operating_expenditures": 220694.43, 100 | "loans_made_by_candidate": 80000, 101 | "offsets_to_legal_accounting": 0, 102 | "fundraising_disbursements": 0 103 | } 104 | ], 105 | "pagination": { 106 | "pages": 1, 107 | "page": 1, 108 | "count": 2, 109 | "per_page": 20 110 | } 111 | } 112 | 113 | export default finances; -------------------------------------------------------------------------------- /client/routes/Officials.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { 3 | Switch, 4 | Route, 5 | Link, 6 | useRouteMatch, 7 | withRouter, 8 | useParams, 9 | } from 'react-router-dom'; 10 | import styled from 'styled-components'; 11 | import { device } from '../components/style/device'; 12 | import Session from '../Session.js'; 13 | import Official from '../components/Official.jsx'; 14 | import Logo from '../components/Logo.jsx'; 15 | import OfficialDetails from '../components/OfficialDetails.jsx'; 16 | import { access } from '../util'; 17 | import { WaveLoading } from 'react-loadingg'; 18 | import colors from '../components/style/colors'; 19 | 20 | const ButtonWrapper = styled.div` 21 | display: flex; 22 | justify-content: space-between; 23 | margin: 15px 10px; 24 | `; 25 | 26 | ButtonWrapper.displayName = 'ButtonsWrapper'; 27 | 28 | const LinkWrapper = styled.div` 29 | a { 30 | display: flex; 31 | justify-content: flex-end; 32 | color: blue; 33 | text-decoration: none; 34 | } 35 | `; 36 | LinkWrapper.displayName = 'LinkWrapper'; 37 | 38 | const ElectionsButton = styled.button` 39 | outline: none; 40 | width: 100px; 41 | padding: 5px 0px 5px 0px; 42 | font-size: 1em; 43 | font-weight: bold; 44 | border: none; 45 | border-radius: 10px; 46 | background-color: ${colors.red}; 47 | color: white; 48 | &:active { 49 | box-shadow: inset 0px 0px 20px 0px #005276; 50 | } 51 | &:hover { 52 | background-color: ${colors.hoverBlue}; 53 | } 54 | `; 55 | ElectionsButton.displayName = 'ElectionsButton'; 56 | 57 | const ElectionLink = withRouter((props) => { 58 | const [elections, setElections] = useState(null); 59 | 60 | useEffect(() => { 61 | Session.getElections() 62 | .then((data) => setElections(data)) 63 | .catch((err) => setElections(undefined)); 64 | }, []); 65 | 66 | const linkTo = { 67 | pathname: '/elections', 68 | state: elections, 69 | }; 70 | 71 | return ( 72 | 73 | 74 | Back 75 | 76 | {elections ? ( 77 | 78 | 79 | Elections 80 | 81 | 82 | ) : null} 83 | 84 | ); 85 | }); 86 | 87 | const Grid = (props) => { 88 | let { id } = useParams(); 89 | 90 | const OfficialsHeader = styled.h2` 91 | display: flex; 92 | justify-content: center; 93 | color: ${colors.black}; 94 | font-size: 2.2em; 95 | margin-bottom: 0px; 96 | `; 97 | OfficialsHeader.displayName = 'OfficialsHeader'; 98 | 99 | const OfficialsWrapper = styled.div` 100 | margin-top: 0px; 101 | display: flex; 102 | flex-direction: column; 103 | align-items: center; 104 | flex-wrap: wrap; 105 | @media ${device.laptop} { 106 | flex-direction: row; 107 | justify-content: center; 108 | flex-wrap: wrap; 109 | } 110 | `; 111 | OfficialsWrapper.displayName = 'OfficialsWrapper'; 112 | 113 | const [data, setData] = useState(null); 114 | 115 | useEffect(() => { 116 | Session.getOfficals() 117 | .then((data) => 118 | setTimeout(() => { 119 | setData(data); 120 | }, 650), 121 | ) 122 | .catch((err) => setData(undefined)); 123 | }, []); 124 | 125 | if (data === null) { 126 | return ; 127 | } 128 | 129 | const officials = data.officials; 130 | const state = access(data).normalizedInput.state(''); 131 | 132 | if (!officials || !officials.length) { 133 | return

An error occurred.

; 134 | } 135 | 136 | if (id !== undefined) { 137 | return ( 138 | 139 | 144 | 145 | ); 146 | } 147 | 148 | const children = officials 149 | .map((props, id) => ( 150 | 151 | )) 152 | .reverse(); 153 | 154 | return ( 155 |
156 | Your Elected Officials 157 | {children} 158 |
159 | ); 160 | }; 161 | 162 | const Profile = (props) => { 163 | return

You selected {props.name}.

; 164 | }; 165 | 166 | const Officials = (props) => { 167 | const { path } = useRouteMatch(); 168 | 169 | return ( 170 |
171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 |
183 | ); 184 | }; 185 | 186 | export default withRouter(Officials); 187 | -------------------------------------------------------------------------------- /__tests__/enzyme.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { configure, shallow, mount } from 'enzyme'; 3 | import { find, findAll, enzymeFind } from 'styled-components/test-utils'; 4 | import Adapter from 'enzyme-adapter-react-16'; 5 | import toJson from 'enzyme-to-json'; 6 | import 'jest-styled-components'; 7 | 8 | import Candidate from '../client/components/Candidate'; 9 | import Contest from '../client/components/Contest'; 10 | import Election from '../client/components/Election'; 11 | import Logo from '../client/components/Logo'; 12 | import Official from '../client/components/Official'; 13 | import Finances from '../client/components/Finances'; 14 | import Officials from '../client/routes/Officials'; 15 | //import Test from '../client/components/TestComponent'; 16 | 17 | configure({ adapter: new Adapter() }); 18 | 19 | describe('React unit tests', () => { 20 | describe('Logo', () => { 21 | let wrapper; 22 | 23 | beforeAll(() => { 24 | wrapper = shallow(); 25 | }); 26 | 27 | it('Logo is encapsulated in styled span "LogoContent"', () => { 28 | expect(wrapper.find('LogoContent')).toHaveLength(1); 29 | expect(wrapper.find('LogoContent').text()).toEqual('Congre$$'); 30 | }); 31 | }); 32 | 33 | describe('Official', () => { 34 | let wrapper; 35 | let noPic; 36 | const props = { 37 | name: 'Donald J. Trump', 38 | address: [ 39 | { 40 | line1: '1600 Pennsylvania Avenue Northwest', 41 | city: 'Washington', 42 | state: 'DC', 43 | zip: '20500', 44 | }, 45 | ], 46 | party: 'Republican Party', 47 | phones: ['(202) 456-1111'], 48 | urls: ['https://www.whitehouse.gov/'], 49 | photoUrl: 50 | 'https://www.whitehouse.gov/sites/whitehouse.gov/files/images/45/PE%20Color.jpg', 51 | channels: [ 52 | { 53 | type: 'Facebook', 54 | id: 'DonaldTrump', 55 | }, 56 | { 57 | type: 'Twitter', 58 | id: 'potus', 59 | }, 60 | { 61 | type: 'YouTube', 62 | id: 'whitehouse', 63 | }, 64 | ], 65 | position: 'President of the United States', 66 | officialId: 0, 67 | }; 68 | 69 | beforeAll(() => { 70 | wrapper = shallow(); 71 | noPic = shallow(); 72 | }); 73 | it('Official has styled components Name and Position and their texts are from the props', () => { 74 | // console.log(wrapper.debug()) 75 | expect(wrapper.find('Name').text()).toEqual(props.name); 76 | expect(wrapper.find('Position').text()).toEqual(props.position); 77 | }); 78 | 79 | it('properly omits the photo on an error', () => { 80 | console.log(noPic.debug()); 81 | expect(noPic.find('Picture')).toHaveLength(0); 82 | }); 83 | 84 | it('Changes button shape based on picture presence', () => { 85 | expect(wrapper.find('MoreInfoButton')).toHaveStyleRule( 86 | 'border-radius', 87 | '0 0 10px 10px', 88 | ); 89 | expect(noPic.find('MoreInfoButton')).toHaveStyleRule( 90 | 'border-radius', 91 | '10px', 92 | ); 93 | }); 94 | }); 95 | 96 | describe('Election', () => { 97 | let wrapper; 98 | let noData; 99 | const dataProps = { 100 | contests: [ 101 | { 102 | type: 'General', 103 | ballotTitle: 'US HOUSE OF REPRESENTATIVES DISTRICT 11', 104 | office: 'US HOUSE OF REPRESENTATIVES DISTRICT 11', 105 | level: ['country'], 106 | district: { 107 | name: 'US HOUSE OF REPRESENTATIVES DISTRICT 11', 108 | scope: 'congressional', 109 | id: '0', 110 | }, 111 | numberElected: '1', 112 | ballotPlacement: '3', 113 | sources: [ 114 | { 115 | name: 'Voting Information Project', 116 | official: true, 117 | }, 118 | ], 119 | candidates: [ 120 | { 121 | name: 'Lynda Bennett', 122 | party: 'REPUBLICAN', 123 | }, 124 | { 125 | name: 'Madison Cawthorn', 126 | party: 'REPUBLICAN', 127 | }, 128 | ], 129 | }, 130 | ], 131 | state: 'NC', 132 | }; 133 | const noDataProps = {}; 134 | 135 | beforeAll(() => { 136 | wrapper = mount(); 137 | noData = mount(); 138 | }); 139 | 140 | afterAll(() => { 141 | wrapper.unmount(); 142 | noData.unmount(); 143 | }); 144 | 145 | it('Correctly has as many contests as there are contests', () => { 146 | expect(wrapper.find('Contest')).toHaveLength(dataProps.contests.length); 147 | }); 148 | 149 | it('correctly has as many Candidate divs as there are candidates', () => { 150 | const numCandidates = dataProps.contests.reduce((acc, curr) => { 151 | return (acc += curr.candidates.length); 152 | }, 0); 153 | 154 | expect(wrapper.find('Candidate')).toHaveLength(numCandidates); 155 | }); 156 | 157 | it('gracefully handles missing election data with a div className = NoContest', () => { 158 | expect(noData.find('.NoContest')).toHaveLength(1); 159 | }); 160 | }); 161 | 162 | describe('Candidate', () => { 163 | let wrapper; 164 | 165 | beforeAll(() => { 166 | wrapper = shallow(); 167 | }); 168 | 169 | it('Pressing the button shows finances', () => { 170 | expect(wrapper.find('Finances')).toHaveLength(0); 171 | wrapper.find('button').simulate('click'); 172 | expect(wrapper.find('Finances')).toHaveLength(1); 173 | }); 174 | }); 175 | 176 | describe('Contest', () => { 177 | let wrapper; 178 | let noProps; 179 | 180 | const dataProps = { 181 | candidates: [ 182 | { 183 | name: 'test', 184 | }, 185 | { 186 | name: 'test', 187 | }, 188 | ], 189 | }; 190 | const noData = {}; 191 | 192 | beforeAll(() => { 193 | wrapper = shallow(); 194 | noProps = shallow(); 195 | }); 196 | 197 | it('Renders as many candidate divs as there are candidates', () => { 198 | expect(wrapper.find('Candidate')).toHaveLength( 199 | dataProps.candidates.length, 200 | ); 201 | }); 202 | 203 | it('Displays an apology div if there is no candidate information', () => { 204 | expect(noProps.find('.NoCand')).toHaveLength(1); 205 | }); 206 | }); 207 | 208 | // xdescribe('test component', () => { 209 | // let wrapper 210 | 211 | // beforeAll(() => { 212 | // wrapper = shallow(); 213 | // }) 214 | 215 | // xit('Renders a
', () =>{ 216 | // console.log(wrapper.type()) 217 | // expect(wrapper.type()).toEqual('div') 218 | // }) 219 | 220 | // }) 221 | }); 222 | -------------------------------------------------------------------------------- /client/components/OfficialDetails.jsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | import { Link } from 'react-router-dom'; 3 | import styled from 'styled-components'; 4 | import { access } from '../util'; 5 | import { device } from '../components/style/device'; 6 | import colors from './style/colors'; 7 | import Finances from '../components/Finances.jsx'; 8 | import img from '../assets/noImage.png'; 9 | 10 | const mediaIcons = { 11 | Facebook: ( 12 | 18 | 19 | 20 | ), 21 | Twitter: ( 22 | 28 | 29 | 30 | ), 31 | YouTube: ( 32 | 38 | 39 | 40 | ), 41 | emails: ( 42 | 48 | 49 | 50 | ), 51 | phones: ( 52 | 58 | 59 | 60 | ), 61 | urls: ( 62 | 68 | 69 | 70 | ), 71 | }; 72 | 73 | const CardWrapper = styled.div` 74 | display: flex; 75 | flex-direction: column; 76 | align-items: center; 77 | margin: 0px 30px 40px 30px; 78 | padding-top: 20px; 79 | padding: 10px; 80 | padding-left: 0px; 81 | max-width: 80vw; 82 | min-width: 80vw; 83 | border-radius: 4px; 84 | @media ${device.laptop} { 85 | max-width: 100px; 86 | min-width: 25vw; 87 | align-items: center; 88 | } 89 | @media ${device.desktop} { 90 | min-width: 15vw; 91 | } 92 | `; 93 | 94 | const InfoWrapper = styled.div` 95 | display: flex; 96 | flex-direction: column; 97 | align-items: center; 98 | `; 99 | 100 | const Picture = styled.img` 101 | object-fit: cover; 102 | max-width: 300px; 103 | min-width: 300px; 104 | max-height: 300px; 105 | min-height: 300px; 106 | border-radius: 5%; 107 | margin-top: 10px; 108 | `; 109 | 110 | const Name = styled.h2` 111 | font-weight: bold; 112 | margin: 0; 113 | margin-bottom: 5px; 114 | padding: 0; 115 | `; 116 | 117 | const Position = styled.h3` 118 | padding: 0px 0px 5px 0px; 119 | margin: 0; 120 | `; 121 | 122 | const MoreInfoButton = styled.button` 123 | width: 300px; 124 | padding: 20px 18px 20px 18px; 125 | margin-top: 0px; 126 | font-size: 1.1em; 127 | font-weight: bold; 128 | border: none; 129 | border-radius: ${(props) => (props.rounded ? '0 0 10px 10px;' : '10px;')} 130 | background-color: ${colors.blue}; 131 | color: white; 132 | `; 133 | 134 | const Party = styled.p` 135 | padding-top: 0px; 136 | padding-bottom: 10px; 137 | margin: 0px 15px; 138 | font-weight: bold; 139 | `; 140 | 141 | Party.displayName = 'Party'; 142 | 143 | const ContactWrapper = styled.div` 144 | display: flex; 145 | justify-content: space-around; 146 | width: 100%; 147 | font-size: 1em; 148 | font-family: Arial; 149 | font-weight: bold; 150 | padding-top: 5px; 151 | `; 152 | 153 | const OfficialContact = styled.a` 154 | background-color: ${colors.red}; 155 | border-radius: 10px; 156 | padding: 0.25em 1em 0.25em 1em; 157 | text-decoration: none; 158 | color: white; 159 | border: 1px solid red; 160 | width: 100px; 161 | `; 162 | 163 | const Media = styled.ul` 164 | list-style: none; 165 | display: flex; 166 | padding: 0px; 167 | margin: 0px; 168 | `; 169 | Media.displayName = 'Media'; 170 | 171 | const ExternalAnchor = styled.a` 172 | &:hover { 173 | opacity: 80%; 174 | } 175 | text-decoration: none; 176 | display: flex; 177 | align-items: center; 178 | box-sizing: border-box; 179 | margin: 1em; 180 | `; 181 | ExternalAnchor.displayName = 'ExternalAnchor'; 182 | 183 | const IdSpan = styled.span` 184 | margin-left: 1em; 185 | font-weight: bold; 186 | `; 187 | 188 | const OfficialDetails = (props) => { 189 | const websiteUrl = access(props).urls[0](null); 190 | const phoneNumber = access(props).phones[0](null); 191 | const address = access(props).address[0].line1(null); 192 | const { 193 | name, 194 | party, 195 | photoUrl, 196 | position, 197 | details, 198 | state, 199 | channels, 200 | urls, 201 | emails, 202 | phones, 203 | } = props; 204 | 205 | const outlets = []; 206 | 207 | if (channels) { 208 | const track = {}; 209 | channels.forEach((el, index) => { 210 | if (!track[el.type]) { 211 | track[el.type] = true; 212 | outlets.push( 213 |
  • 214 | 218 | {mediaIcons[el.type]} 219 | 220 |
  • , 221 | ); 222 | } 223 | }); 224 | } 225 | return ( 226 | 227 | 228 | {name} 229 | {position} 230 | {party} 231 | { 234 | e.target.src = img; 235 | }} 236 | /> 237 | {/* 238 | 239 | {emails ? ( 240 | 241 | Email 242 | 243 | ) : null} 244 | 245 | 246 | {phones ? ( 247 | Call 248 | ) : null} 249 | 250 | 251 | {urls ? ( 252 | Website 253 | ) : null} 254 | 255 | {urls ? Website : null} 256 | */} 257 | 258 |
  • 259 | {emails ? ( 260 | 261 | {mediaIcons.emails} 262 | 263 | ) : null} 264 |
  • 265 |
  • 266 | {phones ? ( 267 | 268 | {mediaIcons.phones} 269 | 270 | ) : null} 271 |
  • 272 |
  • 273 | {urls ? ( 274 | 275 | {mediaIcons.urls} 276 | 277 | ) : null} 278 |
  • 279 | {outlets} 280 |
    281 | 282 |
    283 |
    284 | ); 285 | }; 286 | export default OfficialDetails; 287 | -------------------------------------------------------------------------------- /client/models/officials.js: -------------------------------------------------------------------------------- 1 | const officals = { 2 | "normalizedInput": { 3 | "line1": "24 Starr Street", 4 | "city": "Brooklyn", 5 | "state": "NY", 6 | "zip": "11221" 7 | }, 8 | "kind": "civicinfo#representativeInfoResponse", 9 | "divisions": { 10 | "ocd-division/country:us/state:ny/cd:7": { 11 | "name": "New York's 7th congressional district", 12 | "officeIndices": [ 13 | 3 14 | ] 15 | }, 16 | "ocd-division/country:us/state:ny/place:new_york": { 17 | "name": "New York city", 18 | "officeIndices": [ 19 | 12, 20 | 13, 21 | 14 22 | ] 23 | }, 24 | "ocd-division/country:us/state:ny/sldl:53": { 25 | "name": "New York Assembly district 53", 26 | "officeIndices": [ 27 | 7 28 | ] 29 | }, 30 | "ocd-division/country:us": { 31 | "name": "United States", 32 | "officeIndices": [ 33 | 0, 34 | 1 35 | ] 36 | }, 37 | "ocd-division/country:us/state:ny/supreme_court:2": { 38 | "name": "NY State Supreme Court - 2nd District" 39 | }, 40 | "ocd-division/country:us/state:ny": { 41 | "name": "New York", 42 | "officeIndices": [ 43 | 2, 44 | 4, 45 | 5, 46 | 8, 47 | 9 48 | ] 49 | }, 50 | "ocd-division/country:us/state:ny/sldu:18": { 51 | "name": "New York State Senate district 18", 52 | "officeIndices": [ 53 | 6 54 | ] 55 | }, 56 | "ocd-division/country:us/state:ny/county:kings": { 57 | "name": "Kings County", 58 | "alsoKnownAs": [ 59 | "ocd-division/country:us/state:ny/borough:brooklyn", 60 | "ocd-division/country:us/state:ny/place:new_york/county:kings" 61 | ], 62 | "officeIndices": [ 63 | 10, 64 | 11 65 | ] 66 | } 67 | }, 68 | "offices": [ 69 | { 70 | "name": "President of the United States", 71 | "divisionId": "ocd-division/country:us", 72 | "levels": [ 73 | "country" 74 | ], 75 | "roles": [ 76 | "headOfState", 77 | "headOfGovernment" 78 | ], 79 | "officialIndices": [ 80 | 0 81 | ] 82 | }, 83 | { 84 | "name": "Vice President of the United States", 85 | "divisionId": "ocd-division/country:us", 86 | "levels": [ 87 | "country" 88 | ], 89 | "roles": [ 90 | "deputyHeadOfGovernment" 91 | ], 92 | "officialIndices": [ 93 | 1 94 | ] 95 | }, 96 | { 97 | "name": "U.S. Senator", 98 | "divisionId": "ocd-division/country:us/state:ny", 99 | "levels": [ 100 | "country" 101 | ], 102 | "roles": [ 103 | "legislatorUpperBody" 104 | ], 105 | "officialIndices": [ 106 | 2, 107 | 3 108 | ] 109 | }, 110 | { 111 | "name": "U.S. Representative", 112 | "divisionId": "ocd-division/country:us/state:ny/cd:7", 113 | "levels": [ 114 | "country" 115 | ], 116 | "roles": [ 117 | "legislatorLowerBody" 118 | ], 119 | "officialIndices": [ 120 | 4 121 | ] 122 | }, 123 | { 124 | "name": "Governor of New York", 125 | "divisionId": "ocd-division/country:us/state:ny", 126 | "levels": [ 127 | "administrativeArea1" 128 | ], 129 | "roles": [ 130 | "headOfGovernment" 131 | ], 132 | "officialIndices": [ 133 | 5 134 | ] 135 | }, 136 | { 137 | "name": "Lieutenant Governor of New York", 138 | "divisionId": "ocd-division/country:us/state:ny", 139 | "levels": [ 140 | "administrativeArea1" 141 | ], 142 | "roles": [ 143 | "deputyHeadOfGovernment" 144 | ], 145 | "officialIndices": [ 146 | 6 147 | ] 148 | }, 149 | { 150 | "name": "NY State Senator", 151 | "divisionId": "ocd-division/country:us/state:ny/sldu:18", 152 | "levels": [ 153 | "administrativeArea1" 154 | ], 155 | "roles": [ 156 | "legislatorUpperBody" 157 | ], 158 | "officialIndices": [ 159 | 7 160 | ] 161 | }, 162 | { 163 | "name": "NY State Assemblymember", 164 | "divisionId": "ocd-division/country:us/state:ny/sldl:53", 165 | "levels": [ 166 | "administrativeArea1" 167 | ], 168 | "roles": [ 169 | "legislatorLowerBody" 170 | ], 171 | "officialIndices": [ 172 | 8 173 | ] 174 | }, 175 | { 176 | "name": "NY Attorney General", 177 | "divisionId": "ocd-division/country:us/state:ny", 178 | "levels": [ 179 | "administrativeArea1" 180 | ], 181 | "officialIndices": [ 182 | 9 183 | ] 184 | }, 185 | { 186 | "name": "NY State Comptroller", 187 | "divisionId": "ocd-division/country:us/state:ny", 188 | "levels": [ 189 | "administrativeArea1" 190 | ], 191 | "officialIndices": [ 192 | 10 193 | ] 194 | }, 195 | { 196 | "name": "Brooklyn District Attorney", 197 | "divisionId": "ocd-division/country:us/state:ny/county:kings", 198 | "levels": [ 199 | "administrativeArea2" 200 | ], 201 | "officialIndices": [ 202 | 11 203 | ] 204 | }, 205 | { 206 | "name": "Brooklyn Borough President", 207 | "divisionId": "ocd-division/country:us/state:ny/county:kings", 208 | "levels": [ 209 | "administrativeArea2" 210 | ], 211 | "officialIndices": [ 212 | 12 213 | ] 214 | }, 215 | { 216 | "name": "New York Mayor", 217 | "divisionId": "ocd-division/country:us/state:ny/place:new_york", 218 | "levels": [ 219 | "locality" 220 | ], 221 | "officialIndices": [ 222 | 13 223 | ] 224 | }, 225 | { 226 | "name": "New York City Comptroller", 227 | "divisionId": "ocd-division/country:us/state:ny/place:new_york", 228 | "levels": [ 229 | "locality" 230 | ], 231 | "officialIndices": [ 232 | 14 233 | ] 234 | }, 235 | { 236 | "name": "New York Public Advocate", 237 | "divisionId": "ocd-division/country:us/state:ny/place:new_york", 238 | "levels": [ 239 | "locality" 240 | ], 241 | "officialIndices": [ 242 | 15 243 | ] 244 | } 245 | ], 246 | "officials": [ 247 | { 248 | "name": "Donald J. Trump", 249 | "address": [ 250 | { 251 | "line1": "1600 Pennsylvania Avenue Northwest", 252 | "city": "Washington", 253 | "state": "DC", 254 | "zip": "20500" 255 | } 256 | ], 257 | "party": "Republican Party", 258 | "phones": [ 259 | "(202) 456-1111" 260 | ], 261 | "urls": [ 262 | "https://www.whitehouse.gov/" 263 | ], 264 | "photoUrl": "https://www.whitehouse.gov/sites/whitehouse.gov/files/images/45/PE%20Color.jpg", 265 | "channels": [ 266 | { 267 | "type": "Facebook", 268 | "id": "DonaldTrump" 269 | }, 270 | { 271 | "type": "Twitter", 272 | "id": "potus" 273 | }, 274 | { 275 | "type": "YouTube", 276 | "id": "whitehouse" 277 | } 278 | ] 279 | }, 280 | { 281 | "name": "Mike Pence", 282 | "address": [ 283 | { 284 | "line1": "1600 Pennsylvania Avenue Northwest", 285 | "city": "Washington", 286 | "state": "DC", 287 | "zip": "20500" 288 | } 289 | ], 290 | "party": "Republican Party", 291 | "phones": [ 292 | "(202) 456-1111" 293 | ], 294 | "urls": [ 295 | "https://www.whitehouse.gov/" 296 | ], 297 | "photoUrl": "https://www.whitehouse.gov/sites/whitehouse.gov/files/images/45/VPE%20Color.jpg", 298 | "channels": [ 299 | { 300 | "type": "Facebook", 301 | "id": "mikepence" 302 | }, 303 | { 304 | "type": "Twitter", 305 | "id": "VP" 306 | } 307 | ] 308 | }, 309 | { 310 | "name": "Kirsten E. Gillibrand", 311 | "address": [ 312 | { 313 | "line1": "478 Russell Senate Office Building", 314 | "city": "Washington", 315 | "state": "DC", 316 | "zip": "20510" 317 | } 318 | ], 319 | "party": "Democratic Party", 320 | "phones": [ 321 | "(202) 224-4451" 322 | ], 323 | "urls": [ 324 | "https://www.gillibrand.senate.gov/" 325 | ], 326 | "photoUrl": "http://bioguide.congress.gov/bioguide/photo/G/G000555.jpg", 327 | "channels": [ 328 | { 329 | "type": "Facebook", 330 | "id": "KirstenGillibrand" 331 | }, 332 | { 333 | "type": "Twitter", 334 | "id": "SenGillibrand" 335 | }, 336 | { 337 | "type": "YouTube", 338 | "id": "KirstenEGillibrand" 339 | } 340 | ] 341 | }, 342 | { 343 | "name": "Charles E. Schumer", 344 | "address": [ 345 | { 346 | "line1": "322 Hart Senate Office Building", 347 | "city": "Washington", 348 | "state": "DC", 349 | "zip": "20510" 350 | } 351 | ], 352 | "party": "Democratic Party", 353 | "phones": [ 354 | "(202) 224-6542" 355 | ], 356 | "urls": [ 357 | "https://www.schumer.senate.gov/" 358 | ], 359 | "photoUrl": "http://bioguide.congress.gov/bioguide/photo/S/S000148.jpg", 360 | "channels": [ 361 | { 362 | "type": "Facebook", 363 | "id": "senschumer" 364 | }, 365 | { 366 | "type": "Twitter", 367 | "id": "SenSchumer" 368 | }, 369 | { 370 | "type": "YouTube", 371 | "id": "SenatorSchumer" 372 | }, 373 | { 374 | "type": "YouTube", 375 | "id": "ChuckSchumer" 376 | } 377 | ] 378 | }, 379 | { 380 | "name": "Nydia M. Velázquez", 381 | "address": [ 382 | { 383 | "line1": "2302 Rayburn House Office Building", 384 | "city": "Washington", 385 | "state": "DC", 386 | "zip": "20515" 387 | } 388 | ], 389 | "party": "Democratic Party", 390 | "phones": [ 391 | "(202) 225-2361" 392 | ], 393 | "urls": [ 394 | "https://velazquez.house.gov/" 395 | ], 396 | "photoUrl": "http://bioguide.congress.gov/bioguide/photo/V/V000081.jpg", 397 | "channels": [ 398 | { 399 | "type": "Facebook", 400 | "id": "RepNydiaVelazquez" 401 | }, 402 | { 403 | "type": "Twitter", 404 | "id": "NydiaVelazquez" 405 | }, 406 | { 407 | "type": "YouTube", 408 | "id": "nydiavelazquez" 409 | } 410 | ] 411 | }, 412 | { 413 | "name": "Andrew M. Cuomo", 414 | "address": [ 415 | { 416 | "line1": "The Honorable Andrew M. Cuomo", 417 | "line2": "Governor of New York State", 418 | "line3": "NYS State Capitol Building", 419 | "city": "Albany", 420 | "state": "NY", 421 | "zip": "12224" 422 | } 423 | ], 424 | "party": "Democratic Party", 425 | "phones": [ 426 | "(518) 474-8390" 427 | ], 428 | "urls": [ 429 | "https://andrewcuomo.com/" 430 | ], 431 | "channels": [ 432 | { 433 | "type": "Facebook", 434 | "id": "GovernorAndrewCuomo" 435 | }, 436 | { 437 | "type": "Twitter", 438 | "id": "nygovcuomo" 439 | }, 440 | { 441 | "type": "YouTube", 442 | "id": "nygovcuomo" 443 | } 444 | ] 445 | } 446 | ] 447 | }; 448 | 449 | export default officals; 450 | --------------------------------------------------------------------------------