visit
What our appointment scheduler app will look like We’ll complete our project in 3 major sections:
Appointments
Here, email and phone will be the user's metadata — we'll use their name as the Appointment object's title. Datewill hold the appointment date in YYYY-DD-MM format and slot will be how many hours away the appointment is from 9AM.
Configs/Site
We’re using the Config object to define details about the app that we want to be able to change on the fly, rather than having to redeploy for. And with our simple data scheme in place, we’re ready to get building.
// ./src/index.js
import React from 'react'
import ReactDom from 'react-dom'
import App from './Components/App'
import MuiThemeProvider from 'material-ui/styles/MuiThemeProvider'
import 'normalize.css'
require('./scss/app.scss')
window.React = React
ReactDom.render(
<MuiThemeProvider>
<App />
</MuiThemeProvider>,
document.getElementById('root')
)
// MuiThemeProvider is a wrapper component for MaterialUI's components
// ./src/Components/App.js
import { Component } from 'react'
import injectTapEventPlugin from 'react-tap-event-plugin'
injectTapEventPlugin()
export default class App extends Component {
constructor() {
super()
this.state = {
// initial state
}
//method bindings
}
//component methods?
//lifecycle methods
componentWillMount() {
//fetch data from cosmic, watch window width
}
componentWillUnmount() {
//remove window width event listener
}
render() {
//define variables
return (
<div>
</div>
)
}
}
To start, we need to think about what the app’s state will look like. Here are some considerations:
// ./src/Components/App.js
// ...
this.state = {
loading: true,
navOpen: false,
confirmationModalOpen: false,
confirmationTextVisible: false,
stepIndex: 0,
appointmentDateSelected: false,
appointmentMeridiem: 0,
validEmail: false,
validPhone: false,
smallScreen: window.innerWidth < 768,
confirmationSnackbarOpen: false
}
Note that appointmentMeridiem takes on 0 or 1, such that 0 => 'AM' and 1 => 'PM'.
// ./src/Components/App.js
import { Component } from 'react'
import injectTapEventPlugin from 'react-tap-event-plugin'
injectTapEventPlugin()
export default class App extends Component {
constructor() {
super()
this.state = {
loading: true,
navOpen: false,
confirmationModalOpen: false,
confirmationTextVisible: false,
stepIndex: 0,
appointmentDateSelected: false,
appointmentMeridiem: 0,
validEmail: false,
validPhone: false,
smallScreen: window.innerWidth < 768,
confirmationSnackbarOpen: false
}
//method bindings
this.handleNavToggle = this.handleNavToggle.bind(this)
this.handleNextStep = this.handleNextStep.bind(this)
this.handleSetAppointmentDate = this.handleSetAppointmentDate.bind(this)
this.handleSetAppointmentSlot = this.handleSetAppointmentSlot.bind(this)
this.handleSetAppointmentMeridiem = this.handleSetAppointmentMeridiem.bind(this)
this.handleSubmit = this.handleSubmit.bind(this)
this.validateEmail = this.validateEmail.bind(this)
this.validatePhone = this.validatePhone.bind(this)
this.checkDisableDate = this.checkDisableDate.bind(this)
this.renderAppointmentTimes = this.renderAppointmentTimes.bind(this)
this.renderConfirmationString = this.renderConfirmationString.bind(this)
this.renderAppointmentConfirmation = this.renderAppointmentConfirmation.bind(this)
this.resize = this.resize.bind(this)
}
handleNavToggle() {
}
handleNextStep() {
}
handleSetAppointmentDate(date) {
}
handleSetAppointmentSlot(slot) {
}
handleSetAppointmentMeridiem(meridiem) {
}
handleFetch(response) {
}
handleFetchError(err) {
}
handleSubmit() {
}
validateEmail(email) {
}
validatePhone(phoneNumber) {
}
checkDisableDate(date) {
}
renderConfirmationString() {
}
renderAppointmentTimes() {
}
renderAppointmentConfirmation() {
}
resize() {
}
//lifecycle methods
componentWillMount() {
//fetch data from cosmic, watch window width
}
componentWillUnmount() {
//remove window width event listener
}
render() {
//define variables
return (
<div>
</div>
)
}
}
<MenuItem disabled={true}
style={{
marginLeft: '50%',
transform: 'translate(-50%)'
}}>
{"© Copyright " + moment().format('YYYY')}</MenuItem>
</Drawer>
<section style={{
maxWidth: !smallScreen ? '80%' : '100%',
margin: 'auto',
marginTop: !smallScreen ? 20 : 0,
}}>
{this.renderConfirmationString()}
<Card style={{
padding: '10px 10px 25px 10px',
height: smallScreen ? '100vh' : null
}}>
<Stepper
activeStep={stepIndex}
linear={false}
orientation="vertical">
<Step disabled={loading}>
<StepButton onClick={() => this.setState({ stepIndex: 0 })}>
Choose an available day for your appointment
</StepButton>
<StepContent>
<DatePicker
style={{
marginTop: 10,
marginLeft: 10
}}
value={data.appointmentDate}
hintText="Select a date"
mode={smallScreen ? 'portrait' : 'landscape'}
onChange={(n, date) => this.handleSetAppointmentDate(date)}
shouldDisableDate={day => this.checkDisableDate(day)}
/>
</StepContent>
</Step>
<Step disabled={ !data.appointmentDate }>
<StepButton onClick={() => this.setState({ stepIndex: 1 })}>
Choose an available time for your appointment
</StepButton>
<StepContent>
<SelectField
floatingLabelText="AM or PM"
value={data.appointmentMeridiem}
onChange={(evt, key, payload) => this.handleSetAppointmentMeridiem(payload)}
selectionRenderer={value => value ? 'PM' : 'AM'}>
<MenuItem value={0}>AM</MenuItem>
<MenuItem value={1}>PM</MenuItem>
</SelectField>
<RadioButtonGroup
style={{ marginTop: 15,
marginLeft: 15
}}
name="appointmentTimes"
defaultSelected={data.appointmentSlot}
onChange={(evt, val) => this.handleSetAppointmentSlot(val)}>
{this.renderAppointmentTimes()}
</RadioButtonGroup>
</StepContent>
</Step>
<Step disabled={ !Number.isInteger(this.state.appointmentSlot) }>
<StepButton onClick={() => this.setState({ stepIndex: 2 })}>
Share your contact information with us and we'll send you a reminder
</StepButton>
<StepContent>
<section>
<TextField
style={{ display: 'block' }}
name="first_name"
hintText="First Name"
floatingLabelText="First Name"
onChange={(evt, newValue) => this.setState({ firstName: newValue })}/>
<TextField
style={{ display: 'block' }}
name="last_name"
hintText="Last Name"
floatingLabelText="Last Name"
onChange={(evt, newValue) => this.setState({ lastName: newValue })}/>
<TextField
style={{ display: 'block' }}
name="email"
hintText="[email protected]"
floatingLabelText="Email"
errorText={data.validEmail ? null : 'Enter a valid email address'}
onChange={(evt, newValue) => this.validateEmail(newValue)}/>
<TextField
style={{ display: 'block' }}
name="phone"
hintText="(888) 888-8888"
floatingLabelText="Phone"
errorText={data.validPhone ? null: 'Enter a valid phone number'}
onChange={(evt, newValue) => this.validatePhone(newValue)} />
<RaisedButton
style={{ display: 'block' }}
label={contactFormFilled ? 'Schedule' : 'Fill out your information to schedule'}
labelPosition="before"
primary={true}
fullWidth={true}
onClick={() => this.setState({ confirmationModalOpen: !this.state.confirmationModalOpen })}
disabled={!contactFormFilled || data.processed }
style={{ marginTop: 20, maxWidth: 100}} />
</section>
</StepContent>
</Step>
</Stepper>
</Card>
<Dialog
modal={true}
open={confirmationModalOpen}
actions={modalActions}
title="Confirm your appointment">
{this.renderAppointmentConfirmation()}
</Dialog>
<SnackBar
open={confirmationSnackbarOpen || loading}
message={loading ? 'Loading... ' : data.confirmationSnackbarMessage || ''}
autoHideDuration={10000}
onRequestClose={() => this.setState({ confirmationSnackbarOpen: false })} />
</section>
</div>
)
}
}
// ./src/Components/App.js
// previous imports
import async from 'async'
import axios from 'axios'
export default class App extends Component {
constructor() {}
componentWillMount() {
async.series({
configs(callback) {
axios.get(HOST + 'api/config').then(res =>
callback(null, res.data.data)
)
},
appointments(callback) {
axios.get(HOST + 'api/appointments').then(res => {
callback(null, res.data.data)
})
}
}, (err,response) => {
err ? this.handleFetchError(err) : this.handleFetch(response)
})
addEventListener('resize', this.resize)
}
// rest...
}
We use async to make our axios calls in series, and name them so we have access to them as response.configsand response.appointments in handleFetch(). We also use componentWillMount to start tracking the window width with resize().
// ./src/Components/App.js
// previous imports
import async from 'async'
import axios from 'axios'
export default class App extends Component {
constructor() {}
componentWillUnmount() {
removeEventListener('resize', this.resize)
}
// rest...
}
handleFetch(response) {
const { configs, appointments } = response
const initSchedule = {}
const today = moment().startOf('day')
initSchedule[today.format('YYYY-DD-MM')] = true
const schedule = !appointments.length ? initSchedule : appointments.reduce((currentSchedule, appointment) => {
const { date, slot } = appointment
const dateString = moment(date, 'YYYY-DD-MM').format('YYYY-DD-MM')
!currentSchedule[date] ? currentSchedule[dateString] = Array(8).fill(false) : null
Array.isArray(currentSchedule[dateString]) ?
currentSchedule[dateString][slot] = true : null
return currentSchedule
}, initSchedule)
for (let day in schedule) {
let slots = schedule[day]
slots.length ? (slots.every(slot => slot === true)) ? schedule[day] = true : null : null
}
this.setState({
schedule,
siteTitle: configs.site_title,
aboutPageUrl: configs.about_page_url,
contactPageUrl: configs.contact_page_url,
homePageUrl: configs.home_page_url,
loading: false
})
}
handleFetchError(err) {
console.log('Error fetching data:' + err)
this.setState({ confirmationSnackbarMessage: 'Error fetching data', confirmationSnackbarOpen: true })
}
handleNavToggle() {
return this.setState({ navOpen: !this.state.navOpen })
}
Then, as long as the user isn’t on the last step, we’ll handle incrementing the step.
handleNextStep() {
const { stepIndex } = this.state
return (stepIndex < 3) ? this.setState({ stepIndex: stepIndex + 1}) : null
}
Finally, we’ll simply change the state on resize if the window width is less than 768px.
resize() {
this.setState({ smallScreen: window.innerWidth < 768 })
}
handleSetAppointmentDate(date) {
this.handleNextStep()
this.setState({ appointmentDate: date, confirmationTextVisible: true })
}
handleSetAppointmentSlot(slot) {
this.handleNextStep()
this.setState({ appointmentSlot: slot })
}
handleSetAppointmentMeridiem(meridiem) {
this.setState({ appointmentMeridiem: meridiem})
}
validateEmail(email) {
const regex = /^(([^<>()\[\]\.,;:\s@\"]+(\.[^<>()\[\]\.,;:\s@\"]+)*)|(\".+\"))@(([^<>()[\]\.,;:\s@\"]+\.)+[^<>()[\]\.,;:\s@\"]{2,})$/i
return regex.test(email) ? this.setState({ email: email, validEmail: true }) : this.setState({ validEmail: false })
}
validatePhone(phoneNumber) {
const regex = /^(1\s|1|)?((\(\d{3}\))|\d{3})(\-|\s)?(\d{3})(\-|\s)?(\d{4})$/
return regex.test(phoneNumber) ? this.setState({ phone: phoneNumber, validPhone: true }) : this.setState({ validPhone: false })
}
For checking if a date should be disabled, we need to check if the date passed by DatePicker is either in state.schedule or is today.
checkDisableDate(day) {
const dateString = moment(day).format('YYYY-DD-MM')
return this.state.schedule[dateString] === true || moment(day).startOf('day').diff(moment().startOf('day')) < 0
}
renderConfirmationString() {
const spanStyle = {color: '#00bcd4'}
return this.state.confirmationTextVisible ? <h2 style={{ textAlign: this.state.smallScreen ? 'center' : 'left', color: '#bdbdbd', lineHeight: 1.5, padding: '0 10px', fontFamily: 'Roboto'}}>
{ <span>
Scheduling a
<span style={spanStyle}> 1 hour </span>
appointment {this.state.appointmentDate && <span>
on <span style={spanStyle}>{moment(this.state.appointmentDate).format('dddd[,] MMMM Do')}</span>
</span>} {Number.isInteger(this.state.appointmentSlot) && <span>at <span style={spanStyle}>{moment().hour(9).minute(0).add(this.state.appointmentSlot, 'hours').format('h:mm a')}</span></span>}
</span>}
</h2> : null
}
Then, similarly, we’ll let the user verify their data before confirming submission.
renderAppointmentConfirmation() {
const spanStyle = { color: '#00bcd4' }
return <section>
<p>Name: <span style={spanStyle}>{this.state.firstName} {this.state.lastName}</span></p>
<p>Number: <span style={spanStyle}>{this.state.phone}</span></p>
<p>Email: <span style={spanStyle}>{this.state.email}</span></p>
<p>Appointment: <span style={spanStyle}>{moment(this.state.appointmentDate).format('dddd[,] MMMM Do[,] YYYY')}</span> at <span style={spanStyle}>{moment().hour(9).minute(0).add(this.state.appointmentSlot, 'hours').format('h:mm a')}</span></p>
</section>
}
Finally, we’ll write the method to render the appointment slot radio buttons. To do this we first have to filter the slots by availabity and by whether AM or PM is selected. Both are simple checks; for the first we see if it exists in state.schedule, for the later, we check the meridiem part with moment().format('a'). To compute the time string in 12 hour format, we add the slot to 9AM in units of hours.
renderAppointmentTimes() {
if (!this.state.loading) {
const slots = [...Array(8).keys()]
return slots.map(slot => {
const appointmentDateString = moment(this.state.appointmentDate).format('YYYY-DD-MM')
const t1 = moment().hour(9).minute(0).add(slot, 'hours')
const t2 = moment().hour(9).minute(0).add(slot + 1, 'hours')
const scheduleDisabled = this.state.schedule[appointmentDateString] ? this.state.schedule[moment(this.state.appointmentDate).format('YYYY-DD-MM')][slot] : false
const meridiemDisabled = this.state.appointmentMeridiem ? t1.format('a') === 'am' : t1.format('a') === 'pm'
return <RadioButton
label={t1.format('h:mm a') + ' - ' + t2.format('h:mm a')}
key={slot}
value={slot}
style={{marginBottom: 15, display: meridiemDisabled ? 'none' : 'inherit'}}
disabled={scheduleDisabled || meridiemDisabled}/>
})
} else {
return null
}
}
handleSubmit() {
const appointment = {
date: moment(this.state.appointmentDate).format('YYYY-DD-MM'),
slot: this.state.appointmentSlot,
name: this.state.firstName + ' ' + this.state.lastName,
email: this.state.email,
phone: this.state.phone
}
axios.post(HOST + 'api/appointments', )
axios.post(HOST + 'api/appointments', appointment)
.then(response => this.setState({ confirmationSnackbarMessage: "Appointment succesfully added!", confirmationSnackbarOpen: true, processed: true }))
.catch(err => {
console.log(err)
return this.setState({ confirmationSnackbarMessage: "Appointment failed to save.", confirmationSnackbarOpen: true })
})
}
// ./src/Components/App.js
import { Component } from 'react'
import injectTapEventPlugin from 'react-tap-event-plugin'
import axios from 'axios'
import async from 'async'
import moment from 'moment'
import AppBar from 'material-ui/AppBar'
import Drawer from 'material-ui/Drawer'
import Dialog from 'material-ui/Dialog'
import Divider from 'material-ui/Divider'
import MenuItem from 'material-ui/MenuItem'
import Card from 'material-ui/Card'
import DatePicker from 'material-ui/DatePicker'
import TimePicker from 'material-ui/TimePicker'
import TextField from 'material-ui/TextField'
import SelectField from 'material-ui/SelectField'
import SnackBar from 'material-ui/Snackbar'
import {
Step,
Stepper,
StepLabel,
StepContent,
StepButton
} from 'material-ui/stepper'
import {
RadioButton,
RadioButtonGroup
} from 'material-ui/RadioButton'
import RaisedButton from 'material-ui/RaisedButton';
import FlatButton from 'material-ui/FlatButton'
import logo from './../../dist/assets/logo.svg'
injectTapEventPlugin()
const HOST = PRODUCTION ? '/' : '//localhost:3000/'
export default class App extends Component {
constructor() {
super()
this.state = {
loading: true,
navOpen: false,
confirmationModalOpen: false,
confirmationTextVisible: false,
stepIndex: 0,
appointmentDateSelected: false,
appointmentMeridiem: 0,
validEmail: true,
validPhone: true,
smallScreen: window.innerWidth < 768,
confirmationSnackbarOpen: false
}
this.handleNavToggle = this.handleNavToggle.bind(this)
this.handleNextStep = this.handleNextStep.bind(this)
this.handleSetAppointmentDate = this.handleSetAppointmentDate.bind(this)
this.handleSetAppointmentSlot = this.handleSetAppointmentSlot.bind(this)
this.handleSetAppointmentMeridiem = this.handleSetAppointmentMeridiem.bind(this)
this.handleSubmit = this.handleSubmit.bind(this)
this.validateEmail = this.validateEmail.bind(this)
this.validatePhone = this.validatePhone.bind(this)
this.checkDisableDate = this.checkDisableDate.bind(this)
this.renderAppointmentTimes = this.renderAppointmentTimes.bind(this)
this.renderConfirmationString = this.renderConfirmationString.bind(this)
this.renderAppointmentConfirmation = this.renderAppointmentConfirmation.bind(this)
this.resize = this.resize.bind(this)
}
handleNavToggle() {
return this.setState({ navOpen: !this.state.navOpen })
}
handleNextStep() {
const { stepIndex } = this.state
return (stepIndex < 3) ? this.setState({ stepIndex: stepIndex + 1}) : null
}
handleSetAppointmentDate(date) {
this.handleNextStep()
this.setState({ appointmentDate: date, confirmationTextVisible: true })
}
handleSetAppointmentSlot(slot) {
this.handleNextStep()
this.setState({ appointmentSlot: slot })
}
handleSetAppointmentMeridiem(meridiem) {
this.setState({ appointmentMeridiem: meridiem})
}
handleFetch(response) {
const { configs, appointments } = response
const initSchedule = {}
const today = moment().startOf('day')
initSchedule[today.format('YYYY-DD-MM')] = true
const schedule = !appointments.length ? initSchedule : appointments.reduce((currentSchedule, appointment) => {
const { date, slot } = appointment
const dateString = moment(date, 'YYYY-DD-MM').format('YYYY-DD-MM')
!currentSchedule[date] ? currentSchedule[dateString] = Array(8).fill(false) : null
Array.isArray(currentSchedule[dateString]) ?
currentSchedule[dateString][slot] = true : null
return currentSchedule
}, initSchedule)
for (let day in schedule) {
let slots = schedule[day]
slots.length ? (slots.every(slot => slot === true)) ? schedule[day] = true : null : null
}
this.setState({
schedule,
siteTitle: configs.site_title,
aboutPageUrl: configs.about_page_url,
contactPageUrl: configs.contact_page_url,
homePageUrl: configs.home_page_url,
loading: false
})
}
handleFetchError(err) {
console.log('Error fetching data:' + err)
this.setState({ confirmationSnackbarMessage: 'Error fetching data', confirmationSnackbarOpen: true })
}
handleSubmit() {
const appointment = {
date: moment(this.state.appointmentDate).format('YYYY-DD-MM'),
slot: this.state.appointmentSlot,
name: this.state.firstName + ' ' + this.state.lastName,
email: this.state.email,
phone: this.state.phone
}
axios.post(HOST + 'api/appointments', )
axios.post(HOST + 'api/appointments', appointment)
.then(response => this.setState({ confirmationSnackbarMessage: "Appointment succesfully added!", confirmationSnackbarOpen: true, processed: true }))
.catch(err => {
console.log(err)
return this.setState({ confirmationSnackbarMessage: "Appointment failed to save.", confirmationSnackbarOpen: true })
})
}
validateEmail(email) {
const regex = /^(([^<>()\[\]\.,;:\s@\"]+(\.[^<>()\[\]\.,;:\s@\"]+)*)|(\".+\"))@(([^<>()[\]\.,;:\s@\"]+\.)+[^<>()[\]\.,;:\s@\"]{2,})$/i
return regex.test(email) ? this.setState({ email: email, validEmail: true }) : this.setState({ validEmail: false })
}
validatePhone(phoneNumber) {
const regex = /^(1\s|1|)?((\(\d{3}\))|\d{3})(\-|\s)?(\d{3})(\-|\s)?(\d{4})$/
return regex.test(phoneNumber) ? this.setState({ phone: phoneNumber, validPhone: true }) : this.setState({ validPhone: false })
}
checkDisableDate(day) {
const dateString = moment(day).format('YYYY-DD-MM')
return this.state.schedule[dateString] === true || moment(day).startOf('day').diff(moment().startOf('day')) < 0
}
renderConfirmationString() {
const spanStyle = {color: '#00bcd4'}
return this.state.confirmationTextVisible ? <h2 style={{ textAlign: this.state.smallScreen ? 'center' : 'left', color: '#bdbdbd', lineHeight: 1.5, padding: '0 10px', fontFamily: 'Roboto'}}>
{ <span>
Scheduling a
<span style={spanStyle}> 1 hour </span>
appointment {this.state.appointmentDate && <span>
on <span style={spanStyle}>{moment(this.state.appointmentDate).format('dddd[,] MMMM Do')}</span>
</span>} {Number.isInteger(this.state.appointmentSlot) && <span>at <span style={spanStyle}>{moment().hour(9).minute(0).add(this.state.appointmentSlot, 'hours').format('h:mm a')}</span></span>}
</span>}
</h2> : null
}
renderAppointmentTimes() {
if (!this.state.loading) {
const slots = [...Array(8).keys()]
return slots.map(slot => {
const appointmentDateString = moment(this.state.appointmentDate).format('YYYY-DD-MM')
const t1 = moment().hour(9).minute(0).add(slot, 'hours')
const t2 = moment().hour(9).minute(0).add(slot + 1, 'hours')
const scheduleDisabled = this.state.schedule[appointmentDateString] ? this.state.schedule[moment(this.state.appointmentDate).format('YYYY-DD-MM')][slot] : false
const meridiemDisabled = this.state.appointmentMeridiem ? t1.format('a') === 'am' : t1.format('a') === 'pm'
return <RadioButton
label={t1.format('h:mm a') + ' - ' + t2.format('h:mm a')}
key={slot}
value={slot}
style={{marginBottom: 15, display: meridiemDisabled ? 'none' : 'inherit'}}
disabled={scheduleDisabled || meridiemDisabled}/>
})
} else {
return null
}
}
renderAppointmentConfirmation() {
const spanStyle = { color: '#00bcd4' }
return <section>
<p>Name: <span style={spanStyle}>{this.state.firstName} {this.state.lastName}</span></p>
<p>Number: <span style={spanStyle}>{this.state.phone}</span></p>
<p>Email: <span style={spanStyle}>{this.state.email}</span></p>
<p>Appointment: <span style={spanStyle}>{moment(this.state.appointmentDate).format('dddd[,] MMMM Do[,] YYYY')}</span> at <span style={spanStyle}>{moment().hour(9).minute(0).add(this.state.appointmentSlot, 'hours').format('h:mm a')}</span></p>
</section>
}
resize() {
this.setState({ smallScreen: window.innerWidth < 768 })
}
componentWillMount() {
async.series({
configs(callback) {
axios.get(HOST + 'api/config').then(res =>
callback(null, res.data.data)
)
},
appointments(callback) {
axios.get(HOST + 'api/appointments').then(res => {
callback(null, res.data.data)
})
}
}, (err,response) => {
err ? this.handleFetchError(err) : this.handleFetch(response)
})
addEventListener('resize', this.resize)
}
componentWillUnmount() {
removeEventListener('resize', this.resize)
}
render() {
const { stepIndex, loading, navOpen, smallScreen, confirmationModalOpen, confirmationSnackbarOpen, ...data } = this.state
const contactFormFilled = data.firstName && data.lastName && data.phone && data.email && data.validPhone && data.validEmail
const modalActions = [
<FlatButton
label="Cancel"
primary={false}
onClick={() => this.setState({ confirmationModalOpen : false})} />,
<FlatButton
label="Confirm"
primary={true}
onClick={() => this.handleSubmit()} />
]
return (
<div>
<AppBar
title={data.siteTitle}
onLeftIconButtonTouchTap={() => this.handleNavToggle() }/>
<Drawer
docked={false}
width={300}
open={navOpen}
onRequestChange={(navOpen) => this.setState({navOpen})} >
<img src={logo}
style={{
height: 70,
marginTop: 50,
marginBottom: 30,
marginLeft: '50%',
transform: 'translateX(-50%)'
}}/>
<a style={{textDecoration: 'none'}} href={this.state.homePageUrl}><MenuItem>Home</MenuItem></a>
<a style={{textDecoration: 'none'}} href={this.state.aboutPageUrl}><MenuItem>About</MenuItem></a>
<a style={{textDecoration: 'none'}} href={this.state.contactPageUrl}><MenuItem>Contact</MenuItem></a>
<MenuItem disabled={true}
style={{
marginLeft: '50%',
transform: 'translate(-50%)'
}}>
{"© Copyright " + moment().format('YYYY')}</MenuItem>
</Drawer>
<section style={{
maxWidth: !smallScreen ? '80%' : '100%',
margin: 'auto',
marginTop: !smallScreen ? 20 : 0,
}}>
{this.renderConfirmationString()}
<Card style={{
padding: '10px 10px 25px 10px',
height: smallScreen ? '100vh' : null
}}>
<Stepper
activeStep={stepIndex}
linear={false}
orientation="vertical">
<Step disabled={loading}>
<StepButton onClick={() => this.setState({ stepIndex: 0 })}>
Choose an available day for your appointment
</StepButton>
<StepContent>
<DatePicker
style={{
marginTop: 10,
marginLeft: 10
}}
value={data.appointmentDate}
hintText="Select a date"
mode={smallScreen ? 'portrait' : 'landscape'}
onChange={(n, date) => this.handleSetAppointmentDate(date)}
shouldDisableDate={day => this.checkDisableDate(day)}
/>
</StepContent>
</Step>
<Step disabled={ !data.appointmentDate }>
<StepButton onClick={() => this.setState({ stepIndex: 1 })}>
Choose an available time for your appointment
</StepButton>
<StepContent>
<SelectField
floatingLabelText="AM or PM"
value={data.appointmentMeridiem}
onChange={(evt, key, payload) => this.handleSetAppointmentMeridiem(payload)}
selectionRenderer={value => value ? 'PM' : 'AM'}>
<MenuItem value={0}>AM</MenuItem>
<MenuItem value={1}>PM</MenuItem>
</SelectField>
<RadioButtonGroup
style={{ marginTop: 15,
marginLeft: 15
}}
name="appointmentTimes"
defaultSelected={data.appointmentSlot}
onChange={(evt, val) => this.handleSetAppointmentSlot(val)}>
{this.renderAppointmentTimes()}
</RadioButtonGroup>
</StepContent>
</Step>
<Step disabled={ !Number.isInteger(this.state.appointmentSlot) }>
<StepButton onClick={() => this.setState({ stepIndex: 2 })}>
Share your contact information with us and we'll send you a reminder
</StepButton>
<StepContent>
<section>
<TextField
style={{ display: 'block' }}
name="first_name"
hintText="First Name"
floatingLabelText="First Name"
onChange={(evt, newValue) => this.setState({ firstName: newValue })}/>
<TextField
style={{ display: 'block' }}
name="last_name"
hintText="Last Name"
floatingLabelText="Last Name"
onChange={(evt, newValue) => this.setState({ lastName: newValue })}/>
<TextField
style={{ display: 'block' }}
name="email"
hintText="[email protected]"
floatingLabelText="Email"
errorText={data.validEmail ? null : 'Enter a valid email address'}
onChange={(evt, newValue) => this.validateEmail(newValue)}/>
<TextField
style={{ display: 'block' }}
name="phone"
hintText="(888) 888-8888"
floatingLabelText="Phone"
errorText={data.validPhone ? null: 'Enter a valid phone number'}
onChange={(evt, newValue) => this.validatePhone(newValue)} />
<RaisedButton
style={{ display: 'block' }}
label={contactFormFilled ? 'Schedule' : 'Fill out your information to schedule'}
labelPosition="before"
primary={true}
fullWidth={true}
onClick={() => this.setState({ confirmationModalOpen: !this.state.confirmationModalOpen })}
disabled={!contactFormFilled || data.processed }
style={{ marginTop: 20, maxWidth: 100}} />
</section>
</StepContent>
</Step>
</Stepper>
</Card>
<Dialog
modal={true}
open={confirmationModalOpen}
actions={modalActions}
title="Confirm your appointment">
{this.renderAppointmentConfirmation()}
</Dialog>
<SnackBar
open={confirmationSnackbarOpen || loading}
message={loading ? 'Loading... ' : data.confirmationSnackbarMessage || ''}
autoHideDuration={10000}
onRequestClose={() => this.setState({ confirmationSnackbarOpen: false })} />
</section>
</div>
)
}
}
AppointmentScheduler
|
|--public
|--app.js
|--.gitignore
|--package.json
public will be where we serve our built frontend from and .gitignore will hide node_modules.
# .gitignore
node_modules
We’ll use the following packages:
{
// etc...
"scripts": {
"start": "node app.js"
}
}
Then, before we start working:
yarn add axios body-parser cors cosmicjs express express-session http moment morgan path twilio
const express = require('express')
const path = require('path')
const morgan = require('morgan')
const bodyParser = require('body-parser')
const cors = require('cors')
const config = require('./config')
const http = require('http')
const Cosmic = require('cosmicjs')
const twilio = require('twilio')
const moment = require('moment')
const axios = require('axios')
const config = {
bucket: {
slug: process.env.COSMIC_BUCKET,
read_key: process.env.COSMIC_READ_KEY,
write_key: process.env.COSMIC_WRITE_KEY
},
twilio: {
auth: process.env.TWILIO_AUTH,
sid: process.env.TWILIO_SID,
number: process.env.TWILIO_NUMBER
}
}
const app = express()
const env = process.env.NODE_ENV || 'development'
const twilioSid = config.twilio.sid
const twilioAuth = config.twilio.auth
const twilioClient = twilio(twilioSid, twilioAuth)
const twilioNumber = config.twilio.number
app.set('trust proxy', 1)
app.use(session({
secret: 'sjcimsoc',
resave: false,
saveUninitialized: true,
cookie: { secure: false }
}))
app.use(cors())
app.use(morgan('dev'))
app.use(bodyParser.json())
app.use(bodyParser.urlencoded({ extended: true }))
app.use(express.static(path.join(__dirname, 'public')))
app.set('port', process.env.PORT || 3000)
app.post('/api/appointments', (req, res) => {
//handle posting new appointments to Cosmic
//and sending a confirmation text with Twilio
})
app.get('/api/config', (req, res) => {
//fetch configs from Cosmic, expose to frontend
})
app.get('/api/appointments', (req, res) => {
//fetch appointments from Cosmic, expose to frontend without personal data
})
app.get('/', (req, res) => {
res.send('index.html')
})
app.get('*', (req, res) => {
res.redirect('/')
})
http.createServer(app).listen(app.get('port'), () =>
console.log('Server running at: ' + app.get('port'))
)
Note: we’ll provide all process.env variables at deployment with Cosmic. Cosmic-specific variables are supplied automatically.
app.post('/api/appointments', (req, res) => {
const appointment = req.body
appointment.phone = appointment.phone.replace(/\D/g,'')
const date = moment(appointment.date, 'YYYY-DD-MM').startOf('day')
const time = date.hour(9).add(appointment.slot, 'hours')
const smsBody = `${appointment.name}, this message is to confirm your appointment at ${time.format('h:mm a')} on ${date.format('dddd MMMM Do[,] YYYY')}.`
//send confirmation message to user
twilioClient.messages.create({
to: '+1' + appointment.phone,
from: twilioNumber,
body: smsBody
}, (err, message) => console.log(message, err))
//push to cosmic
const cosmicObject = {
"title": appointment.name,
"type_slug": "appointments",
"write_key": config.bucket.write_key,
"metafields": [
{
"key": "date",
"type": "text",
"value": date.format('YYYY-DD-MM')
},
{
"key": "slot",
"type": "text",
"value": appointment.slot
},
{
"key": "email",
"type": "text",
"value": appointment.email
},{
"key": "phone",
"type": "text",
"value": appointment.phone //which is now stripped of all non-digits
}
]
}
axios.post(`//api.cosmicjs.com/v1/${config.bucket.slug}/add-object`, cosmicObject)
.then(response => res.json({ data: 'success' })).catch(err => res.json({ data: 'error '}))
})
app.get('/api/config', (req,res) => {
Cosmic.getObject(config, { slug: 'site-config' }, (err, response) => {
const data = response.object.metadata
err ? res.status(500).json({ data: 'error' }) : res.json({ data })
})
})
app.get('/api/appointments', (req, res) => {
Cosmic.getObjectType(config, { type_slug: 'appointments' }, (err, response) => {
const appointments = response.objects.all ? response.objects.all.map(appointment => {
return {
date: appointment.metadata.date,
slot: appointment.metadata.slot
}
}) : {}
res.json({ data: appointments })
})
})
const express = require('express')
const path = require('path')
const morgan = require('morgan')
const bodyParser = require('body-parser')
const cors = require('cors')
const config = require('./config')
const http = require('http')
const Cosmic = require('cosmicjs')
const twilio = require('twilio')
const moment = require('moment')
const axios = require('axios')
const config = {
bucket: {
slug: process.env.COSMIC_BUCKET,
read_key: process.env.COSMIC_READ_KEY,
write_key: process.env.COSMIC_WRITE_KEY
},
twilio: {
auth: process.env.TWILIO_AUTH,
sid: process.env.TWILIO_SID,
number: process.env.TWILIO_NUMBER
}
}
const app = express()
const env = process.env.NODE_ENV || 'development'
const twilioSid = config.twilio.sid
const twilioAuth = config.twilio.auth
const twilioClient = twilio(twilioSid, twilioAuth)
const twilioNumber = config.twilio.number
app.set('trust proxy', 1)
app.use(session({
secret: 'sjcimsoc',
resave: false,
saveUninitialized: true,
cookie: { secure: false }
}))
app.use(cors())
app.use(morgan('dev'))
app.use(bodyParser.json())
app.use(bodyParser.urlencoded({ extended: true }))
app.use(express.static(path.join(__dirname, 'public')))
app.set('port', process.env.PORT || 3000)
app.post('/api/appointments', (req, res) => {
const appointment = req.body
appointment.phone = appointment.phone.replace(/\D/g,'')
const date = moment(appointment.date, 'YYYY-DD-MM').startOf('day')
const time = date.hour(9).add(appointment.slot, 'hours')
const smsBody = `${appointment.name}, this message is to confirm your appointment at ${time.format('h:mm a')} on ${date.format('dddd MMMM Do[,] YYYY')}.`
//send confirmation message to user
twilioClient.messages.create({
to: '+1' + appointment.phone,
from: twilioNumber,
body: smsBody
}, (err, message) => console.log(message, err))
//push to cosmic
const cosmicObject = {
"title": appointment.name,
"type_slug": "appointments",
"write_key": config.bucket.write_key,
"metafields": [
{
"key": "date",
"type": "text",
"value": date.format('YYYY-DD-MM')
},
{
"key": "slot",
"type": "text",
"value": appointment.slot
},
{
"key": "email",
"type": "text",
"value": appointment.email
},{
"key": "phone",
"type": "text",
"value": appointment.phone //which is now stripped of all non-digits
}
]
}
axios.post(`//api.cosmicjs.com/v1/${config.bucket.slug}/add-object`, cosmicObject)
.then(response => res.json({ data: 'success' })).catch(err => res.json({ data: 'error '}))
})
app.get('/api/config', (req,res) => {
Cosmic.getObject(config, { slug: 'site-config' }, (err, response) => {
const data = response.object.metadata
err ? res.status(500).json({ data: 'error' }) : res.json({ data })
})
})
app.get('/api/appointments', (req, res) => {
Cosmic.getObjectType(config, { type_slug: 'appointments' }, (err, response) => {
const appointments = response.objects.all ? response.objects.all.map(appointment => {
return {
date: appointment.metadata.date,
slot: appointment.metadata.slot
}
}) : {}
res.json({ data: appointments })
})
})
app.get('/', (req, res) => {
res.send('index.html')
})
app.get('*', (req, res) => {
res.redirect('/')
})
http.createServer(app).listen(app.get('port'), () =>
console.log('Server running at: ' + app.get('port'))
)
import { Component } from 'react'
import injectTapEventPlugin from 'react-tap-event-plugin'
injectTapEventPlugin()
export default class App extends Component {
constructor(props) {
super(props)
// set initial state
// bind component methods
}
// component methods, lifecycle methods
render() {
return (
//Material UI components
)
}
}
Thinking about what we need for an initial state:
this.state = {
config: props.config,
snackbarDisabled: false,
snackbarMessage: 'Loading...',
toolbarDropdownValue: 1,
appointments: {},
filteredAppointments: {},
datePickerDisabled: true,
selectedRows: [],
deleteButtonDisabled: true,
allRowsSelected: false
}
import { Component } from 'react'
import injectTapEventPlugin from 'react-tap-event-plugin'
injectTapEventPlugin()
export default class App extends Component {
constructor(props) {
super(props)
this.state = {
config: props.config,
snackbarDisabled: false,
snackbarMessage: 'Loading...',
toolbarDropdownValue: 1,
appointments: {},
filteredAppointments: {},
datePickerDisabled: true,
selectedRows: [],
deleteButtonDisabled: true,
allRowsSelected: false
}
}
handleFetchError(err) {
//handle errors fetching data from Cosmic JS
}
handleFetch(response) {
//process data fetched from Cosmic JS
}
handleToolbarDropdownChange(val) {
// set the dropdown value and clear filteredAppointments() if
// "List All" is selected. (State 1).
}
handleRowSelection(rowsToSelect) {
// Table returns 'all' if the select-all button was used, an array of selected
// row numbers, otherwise. We need to make sense of this.
}
handleDelete(selectedRows) {
//send a post request to Cosmic JS's api to get rid of unwanted appointments
}
checkDisableDate(date) {
//feed the DatePicker days based on availability determined by appointments
//retrieved from Cosmic
}
filterAppointments(date) {
//Only show appointments occuring on date
}
setTableChildren(selectedRows = this.state.selectedRows, appointments = this.state.appointments) {
//render a TableRow for each appointment loaded
}
componentWillMount() {
//fetch data immediately
}
render() {
return (
//Material UI components
)
}
}
render() {
const { snackbarDisabled, appointments, datePickerDisabled, deleteButtonDisabled, ...data } = this.state
return (
<div style={{ fontFamily: 'Roboto' }}>
<AppBar
showMenuIconButton={false}
title="Appointment Manager"/>
<SnackBar
message={data.snackbarMessage}
open={!snackbarDisabled} />
<Toolbar>
<ToolbarGroup firstChild={true}>
<DropDownMenu
value={data.toolbarDropdownValue}
onChange={(evt, key, val) => this.handleToolbarDropdownChange(val)}>
<MenuItem value={0} primaryText="Filter Appointments By Date" />
<MenuItem value={1} primaryText="List All Appointments" />
</DropDownMenu>
<DatePicker
hintText="Select a date"
autoOk={true}
disabled={datePickerDisabled}
name="date-select"
onChange={(n, date) => this.filterAppointments(date)}
shouldDisableDate={(day) => this.checkDisableDate(day)} />
</ToolbarGroup>
<ToolbarGroup lastChild={true}>
<RaisedButton
primary={true}
onClick={() => this.handleDelete(data.selectedRows)}
disabled={deleteButtonDisabled}
label={`Delete Selected ${data.selectedRows.length ? '(' + data.selectedRows.length + ')' : ''}`} />
</ToolbarGroup>
</Toolbar>
<Table
onRowSelection={rowsToSelect => this.handleRowSelection(rowsToSelect)}
multiSelectable={true} >
<TableHeader>
<TableRow>
<TableHeaderColumn>ID</TableHeaderColumn>
<TableHeaderColumn>Name</TableHeaderColumn>
<TableHeaderColumn>Email</TableHeaderColumn>
<TableHeaderColumn>Phone</TableHeaderColumn>
<TableHeaderColumn>Date</TableHeaderColumn>
<TableHeaderColumn>Time</TableHeaderColumn>
</TableRow>
</TableHeader>
<TableBody
children={data.tableChildren}
allRowsSelected={data.allRowsSelected}>
</TableBody>
</Table>
</div>
)
}
componentWillMount() {
Cosmic.getObjectType(this.state.config, { type_slug: 'appointments' }, (err, response) => err ? this.handleFetchError(err) : this.handleFetch(response)
)
}
We’ll handle errors with handleFetchError(), which will show the user that an error occured in the SnackBar.
handleFetchError(err) {
console.log(err)
this.setState({ snackbarMessage: 'Error loading data' })
}
If data is successfully returned, we’ll process it with handleFetch().
handleFetch(response) {
const appointments = response.objects.all ? response.objects.all.reduce((currentAppointments, appointment) => {
const date = appointment.metadata.date
if (!currentAppointments[date]) currentAppointments[date] = []
const appointmentData = {
slot: appointment.metadata.slot,
name: appointment.title,
email: appointment.metadata.email,
phone: appointment.metadata.phone,
slug: appointment.slug
}
currentAppointments[date].push(appointmentData)
currentAppointments[date].sort((a,b) => a.slot - b.slot)
return currentAppointments
}, {}) : {}
this.setState({ appointments, snackbarDisabled: true, tableChildren: this.setTableChildren([], appointments) })
}
From the array of Appointment objects our bucket sends, we create a schedule of all loaded appointments, appointments. We then save that to the state and pass it to setTableChildren() to use in rendering the Table.
handleToolbarDropdownChange(val) {
//0: filter by date, 1: list all
val ? this.setState({ filteredAppointments: {}, datePickerDisabled: true, toolbarDropdownValue: 1 }) : this.setState({ toolbarDropdownValue: 0, datePickerDisabled: false })
}
For handling row selection, we save the selected rows to the state, set the table children based on the rows selected, and enable the delete button if at least one row is selected.
handleRowSelection(rowsToSelect) {
const allRows = [...Array(this.state.tableChildren.length).keys()]
const allRowsSelected = rowsToSelect === 'all'
const selectedRows = Array.isArray(rowsToSelect) ? rowsToSelect : allRowsSelected ? allRows : []
const appointments = _.isEmpty(this.state.filteredAppointments) ? this.state.appointments : this.state.filteredAppointments
const deleteButtonDisabled = selectedRows.length == 0
const tableChildren = allRowsSelected ? this.setTableChildren([], appointments) : this.setTableChildren(selectedRows, appointments)
this.setState({ selectedRows, deleteButtonDisabled, tableChildren })
}
For disabling dates, we only make them active if state.appointments.date, where date = 'YYYY-DD-MM', exists.
checkDisableDate(day) {
return !this.state.appointments[moment(day).format('YYYY-DD-MM')]
}
filterAppointments(date) {
const dateString = moment(date).format('YYYY-DD-MM')
const filteredAppointments = {}
filteredAppointments[dateString] = this.state.appointments[dateString]
this.setState({ filteredAppointments, tableChildren: this.setTableChildren([], filteredAppointments) })
}
When filterAppointments() (or any other method) calls setTableChildren() we can optionally pass an array of selected rows and an appointments object or let it default to state.selectedRows and state.appointments. If the appointments are filtered, we sort them by time before rendering.
setTableChildren(selectedRows = this.state.selectedRows, appointments = this.state.appointments) {
const renderAppointment = (date, appointment, index) => {
const { name, email, phone, slot } = appointment
const rowSelected = selectedRows.includes(index)
return <TableRow key={index} selected={rowSelected}>
<TableRowColumn>{index}</TableRowColumn>
<TableRowColumn>{name}</TableRowColumn>
<TableRowColumn>{email}</TableRowColumn>
<TableRowColumn>{phone}</TableRowColumn>
<TableRowColumn>{moment(date, 'YYYY-DD-MM').format('M[/]D[/]YYYY')}</TableRowColumn>
<TableRowColumn>{moment().hour(9).minute(0).add(slot, 'hours').format('h:mm a')}</TableRowColumn>
</TableRow>
}
const appointmentsAreFiltered = !_.isEmpty(this.state.filteredAppointments)
const schedule = appointmentsAreFiltered ? this.state.filteredAppointments : appointments
const els = []
let counter = 0
appointmentsAreFiltered ?
Object.keys(schedule).forEach(date => {
schedule[date].forEach((appointment, index) => els.push(renderAppointment(date, appointment, index)))
}) :
Object.keys(schedule).sort((a,b) => moment(a, 'YYYY-DD-MM').isBefore(moment(b, 'YYYY-MM-DD')))
.forEach((date, index) => {
schedule[date].forEach(appointment => {
els.push(renderAppointment(date, appointment, counter))
counter++
})
})
return els
}
handleDelete(selectedRows) {
const { config } = this.state
return selectedRows.map(row => {
const { tableChildren, appointments } = this.state
const date = moment(tableChildren[row].props.children[4].props.children, 'M-D-YYYY').format('YYYY-DD-MM')
const slot = moment(tableChildren[row].props.children[5].props.children, 'h:mm a').diff(moment().hours(9).minutes(0).seconds(0), 'hours') + 1
return _.find(appointments[date], appointment =>
appointment.slot === slot
)
}).map(appointment => appointment.slug).forEach(slug =>
Cosmic.deleteObject(config, { slug, write_key: config.bucket.write_key }, (err, response) => {
if (err) {
console.log(err)
this.setState({ snackbarDisabled: false, snackbarMessage: 'Failed to delete appointments' })
} else {
this.setState({ snackbarMessage: 'Loading...', snackbarDisabled: false })
Cosmic.getObjectType(this.state.config, { type_slug: 'appointments' }, (err, response) =>
err ? this.handleFetchError(err) : this.handleFetch(response)
)}
}
)
)
this.setState({ selectedRows: [], deleteButtonDisabled: true})
}
// ./src/Components/App.js
import { Component } from 'react'
import injectTapEventPlugin from 'react-tap-event-plugin'
import axios from 'axios'
import async from 'async'
import _ from 'lodash'
import moment from 'moment'
import Cosmic from 'cosmicjs'
import AppBar from 'material-ui/AppBar'
import FlatButton from 'material-ui/FlatButton'
import RaisedButton from 'material-ui/RaisedButton'
import SnackBar from 'material-ui/SnackBar'
import DropDownMenu from 'material-ui/DropDownMenu'
import MenuItem from 'material-ui/MenuItem'
import DatePicker from 'material-ui/DatePicker'
import {
Toolbar,
ToolbarGroup
} from 'material-ui/Toolbar'
import {
Table,
TableBody,
TableHeader,
TableHeaderColumn,
TableRow,
TableRowColumn,
} from 'material-ui/Table';
injectTapEventPlugin()
export default class App extends Component {
constructor(props) {
super(props)
this.state = {
config: props.config,
snackbarDisabled: false,
snackbarMessage: 'Loading...',
toolbarDropdownValue: 1,
appointments: {},
filteredAppointments: {},
datePickerDisabled: true,
selectedRows: [],
deleteButtonDisabled: true,
allRowsSelected: false
}
this.handleFetchError = this.handleFetchError.bind(this)
this.handleFetch = this.handleFetch.bind(this)
this.handleRowSelection = this.handleRowSelection.bind(this)
this.handleToolbarDropdownChange = this.handleToolbarDropdownChange.bind(this)
this.handleDelete = this.handleDelete.bind(this)
this.checkDisableDate = this.checkDisableDate.bind(this)
this.setTableChildren = this.setTableChildren.bind(this)
}
handleFetchError(err) {
console.log(err)
this.setState({ snackbarMessage: 'Error loading data' })
}
handleFetch(response) {
const appointments = response.objects.all ? response.objects.all.reduce((currentAppointments, appointment) => {
const date = appointment.metadata.date
if (!currentAppointments[date]) currentAppointments[date] = []
const appointmentData = {
slot: appointment.metadata.slot,
name: appointment.title,
email: appointment.metadata.email,
phone: appointment.metadata.phone,
slug: appointment.slug
}
currentAppointments[date].push(appointmentData)
currentAppointments[date].sort((a,b) => a.slot - b.slot)
return currentAppointments
}, {}) : {}
this.setState({ appointments, snackbarDisabled: true, tableChildren: this.setTableChildren([], appointments) })
}
handleToolbarDropdownChange(val) {
//0: filter by date, 1: list all
val ? this.setState({ filteredAppointments: {}, datePickerDisabled: true, toolbarDropdownValue: 1 }) : this.setState({ toolbarDropdownValue: 0, datePickerDisabled: false })
}
handleRowSelection(rowsToSelect) {
const allRows = [...Array(this.state.tableChildren.length).keys()]
const allRowsSelected = rowsToSelect === 'all'
const selectedRows = Array.isArray(rowsToSelect) ? rowsToSelect : allRowsSelected ? allRows : []
const appointments = _.isEmpty(this.state.filteredAppointments) ? this.state.appointments : this.state.filteredAppointments
const deleteButtonDisabled = selectedRows.length == 0
const tableChildren = allRowsSelected ? this.setTableChildren([], appointments) : this.setTableChildren(selectedRows, appointments)
this.setState({ selectedRows, deleteButtonDisabled, tableChildren })
}
handleDelete(selectedRows) {
const { config } = this.state
return selectedRows.map(row => {
const { tableChildren, appointments } = this.state
const date = moment(tableChildren[row].props.children[4].props.children, 'M-D-YYYY').format('YYYY-DD-MM')
const slot = moment(tableChildren[row].props.children[5].props.children, 'h:mm a').diff(moment().hours(9).minutes(0).seconds(0), 'hours') + 1
return _.find(appointments[date], appointment =>
appointment.slot === slot
)
}).map(appointment => appointment.slug).forEach(slug =>
Cosmic.deleteObject(config, { slug, write_key: config.bucket.write_key }, (err, response) => {
if (err) {
console.log(err)
this.setState({ snackbarDisabled: false, snackbarMessage: 'Failed to delete appointments' })
} else {
this.setState({ snackbarMessage: 'Loading...', snackbarDisabled: false })
Cosmic.getObjectType(this.state.config, { type_slug: 'appointments' }, (err, response) =>
err ? this.handleFetchError(err) : this.handleFetch(response)
)}
}
)
)
this.setState({ selectedRows: [], deleteButtonDisabled: true})
}
checkDisableDate(day) {
return !this.state.appointments[moment(day).format('YYYY-DD-MM')]
}
filterAppointments(date) {
const dateString = moment(date).format('YYYY-DD-MM')
const filteredAppointments = {}
filteredAppointments[dateString] = this.state.appointments[dateString]
this.setState({ filteredAppointments, tableChildren: this.setTableChildren([], filteredAppointments) })
}
setTableChildren(selectedRows = this.state.selectedRows, appointments = this.state.appointments) {
const renderAppointment = (date, appointment, index) => {
const { name, email, phone, slot } = appointment
const rowSelected = selectedRows.includes(index)
return <TableRow key={index} selected={rowSelected}>
<TableRowColumn>{index}</TableRowColumn>
<TableRowColumn>{name}</TableRowColumn>
<TableRowColumn>{email}</TableRowColumn>
<TableRowColumn>{phone}</TableRowColumn>
<TableRowColumn>{moment(date, 'YYYY-DD-MM').format('M[/]D[/]YYYY')}</TableRowColumn>
<TableRowColumn>{moment().hour(9).minute(0).add(slot, 'hours').format('h:mm a')}</TableRowColumn>
</TableRow>
}
const appointmentsAreFiltered = !_.isEmpty(this.state.filteredAppointments)
const schedule = appointmentsAreFiltered ? this.state.filteredAppointments : appointments
const els = []
let counter = 0
appointmentsAreFiltered ?
Object.keys(schedule).forEach(date => {
schedule[date].forEach((appointment, index) => els.push(renderAppointment(date, appointment, index)))
}) :
Object.keys(schedule).sort((a,b) => moment(a, 'YYYY-DD-MM').isBefore(moment(b, 'YYYY-MM-DD')))
.forEach((date, index) => {
schedule[date].forEach(appointment => {
els.push(renderAppointment(date, appointment, counter))
counter++
})
})
return els
}
componentWillMount() {
Cosmic.getObjectType(this.state.config, { type_slug: 'appointments' }, (err, response) =>
err ? this.handleFetchError(err) : this.handleFetch(response)
)
}
render() {
const { snackbarDisabled, appointments, datePickerDisabled, deleteButtonDisabled, ...data } = this.state
return (
<div style={{ fontFamily: 'Roboto' }}>
<AppBar
showMenuIconButton={false}
title="Appointment Manager"/>
<SnackBar
message={data.snackbarMessage}
open={!snackbarDisabled} />
<Toolbar>
<ToolbarGroup firstChild={true}>
<DropDownMenu
value={data.toolbarDropdownValue}
onChange={(evt, key, val) => this.handleToolbarDropdownChange(val)}>
<MenuItem value={0} primaryText="Filter Appointments By Date" />
<MenuItem value={1} primaryText="List All Appointments" />
</DropDownMenu>
<DatePicker
hintText="Select a date"
autoOk={true}
disabled={datePickerDisabled}
name="date-select"
onChange={(n, date) => this.filterAppointments(date)}
shouldDisableDate={(day) => this.checkDisableDate(day)} />
</ToolbarGroup>
<ToolbarGroup lastChild={true}>
<RaisedButton
primary={true}
onClick={() => this.handleDelete(data.selectedRows)}
disabled={deleteButtonDisabled}
label={`Delete Selected ${data.selectedRows.length ? '(' + data.selectedRows.length + ')' : ''}`} />
</ToolbarGroup>
</Toolbar>
<Table
onRowSelection={rowsToSelect => this.handleRowSelection(rowsToSelect)}
multiSelectable={true} >
<TableHeader>
<TableRow>
<TableHeaderColumn>ID</TableHeaderColumn>
<TableHeaderColumn>Name</TableHeaderColumn>
<TableHeaderColumn>Email</TableHeaderColumn>
<TableHeaderColumn>Phone</TableHeaderColumn>
<TableHeaderColumn>Date</TableHeaderColumn>
<TableHeaderColumn>Time</TableHeaderColumn>
</TableRow>
</TableHeader>
<TableBody
children={data.tableChildren}
allRowsSelected={data.allRowsSelected}>
</TableBody>
</Table>
</div>
)
}
}
// appointment-scheduler-extension/dist/extension.json
{
"title": "Appointment Manager",
"font_awesome_class": "fa-calendar",
"image_url": ""
}
Then, compress dist, upload it to Cosmic, and we're ready to start managing appointments.
Using Cosmic JS, Twilio, Express, and React, we’ve built a modular, easy to extend appointment scheduler to both give others easy access to our time while saving more of it for ourselves. The speed at which we’re able to get our app deployed and the simplicity of managing our data reinforces that it was an obvious choice to use Cosmic JS both for CMS and deployment. Although our appointment scheduler will definitely save us time in the future, it’s a sure thing that it can never compete with the time Cosmic will save us on future projects.
Matt Cain builds smart web applications and writes about the tech used to build them. You can learn more about him on his .