├── src ├── cache │ └── cache.txt ├── test │ ├── mocha.opts │ ├── connection.js │ ├── save-event.js │ ├── update-event.js │ ├── save-user.js │ ├── search-event.js │ ├── delete.js │ └── user-book.js ├── views │ ├── partials │ │ ├── footer.ejs │ │ ├── event-view-cards.ejs │ │ ├── html-scripts.ejs │ │ ├── html-head.ejs │ │ ├── event-edit-cards.ejs │ │ ├── event-preview.ejs │ │ └── navbar.ejs │ ├── error_views │ │ ├── auth-error.ejs │ │ ├── event-not-found.ejs │ │ └── 404-error.ejs │ ├── index.ejs │ ├── pre-register.ejs │ ├── profile.ejs │ ├── activity-log.ejs │ ├── search.ejs │ ├── sign-in.ejs │ ├── booked.ejs │ ├── manage.ejs │ ├── register.ejs │ ├── update-profile.ejs │ ├── checkout.ejs │ ├── create.ejs │ ├── event-page.ejs │ └── update-event.ejs ├── models │ ├── counter.js │ ├── event.js │ └── user.js ├── public │ ├── register-form.js │ ├── event-page.js │ ├── delete-event.js │ ├── update-profile.js │ ├── checkout.js │ ├── style.css │ ├── create-form.js │ └── update-form.js ├── routes │ ├── common │ │ ├── authCheck.js │ │ └── eventParser.js │ ├── home │ │ ├── sign-in.js │ │ ├── booked.js │ │ ├── manage.js │ │ ├── index.js │ │ ├── search.js │ │ └── register.js │ ├── event │ │ ├── delete.js │ │ ├── create.js │ │ ├── book.js │ │ ├── update.js │ │ └── index.js │ └── user │ │ └── index.js └── app.js ├── Procfile ├── .gitignore ├── LICENSE ├── package.json └── README.md /src/cache/cache.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: npm run serve -------------------------------------------------------------------------------- /src/test/mocha.opts: -------------------------------------------------------------------------------- 1 | --exit 2 | --require babel-register 3 | --timeout 7000 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .eslintrc.json 3 | .vscode 4 | src/cache/* 5 | !src/cache/cache.txt 6 | .env 7 | dist 8 | -------------------------------------------------------------------------------- /src/views/partials/footer.ejs: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/models/counter.js: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | const Schema = mongoose.Schema; 3 | 4 | const counterSchema = new Schema({ 5 | _id: String, 6 | value: Number 7 | }); 8 | 9 | const Counter = mongoose.model('Counter', counterSchema); 10 | export default Counter; -------------------------------------------------------------------------------- /src/views/partials/event-view-cards.ejs: -------------------------------------------------------------------------------- 1 |
2 | <% for (let event of events) { %> 3 |
4 |
5 |
6 | 7 | <%= event.eventName %> 8 | 9 |
10 | <%- include event-preview %> 11 |
12 |
13 | <% } %> 14 |
-------------------------------------------------------------------------------- /src/test/connection.js: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | mongoose.Promise = global.Promise; 3 | 4 | const dbuser = 'test'; 5 | const dbpassword = 'test'; 6 | before(done => { 7 | mongoose.connect(`mongodb://${dbuser}:${dbpassword}@ds233769.mlab.com:33769/dev-event`); 8 | mongoose.connection 9 | .once('open', () => { 10 | console.log('Connected to mongoDB...'); 11 | done(); 12 | }) 13 | .on('error', err => console.log(err)); 14 | }); 15 | -------------------------------------------------------------------------------- /src/public/register-form.js: -------------------------------------------------------------------------------- 1 | // check matching password 2 | const pw = $('#password')[0]; 3 | const confPw = $('#confPassword')[0]; 4 | pw.onchange = () => checkPw(); 5 | confPw.onchange = () => checkPw(); 6 | 7 | function checkPw() { 8 | const pwValue = pw.value; 9 | const confPwValue = confPw.value; 10 | if (pwValue !== confPwValue) { 11 | confPw.setCustomValidity('Password does not match.'); 12 | } else { 13 | confPw.setCustomValidity(''); 14 | } 15 | } 16 | 17 | -------------------------------------------------------------------------------- /src/views/error_views/auth-error.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Authentication Error 8 | 14 | 15 | 16 |
<%= error %>
17 | Go back 18 | 19 | -------------------------------------------------------------------------------- /src/views/error_views/event-not-found.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Event Not Found 8 | 14 | 15 | 16 |
<%= error %>
17 | Go back 18 | 19 | -------------------------------------------------------------------------------- /src/views/error_views/404-error.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 404 Page not found 8 | 16 | 17 | 18 |

404 - Not Found

19 | Go back 20 | 21 | -------------------------------------------------------------------------------- /src/views/partials/html-scripts.ejs: -------------------------------------------------------------------------------- 1 | 3 | 5 | -------------------------------------------------------------------------------- /src/routes/common/authCheck.js: -------------------------------------------------------------------------------- 1 | let err = new Error('You are not authorized.'); 2 | err.status = 401; 3 | 4 | export function isSignedIn(req, res, next) { 5 | if (res.locals.options.type) return next(); 6 | // Not authorized 7 | return res.status(err.status).render('error_views/auth-error', { 8 | error: err.message, 9 | link: '/' 10 | }); 11 | } 12 | 13 | export function isStaff(req, res, next) { 14 | if (res.locals.options.type === 'staff') return next(); 15 | // Not authorized 16 | return res.status(err.status).render('error_views/auth-error', { 17 | error: err.message, 18 | link: '/' 19 | }); 20 | } -------------------------------------------------------------------------------- /src/views/partials/html-head.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 7 | 9 | 10 | -------------------------------------------------------------------------------- /src/routes/common/eventParser.js: -------------------------------------------------------------------------------- 1 | import moment from 'moment'; 2 | 3 | export function parseEvents(inputEvents) { 4 | let outputEvents = inputEvents.map(parseEvent); 5 | return outputEvents; 6 | } 7 | 8 | export function parseEvent(inputEvent) { 9 | let event = inputEvent.toObject(); 10 | if (!event.price) { 11 | event.price = 'Free'; 12 | } else { 13 | event.price = '$' + event.price; 14 | } 15 | if (!event.promoCode) { 16 | event.promoCode = null; 17 | } 18 | let fromDate = moment(event.startDate).format('ddd D MMM YYYY, hh:mm A'); 19 | let toDate = moment(event.endDate).format('hh:mm A'); 20 | event.durationString = `${fromDate} to ${toDate}`; 21 | return event; 22 | } -------------------------------------------------------------------------------- /src/views/partials/event-edit-cards.ejs: -------------------------------------------------------------------------------- 1 |
2 | <% for (let event of events) { %> 3 |
4 |
5 |
6 | 7 | <%= event.eventName %> 8 | 9 | Edit 10 | 11 |
12 | <%- include event-preview %> 13 |
14 |
15 | <% } %> 16 |
-------------------------------------------------------------------------------- /src/views/index.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Event Booking System 6 | <%- include partials/html-head %> 7 | 8 | 9 | 10 | <%- include partials/navbar %> 11 | 12 | 13 |
14 |
Upcoming Events
15 | 16 | <%- include partials/event-view-cards %> 17 |
18 | 19 | 20 | <%- include partials/footer %> 21 | 22 | 24 | 25 | <%- include partials/html-scripts %> 26 | 27 | 28 | -------------------------------------------------------------------------------- /src/views/partials/event-preview.ejs: -------------------------------------------------------------------------------- 1 |
2 |

3 | <%= event.summary %> 4 |

5 |
6 |
7 | 8 | 9 | <%= event.durationString %> 10 | 11 | 12 |
13 |
14 | 15 | 16 | <%= event.address %> 17 | 18 | 19 |
20 |
21 | 22 | 23 | <%= event.price %> 24 | 25 | 26 |
27 | View Details 28 |
29 |
-------------------------------------------------------------------------------- /src/routes/home/sign-in.js: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import User from '../../models/user'; 3 | 4 | const router = express.Router(); 5 | 6 | router.get('/sign-in', (req, res) => { 7 | res.locals.options.page = 'sign-in'; 8 | res.render('sign-in', res.locals.options); 9 | }); 10 | 11 | router.post('/sign-in', (req, res) => { 12 | const { username, password } = req.body; 13 | User.authenticate(username, password, function (err, user) { 14 | if (err) { 15 | return res.status(err.status).render('error_views/auth-error', 16 | { error: err.message, link: '/sign-in' }); 17 | } 18 | req.session.username = user.username; 19 | if (req.body.remember_me) { 20 | req.session.cookie.maxAge = 14 * 24 * 3600 * 1000; // 2 wks 21 | } else { 22 | req.session.cookie.maxAge = 24 * 3600 * 1000; // 1 day 23 | } 24 | return res.redirect('/'); 25 | }); 26 | }); 27 | 28 | export default router; -------------------------------------------------------------------------------- /src/routes/home/booked.js: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import User from '../../models/user'; 3 | import Event from '../../models/event'; 4 | import { parseEvents } from '../common/eventParser'; 5 | import { isSignedIn } from '../common/authCheck'; 6 | 7 | const router = express.Router(); 8 | 9 | router.get('/booked-events', isSignedIn, async (req, res, next) => { 10 | try { 11 | let user = await User.findOne({ 12 | username: res.locals.options.username 13 | }); 14 | 15 | let events = await Event.find({ 16 | eventId: { 17 | $in: user.eventsBooked 18 | }, 19 | endDate: { 20 | $gt: new Date() 21 | } 22 | }).sort({ 23 | startDate: 1 24 | }); 25 | 26 | res.locals.options.events = parseEvents(events); 27 | 28 | res.locals.options.page = 'booked-events'; 29 | res.render('booked', res.locals.options); 30 | } catch (err) { 31 | next(err); 32 | } 33 | }); 34 | 35 | export default router; -------------------------------------------------------------------------------- /src/public/event-page.js: -------------------------------------------------------------------------------- 1 | let button = $('#btn-book'); 2 | let eventId = Number(button.data('eventid')); 3 | 4 | let data = { 5 | eventId, 6 | }; 7 | 8 | if (button.data('status') === 'book-in') { 9 | data.type = 'book-in'; 10 | } else { 11 | data.type = 'cancel'; 12 | } 13 | 14 | if (button.data('price') !== 'Free' && data.type === 'book-in') { 15 | button.click(() => { 16 | window.location.href = `/event/id/${eventId}/checkout`; 17 | }); 18 | } else { 19 | button.click(() => { 20 | button.attr('disabled', true); 21 | $.ajax({ 22 | type: 'POST', 23 | url: '/event/book', 24 | data: JSON.stringify(data), 25 | contentType: 'application/json', 26 | success: data => { 27 | if (!data.error) { 28 | window.location.reload(); 29 | } else { 30 | $('#event-modal').on('hide.bs.modal', () => { 31 | window.location.reload(); 32 | }); 33 | $('#event-modal .modal-body p').text(data.error.message); 34 | $('#event-modal').modal('show'); 35 | } 36 | }, 37 | }); 38 | }); 39 | } -------------------------------------------------------------------------------- /src/routes/home/manage.js: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import Event from '../../models/event'; 3 | import { parseEvents } from '../common/eventParser'; 4 | import { isStaff } from '../common/authCheck'; 5 | 6 | const router = express.Router(); 7 | 8 | router.get('/manage-events', isStaff, async (req, res) => { 9 | let events, filter_type; 10 | switch (req.query.filter_type) { 11 | case 'past': 12 | events = await Event.find({}) 13 | .where('startDate').lt(new Date()) 14 | .sort('startDate'); 15 | filter_type = 'past'; 16 | break; 17 | case 'upcoming': 18 | events = await Event.find({}) 19 | .where('startDate').gt(new Date()) 20 | .sort('startDate'); 21 | filter_type = 'upcoming'; 22 | break; 23 | default: 24 | events = await Event.find({}).sort('startDate'); 25 | filter_type = 'all'; 26 | } 27 | res.locals.options.events = parseEvents(events); 28 | res.locals.options.page = 'manage-events'; 29 | res.locals.options.filter_type = filter_type; 30 | res.render('manage', res.locals.options); 31 | }); 32 | 33 | export default router; -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Hieu C. Chu 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/public/delete-event.js: -------------------------------------------------------------------------------- 1 | let footer = $('.modal-footer'); 2 | $('.toggle').click(function () { 3 | let toggle = $(this); 4 | $('.delete').click(function () { 5 | $('#deleteModal').on('hide.bs.modal', e => { 6 | e.preventDefault(); 7 | }); 8 | 9 | let text = 'Deleting event'; 10 | let stopper = text + '...'; 11 | let body = $('.modal-body'); 12 | body.text(text); 13 | let loading = setInterval(() => { 14 | (body.text() === stopper) 15 | ? body.text(text) 16 | : body.append('.'); 17 | }, 300); 18 | $.ajax({ 19 | type: 'DELETE', 20 | url: toggle.data('href'), 21 | success: event => { 22 | clearInterval(loading); 23 | footer.empty(); 24 | footer.html(`Back to ${event.page}`); 25 | body.text('This event has been succesfully deleted.'); 26 | $('#deleteModal').on('hidden.bs.modal', () => { 27 | window.location.href = event.redirect; 28 | }); 29 | $('#deleteModal').off('hide.bs.modal'); 30 | }, 31 | error: err => { 32 | console.log(err); 33 | }, 34 | }); 35 | }); 36 | }); -------------------------------------------------------------------------------- /src/routes/home/index.js: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import Event from '../../models/event'; 3 | import registerRouter from './register'; 4 | import signInRouter from './sign-in'; 5 | import searchRouter from './search'; 6 | import bookedRouter from './booked'; 7 | import manageRouter from './manage'; 8 | import { parseEvents } from '../common/eventParser'; 9 | import { isSignedIn } from '../common/authCheck'; 10 | 11 | const router = express.Router(); 12 | 13 | router.use('/', registerRouter); 14 | router.use('/', signInRouter); 15 | router.use('/', searchRouter); 16 | router.use('/', bookedRouter); 17 | router.use('/', manageRouter); 18 | 19 | router.get('/', (req, res) => { 20 | Event.find({}) 21 | .where('startDate').gt(new Date()) 22 | .sort('startDate') 23 | .then(result => { 24 | let events = parseEvents(result); 25 | res.locals.options.page = 'home'; 26 | res.locals.options.events = events; 27 | res.render('index', res.locals.options); 28 | }); 29 | }); 30 | 31 | router.get('/sign-out', isSignedIn, (req, res, next) => { 32 | if (res.locals.options.username) { 33 | req.session.destroy(err => { 34 | if (err) return next(err); 35 | return res.redirect('/'); 36 | }); 37 | } 38 | }); 39 | 40 | export default router; -------------------------------------------------------------------------------- /src/routes/event/delete.js: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import Event from '../../models/event'; 3 | import User from '../../models/user'; 4 | import { isStaff } from '../common/authCheck'; 5 | 6 | const router = express.Router(); 7 | 8 | router.delete('/id/:eventID', isStaff, (req, res) => { 9 | Event.findOneAndRemove({ eventId: req.params.eventID }).then(result => { 10 | if (!result) { 11 | return res.send({ 12 | message: 'There is no event with id ' + req.params.eventID 13 | }); 14 | } 15 | const redirect = '/manage-events'; 16 | const page = 'Manage Events'; 17 | 18 | User.findOneAndUpdate( 19 | { username: res.locals.options.username }, 20 | { 21 | $push: { 22 | history: { 23 | action: `Deleted event ${result.eventName}`, 24 | time: Date.now() 25 | } 26 | } 27 | } 28 | ).then(() => res.send({ 29 | message: 'Event with an id of ' + result.eventId + ' has been removed.', 30 | redirect, 31 | page 32 | })); 33 | }).catch(err => { 34 | console.log(err); 35 | return res.send({ 36 | message: 'There is no event with id ' + req.params.eventID 37 | }); 38 | }); 39 | }); 40 | 41 | export default router; -------------------------------------------------------------------------------- /src/views/pre-register.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Register - Event Booking System 6 | <%- include partials/html-head %> 7 | 8 | 9 | 10 | <%- include partials/navbar %> 11 | 12 | 13 |
14 |
15 |
16 | 17 |
18 |
19 |
Register As
20 | Student 21 | Staff 22 |
23 |
24 | 25 |
26 |
27 |
28 | 29 | 30 | <%- include partials/footer %> 31 | 32 | 34 | 35 | <%- include partials/html-scripts %> 36 | 37 | 38 | -------------------------------------------------------------------------------- /src/public/update-profile.js: -------------------------------------------------------------------------------- 1 | // check matching password 2 | const pw = $('#password')[0]; 3 | const confPw = $('#confPassword')[0]; 4 | const email = $('#email')[0]; 5 | 6 | pw.oninput = () => checkPw(); 7 | confPw.oninput = () => checkPw(); 8 | 9 | function checkPw() { 10 | const pwValue = pw.value; 11 | const confPwValue = confPw.value; 12 | if (pwValue !== confPwValue && confPwValue !== '') { 13 | confPw.setCustomValidity('Password does not match.'); 14 | } else { 15 | confPw.setCustomValidity(''); 16 | } 17 | } 18 | 19 | // form submit handler 20 | let form = $('form').eq(1); 21 | let url = '/user/' + form.data('username'); 22 | form.submit(e => { 23 | $('.btn-update').attr('disabled', true); 24 | e.preventDefault(); 25 | let data = { 26 | email: email.value, 27 | password: pw.value, 28 | }; 29 | $.ajax({ 30 | type: 'PUT', 31 | url, 32 | data: JSON.stringify(data), 33 | contentType: 'application/json', 34 | success: data => { 35 | if (!data.error) { 36 | window.location.replace(url); 37 | } else { 38 | $('#user-modal').on('hide.bs.modal', () => { 39 | $('.btn-update').attr('disabled', false); 40 | }); 41 | $('#user-modal .modal-body p').text(data.error.message); 42 | $('#user-modal').modal('show'); 43 | } 44 | }, 45 | }); 46 | }); 47 | 48 | -------------------------------------------------------------------------------- /src/views/profile.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | <%= username %>'s Profile - Event Booking System 7 | <%- include partials/html-head %> 8 | 9 | 10 | 11 | <%- include partials/navbar %> 12 | 13 | 14 |
15 |
16 |
Account Details
17 | Edit 18 |
19 |
20 |
21 |
Username: 22 | <%= username %> 23 |
24 |
Email: 25 | <%= email %> 26 |
27 |
28 | Account Type: 29 | <%= type[0].toUpperCase() + type.slice(1) %> 30 |
31 |
32 |
33 |
34 | 35 | 36 | <%- include partials/footer %> 37 | 38 | 40 | 41 | <%- include partials/html-scripts %> 42 | 43 | 44 | -------------------------------------------------------------------------------- /src/views/activity-log.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Activity Log - Event Booking System 6 | <%- include partials/html-head %> 7 | 8 | 9 | 10 | <%- include partials/navbar %> 11 | 12 | 13 |
14 |
Activity Log
15 |
16 |
17 |
    18 | <% const reversedHistory = history.slice().reverse(); %> 19 | <% for (activity of reversedHistory) { %> 20 | 28 | <% } %> 29 |
30 |
31 |
32 |
33 | 34 | 35 | <%- include partials/footer %> 36 | 37 | 39 | 40 | <%- include partials/html-scripts %> 41 | 42 | 43 | -------------------------------------------------------------------------------- /src/routes/event/create.js: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import Event from '../../models/event'; 3 | import User from '../../models/user'; 4 | import { isStaff } from '../common/authCheck'; 5 | 6 | const router = express.Router(); 7 | 8 | router.get('/', isStaff, (req, res) => { 9 | res.locals.options.page = 'create'; 10 | res.render('create', res.locals.options); 11 | }); 12 | 13 | router.post('/', isStaff, (req, res) => { 14 | let { eventName, summary, address, startDate, endDate, fullDesc, capacity, promoCode, discount, price } = req.body; 15 | startDate = new Date(startDate); 16 | endDate = new Date(endDate); 17 | 18 | Event.create({ 19 | eventName, 20 | summary, 21 | address, 22 | startDate, 23 | endDate, 24 | fullDesc, 25 | capacity, 26 | promoCode, 27 | discount, 28 | price, 29 | }).then(event => { 30 | User.findOneAndUpdate( 31 | { username: res.locals.options.username }, 32 | { 33 | $push: { 34 | history: { 35 | action: `Created event ${event.eventName}`, 36 | time: Date.now() 37 | } 38 | } 39 | } 40 | ).then(() => res.status(201).json({ id: event.eventId })); 41 | }).catch(error => { 42 | console.log(error); 43 | res.status(500).json({ message: 'Error when creating event' }); 44 | }); 45 | }); 46 | 47 | export default router; -------------------------------------------------------------------------------- /src/models/event.js: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | import Counter from './counter'; 3 | const Schema = mongoose.Schema; 4 | 5 | const eventSchema = new Schema({ 6 | eventId: { 7 | type: Number 8 | }, 9 | eventName: { 10 | type: String, 11 | required: true 12 | }, 13 | summary: { 14 | type: String, 15 | required: true 16 | }, 17 | address: { 18 | type: String, 19 | required: true 20 | }, 21 | startDate: { 22 | type: Date, 23 | required: true 24 | }, 25 | endDate: { 26 | type: Date, 27 | required: true 28 | }, 29 | fullDesc: { 30 | type: String, 31 | required: true 32 | }, 33 | capacity: { 34 | type: Number, 35 | required: true 36 | }, 37 | currentBookings: { 38 | type: Number, 39 | default: 0 40 | }, 41 | promoCode: String, 42 | discount: Number, 43 | price: Number 44 | }); 45 | 46 | eventSchema.index({eventName: 'text', summary: 'text'}); 47 | 48 | eventSchema.pre('save', function (next) { 49 | var event = this; 50 | if (!event.isNew) { 51 | next(); 52 | } 53 | Counter.count({}).then(count => { 54 | if (count === 0) { 55 | Counter.create({ 56 | _id: 'entity', 57 | value: 1000 58 | }).then(result => { 59 | event.eventId = result.value; 60 | next(); 61 | }); 62 | } else { 63 | Counter.findOneAndUpdate( 64 | { _id: 'entity' }, 65 | { $inc: { value: 1 } }, 66 | { new: true }).then(result => { 67 | event.eventId = result.value; 68 | next(); 69 | }); 70 | } 71 | }); 72 | }); 73 | 74 | const Event = mongoose.model('Event', eventSchema); 75 | export default Event; -------------------------------------------------------------------------------- /src/views/search.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Event Booking System 6 | <%- include partials/html-head %> 7 | 8 | 9 | 10 | <%- include partials/navbar %> 11 | 12 | 13 |
14 |
Search results
15 | <% if (searchString) { %> 16 | Showing results for "<%= searchString %>" 17 | <% } else { %> 18 | Showing all results 19 | <% } %> 20 | 21 |
22 | 23 | 24 | 29 |
30 | 31 | <%- include partials/event-view-cards %> 32 |
33 | 34 | 35 | <%- include partials/footer %> 36 | 37 | 39 | 40 | <%- include partials/html-scripts %> 41 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /src/models/user.js: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | import bcrypt from 'bcryptjs'; 3 | const Schema = mongoose.Schema; 4 | 5 | const userSchema = new Schema({ 6 | username: { 7 | type: String, 8 | required: true, 9 | unique: true, 10 | trim: true, 11 | }, 12 | email: { 13 | type: String, 14 | required: true, 15 | unique: true, 16 | trim: true, 17 | }, 18 | password: { 19 | type: String, 20 | required: true, 21 | }, 22 | userType: { 23 | type: String, 24 | enum: ['student', 'staff'], 25 | }, 26 | eventsBooked: [Number], 27 | history: [ 28 | { 29 | action: String, 30 | time: Date 31 | } 32 | ] 33 | }); 34 | 35 | userSchema.pre('save', function (next) { 36 | if (!this.isModified('password')) { 37 | next(); 38 | } 39 | let user = this; 40 | bcrypt.hash(user.password, 10, function (err, hash) { 41 | if (err) return next(err); 42 | user.password = hash; 43 | next(); 44 | }); 45 | }); 46 | 47 | userSchema.statics.authenticate = function (username, password, callback) { 48 | User.findOne({ username: username }) 49 | .then(user => { 50 | if (!user) { 51 | let err = new Error('Username not found.'); 52 | err.status = 401; 53 | return callback(err); 54 | } 55 | // check password 56 | bcrypt.compare(password, user.password, function (error, result) { 57 | if (result) { 58 | return callback(null, user); 59 | } 60 | let err = new Error('Invalid password.'); 61 | err.status = 401; 62 | return callback(err); 63 | }); 64 | }) 65 | .catch(err => { 66 | return callback(err); 67 | }); 68 | 69 | }; 70 | 71 | const User = mongoose.model('User', userSchema); 72 | export default User; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "uow-event", 3 | "version": "1.0.0", 4 | "description": "UOW event booking system", 5 | "main": "app.js", 6 | "directories": { 7 | "test": "src/test" 8 | }, 9 | "scripts": { 10 | "start": "babel-watch src/app.js", 11 | "heroku-postbuild": "npm run build", 12 | "test": "mocha src/test --opts src/test/mocha.opts", 13 | "build": "babel src -d dist --copy-files --no-comments && npm run remove-cache", 14 | "serve": "node dist/app.js", 15 | "remove-cache": "rm -rf dist/cache/* && touch dist/cache/cache.txt" 16 | }, 17 | "babel": { 18 | "presets": [ 19 | "env" 20 | ] 21 | }, 22 | "repository": { 23 | "type": "git", 24 | "url": "git+https://github.com/aaazureee/uow-event.git" 25 | }, 26 | "keywords": [ 27 | "express", 28 | "nodejs", 29 | "restful" 30 | ], 31 | "author": "HCC", 32 | "license": "MIT", 33 | "bugs": { 34 | "url": "https://github.com/aaazureee/uow-event/issues" 35 | }, 36 | "homepage": "https://github.com/aaazureee/uow-event#readme", 37 | "dependencies": { 38 | "babel-polyfill": "^6.26.0", 39 | "bcryptjs": "^2.4.3", 40 | "body-parser": "^1.18.2", 41 | "compression": "^1.7.2", 42 | "connect-mongo": "^2.0.1", 43 | "dotenv": "^5.0.1", 44 | "ejs": "^2.5.8", 45 | "express": "^4.16.3", 46 | "express-http-to-https": "^1.1.4", 47 | "express-minify": "^1.0.0", 48 | "express-session": "^1.15.6", 49 | "moment": "^2.22.0", 50 | "mongoose": "^5.6.0", 51 | "uglify-es": "^3.3.9" 52 | }, 53 | "devDependencies": { 54 | "babel-cli": "^6.26.0", 55 | "babel-core": "^6.26.0", 56 | "babel-preset-env": "^1.6.1", 57 | "babel-register": "^6.26.0", 58 | "babel-watch": "^2.0.7", 59 | "mocha": "^5.0.5" 60 | }, 61 | "engines": { 62 | "npm": "6.0.0", 63 | "node": "8.9.4" 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/test/save-event.js: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | import Event from '../models/event'; 3 | import Counter from '../models/counter'; 4 | import assert from 'assert'; 5 | 6 | describe('Event saving test', () => { 7 | const eventName = 'Sample event 1'; 8 | const summary = 'Sample summary 1'; 9 | const address = 'UOW'; 10 | const startDate = new Date(2018, 3, 14); 11 | const endDate = new Date(2018, 4, 15); 12 | const fullDesc = 'Lorem ipsum dolor sit amet consectetur adipisicing elit. Totam placeat quasi atque, quae dignissimos aliquid harum porro consequuntur magnam voluptate hic laboriosam non cum. Modi consequuntur ipsam aspernatur nam temporibus maiores, sint voluptas? Doloribus, illo ex eum maxime assumenda molestias iste impedit illum, sit possimus sapiente saepe, ab fugiat aliquam.'; 13 | const capacity = 40; 14 | 15 | beforeEach(done => { 16 | mongoose.connection.db.dropCollection('events', () => done()); 17 | }); 18 | 19 | it('Save an event to the database', done => { 20 | // sample event 21 | const eventDetails = { 22 | eventName, summary, address, startDate, endDate, fullDesc, capacity 23 | }; 24 | const event1 = new Event(eventDetails); 25 | const event2 = new Event(eventDetails); 26 | let count = 0; 27 | Counter.findById('entity').then(result => { 28 | count = result.value; // current count; 29 | }).then(() => { 30 | event1.save() 31 | .then(event => { 32 | assert(!event1.isNew); 33 | assert(event.eventId === count + 1); 34 | }) 35 | .then(() => { 36 | event2.save() 37 | .then(event => { 38 | assert(!event2.isNew); 39 | assert(event.eventId === count + 2); 40 | done(); 41 | }); 42 | }); 43 | }); 44 | }); 45 | 46 | after(done => { 47 | mongoose.connection.db.dropCollection('events', () => done()); 48 | }); 49 | 50 | }); 51 | -------------------------------------------------------------------------------- /src/test/update-event.js: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | import Event from '../models/event'; 3 | import assert from 'assert'; 4 | 5 | describe('Event updating test', () => { 6 | const eventName = 'Sample event 1'; 7 | const summary = 'Sample summary 1'; 8 | const address = 'UOW'; 9 | const startDate = new Date(2018, 3, 14); 10 | const endDate = new Date(2018, 4, 15); 11 | const fullDesc = 'Lorem ipsum dolor sit amet consectetur adipisicing elit. Totam placeat quasi atque, quae dignissimos aliquid harum porro consequuntur magnam voluptate hic laboriosam non cum. Modi consequuntur ipsam aspernatur nam temporibus maiores, sint voluptas? Doloribus, illo ex eum maxime assumenda molestias iste impedit illum, sit possimus sapiente saepe, ab fugiat aliquam.'; 12 | const capacity = 40; 13 | 14 | beforeEach(done => { 15 | mongoose.connection.db.dropCollection('events', () => done()); 16 | }); 17 | 18 | it('Update a sample event', done => { 19 | const event = new Event({ 20 | eventName, summary, address, startDate, endDate, fullDesc, capacity 21 | }); 22 | event.save().then(() => { 23 | Event.findByIdAndUpdate( 24 | event._id, 25 | { 26 | eventName: 'Updated name 1', 27 | address: 'UTS', 28 | endDate: new Date(2018, 11, 2), 29 | price: 25, 30 | promoCode: 'UOW50', 31 | discount: 0.5 32 | }, 33 | { new: true } 34 | ) 35 | .then(updated => { 36 | const { eventName, address, endDate, price, promoCode, discount } = updated; 37 | assert(eventName === 'Updated name 1'); 38 | assert(address === 'UTS'); 39 | assert(endDate.getTime() === new Date(2018, 11, 2).getTime()); 40 | assert(price === 25); 41 | assert(promoCode === 'UOW50'); 42 | assert(discount === 0.5); 43 | done(); 44 | }); 45 | }); 46 | }); 47 | 48 | after(done => { 49 | mongoose.connection.db.dropCollection('events', () => done()); 50 | }); 51 | 52 | }); 53 | -------------------------------------------------------------------------------- /src/routes/user/index.js: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import moment from 'moment'; 3 | import User from '../../models/user'; 4 | import bcrypt from 'bcryptjs'; 5 | 6 | const router = express.Router(); 7 | 8 | router.use([ 9 | '/:username', '/:username/activity', '/:username/update' 10 | ], (req, res, next) => { 11 | if (res.locals.options.username !== req.params.username) { 12 | return res.status(401).render('error_views/auth-error', { 13 | error: 'You are not authorized.', 14 | link: '/' 15 | }); 16 | } 17 | next(); 18 | }); 19 | 20 | router.get('/:username', (req, res) => { 21 | res.render('profile', res.locals.options); 22 | }); 23 | 24 | // update form 25 | router.get('/:username/update', (req, res) => { 26 | res.render('update-profile', res.locals.options); 27 | }); 28 | 29 | router.put('/:username', async (req, res, next) => { 30 | // different email than original 31 | if (res.locals.options.email !== req.body.email) { 32 | const count = await User.count({ email: req.body.email }); 33 | if (count) { 34 | return res.json({ error: { type: 'duplicateEmail', message: 'This email has already been registered.' } }); 35 | } 36 | } 37 | // populated password field 38 | if (req.body.password.trim()) { 39 | try { 40 | const hash = await bcrypt.hash(req.body.password, 10); 41 | await User.findOneAndUpdate( 42 | { username: res.locals.options.username }, 43 | { email: req.body.email, password: hash } 44 | ); 45 | } catch (err) { 46 | return next(err); 47 | } 48 | } else { 49 | await User.findOneAndUpdate( 50 | { username: res.locals.options.username }, 51 | { email: req.body.email } 52 | ); 53 | } 54 | return res.json({ success: true }); 55 | }); 56 | 57 | router.get('/:username/activity', (req, res) => { 58 | res.locals.options.history.forEach(elem => { 59 | elem.timeString = moment(elem.time).format('ddd D MMM YYYY, hh:mm:ss A'); 60 | }); 61 | return res.render('activity-log', res.locals.options); 62 | }); 63 | 64 | export default router; -------------------------------------------------------------------------------- /src/routes/event/book.js: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import User from '../../models/user'; 3 | import Event from '../../models/event'; 4 | import { isSignedIn } from '../common/authCheck'; 5 | const router = express.Router(); 6 | 7 | router.post('/', isSignedIn, async (req, res, next) => { 8 | try { 9 | let user = await User.findOne({ username: res.locals.options.username }); 10 | let event = await Event.findOne({ eventId: req.body.eventId }); 11 | 12 | if (req.body.type === 'book-in') { 13 | if (!event) { 14 | res.json({ error: { type: 'eventNonExistent', message: 'Event no longer exists.' } }); 15 | } else if (event.currentBookings >= event.capacity) { 16 | res.json({ error: { type: 'eventFull', message: 'Event is already fully booked.' } }); 17 | } else if (event.endDate < new Date()) { 18 | res.json({ error: { type: 'eventEnded', message: 'Event has already ended.' } }); 19 | } else if (user.eventsBooked.includes(event.eventId)) { 20 | res.json({ error: { type: 'alreadyBooked', message: 'You have already booked into this event.' } }); 21 | } else { 22 | //process booking 23 | event.currentBookings += 1; 24 | await event.save(); 25 | user.eventsBooked.push(event.eventId); 26 | user.history.push({ 27 | action: `Booked ${event.eventName}`, 28 | time: Date.now() 29 | }); 30 | await user.save(); 31 | res.json({ success: true }); 32 | } 33 | } else if (req.body.type === 'cancel') { 34 | event.currentBookings -= 1; 35 | await event.save(); 36 | user.eventsBooked = user.eventsBooked.filter(value => value !== event.eventId); 37 | user.history.push({ 38 | action: `Cancelled booking for ${event.eventName}`, 39 | time: Date.now() 40 | }); 41 | await user.save(); 42 | res.json({ success: true }); 43 | } 44 | } catch (err) { 45 | console.log(err); 46 | next(err); 47 | } 48 | }); 49 | 50 | export default router; -------------------------------------------------------------------------------- /src/routes/home/search.js: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import Event from '../../models/event'; 3 | import User from '../../models/user'; 4 | import { parseEvents } from '../common/eventParser'; 5 | 6 | const router = express.Router(); 7 | 8 | router.get('/search', async (req, res) => { 9 | let findOptions = { 10 | $text: { $search: req.query.q } 11 | }; 12 | const regex = new RegExp(req.query.q, 'i'); 13 | 14 | // Filter type 15 | let filter_type; 16 | 17 | switch (req.query.filter_type) { 18 | case 'past': 19 | findOptions.startDate = { $lt: new Date() }; 20 | filter_type = 'past'; 21 | break; 22 | case 'upcoming': 23 | findOptions.startDate = { $gt: new Date() }; 24 | filter_type = 'upcoming'; 25 | break; 26 | default: 27 | filter_type = 'all'; 28 | } 29 | res.locals.options.filter_type = filter_type; 30 | 31 | const [partialSearch, fullSearch] = await Promise.all([ 32 | Event.find({ 33 | $or: [ 34 | { eventName: regex }, 35 | { summary: regex } 36 | ] 37 | }), 38 | Event.find(findOptions) 39 | ]); 40 | const duplicates = [...partialSearch, ...fullSearch]; 41 | const duplicateIds = duplicates.map(result => result._id.toString()); 42 | let results = duplicateIds.filter((id, pos) => 43 | duplicateIds.indexOf(id) === pos 44 | ); // de-duplicate ids 45 | results = results.map(id => 46 | duplicates.find(result => result._id.toString() === id) 47 | ); // map id to event objects 48 | 49 | let events = parseEvents(results); 50 | res.locals.options.events = events; 51 | res.locals.options.searchString = req.query.q; 52 | if (res.locals.options.username) { 53 | User.findOneAndUpdate( 54 | { username: res.locals.options.username }, 55 | { 56 | $push: { 57 | history: { 58 | action: `Searched for ${req.query.q}`, 59 | time: Date.now() 60 | } 61 | } 62 | } 63 | ).then(() => res.render('search', res.locals.options)); 64 | } else { 65 | return res.render('search', res.locals.options); 66 | } 67 | }); 68 | 69 | export default router; -------------------------------------------------------------------------------- /src/public/checkout.js: -------------------------------------------------------------------------------- 1 | let eventId = $('.redeem').data('eventid'); 2 | let data = { 3 | eventId, 4 | type: 'book-in' 5 | }; 6 | 7 | $('#promo').focus(() => { 8 | $('#promo').val(''); 9 | $('.invalid').hide(); 10 | }); 11 | 12 | $('#promo').keypress(function (e) { 13 | if (e.which == 13) { 14 | e.preventDefault(); 15 | $('.redeem').click(); 16 | } 17 | }); 18 | 19 | $('.redeem').click(() => { 20 | $(this).attr('disabled', true); 21 | $.ajax({ 22 | type: 'POST', 23 | data: { 24 | promo: $('#promo').val() 25 | }, 26 | url: `/event/id/${eventId}/promo`, 27 | success: data => { 28 | if (data.valid) { 29 | //TODO 30 | console.log('discount: ' + data.discount); 31 | const discountItem = `
  • 32 |
    33 |
    34 |
    Promo code
    35 | KAPPA123 36 |
    37 | 38 | -${data.discount}% 39 | 40 |
    41 |
  • `; 42 | $('.first').after(discountItem); 43 | const total = Number($('.price').text().trim().replace('$', '')); 44 | const discounted = (total - total * data.discount / 100).toFixed(2); 45 | $('.total').text('$' + discounted); 46 | $('.invalid').hide(); 47 | $('.redeem').attr('disabled', true); 48 | $('#promo').attr('disabled', true); 49 | } else { 50 | $('.invalid').show(); 51 | } 52 | } 53 | }); 54 | }); 55 | 56 | $('form').eq(1).submit(e => { 57 | e.preventDefault(); 58 | $.ajax({ 59 | type: 'POST', 60 | url: '/event/book', 61 | data: JSON.stringify(data), 62 | contentType: 'application/json', 63 | success: data => { 64 | if (!data.error) { 65 | window.location.href = `/event/id/${eventId}`; 66 | } else { 67 | $('#error-modal').on('hide.bs.modal', () => { 68 | window.location.href = `/event/id/${eventId}`; 69 | }); 70 | $('#error-modal .modal-body p').text(data.error.message); 71 | $('#error-modal').modal('show'); 72 | } 73 | }, 74 | }); 75 | }); -------------------------------------------------------------------------------- /src/routes/event/update.js: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import Event from '../../models/event'; 3 | import User from '../../models/user'; 4 | import { parseEvent } from '../common/eventParser'; 5 | import { isStaff } from '../common/authCheck'; 6 | const router = express.Router(); 7 | 8 | router.get('/id/:eventID/', isStaff, (req, res) => { 9 | res.locals.options.page = 'manage-events'; 10 | Event.findOne({ eventId: req.params.eventID }).then(result => { 11 | if (!result) { 12 | return res.status(404).render('error_views/event-not-found', { 13 | error: 'There is no event with id: ' + req.params.eventID, 14 | link: '/' 15 | }); 16 | } 17 | res.locals.options.event = parseEvent(result); 18 | res.render('update-event', res.locals.options); 19 | }).catch(() => { 20 | return res.status(404).render('error_views/event-not-found', { 21 | error: 'There is no event with id: ' + req.params.eventID, 22 | link: '/' 23 | }); 24 | }); 25 | }); 26 | 27 | router.put('/id/:eventID/', isStaff, async (req, res) => { 28 | let { eventName, summary, address, startDate, endDate, fullDesc, capacity, promoCode, discount, price } = req.body; 29 | startDate = new Date(startDate); 30 | endDate = new Date(endDate); 31 | 32 | let event = await Event.findOne({ eventId: req.params.eventID }); 33 | 34 | if (capacity < event.currentBookings) { 35 | return res.json({ error: 'Updated event\'s capacity must be higher than current bookings.' }); 36 | } 37 | 38 | Event.findOneAndUpdate({ eventId: req.params.eventID }, { 39 | eventName, 40 | summary, 41 | address, 42 | startDate, 43 | endDate, 44 | fullDesc, 45 | capacity, 46 | promoCode, 47 | discount, 48 | price, 49 | }).then(event => { 50 | User.findOneAndUpdate( 51 | { username: res.locals.options.username }, 52 | { 53 | $push: { 54 | history: { 55 | action: `Updated event ${event.eventName}`, 56 | time: Date.now() 57 | } 58 | } 59 | } 60 | ).then(() => res.status(201).json({ id: event.eventId })); 61 | }).catch(error => { 62 | console.log(error); 63 | res.status(500).json({ message: 'Error when updating event' }); 64 | }); 65 | }); 66 | 67 | export default router; -------------------------------------------------------------------------------- /src/test/save-user.js: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | import User from '../models/user'; 3 | import assert from 'assert'; 4 | 5 | describe('User saving test', () => { 6 | // sample user 7 | const username = 'aaazureee'; 8 | const email = 'aaazureee@gmail.com'; 9 | const password = '123'; 10 | const eventsBooked = [1000]; 11 | 12 | beforeEach(done => { 13 | mongoose.connection.db.dropCollection('users', () => done()); 14 | }); 15 | 16 | it('Save an user with all accepted attributes', done => { 17 | const user = new User({ 18 | username, email, password, eventsBooked 19 | }); 20 | user.save().then(() => { 21 | assert(!user.isNew); 22 | done(); 23 | }); 24 | }); 25 | 26 | it('Save an user with missing email is not allowed', done => { 27 | const missingEmailUser = new User({ 28 | username, password, eventsBooked 29 | }); 30 | missingEmailUser.save().catch(err => { 31 | assert(err.message); 32 | done(); 33 | }); 34 | }); 35 | 36 | it('Save 2 user with duplicate values (concurrent) is not allowed', done => { 37 | const user1 = { username, email, password, eventsBooked }; 38 | const user2 = Object.assign({}, user1); 39 | user2.email = 'kappa123@gmail.com'; 40 | 41 | User.ensureIndexes(err => { 42 | assert.ifError(err); 43 | User.create([user1, user2]) 44 | .catch(error => { 45 | assert(error); 46 | assert(!error.errors); 47 | assert(error.message.indexOf('duplicate key error') !== -1); 48 | done(); 49 | }); 50 | }); 51 | }); 52 | 53 | it('Save 2 user with duplicate values (sequential) is not allowed', done => { 54 | const user1 = { username, email, password, eventsBooked }; 55 | const user2 = { username, email, password, eventsBooked }; 56 | user2.email = 'kappa123@gmail.com'; 57 | 58 | User.ensureIndexes(() => { 59 | User.create(user1) 60 | .then(() => User.create(user2)) 61 | .then(() => done()) 62 | .catch(error => { 63 | assert(error); 64 | assert(!error.errors); 65 | assert(error.message.indexOf('duplicate key error') !== -1); 66 | User.count({}).then(count => { 67 | assert(count === 1); 68 | done(); 69 | }); 70 | }); 71 | }); 72 | }); 73 | 74 | after(done => { 75 | mongoose.connection.db.dropCollection('users', () => done()); 76 | }); 77 | }); 78 | -------------------------------------------------------------------------------- /src/test/search-event.js: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | import Event from '../models/event'; 3 | import Counter from '../models/counter'; 4 | import assert from 'assert'; 5 | 6 | describe('Event searching test', () => { 7 | const eventName = 'Sample event 1'; 8 | const summary = 'Sample summary 1'; 9 | const address = 'UOW'; 10 | const startDate = new Date(2018, 3, 14); 11 | const endDate = new Date(2018, 4, 15); 12 | const fullDesc = 'Lorem ipsum dolor sit amet consectetur adipisicing elit. Totam placeat quasi atque, quae dignissimos aliquid harum porro consequuntur magnam voluptate hic laboriosam non cum. Modi consequuntur ipsam aspernatur nam temporibus maiores, sint voluptas? Doloribus, illo ex eum maxime assumenda molestias iste impedit illum, sit possimus sapiente saepe, ab fugiat aliquam.'; 13 | const capacity = 40; 14 | 15 | beforeEach(done => { 16 | mongoose.connection.db.dropCollection('events', () => { 17 | done(); 18 | }); 19 | }); 20 | 21 | it('Search for event, using summary keyword', done => { 22 | // sample event 23 | const event = new Event({ 24 | eventName, summary, address, startDate, endDate, fullDesc, capacity 25 | }); 26 | const summarySearch = 'sample sum'; 27 | const regex = new RegExp(summarySearch, 'i'); 28 | event.save().then(() => { 29 | Event.find({ 30 | $or: [ 31 | { summary: regex }, 32 | { fullDesc: regex } 33 | ] 34 | }) 35 | .then(eventArray => { 36 | assert(eventArray[0].summary.toLowerCase().includes(summarySearch.toLowerCase())); 37 | done(); 38 | }); 39 | }); 40 | }); 41 | 42 | it('Search for event, using full description keyword', done => { 43 | // sample event 44 | const event = new Event({ 45 | eventName, summary, address, startDate, endDate, fullDesc, capacity 46 | }); 47 | const fullDescSearch = 'sint vOluPt'; 48 | const regex = new RegExp(fullDescSearch, 'i'); 49 | event.save().then(() => { 50 | Event.find({ 51 | $or: [ 52 | { summary: regex }, 53 | { fullDesc: regex } 54 | ] 55 | }) 56 | .then(eventArray => { 57 | assert(eventArray[0].fullDesc.toLowerCase().includes(fullDescSearch.toLowerCase())); 58 | done(); 59 | }); 60 | }); 61 | }); 62 | 63 | after(done => { 64 | mongoose.connection.db.dropCollection('events', () => { 65 | done(); 66 | }); 67 | }); 68 | 69 | }); 70 | -------------------------------------------------------------------------------- /src/views/sign-in.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Sign In - Event Booking System 6 | <%- include partials/html-head %> 7 | 8 | 9 | 10 | <%- include partials/navbar %> 11 | 12 | 13 |
    14 |
    15 |
    16 | 17 |
    18 |
    19 |
    Sign In
    20 |
    21 |
    22 | 23 | 24 |
    25 | 26 |
    27 | 28 | 29 |
    30 | 31 |
    32 | 33 | 36 |
    37 | 38 | 39 |
    40 |
    41 |
    42 |
    43 |
    44 |
    45 | 46 | 47 | <%- include partials/footer %> 48 | 49 | 51 | 52 | <%- include partials/html-scripts %> 53 | 67 | 68 | 69 | -------------------------------------------------------------------------------- /src/test/delete.js: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | import User from '../models/user'; 3 | import Event from '../models/event'; 4 | import assert from 'assert'; 5 | 6 | describe('Deleting test', () => { 7 | // sample user 8 | const username = 'aaazureee'; 9 | const email = 'aaazureee@gmail.com'; 10 | const password = '123'; 11 | 12 | beforeEach(done => { 13 | const promises = [ 14 | mongoose.connection.db.dropCollection('users'), mongoose.connection.db.dropCollection('events') 15 | ]; 16 | Promise.all(promises).then(() => done()) 17 | .catch(() => done()); 18 | }); 19 | 20 | it('Delete an user from database', done => { 21 | const user = new User({ 22 | username, email, password 23 | }); 24 | user.save().then(() => { 25 | User.findOneAndRemove(user._id) 26 | .then(removed => { 27 | assert(removed._id.equals(user._id)); 28 | }) 29 | .then(() => User.findById(user._id)) 30 | .then(result => { 31 | assert(result === null); 32 | done(); 33 | }); 34 | }); 35 | }); 36 | 37 | it('Delete an event from database', done => { 38 | const eventName = 'Sample event 1'; 39 | const summary = 'Sample summary 1'; 40 | const address = 'UOW'; 41 | const startDate = new Date(2018, 3, 14); 42 | const endDate = new Date(2018, 4, 15); 43 | const fullDesc = 'Lorem ipsum dolor sit amet consectetur adipisicing elit. Totam placeat quasi atque, quae dignissimos aliquid harum porro consequuntur magnam voluptate hic laboriosam non cum. Modi consequuntur ipsam aspernatur nam temporibus maiores, sint voluptas? Doloribus, illo ex eum maxime assumenda molestias iste impedit illum, sit possimus sapiente saepe, ab fugiat aliquam.'; 44 | const capacity = 40; 45 | 46 | const event = new Event({ 47 | eventName, summary, address, startDate, endDate, fullDesc, capacity 48 | }); 49 | event.save().then(() => { 50 | Event.findOneAndRemove(event._id) 51 | .then(removed => { 52 | assert(removed._id.equals(event._id)); 53 | }) 54 | .then(() => Event.findById(event._id)) 55 | .then(result => { 56 | assert(result === null); 57 | done(); 58 | }); 59 | }); 60 | 61 | }); 62 | 63 | after(done => { 64 | const promises = [ 65 | mongoose.connection.db.dropCollection('users'), mongoose.connection.db.dropCollection('events') 66 | ]; 67 | Promise.all(promises).then(() => done()) 68 | .catch(() => done()); 69 | }); 70 | }); 71 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Event Booking System 2 | A web application that simulates an event booking system for my university, using Node.js, Express and MongoDB. The system provides the following functions: 3 | - Event creation and management: Staff-level users can create and launch a new event, or adjust the 4 | price, dates/sessions, promotional codes, capacities, etc. on an existing event. 5 | - Event booking: System users can view the list of events, make a booking, and modify or cancel an existing booking. 6 | - User management: System users can view their profile and activity log (history). Users can also update their personal details such as email and password. 7 | 8 | **Register for a staff account to access all features above.** 9 | 10 | ## Getting started 11 | ### Prerequisites 12 | Download Node.js and npm here: https://nodejs.org/en/ 13 | ### Installation 14 | 1. `npm install` 15 | 2. Create a .env file in root project folder 16 | ``` 17 | SESSION_SECRET=XXXXXX 18 | DB_URI=XXXXXX 19 | ``` 20 | - SESSION_SECRET can be a random string to secure the session. 21 | - DB_URI is the connection string to MongoDB server (check out [MongoDB Atlas](https://www.mongodb.com/cloud/atlas)). Here is an example connection string `mongodb+srv://:@cluster123-tnkmj.gcp.mongodb.net/test`, where `username` and `password` should be substituted with your own user credentials, `test` is the name of the database selected. 22 | 3. `npm start` 23 | 4. Go to http://localhost:3000 to see your app. 24 | 25 | ### Build 26 | `npm run build` and `npm run serve` to run the production version after build. 27 | 28 | ### Testing 29 | The tests are implemented in `src/test` using [Mocha](https://github.com/mochajs/mocha). `npm run test` to run the test files. 30 | 31 | ## Built with 32 | - [Express.js](https://github.com/expressjs/express) - Node.js web application framework 33 | - [mongoose](https://github.com/expressjs/express) - MongoDB object modeling for Node.js 34 | - [ejs](https://github.com/mde/ejs) - Template engine 35 | - [bcryptjs](https://github.com/dcodeIO/bcrypt.js) - Hash user password 36 | - [express-session](https://github.com/expressjs/session) - User session middleware 37 | - [connect-mongo](https://github.com/jdesboeufs/connect-mongo) - Use MongoDB for persistent session store 38 | - [moment](https://github.com/moment/moment) - Formatting and displaying dates and times 39 | - [Mocha](https://github.com/mochajs/mocha) - API and database testing 40 | 41 | ## Authors and contributors 42 | - Hieu Chu 43 | - Long Hung Nguyen 44 | 45 | ## License 46 | Distributed under the MIT License. See [LICENSE](LICENSE) for more information. 47 | -------------------------------------------------------------------------------- /src/routes/home/register.js: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import User from '../../models/user'; 3 | 4 | const router = express.Router(); 5 | 6 | router.use( 7 | ['/register', '/student-register', '/staff-register'], (req, res, next) => { 8 | res.locals.options.page = 'register'; 9 | next(); 10 | }); 11 | 12 | // pre-register page 13 | router.get('/register', (req, res) => { 14 | res.render('pre-register', res.locals.options); 15 | }); 16 | 17 | // student-register form 18 | router.get('/student-register', (req, res) => { 19 | res.locals.options.register_type = 'Student'; 20 | res.render('register', res.locals.options); 21 | }); 22 | 23 | // staff-register form 24 | router.get('/staff-register', (req, res) => { 25 | res.locals.options.register_type = 'Staff'; 26 | res.render('register', res.locals.options); 27 | }); 28 | 29 | const checkRegisterError = async (username, email) => { 30 | let userNameCount = await User.count({ username }); 31 | let emailCount = await User.count({ email }); 32 | let err; 33 | if (userNameCount || emailCount) { 34 | if (userNameCount) { 35 | err = new Error('Username already exists.'); 36 | } else { 37 | err = new Error('Email already exists.'); 38 | } 39 | err.status = 409; 40 | } 41 | return err; 42 | }; 43 | 44 | // handle form submission for student 45 | router.post('/student-register', async (req, res, next) => { 46 | const { username, email, password } = req.body; 47 | const userType = 'student'; 48 | const err = await checkRegisterError(username, email); 49 | if (err) { 50 | return res.status(err.status).render('error_views/auth-error', 51 | { error: err.message, link: '/student-register' }); 52 | } 53 | User.create({ 54 | username, email, password, userType 55 | }) 56 | .then(user => { 57 | req.session.username = user.username; 58 | res.redirect('/'); 59 | }) 60 | .catch(err => { 61 | next(err); 62 | }); 63 | }); 64 | 65 | // handle form submission for staff 66 | router.post('/staff-register', async (req, res, next) => { 67 | const { username, email, password } = req.body; 68 | const userType = 'staff'; 69 | const err = await checkRegisterError(username, email); 70 | if (err) { 71 | return res.status(err.status).render('error_views/auth-error', 72 | { error: err.message, link: '/staff-register' }); 73 | } 74 | User.create({ 75 | username, email, password, userType 76 | }) 77 | .then(user => { 78 | req.session.username = user.username; 79 | res.redirect('/'); 80 | }) 81 | .catch(err => { 82 | next(err); 83 | }); 84 | }); 85 | 86 | 87 | export default router; -------------------------------------------------------------------------------- /src/views/booked.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Booked Events - Event Booking System 6 | <%- include partials/html-head %> 7 | 8 | 9 | 10 | <%- include partials/navbar %> 11 | 12 | 13 |
    14 |
    Booking Reminders
    15 |
    16 |
    17 |
    18 | <% for (event of events) { %> 19 | 20 |
    <%= event.eventName %>
    21 | <%= event.durationString %> 22 |
    23 | <% } %> 24 |
    25 |
    26 |
    27 |
    28 | <% for (event of events) { %> 29 |
    30 |
    31 |
    32 | 33 | <%= event.eventName %> 34 | 35 |
    36 |
    37 |

    38 | <%= event.summary %> 39 |

    40 |
    41 |
    42 | 43 | 44 | <%= event.address %> 45 | 46 | 47 |
    48 | View Details 49 |
    50 |
    51 |
    52 |
    53 | <% } %> 54 |
    55 |
    56 |
    57 | 58 | 59 | <%- include partials/footer %> 60 | 61 | 63 | 64 | <%- include partials/html-scripts %> 65 | 66 | 67 | -------------------------------------------------------------------------------- /src/views/manage.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Manage Events - Event Booking System 6 | <%- include partials/html-head %> 7 | 8 | 9 | 10 | <%- include partials/navbar %> 11 | 12 | 13 |
    14 |
    Manage Events
    15 |
    16 | 17 | 22 |
    23 | <%- include partials/event-edit-cards %> 24 | 25 | 44 | 45 | 46 |
    47 | 48 | 49 | <%- include partials/footer %> 50 | 51 | 53 | 54 | <%- include partials/html-scripts %> 55 | 56 | 62 | 63 | 64 | -------------------------------------------------------------------------------- /src/test/user-book.js: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | import Event from '../models/event'; 3 | import User from '../models/user'; 4 | import assert from 'assert'; 5 | 6 | describe('User booking test', () => { 7 | // sample event 8 | const eventName = 'Sample event 1'; 9 | const summary = 'Sample summary 1'; 10 | const address = 'UOW'; 11 | const startDate = new Date(2018, 3, 14); 12 | const endDate = new Date(2018, 4, 15); 13 | const fullDesc = 'Lorem ipsum dolor sit amet consectetur adipisicing elit. Totam placeat quasi atque, quae dignissimos aliquid harum porro consequuntur magnam voluptate hic laboriosam non cum. Modi consequuntur ipsam aspernatur nam temporibus maiores, sint voluptas? Doloribus, illo ex eum maxime assumenda molestias iste impedit illum, sit possimus sapiente saepe, ab fugiat aliquam.'; 14 | const capacity = 40; 15 | 16 | // sample user 17 | const username = 'aaazureee'; 18 | const email = 'aaazureee@gmail.com'; 19 | const password = '123'; 20 | 21 | beforeEach(done => { 22 | const promises = [ 23 | mongoose.connection.db.dropCollection('users'), mongoose.connection.db.dropCollection('events') 24 | ]; 25 | Promise.all(promises).then(() => done()) 26 | .catch(() => done()); 27 | }); 28 | 29 | it('An user make an event booking', done => { 30 | // sample event 31 | const event = new Event({ 32 | eventName, summary, address, startDate, endDate, fullDesc, capacity 33 | }); 34 | const user = new User({ 35 | username, email, password 36 | }); 37 | let eventId; 38 | event.save().then(() => { 39 | user.save() 40 | .then(() => { 41 | assert(!user.isNew); 42 | // inc current bookings 43 | return Event.findByIdAndUpdate( 44 | event._id, { $inc: { currentBookings: 1 }, }, { new: true } 45 | ); 46 | }) 47 | .then(event => { 48 | eventId = event.eventId; 49 | assert(event.currentBookings === 1); 50 | // push eventId to user bookings 51 | return User.findByIdAndUpdate( 52 | user._id, { $push: { eventsBooked: event.eventId } }, { new: true } 53 | ); 54 | }) 55 | .then(user => { 56 | assert(user.eventsBooked.length === 1); 57 | assert(user.eventsBooked.indexOf(eventId) !== -1); 58 | done(); 59 | }); 60 | }); 61 | }); 62 | 63 | after(done => { 64 | const promises = [ 65 | mongoose.connection.db.dropCollection('users'), mongoose.connection.db.dropCollection('events') 66 | ]; 67 | Promise.all(promises).then(() => done()) 68 | .catch(() => done()); 69 | }); 70 | }); 71 | -------------------------------------------------------------------------------- /src/views/register.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Register As 7 | <%= register_type %> - Event Booking System 8 | 9 | <%- include partials/html-head %> 10 | 11 | 12 | 13 | <%- include partials/navbar %> 14 | 15 |
    16 |
    17 |
    18 | 19 |
    20 |
    21 | 22 | 23 | 24 |
    25 | 26 |
    27 |
    28 | <%= register_type %> Registration 29 |
    30 | <% register_type = register_type[0].toLowerCase() + register_type.slice(1) %> 31 |
    32 |
    33 | 34 | 35 |
    36 | 37 |
    38 | 39 | 40 |
    41 | 42 |
    43 | 44 | 45 |
    46 | 47 |
    48 | 49 | 50 |
    51 | 52 |
    53 |
    54 |
    55 |
    56 |
    57 |
    58 | 59 | 60 | <%- include partials/footer %> 61 | 62 | 64 | 65 | <%- include partials/html-scripts %> 66 | 67 | 68 | 69 | -------------------------------------------------------------------------------- /src/routes/event/index.js: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | const router = express.Router(); 3 | import Event from '../../models/event'; 4 | import User from '../../models/user'; 5 | import createRouter from './create'; 6 | import bookRouter from './book'; 7 | import updateRouter from './update'; 8 | import deleteRouter from './delete'; 9 | import { parseEvent } from '../common/eventParser'; 10 | import { isSignedIn } from '../common/authCheck'; 11 | 12 | router.use('/create', createRouter); 13 | router.use('/book', bookRouter); 14 | router.use('/update', updateRouter); 15 | router.use('/delete', deleteRouter); 16 | 17 | // view middleware 18 | router.get(['/id/:eventID', '/id/:eventID/checkout'], (req, res, next) => { 19 | Event.findOne({ eventId: req.params.eventID }).then(result => { 20 | if (!result) { 21 | return res.status(404).render('error_views/event-not-found', { 22 | error: 'There is no event with id: ' + req.params.eventID, 23 | link: '/' 24 | }); 25 | } 26 | res.locals.options.event = parseEvent(result); 27 | return next(); 28 | }).catch(() => { 29 | return res.status(404).render('error_views/event-not-found', { 30 | error: 'There is no event with id: ' + req.params.eventID, 31 | link: '/' 32 | }); 33 | }); 34 | }); 35 | 36 | router.get('/id/:eventID', async (req, res, next) => { 37 | try { 38 | let event = res.locals.options.event; 39 | 40 | res.locals.options.eventFull = false; 41 | res.locals.options.booked = false; 42 | 43 | let user = await User.findOne({username: res.locals.options.username}); 44 | //when user is logged in 45 | if (user) { 46 | if (user.eventsBooked.includes(event.eventId)) { 47 | res.locals.options.booked = true; 48 | } 49 | } 50 | 51 | if (event.currentBookings >= event.capacity) { 52 | res.locals.options.eventFull = true; 53 | } 54 | 55 | res.render('event-page', res.locals.options); 56 | 57 | } catch (err) { 58 | console.log(err); 59 | next(err); 60 | } 61 | }); 62 | 63 | router.get('/id/:eventID/checkout', isSignedIn, (req, res) => { 64 | if (res.locals.options.event.price !== 'Free') { 65 | return res.render('checkout', res.locals.options); 66 | } 67 | return res.redirect('/event/id/' + req.params.eventID); 68 | }); 69 | 70 | router.post('/id/:eventId/promo', isSignedIn, async (req, res, next) => { 71 | try { 72 | let event = await Event.findOne({ eventId: req.params.eventId }); 73 | if (req.body.promo === event.promoCode) { 74 | res.json({valid: true, discount: event.discount}); 75 | } else { 76 | res.json({valid: false}); 77 | } 78 | } catch (err) { 79 | next(err); 80 | } 81 | }); 82 | export default router; -------------------------------------------------------------------------------- /src/app.js: -------------------------------------------------------------------------------- 1 | import 'babel-polyfill'; 2 | require('dotenv').config(); 3 | import express from 'express'; 4 | import mongoose from 'mongoose'; 5 | import bodyParser from 'body-parser'; 6 | import session from 'express-session'; 7 | import User from './models/user'; 8 | const MongoStore = require('connect-mongo')(session); 9 | import compression from 'compression'; 10 | import minify from 'express-minify'; 11 | import uglifyEs from 'uglify-es'; 12 | const redirectToHTTPS = require('express-http-to-https').redirectToHTTPS; 13 | import path from 'path'; 14 | 15 | // router 16 | import homeRouter from './routes/home'; 17 | import eventRouter from './routes/event'; 18 | import userRouter from './routes/user'; 19 | 20 | const app = express(); 21 | app.use(compression()); 22 | app.use(minify({ 23 | uglifyJsModule: uglifyEs 24 | 25 | })); 26 | // connect to database 27 | mongoose.connect(process.env.DB_URI, {useNewUrlParser: true}); 28 | const db = mongoose.connection; 29 | db.on('error', console.error.bind(console, 'MongoDB connection error:')); 30 | db.on('open', () => console.log('Connection to database established')); 31 | 32 | app.set('views', path.join(__dirname, 'views')); 33 | app.set('view engine', 'ejs'); 34 | app.use('/assets', express.static(path.join(__dirname + '/public'))); 35 | app.use(bodyParser.json()); 36 | app.use(bodyParser.urlencoded({ 37 | extended: false 38 | })); 39 | app.use(session({ 40 | secret: process.env.SESSION_SECRET, 41 | resave: true, 42 | saveUninitialized: false, 43 | store: new MongoStore({ 44 | mongooseConnection: db 45 | }) 46 | })); 47 | 48 | // redirect to https except localhost 49 | app.use(redirectToHTTPS([/localhost:(\d{4})/])); 50 | 51 | // construct res.locals from session to pass to other middlewares 52 | // check authentication 53 | app.use((req, res, next) => { 54 | res.locals.options = { 55 | username: null, 56 | page: null, 57 | type: null 58 | }; 59 | User.findOne({ 60 | username: req.session.username 61 | }) 62 | .then(user => { 63 | if (user) { 64 | res.locals.options.username = user.username; 65 | res.locals.options.email = user.email; 66 | res.locals.options.type = user.userType; 67 | res.locals.options.history = user.history; 68 | } 69 | return next(); 70 | }); 71 | }); 72 | 73 | // app main routers 74 | app.use('/', homeRouter); 75 | app.use('/event', eventRouter); 76 | app.use('/user', userRouter); 77 | 78 | //page not found handler 79 | app.use((req, res) => { 80 | res.status(404).render('error_views/404-error'); 81 | }); 82 | 83 | //error handler 84 | app.use((err, req, res, next) => { 85 | console.log(err); 86 | res.status = err.status || 500; 87 | res.send(err.message); 88 | }); 89 | 90 | const listener = app.listen(process.env.PORT || 3000, () => { 91 | console.log('Listening at port ' + listener.address().port); 92 | }); -------------------------------------------------------------------------------- /src/views/update-profile.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Update 6 | <%= username %>'s Profile - Event Booking System 7 | <%- include partials/html-head %> 8 | 9 | 10 | 11 | 12 | <%- include partials/navbar %> 13 | 14 | 15 |
    16 |
    17 |
    18 |
    Update 19 | <%= username %>'s Profile
    20 |
    21 |
    22 | 23 | 24 |
    25 | 26 |
    27 | 28 | 29 | 30 | Leave empty if password is unchanged. 31 | 32 |
    33 | 34 |
    35 | 36 | 37 |
    38 | 39 |
    40 |
    41 |
    42 | 43 | 44 | 62 |
    63 | 64 | <%- include partials/footer %> 65 | 66 | 68 | 69 | 70 | <%- include partials/html-scripts %> 71 | 72 | 73 | 74 | -------------------------------------------------------------------------------- /src/views/partials/navbar.ejs: -------------------------------------------------------------------------------- 1 | 2 |
    3 | 94 |
    -------------------------------------------------------------------------------- /src/public/style.css: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css?family=Nunito:400,400i'); 2 | body { 3 | display: flex; 4 | flex-direction: column; 5 | height: 100vh; 6 | background: #FAFAFA; 7 | font-family: 'Nunito', sans-serif; 8 | } 9 | 10 | .header { 11 | min-height: 11.5vh; 12 | } 13 | 14 | main { 15 | flex: auto; 16 | } 17 | 18 | main.register, 19 | main.pre-register, 20 | main.sign-in { 21 | display: flex; 22 | } 23 | 24 | .footer { 25 | background: white; 26 | } 27 | 28 | .search { 29 | background: white; 30 | border: 1px solid #CED4DA; 31 | } 32 | 33 | .fa-search { 34 | color: rgba(0, 0, 0, 0.5); 35 | } 36 | 37 | .nav-link { 38 | font-size: 1.1em; 39 | } 40 | 41 | .nav-link:focus { 42 | color: rgba(0, 0, 0, .5) !important; 43 | } 44 | 45 | .active .nav-link { 46 | color: #039BE5 !important; 47 | } 48 | 49 | li:not(.active) .nav-link:hover { 50 | color: #039BE5 !important; 51 | } 52 | 53 | .dropdown-item:active { 54 | background: #039BE5; 55 | } 56 | 57 | .home-page .card { 58 | min-height: 25em; 59 | } 60 | 61 | .book-in { 62 | margin-top: 0.3em; 63 | width: 5.5em; 64 | } 65 | 66 | .fa-edit span { 67 | margin-left: -0.1em; 68 | } 69 | 70 | .fa-edit:hover { 71 | color: #039BE5 !important; 72 | } 73 | 74 | .info span { 75 | color: rgba(0, 0, 0, 0.8); 76 | font-weight: 400; 77 | line-height: 1.3em; 78 | font-family: 'Nunito', sans-serif; 79 | } 80 | 81 | .title { 82 | color: #0091EA; 83 | font-size: 2em; 84 | margin-bottom: -0.5em; 85 | } 86 | 87 | .fa-calendar-alt span { 88 | margin-left: 0.3em; 89 | } 90 | 91 | .fa-map-marker-alt span { 92 | margin-left: 0.4em; 93 | } 94 | 95 | .fa-dollar-sign { 96 | margin-left: 0.1em; 97 | } 98 | 99 | .fa-dollar-sign span { 100 | margin-left: 0.5em; 101 | } 102 | 103 | .home-page .card-text { 104 | font-size: 1.1em; 105 | } 106 | 107 | .card-text p { 108 | margin: 0 !important; 109 | } 110 | 111 | form[action="search"].input-group { 112 | width: 15em !important; 113 | } 114 | 115 | .dropdown-menu { 116 | width: 10em; 117 | } 118 | 119 | .big-font { 120 | font-size: 1.1em; 121 | } 122 | 123 | .big-font .btn { 124 | font-size: 1rem; 125 | } 126 | 127 | #date { 128 | background: white; 129 | } 130 | 131 | .picker__frame { 132 | position: static !important; 133 | margin-top: 7em !important; 134 | } 135 | 136 | .form-text { 137 | margin-left: 0.17em; 138 | } 139 | 140 | #full-desc { 141 | height: 15em; 142 | background: white; 143 | border: 1px solid #ced4da; 144 | width: 100%; 145 | transition: border-color .15s ease-in-out, box-shadow .15s ease-in-out; 146 | } 147 | 148 | .picker__input.picker__input--active, 149 | .quill-active { 150 | color: #495057; 151 | background-color: #fff; 152 | border-color: #80bdff !important; 153 | outline: 0; 154 | box-shadow: 0 0 0 .2rem rgba(0, 123, 255, .25); 155 | } 156 | 157 | .ql-toolbar { 158 | display: none; 159 | } 160 | 161 | .ql-container { 162 | border-radius: 0.25rem; 163 | font-family: 'Nunito', sans-serif !important; 164 | font-size: 1rem !important; 165 | } 166 | 167 | .ql-editor.ql-blank::before { 168 | font-style: normal !important; 169 | } 170 | 171 | .fix { 172 | flex: 1; 173 | } 174 | 175 | .register input, 176 | button[type="submit"], 177 | .btn-block { 178 | font-size: 1em; 179 | } 180 | 181 | .home-page footer { 182 | margin-top: 2em; 183 | } 184 | 185 | .pre-register .border { 186 | height: 20em !important; 187 | } 188 | 189 | .close { 190 | -webkit-appearance: none !important; 191 | } 192 | 193 | a.close:hover { 194 | color: #039BE5 !important; 195 | } 196 | 197 | @media (min-width: 992px) { 198 | .home { 199 | margin-left: -0.45em; 200 | } 201 | } 202 | 203 | a:active, 204 | a:focus { 205 | outline: 0; 206 | } 207 | 208 | .event-page .card-body { 209 | margin: -0.5em 0; 210 | } 211 | 212 | .card-flex { 213 | display: flex; 214 | align-items: center; 215 | } 216 | 217 | .booked h5 { 218 | margin-bottom: 0; 219 | } 220 | 221 | .checkout-btn { 222 | font-size: 1.1em !important; 223 | } 224 | 225 | .checkout h6 { 226 | margin: 0 !important; 227 | line-height: unset !important; 228 | } 229 | 230 | .cart { 231 | margin-top: 2em; 232 | } 233 | 234 | .btn-success { 235 | margin-top: 0.25em; 236 | } 237 | 238 | .profile { 239 | font-size: 1.1em; 240 | } 241 | 242 | @media (max-height: 500px) { 243 | .register-mobile .footer { 244 | margin-top: 1.5em; 245 | } 246 | .register-mobile .header { 247 | margin-bottom: 2.5em; 248 | } 249 | 250 | @media screen and (-webkit-min-device-pixel-ratio:0) 251 | and (min-resolution:.001dpcm) { 252 | .register-mobile { 253 | height: unset; 254 | } 255 | } 256 | } 257 | 258 | @media (min-height: 501px) and (max-height: 640px) { 259 | .register-mobile .footer { 260 | margin-top: 1.5em; 261 | } 262 | .register-mobile .header { 263 | margin-bottom: 2em; 264 | } 265 | 266 | @media screen and (-webkit-min-device-pixel-ratio:0) 267 | and (min-resolution:.001dpcm) { 268 | .register-mobile { 269 | height: unset; 270 | } 271 | } 272 | } -------------------------------------------------------------------------------- /src/public/create-form.js: -------------------------------------------------------------------------------- 1 | // Date and time picker setup 2 | const datePicker = $('#date').pickadate({ 3 | editable: true, 4 | min: true, 5 | format: 'ddd d mmmm, yyyy' 6 | }).pickadate('picker'); 7 | 8 | const timeOption = { 9 | editable: true, 10 | min: [7, 0], // min = 7 AM 11 | max: [22, 0] // max = 10PM 12 | }; 13 | 14 | let startPicker = $('#start-time').pickatime(timeOption).pickatime('picker'); 15 | let endPicker = $('#end-time').pickatime(timeOption).pickatime('picker'); 16 | startPicker.on('open', () => { 17 | startPicker.close(); 18 | }); 19 | endPicker.on('open', () => { 20 | endPicker.close(); 21 | }); 22 | $(window).on('load', function () { 23 | // hack-fix timepicker pop up if clicked before page loaded 24 | setTimeout(() => { 25 | startPicker.off('open'); 26 | endPicker.off('open'); 27 | }, 500); 28 | }); 29 | $('#date').click(() => datePicker.open()); 30 | $('#start-time').click(() => startPicker.open()); 31 | $('#end-time').click(() => endPicker.open()); 32 | 33 | // Quilljs setup 34 | const quill = new Quill('#full-desc', { 35 | placeholder: 'Event full description', 36 | theme: 'snow' 37 | }); 38 | 39 | $('#full-desc').click(function () { 40 | $(this).addClass('quill-active'); 41 | }); 42 | 43 | $(document).click(function (event) { 44 | if (!$(event.target).closest('#full-desc').length) { 45 | $('#full-desc').removeClass('quill-active'); 46 | } 47 | }); 48 | 49 | // Submit handler 50 | let form = document.getElementsByClassName('needs-validation')[0]; 51 | form.onsubmit = event => { 52 | event.preventDefault(); 53 | let valid = true; 54 | 55 | //reset 56 | $('#discount').prop('required', false); 57 | $('#promo-code').prop('required', false); 58 | // Promocode and discount percentage complementary handler 59 | if ($('#promo-code').val().trim()) { 60 | $('#discount').prop('required', true); 61 | valid = false; 62 | } 63 | if ($('#discount').val().trim()) { 64 | $('#promo-code').prop('required', true); 65 | valid = false; 66 | } 67 | 68 | if (($('#promo-code').val().trim()) && ($('#discount').val().trim())) valid = true; 69 | 70 | form.classList.add('was-validated'); 71 | // Textarea handler 72 | if (quill.getLength() === 1) { 73 | $('#quill-feedback').show(); 74 | $('.ql-container').css('border-color', '#dc3545'); 75 | valid = false; 76 | } else { 77 | $('#quill-feedback').hide(); 78 | $('.ql-container').css('border-color', '#28a745'); 79 | } 80 | 81 | // Time handler 82 | let filledStatus; 83 | filledStatus = (datePicker.get('select') === null || startPicker.get('select') === null || 84 | endPicker.get('select') === null) ? false : true; 85 | if (filledStatus) { 86 | const validTime = checkTime(datePicker, startPicker, endPicker); 87 | if (!validTime) { 88 | $('#start-feedback').text('Start time must be before end time.'); 89 | $('#start-time').addClass('is-invalid'); 90 | $('#start-time').css('border-color', '#dc3545'); // red 91 | $('#end-time').css('border-color', '#dc3545'); 92 | valid = false; 93 | } else { 94 | $('#start-time').removeClass('is-invalid'); 95 | $('#start-time').css('border-color', '#28a745'); // green 96 | $('#end-time').css('border-color', '#28a745'); 97 | } 98 | } 99 | 100 | //submit request 101 | if (!form.checkValidity()) valid = false; // html5 input check 102 | if (valid === false) return false; 103 | // construct start Date and end Date object 104 | const { hour: startHour, mins: startMin } = startPicker.get('select'); 105 | const { hour: endHour, mins: endMin } = endPicker.get('select'); 106 | const date = datePicker.get('select').obj.getTime(); 107 | let startDate = new Date(date); 108 | startDate.setHours(startHour); 109 | startDate.setMinutes(startMin); 110 | let endDate = new Date(date); 111 | endDate.setHours(endHour); 112 | endDate.setMinutes(endMin); 113 | 114 | let newEvent = { 115 | eventName: $('#event-name').val().trim(), 116 | summary: $('#overview').val().trim(), 117 | address: $('#address').val().trim(), 118 | startDate: startDate.toJSON(), 119 | endDate: endDate.toJSON(), 120 | fullDesc: quill.root.innerHTML, 121 | capacity: $('#capacity').val(), 122 | promoCode: $('#promo-code').val().trim(), 123 | discount: $('#discount').val(), 124 | price: $('#price').val(), 125 | }; 126 | 127 | //disable submit button 128 | $('.submit-btn').attr('disabled', true); 129 | 130 | $.ajax({ 131 | type: 'POST', 132 | url: '/event/create', 133 | data: JSON.stringify(newEvent), 134 | contentType: 'application/json', 135 | success: eventCreated => { 136 | //redirect user to event page 137 | window.location.href = `/event/id/${eventCreated.id}`; 138 | }, 139 | error: err => { 140 | console.log(err); 141 | }, 142 | 143 | }); 144 | 145 | }; 146 | 147 | // check if start time < end time 148 | function checkTime(datePicker, startPicker, endPicker) { 149 | const { hour: startHour, mins: startMin } = startPicker.get('select'); 150 | const { hour: endHour, mins: endMin } = endPicker.get('select'); 151 | 152 | const date = datePicker.get('select').obj; 153 | const startTime = new Date(date.getTime()); 154 | startTime.setHours(startHour); 155 | startTime.setMinutes(startMin); 156 | 157 | const endTime = new Date(date.getTime()); 158 | endTime.setHours(endHour); 159 | endTime.setMinutes(endMin); 160 | 161 | return startTime.getTime() < endTime.getTime(); 162 | } -------------------------------------------------------------------------------- /src/views/checkout.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Checkout 6 | <%= event.eventName %> - Event Booking System 7 | <%- include partials/html-head %> 8 | 9 | 10 | 11 | 12 | <%- include partials/navbar %> 13 | 14 | 15 |
    16 |
    17 |
    18 |
    Checkout Form
    19 |
    20 |
    21 | 22 | 23 | 24 | As displayed on your card. 25 | 26 |
    27 |
    28 |
    29 | 30 | 31 | 32 | No dashes or spaces. 33 | 34 |
    35 |
    36 | 37 | 38 |
    39 |
    40 | 41 | 42 |
    43 |
    44 | 58 |
    59 |
    60 | 61 | 69 |
    70 |
    71 |
    72 |
    Your Cart
    73 |
  • 74 |
    75 |
    76 | <%= event.eventName %> 77 |
    78 | 79 | <%= event.price %> 80 | 81 |
    82 |
  • 83 |
  • 84 |
    85 |
    86 | Total 87 |
    88 | 89 | <%= event.price %> 90 | 91 |
    92 |
  • 93 |
    94 |
    95 |
    96 | 97 | 98 |
    99 | 100 |
    101 |
    102 |
    103 | 106 | 107 |
    108 | 109 |
    110 |
    111 | 112 | 113 | 131 | 132 | <%- include partials/footer %> 133 | 134 | 136 | 137 | 138 | <%- include partials/html-scripts %> 139 | 140 | 141 | 142 | -------------------------------------------------------------------------------- /src/views/create.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Create New Event - Event Booking System 6 | <%- include partials/html-head %> 7 | 8 | 10 | 12 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | <%- include partials/navbar %> 21 | 22 | 23 |
    24 |
    25 |
    26 |
    Create New Event
    27 |
    28 |
    29 | 30 | 31 |
    32 | Event Name is required. 33 |
    34 |
    35 | 36 |
    37 | 38 | 39 |
    40 | Event Summary is required. 41 |
    42 |
    43 | 44 |
    45 | 46 | 47 |
    48 | Event Address is required. 49 |
    50 |
    51 | 52 |
    53 | 54 | 55 |
    56 | Event Date is required. 57 |
    58 |
    59 | 60 |
    61 |
    62 | 63 | 64 |
    65 | Event start time is required. 66 |
    67 |
    68 | 69 |
    70 | 71 | 72 |
    73 | Event end time is required. 74 |
    75 |
    76 |
    77 | 78 | 79 |
    80 | 81 |
    82 |
    83 | Event description is required. 84 |
    85 |
    86 | 87 |
    88 |
    89 | 90 | 91 |
    92 | Please enter a valid number. 93 |
    94 | 95 | Leave empty if free. 96 | 97 |
    98 |
    99 | 100 | 101 |
    102 | Please enter a valid number. 103 |
    104 |
    105 |
    106 | 107 |
    108 |
    109 | 110 | 111 | 112 | Optional. 113 | 114 |
    115 | Please provide a promo-code when discount percentage is present. 116 |
    117 |
    118 | 119 |
    120 | 121 | 122 | 123 | Associated with promotional code. 124 | 125 |
    126 | Please enter a valid number. 127 |
    128 |
    129 |
    130 | 131 | 132 |
    133 |
    134 |
    135 |
    136 | 137 | <%- include partials/footer %> 138 | 139 | 141 | 142 | 143 | <%- include partials/html-scripts %> 144 | 146 | 148 | 150 | 151 | 152 | 153 | 154 | 155 | -------------------------------------------------------------------------------- /src/public/update-form.js: -------------------------------------------------------------------------------- 1 | // Date and time picker setup 2 | const datePicker = $('#date').pickadate({ 3 | editable: true, 4 | format: 'ddd d mmmm, yyyy' 5 | }).pickadate('picker'); 6 | 7 | const timeOption = { 8 | editable: true, 9 | min: [7, 0], // min = 7 AM 10 | max: [22, 0] // max = 10PM 11 | }; 12 | 13 | let startPicker = $('#start-time').pickatime(timeOption).pickatime('picker'); 14 | let endPicker = $('#end-time').pickatime(timeOption).pickatime('picker'); 15 | 16 | // Quilljs setup 17 | const quill = new Quill('#full-desc', { 18 | placeholder: 'Event full description', 19 | theme: 'snow' 20 | }); 21 | 22 | // reset button 23 | $('.reset').click(() => { 24 | startPicker.on('open', () => { 25 | startPicker.close(); 26 | }); 27 | endPicker.on('open', () => { 28 | endPicker.close(); 29 | }); 30 | $('form').get(1).reset(); 31 | datePicker.set('select', new Date($('#date').data('date'))); 32 | startPicker.set('select', new Date($('#start-time').data('start-time'))); 33 | endPicker.set('select', new Date($('#end-time').data('end-time'))); 34 | quill.root.innerHTML = $('#full-desc').data('full-desc'); 35 | setTimeout(() => { 36 | startPicker.off('open'); 37 | endPicker.off('open'); 38 | }, 200); 39 | }); 40 | 41 | datePicker.set('select', new Date($('#date').data('date'))); 42 | startPicker.set('select', new Date($('#start-time').data('start-time'))); 43 | endPicker.set('select', new Date($('#end-time').data('end-time'))); 44 | quill.root.innerHTML = $('#full-desc').data('full-desc'); 45 | 46 | startPicker.on('open', () => { 47 | startPicker.close(); 48 | }); 49 | endPicker.on('open', () => { 50 | endPicker.close(); 51 | }); 52 | $(window).on('load', function () { 53 | // hack-fix timepicker pop up if clicked before page loaded 54 | setTimeout(() => { 55 | startPicker.off('open'); 56 | endPicker.off('open'); 57 | }, 500); 58 | }); 59 | $('#date').click(() => datePicker.open()); 60 | $('#start-time').click(() => startPicker.open()); 61 | $('#end-time').click(() => endPicker.open()); 62 | 63 | $('#full-desc').click(function () { 64 | $(this).addClass('quill-active'); 65 | }); 66 | 67 | $(document).click(function (event) { 68 | if (!$(event.target).closest('#full-desc').length) { 69 | $('#full-desc').removeClass('quill-active'); 70 | } 71 | }); 72 | 73 | // Submit handler 74 | let form = document.getElementsByClassName('needs-validation')[0]; 75 | form.onsubmit = event => { 76 | event.preventDefault(); 77 | let valid = true; 78 | 79 | //reset 80 | $('#discount').prop('required', false); 81 | $('#promo-code').prop('required', false); 82 | // Promocode and discount percentage complementary handler 83 | if ($('#promo-code').val().trim()) { 84 | $('#discount').prop('required', true); 85 | valid = false; 86 | } 87 | if ($('#discount').val().trim()) { 88 | $('#promo-code').prop('required', true); 89 | valid = false; 90 | } 91 | 92 | if (($('#promo-code').val().trim()) && ($('#discount').val().trim())) valid = true; 93 | 94 | form.classList.add('was-validated'); 95 | // Textarea handler 96 | if (quill.getLength() === 1) { 97 | $('#quill-feedback').show(); 98 | $('.ql-container').css('border-color', '#dc3545'); 99 | valid = false; 100 | } else { 101 | $('#quill-feedback').hide(); 102 | $('.ql-container').css('border-color', '#28a745'); 103 | } 104 | 105 | // Time handler 106 | let filledStatus; 107 | filledStatus = (datePicker.get('select') === null || startPicker.get('select') === null || 108 | endPicker.get('select') === null) ? false : true; 109 | if (filledStatus) { 110 | const validTime = checkTime(datePicker, startPicker, endPicker); 111 | if (!validTime) { 112 | $('#start-feedback').text('Start time must be before end time.'); 113 | $('#start-time').addClass('is-invalid'); 114 | $('#start-time').css('border-color', '#dc3545'); // red 115 | $('#end-time').css('border-color', '#dc3545'); 116 | valid = false; 117 | } else { 118 | $('#start-time').removeClass('is-invalid'); 119 | $('#start-time').css('border-color', '#28a745'); // green 120 | $('#end-time').css('border-color', '#28a745'); 121 | } 122 | } 123 | 124 | //submit request 125 | if (!form.checkValidity()) valid = false; // html5 input check 126 | if (valid === false) return false; 127 | // construct start Date and end Date object 128 | const { hour: startHour, mins: startMin } = startPicker.get('select'); 129 | const { hour: endHour, mins: endMin } = endPicker.get('select'); 130 | const date = datePicker.get('select').obj.getTime(); 131 | let startDate = new Date(date); 132 | startDate.setHours(startHour); 133 | startDate.setMinutes(startMin); 134 | let endDate = new Date(date); 135 | endDate.setHours(endHour); 136 | endDate.setMinutes(endMin); 137 | 138 | let updatedEvent = { 139 | eventName: $('#event-name').val().trim(), 140 | summary: $('#overview').val().trim(), 141 | address: $('#address').val().trim(), 142 | startDate: startDate.toJSON(), 143 | endDate: endDate.toJSON(), 144 | fullDesc: quill.root.innerHTML, 145 | capacity: $('#capacity').val(), 146 | promoCode: $('#promo-code').val().trim(), 147 | discount: $('#discount').val(), 148 | price: $('#price').val(), 149 | }; 150 | 151 | //disable submit button 152 | $('.submit-btn').attr('disabled', true); 153 | 154 | $.ajax({ 155 | type: 'PUT', 156 | url: window.location.href, 157 | data: JSON.stringify(updatedEvent), 158 | contentType: 'application/json', 159 | success: result => { 160 | if (result.error) { 161 | $('#update-modal').on('hide.bs.modal', () => { 162 | $('.submit-btn').attr('disabled', false); 163 | }); 164 | $('#update-modal .modal-body p').text(result.error); 165 | $('#update-modal').modal('show'); 166 | } else { 167 | //redirect user to event page 168 | window.location.href = `/event/id/${result.id}`; 169 | } 170 | }, 171 | error: err => { 172 | console.log(err); 173 | }, 174 | 175 | }); 176 | 177 | }; 178 | 179 | // check if start time < end time 180 | function checkTime(datePicker, startPicker, endPicker) { 181 | const { hour: startHour, mins: startMin } = startPicker.get('select'); 182 | const { hour: endHour, mins: endMin } = endPicker.get('select'); 183 | 184 | const date = datePicker.get('select').obj; 185 | const startTime = new Date(date.getTime()); 186 | startTime.setHours(startHour); 187 | startTime.setMinutes(startMin); 188 | 189 | const endTime = new Date(date.getTime()); 190 | endTime.setHours(endHour); 191 | endTime.setMinutes(endMin); 192 | 193 | return startTime.getTime() < endTime.getTime(); 194 | } -------------------------------------------------------------------------------- /src/views/event-page.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | <%= event.eventName %> - Event Booking System 7 | <%- include partials/html-head %> 8 | 9 | 10 | 11 | <%- include partials/navbar %> 12 | 13 | 14 |
    15 |
    16 |
    17 | <%= event.eventName %> 18 |
    19 | <% if (type === 'staff') { %> 20 | Edit 21 | 22 | <% } %> 23 | 24 | 43 |
    44 |
    45 |
    46 |
    47 |
    48 | 49 | 50 | <%= event.durationString %> 51 | 52 | 53 |
    54 |
    55 | 56 | 57 | <%= event.address%> 58 | 59 | 60 |
    61 |
    62 | 63 | 64 | <%= event.price %> 65 | 66 | 67 |
    68 |
    69 |
    70 |
    71 | <% if (event.endDate > new Date() && booked) { %> 72 |
    You are currently booked into this event.
    73 | <% } %> 74 | 75 | <% if (event.endDate < new Date()) { %> 76 |
    This event has already ended.
    77 | <% } %> 78 |
    79 |
    Description
    80 |
    81 |
    82 | <%- event.fullDesc %> 83 |
    84 |
    85 |
    86 | <% if (eventFull && !booked && event.endDate > new Date()) { %> 87 |
    This event is currently fully booked.
    88 | <% } %> 89 | 90 | <% if (event.endDate > new Date() && username != null ) { %> 91 | 94 | <% } %> 95 |
    96 | 97 | <% if (type === 'staff') { %> 98 |
    99 |
    100 |
    Other Information
    101 |
    102 | 103 | Maximum capacity: 104 | 105 |
    106 | <%= event.capacity %> 107 |
    108 | 109 | Number of bookings: 110 | 111 |
    112 | <%= event.currentBookings %> 113 |
    114 | 115 | Promotional Code: 116 | 117 |
    118 | <% if (!event.promoCode) { %> 119 | N/A 120 | <% } else { %> 121 | <%= event.promoCode %> 122 | <% } %> 123 |
    124 | 125 | Discount Percentage: 126 | 127 |
    128 | <% if (!event.discount) { %> 129 | N/A 130 | <% } else { %> 131 | <%= event.discount %>% 132 | <% } %> 133 |
    134 |
    135 |
    136 |
    137 | <% } %> 138 | 139 |
    140 |
    141 |
    142 |
    143 | 144 | 162 | 163 | <%- include partials/footer %> 164 | 165 | 167 | 168 | <%- include partials/html-scripts %> 169 | 170 | 171 | 172 | 173 | -------------------------------------------------------------------------------- /src/views/update-event.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Update 6 | <%= event.eventName %> - Event Booking System 7 | <%- include partials/html-head %> 8 | 9 | 11 | 13 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | <%- include partials/navbar %> 22 | 23 | 24 |
    25 |
    26 |
    27 |
    Update 28 | <%= event.eventName %> 29 |
    30 |
    31 |
    32 | 33 | 34 |
    35 | Event Name is required. 36 |
    37 |
    38 | 39 |
    40 | 41 | 42 |
    43 | Event Summary is required. 44 |
    45 |
    46 | 47 |
    48 | 49 | 50 |
    51 | Event Address is required. 52 |
    53 |
    54 | 55 |
    56 | 57 | 58 |
    59 | Event Date is required. 60 |
    61 |
    62 | 63 |
    64 |
    65 | 66 | 68 |
    69 | Event start time is required. 70 |
    71 |
    72 | 73 |
    74 | 75 | 76 |
    77 | Event end time is required. 78 |
    79 |
    80 |
    81 | 82 | 83 |
    84 | 85 |
    86 |
    87 | Event description is required. 88 |
    89 |
    90 | 91 |
    92 |
    93 | 94 | 95 |
    96 | Please enter a valid number. 97 |
    98 | 99 | Leave empty if free. 100 | 101 |
    102 |
    103 | 104 | 106 |
    107 | Please enter a valid number. 108 |
    109 |
    110 |
    111 | 112 |
    113 |
    114 | 115 | 116 | 117 | Optional. 118 | 119 |
    120 | Please provide a promo-code when discount percentage is present. 121 |
    122 |
    123 | 124 |
    125 | 126 | 127 | 128 | Associated with promotional code. 129 | 130 |
    131 | Please enter a valid number. 132 |
    133 |
    134 |
    135 | 136 | 137 | 138 |
    139 |
    140 |
    141 | 142 | 143 | 161 |
    162 | 163 | <%- include partials/footer %> 164 | 165 | 167 | 168 | 169 | <%- include partials/html-scripts %> 170 | 172 | 174 | 176 | 177 | 178 | 179 | 180 | 181 | --------------------------------------------------------------------------------