Commit 0800d55a authored by Mattias J Duffy's avatar Mattias J Duffy

Merge branch '2.1-dev' into 'master'

2.1 dev

Closes #16, #29, and #14

See merge request !6
parents 3898f5d4 2c4488e1
Pipeline #2102 passed with stage
in 1 minute and 46 seconds
......@@ -117,6 +117,7 @@
"template-curly-spacing": "error",
"react/no-unescaped-entities": "off",
"react/prop-types": "off",
"react/no-find-dom-node": "off",
"react/boolean-prop-naming": "warn",
"react/no-danger": "error",
"react/no-typos": "warn",
......
......@@ -10,24 +10,20 @@ build:
API_GET_FACILITIES: "https://api.srct.gmu.edu/whatsopen/v2/facilities/"
script:
- npm install -g yarn
- >
sed "s|^const API_GET_FACILITIES .*|const API_GET_FACILITIES = '$API_GET_FACILITIES';|" -i src/actions/api.js
- yarn install
- CI=false npm run build
- CI=false REACT_APP_API_GET_FACILITIES=$API_GET_FACILITIES npm run build
artifacts:
paths:
- build
build_shopmason:
stage: build
variables:
API_GET_FACILITIES: "https://api.srct.gmu.edu/whatsopen/v2/facilities/?facility_classifier=shopmason"
script:
- npm install -g yarn
- >
sed "s|^const API_GET_FACILITIES .*|const API_GET_FACILITIES = '$API_GET_FACILITIES';|" -i src/actions/api.js
- yarn install
- CI=false npm run build
- CI=false REACT_APP_API_GET_FACILITIES=$API_GET_FACILITIES npm run build
artifacts:
paths:
- build
......@@ -40,7 +36,7 @@ deploy_staging:
name: staging
url: https://whatsopen.gmu.io
only:
- master
- 2.1-dev
deploy_production:
stage: deploy
......@@ -51,4 +47,4 @@ deploy_production:
when: manual
only:
- master
# Changelog
## [2.1.0] - 2018-02-08
### Added
- Alert support
- Api classifier in build script
### Changed
- Facility cards now sorted by open / closed
- Facility card shadowing
- Search now includes friendly name
### Fixed
- Redux extension support
- Phone number styling
- Search engine metadata
- SciTech campus now works properly
## [2.0.1] - 2018-01-23
### Fixed
- Use relative paths for js and css bundles.
[2.0.1]: https://git.gmu.edu/srct/whats-open-web/compare/v2.0...v2.0.1
[2.1.0]: https://git.gmu.edu/srct/whats-open-web/compare/v2.0.1...v2.1
\ No newline at end of file
......@@ -7617,6 +7617,11 @@
"sha.js": "2.4.9"
}
},
"phone-formatter": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/phone-formatter/-/phone-formatter-0.0.2.tgz",
"integrity": "sha1-82JsfSdIYPAU9w9DqHVmoWsOes4="
},
"pify": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
......@@ -9470,6 +9475,16 @@
}
}
},
"react-notification-system": {
"version": "0.2.16",
"resolved": "https://registry.npmjs.org/react-notification-system/-/react-notification-system-0.2.16.tgz",
"integrity": "sha1-m52iCw00eGtgBXxStCUW6hKVN0o=",
"requires": {
"create-react-class": "15.6.2",
"object-assign": "4.1.1",
"prop-types": "15.6.0"
}
},
"react-popper": {
"version": "0.7.4",
"resolved": "https://registry.npmjs.org/react-popper/-/react-popper-0.7.4.tgz",
......@@ -10187,7 +10202,6 @@
"os-tmpdir": {
"version": "1.0.2",
"bundled": true,
"dev": true,
"optional": true
},
"osenv": {
......
......@@ -3,7 +3,8 @@
<head>
<title>What's Open</title>
<meta name="description" content="Check which stores, restaurants, and buildings are open at all George Mason campuses">
<meta name="keywords" content="whats open,open,SRCT,george mason,whats open gmu,gmu,is open,is open gmu,is closed gmu,schedule gmu,restaurants open gmu, resturants open gmu, places closed gmu, places open gmu">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="theme-color" content="#000000">
......
export const SET_SIDEBAR = 'SET_SIDEBAR';
export const SET_FACILITIES = 'SET_FACILITIES';
export const GET_FACILITIES = 'GET_FACILITIES';
export const SET_ALERTS = 'SET_ALERTS';
export const GET_ALERTS = 'GET_ALERTS';
export const VIEW_ALERT = 'VIEW_ALERT';
export const SET_SELECTED_FACILITY = 'SET_SELECTED_FACILITY';
export const SET_SEARCH_TERM = 'SET_SEARCH_TERM';
export const SET_CAMPUS_REGION = 'SET_CAMPUS_REGION';
export const ADD_FAVORITE_FACILITY = 'ADD_FAVORITE_FACILITY';
export const REMOVE_FAVORITE_FACILITY = 'REMOVE_FAVORITE_FACILITY';
export const SET_ALL_FAVORITES = 'SET_ALL_FAVORITES';
export const SORT_BY_FAVORITES = 'SORT_BY_FAVORITES';
\ No newline at end of file
export const SORT_FACILITY_CARDS = 'SORT_FACILITY_CARDS';
\ No newline at end of file
import {GET_FACILITIES, SET_FACILITIES, SORT_BY_FAVORITES} from './action-types';
import {GET_ALERTS, GET_FACILITIES, SET_ALERTS, SET_FACILITIES, SORT_FACILITY_CARDS, VIEW_ALERT} from './action-types';
const API_GET_FACILITIES = 'https://api.srct.gmu.edu/whatsopen/v2/facilities/';
// SHOP MASON ONLY
//const API_GET_FACILITIES = 'https://api.srct.gmu.edu/whatsopen/v2/facilities/?facility_classifier=shopmason';
const API_GET_FACILITIES = process.env.REACT_APP_API_GET_FACILITIES ? process.env.REACT_APP_API_GET_FACILITIES :
'https://api.srct.gmu.edu/whatsopen/v2/facilities/';
const API_GET_ALERTS = 'https://api.srct.gmu.edu/whatsopen/v2/alerts/?ordering=urgency_tag';
export const getFacilities = () => (dispatch) => {
dispatch({
type: GET_FACILITIES
......@@ -37,6 +37,64 @@ export const setFacilities = (facilities) => {
};
};
export const sortByFavorites = () => ({
type: SORT_BY_FAVORITES
});
\ No newline at end of file
export const sortFacilityCards = () => ({
type: SORT_FACILITY_CARDS
});
export const getAlerts = () => (dispatch) => {
dispatch({
type: GET_ALERTS
});
const request = new Request(API_GET_ALERTS, {
method: 'GET'
});
return fetch(request)
.then((res) => {
if (res.status < 200 || res.status >= 300) {
throw new Error(res.statusText);
}
return res.json();
}).then((json) => {
dispatch(setAlerts(json));
});
};
export const setAlerts = (alerts) => {
const viewedAlerts = JSON.parse(localStorage.getItem('viewedAlerts'));
if (viewedAlerts) {
alerts.forEach((alert) => {
alert['viewed'] = viewedAlerts.includes(alert.id);
});
}
return {
type: SET_ALERTS,
alerts: alerts
};
};
export const viewAlert = (alert) => {
try {
let viewedAlerts = JSON.parse(localStorage.getItem('viewedAlerts'));
if (!viewedAlerts) {
viewedAlerts = [];
}
if (!viewedAlerts.includes(alert.id)) {
viewedAlerts.push(alert.id);
}
localStorage.setItem('viewedAlerts', JSON.stringify(viewedAlerts));
} catch (e) {
//Empty
}
return {
type: VIEW_ALERT,
alert
};
};
......@@ -37,3 +37,4 @@ export const setAllFavorites = (favorites) => ({
type: SET_ALL_FAVORITES,
favorites
});
import React from 'react';
import classNames from 'classnames';
import {findLink} from '../utils/nameUtils';
import Chip from 'material-ui/Chip';
const Alert = ({alert}) => {
const getUrgencyClass = () => {
switch (alert.urgency_tag) {
case 'emergency':
return 'alert-emergency';
case 'major':
return 'alert-major';
case 'minor':
return 'alert-minor';
case 'info':
default:
return 'alert-info';
}
};
const getMessage = () => {
const links = findLink(alert.message);
if (!links) {
return (<span className={'alert-message'}>
{alert.message}
</span>);
}
return (
<span className={'alert-message'}>
{alert.message.substring(0, links.index)}
<a href={links[0]} className={'alert-link'} target="_blank" rel="noopener">{links[0]}</a>
{alert.message.substring(links.index + links[0].length)}
</span>
);
};
const getChipLabel = () => alert.urgency_tag.charAt(0).toUpperCase() + alert.urgency_tag.slice(1);
return (
<div className={'alert'}>
{getMessage()}
<Chip label={getChipLabel()} className={classNames('alert-urgency-chip', getUrgencyClass())}/>
</div>
);
};
export default Alert;
\ No newline at end of file
......@@ -7,6 +7,7 @@ import IconButton from 'material-ui/IconButton';
import MenuIcon from 'material-ui-icons/Menu';
import SearchBar from '../containers/SearchBar';
import classNames from 'classnames';
import AlertContainer from '../containers/AlertContainer';
class CustomAppBar extends React.Component {
......@@ -38,7 +39,10 @@ class CustomAppBar extends React.Component {
What's Open
</Typography>
</div>
<div className={'app-bar-search-menu'}>
<div className={'app-bar-right-section'}>
<div className={'app-bar-alert-container'}>
<AlertContainer/>
</div>
<SearchBar onSearchExpand={() => this.setState({
isSearchExpanded: true
})}
......@@ -53,11 +57,11 @@ class CustomAppBar extends React.Component {
<div
className={classNames('app-bar-link-container', !this.state.isAppBarExpanded && 'app-bar-hide')}>
<Button className={classNames('app-bar-link-button', 'app-bar-text-color')}
href={'https://srct.gmu.edu/'} target="_blank">
href={'https://srct.gmu.edu/'} target="_blank" rel="noopener">
About
</Button>
<Button className={classNames('app-bar-link-button', 'app-bar-text-color')}
href={'https://srct.gmu.edu/contact/'} target="_blank">
href={'https://srct.gmu.edu/contact/'} target="_blank" rel="noopener">
Feedback
</Button>
</div>
......
......@@ -14,6 +14,7 @@ const CardContainer = ({searchTerm, campusRegion, facilities}) => {
const facilityLocation = facility.facility_location.building.toLowerCase();
const facilityCategory = facility.facility_category.name.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '');
const facilityTags = facility.facility_product_tags;
const friendlyName = facility.friendly_building;
facilityTags.forEach((tag) => {
return tag.toLowerCase();
......@@ -26,7 +27,7 @@ const CardContainer = ({searchTerm, campusRegion, facilities}) => {
const hasTag = index !== -1;
return facilityName.includes(lSearchTerm) || facilityLocation.includes(lSearchTerm) ||
facilityCategory.includes(lSearchTerm) || hasTag;
facilityCategory.includes(lSearchTerm) || hasTag || facilityLocation.includes(friendlyName);
};
return (
......
......@@ -11,6 +11,7 @@ import FacilityLabels from './FacilityLabels';
import CloseIcon from 'material-ui-icons/Close';
import IconButton from 'material-ui/IconButton';
import {removeBrackets} from '../utils/nameUtils';
import phoneFormatter from 'phone-formatter';
const Sidebar = ({facility, isSidebarOpen, facilities, setSidebar, setSelectedFacility, campusRegion}) => {
......@@ -22,6 +23,7 @@ const Sidebar = ({facility, isSidebarOpen, facilities, setSidebar, setSelectedFa
return (
<div
className={classNames(['card-container-offset', (isSidebarOpen && 'card-container-offset-open'), (!isSidebarOpen && 'card-container-offset-closed')])}>
<meta></meta>
<Paper
className={classNames(['sidebar-root', (isSidebarOpen && 'sidebar-open'), (!isSidebarOpen && 'sidebar-closed')])}>
<IconButton className={'sidebar-close-btn'} onClick={handleSidebarClose}>
......@@ -41,7 +43,7 @@ const Sidebar = ({facility, isSidebarOpen, facilities, setSidebar, setSelectedFa
<TextwTitle label="Address"
content={facility.facility_location && facility.facility_location.address} />
<TextwTitle label="Phone Number"
content={facility.phone_number ? facility.phone_number : 'Unknown'} />
content={facility.phone_number ? phoneFormatter.format(facility.phone_number, "(NNN) NNN-NNNN") : 'Unknown'} />
<TextwTitle label="Labels" content={<FacilityLabels facility={facility} />} />
<TextwTitle label="Hours" content={<WeekHours facility={facility} />} />
</div>
......
import React from 'react';
import {findDOMNode} from 'react-dom';
import IconButton from 'material-ui/IconButton';
import Popover from 'material-ui/Popover';
import Alert from '../components/Alert';
import NotificationsIcon from 'material-ui-icons/Notifications';
import CloseIcon from 'material-ui-icons/Close';
import Typography from 'material-ui/Typography';
import {connect} from 'react-redux';
import {viewAlert} from '../actions/api';
class AlertContainer extends React.Component {
constructor() {
super();
this.state = {
isOpen: false,
anchorEl: null
};
}
handleOpen = () => {
this.setState({
isOpen: true
});
this.props.alerts.forEach((alert) => this.props.viewAlert(alert));
};
handleClose = () => {
this.setState({
isOpen: false
});
};
handleBtnRef = (c) => {
this.setState({
anchorEl: findDOMNode(c)
});
};
isAlertActive = (alert) => {
const curDate = new Date();
const startDate = new Date(alert.start_datetime);
const endDate = new Date(alert.end_datetime);
return curDate > startDate && curDate < endDate;
};
render() {
const {alerts} = this.props;
const activeAlerts = alerts.filter(this.isAlertActive);
return (
<div>
<IconButton classes={{
root: 'alert-container-btn'
}} ref={this.handleBtnRef} onClick={this.handleOpen}>
{activeAlerts.filter((alert) => !alert.viewed).length !== 0 &&
<span className={'alert-container-number'}>
<Typography type={'caption'} className={'alert-container-number-text'}>
{activeAlerts.length}
</Typography>
</span>}
<NotificationsIcon>
</NotificationsIcon>
</IconButton>
<Popover
open={this.state.isOpen}
anchorEl={this.state.anchorEl}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'center'
}}
transformOrigin={{
vertical: 'top',
horizontal: 'center'
}}
onClose={this.handleClose}>
<div className={'alert-container-popover'}>
<div className={'alert-container-header'}>
<Typography type={'title'} className={'alert-container-title'}>
Alerts
</Typography>
<IconButton className={'alert-container-close-btn'} onClick={this.handleClose}>
<CloseIcon className={'alert-container-close-icon'}/>
</IconButton>
</div>
{
activeAlerts.length === 0 ?
<div>
<div className={'alert'}>
<span>There are no alerts at this time.</span>
</div>
</div> :
<div>
{activeAlerts.map((alert) => {
return (
<Alert key={alert.id} alert={alert}/>
);
})}
</div>
}
</div>
</Popover>
</div>
);
}
}
function mapStateToProps(state) {
return {
alerts: state.alerts
};
}
export default connect(mapStateToProps, {
viewAlert
})(AlertContainer);
import React from 'react';
import Card, {CardContent, CardMedia} from 'material-ui/Card';
import Card, {CardContent} from 'material-ui/Card';
import Typography from 'material-ui/Typography';
import Grid from 'material-ui/Grid';
import FacilityStatus from '../components/FacilityStatus';
......@@ -68,16 +68,15 @@ class FacilityCard extends React.Component {
const buildingName = facility.facility_location.friendly_building ?
facility.facility_location.friendly_building :
facility.facility_location.building;
return (
<Card onClick={this.handleCardClick} className={classNames('fc-root', isSelected && 'fc-selected')}
onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave} raised>
<CardMedia className={'fc-media'}
image={'https://gmucampus.files.wordpress.com/2010/09/00sothside2.jpg'} />
onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave} elevation={3}>
{/*<CardMedia className={'fc-media'}
image={'https://gmucampus.files.wordpress.com/2010/09/00southside2.jpg'} />*/}
<div className={'fc-logo-container'}>
<CardMedia className={'fc-logo'}
image={facility.logo} />
<img className={'fc-logo'}
alt={facility.slug} src={facility.logo} />
</div>
......
import React from 'react';
import {connect} from 'react-redux';
import {setAllFavorites} from '../actions/ui';
import AppBar from '../components/AppBar';
import Sidebar from '../components/Sidebar';
import {getFacilities, setFacilities, sortByFavorites} from '../actions/api';
import {setSidebar, setSelectedFacility} from '../actions/ui';
import {getAlerts, getFacilities, setAlerts, setFacilities, sortFacilityCards} from '../actions/api';
import {setSidebar, setSelectedFacility, setAllFavorites} from '../actions/ui';
import CardContainer from '../components/CardContainer';
class Layout extends React.Component {
......@@ -28,13 +27,13 @@ class Layout extends React.Component {
const favorites = JSON.parse(localStorage.getItem('favorites'));
this.props.setAllFavorites(favorites);
}
this.props.sortByFavorites();
this.props.sortFacilityCards();
} catch (e) {
console.warn('you should enable cookies so we can remember what places you favorite');
}
this.props.getFacilities();
this.props.getAlerts();
};
render() {
......@@ -42,16 +41,18 @@ class Layout extends React.Component {
return (
<div className={'layout-root'}>
<AppBar isOpen={false} />
<AppBar isOpen={false}/>
<div className={'layout-container'}>
<div className={'layout-main-content'}>
<div className={'layout-card-container'}>
<CardContainer styles={'layout-card-container'} searchTerm={searchTerm}
campusRegion={campusRegion} facilities={facilities} />
campusRegion={campusRegion} facilities={facilities}/>
</div>
</div>
<Sidebar facilities={facilities} facility={selectedFacility} isSidebarOpen={isSidebarOpen} setSidebar={setSidebar} setSelectedFacility={setSelectedFacility} campusRegion={campusRegion}/>
<Sidebar facilities={facilities} facility={selectedFacility} isSidebarOpen={isSidebarOpen}
setSidebar={setSidebar} setSelectedFacility={setSelectedFacility}
campusRegion={campusRegion}/>
</div>
</div>
);
......@@ -61,6 +62,7 @@ class Layout extends React.Component {
function mapStateToProps(state) {
return {
facilities: state.facilities.data,
alerts: state.alerts,
favorites: state.ui.favorites,
searchTerm: state.ui.search.term,
campusRegion: state.ui.search.campusRegion,
......@@ -73,8 +75,10 @@ function mapStateToProps(state) {
export default connect(mapStateToProps, {
getFacilities,
setFacilities,
getAlerts,
setAlerts,
setAllFavorites,
sortByFavorites,
sortFacilityCards,
setSidebar,
setSelectedFacility
})(Layout);
......@@ -115,7 +115,7 @@ class SearchBar extends React.Component {
onChange={this.handleRegionChange}>
<MenuItem value={'fairfax'}>Fairfax</MenuItem>
<MenuItem value={'arlington'}>Arlington</MenuItem>
<MenuItem value={'prince william county science and technology'}>Prince William</MenuItem>
<MenuItem value={'prince william'}>SciTech</MenuItem>
<MenuItem value={'mason korea'}>Korea</MenuItem>
</Select>
</FormControl>
......
......@@ -20,7 +20,7 @@ const extension = window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS
const isProduction = process.env.NODE_ENV === 'production';
let enhance;
if (isProduction) {
if (isProduction || !extension) {
enhance = compose(applyMiddleware(ReduxThunk, routerMiddleware(history)));
} else {
enhance = compose(applyMiddleware(ReduxThunk, routerMiddleware(history)), extension);
......
import {GET_FACILITIES, SET_FACILITIES, SORT_BY_FAVORITES} from '../actions/action-types';
import {GET_ALERTS, GET_FACILITIES, SET_ALERTS, SET_FACILITIES, SORT_FACILITY_CARDS, VIEW_ALERT } from '../actions/action-types';
import cloneDeep from 'lodash/cloneDeep';
const defaultState = {
import facilityUtils from '../utils/facilityUtils';
const defaultFacilityState = {
isLoading: false,
data: []
};
export const facilities = (state = defaultState, action, ui) => {
const sortFunc = (a, b) => {
export const facilities = (state = defaultFacilityState, action, ui) => {
const facilitySort = (a, b) => {
const favoriteCheck = ui.favorites.includes(b.slug) - ui.favorites.includes(a.slug);
if (favoriteCheck !== 0) {
return ui.favorites.includes(b.slug) - ui.favorites.includes(a.slug);
return favoriteCheck;
}
const openCheck = facilityUtils.isFacilityOpen(b) - facilityUtils.isFacilityOpen(a);
if (openCheck !== 0) {
return openCheck;
}
if (a.slug < b.slug) {
if (a.facility_name < b.facility_name) {
return -1;
}
if (a.slug > b.slug) {
if (a.facility_name > b.facility_name) {
return 1;
}
return 0;
};
const newData = cloneDeep(state.data);
switch (action.type) {
case GET_FACILITIES:
return Object.assign({}, state, {
......@@ -32,15 +39,35 @@ export const facilities = (state = defaultState, action, ui) => {
});
case SET_FACILITIES:
return Object.assign({}, state, {
data: action.facilities.sort(sortFunc),
data: action.facilities.sort(facilitySort),
isLoading: false
});
case SORT_BY_FAVORITES:
const newData = cloneDeep(state.data);
case SORT_FACILITY_CARDS:
return Object.assign({}, state, {
data: newData.sort(sortFunc)
data: newData.sort(facilitySort)
});
default:
return state;
}
};
export const alerts = (state = [], action) => {
switch (action.type) {
case GET_ALERTS:
return state;
case SET_ALERTS:
return [...state, ...action.alerts];
case VIEW_ALERT:
const index = state.findIndex((alert) => alert.id === action.alert.id);
const alert = cloneDeep(state[index]);
alert.viewed = true;
const stateClone = state.slice();
stateClone[index] = alert;
return stateClone;
default:
return state;
}
};
import {routerReducer} from 'react-router-redux';
import ui from './ui';
import {facilities} from './api';
import {facilities, alerts} from './api';
const reducers = (state = {}, action) => ({
router: routerReducer(state.router, action),
ui: ui(state.ui