├── .eslintrc ├── data.sample ├── config.json ├── index.html └── data.json ├── index.js ├── views ├── index.hbs ├── items.hbs ├── layouts │ └── default.hbs └── item.hbs ├── assets ├── item.css ├── items.css ├── main.css └── bid.js ├── package.json ├── LICENSE ├── logic.js └── routes.js /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "indent": ["error", "tab"], 4 | "no-tabs": "off" 5 | }, 6 | "extends": "airbnb" 7 | } 8 | -------------------------------------------------------------------------------- /data.sample/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Auction Name", 3 | "imgix": "some-imgix-source.imgix.net", 4 | "bidinfo": "We will contact the highest bidder on ..." 5 | } 6 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node, es6 */ 2 | 3 | const express = require('express'); 4 | const routes = require('./routes'); 5 | 6 | const app = express(); 7 | app.use(routes); 8 | 9 | app.listen(process.env.PORT || 8080, process.env.IP || "127.0.0.1"); 10 | -------------------------------------------------------------------------------- /data.sample/index.html: -------------------------------------------------------------------------------- 1 |

2 | Sit dolorum iste excepturi repellendus ex quo rerum voluptates molestiae optio provident. Sed facere quidem suscipit laudantium minus sint. Delectus dicta blanditiis est veritatis officiis voluptatum amet quasi provident. Culpa. 3 |

4 | -------------------------------------------------------------------------------- /views/index.hbs: -------------------------------------------------------------------------------- 1 |
2 |
3 | {{{content}}} 4 | 5 |
6 | Browse items for auction 7 |
8 |
9 |
10 | -------------------------------------------------------------------------------- /assets/item.css: -------------------------------------------------------------------------------- 1 | .crop-wide { 2 | display: none; 3 | } 4 | .crop-narrow { 5 | display: block; 6 | } 7 | 8 | .images { 9 | margin: -1rem; 10 | } 11 | .image { 12 | margin: 1rem; 13 | } 14 | 15 | @media (min-width: 768px) { 16 | .crop-wide { 17 | display: block; 18 | } 19 | .crop-narrow { 20 | display: none; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /data.sample/data.json: -------------------------------------------------------------------------------- 1 | { 2 | "items": [ 3 | { 4 | "id": 1, 5 | "description": "Fancy cards", 6 | "quantity": 1, 7 | "bid": { 8 | "starting": 10, 9 | "increment": 2 10 | }, 11 | "images": [ 12 | { 13 | "src": "1.jpg" 14 | } 15 | ] 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "simple-auction", 3 | "scripts": { 4 | "start": "node --harmony index.js" 5 | }, 6 | "dependencies": { 7 | "body-parser": "^1.15.2", 8 | "bootstrap": "^4.0.0-alpha.5", 9 | "express": "^4.14.0", 10 | "express-handlebars": "^3.0.0", 11 | "imgix.js": "^3.0.4", 12 | "jquery": "^3.1.1", 13 | "lowdb": "^0.14.0" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /views/items.hbs: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | {{#each items}} 5 |
6 | 7 | 8 | 9 | 10 |
11 | {{/each}} 12 |
13 |
14 |
15 | -------------------------------------------------------------------------------- /assets/items.css: -------------------------------------------------------------------------------- 1 | .items { 2 | margin: -1rem; 3 | } 4 | .item { 5 | margin: 1rem; 6 | } 7 | 8 | .item { 9 | position: relative; 10 | } 11 | .item:after { 12 | content: ''; 13 | display: block; 14 | width: 100%; 15 | padding-top: calc(10 / 16 * 100%); 16 | } 17 | .item a { 18 | display: block; 19 | position: absolute; 20 | top: 0; 21 | } 22 | 23 | 24 | @media (min-width: 768px) { 25 | .items { 26 | display: flex; 27 | flex-wrap: wrap; 28 | } 29 | .item { 30 | flex: 1 0 auto; 31 | width: calc(50% - 4rem); 32 | } 33 | 34 | .item:after { 35 | padding-top: calc(4 / 3 * 100%); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /assets/main.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: "Fira Sans", 3 | -apple-system, 4 | BlinkMacSystemFont, 5 | "Segoe UI", 6 | "Roboto", 7 | "Helvetica Neue", Arial, sans-serif; 8 | -webkit-font-smoothing: antialiased; 9 | } 10 | 11 | .navbar-brand { 12 | /* font-weight: 600; */ 13 | 14 | } 15 | 16 | 17 | 18 | main, 19 | main.container-fluid { 20 | padding: 1rem; 21 | } 22 | 23 | .navbar { 24 | margin-bottom: 1rem; 25 | } 26 | 27 | @media (min-width: 768px) { 28 | main, 29 | main.container-fluid { 30 | padding: 2rem; 31 | } 32 | 33 | .navbar { 34 | margin-bottom: 2rem; 35 | } 36 | } 37 | 38 | @media (max-width: 480px) { 39 | .navbar-brand { 40 | width: 100%; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to 25 | -------------------------------------------------------------------------------- /views/layouts/default.hbs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | {{#if title}}{{title}}{{else}}{{@root.config.title}}{{/if}} 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 |
16 |
17 | 24 |
25 |
26 | 27 | {{{body}}} 28 |
29 | 30 | 31 | 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /assets/bid.js: -------------------------------------------------------------------------------- 1 | $('#placeBid').click(function () { 2 | var name = $('#name').val(); 3 | var phone = $('#phone').val(); 4 | var amount = $('#amount').val(); 5 | 6 | var success = function success(data) { 7 | var message = data.message; 8 | $('#bidModal').modal('hide'); 9 | $('#success').html( 10 | 'Success! Your bid has been counted. ' + (message ? 'Message: ' + message : '') 11 | ).fadeIn(100); 12 | 13 | update(); 14 | }; 15 | 16 | var error = function error(xhr) { 17 | var message = JSON.parse(xhr.responseText).message; 18 | $('#bidModal').modal('hide'); 19 | $('#error').html( 20 | 'Error! Your bid was rejected. ' + (message ? 'Reason: ' + message : '') 21 | ).fadeIn(100); 22 | }; 23 | 24 | $('#error').fadeOut(); 25 | $('#success').fadeOut(); 26 | 27 | $.ajax({ 28 | method: 'PUT', 29 | url: window.location.pathname.replace(/\/$/g, '') + '/bids/', 30 | dataType: 'json', 31 | data: { 32 | name: name, 33 | phone: phone, 34 | amount: amount 35 | }, 36 | success: success, 37 | error: error 38 | }); 39 | }); 40 | 41 | var update = function update() { 42 | var success = function success(data) { 43 | if (data.bid) { 44 | $('#highest').text('$' + (data.bid.highest || data.bid.starting)); 45 | } 46 | }; 47 | 48 | var error = function error(xhr) { 49 | var message = xhr.responseText; 50 | console.warn('error', message); 51 | }; 52 | 53 | $.ajax({ 54 | method: 'GET', 55 | url: window.location.pathname.replace(/\/$/g, ''), 56 | dataType: 'json', 57 | success: success, 58 | error: error 59 | }); 60 | }; 61 | 62 | setInterval(update, 1000); 63 | 64 | //$('#amount').change(function () { 65 | setInterval(function () { 66 | var amount = $('#amount').val(); 67 | $('#bidModal').find('.modal-title').text('Place bid of $' + amount); 68 | }, 500); 69 | //}); 70 | 71 | 72 | -------------------------------------------------------------------------------- /logic.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node, es6 */ 2 | 3 | const fs = require('fs'); 4 | const path = require('path'); 5 | 6 | const low = require('lowdb'); 7 | const fileAsync = require('lowdb/lib/file-async'); 8 | 9 | class Logic { 10 | constructor() { 11 | const BASE_DIR = path.join(__dirname, 'data'); 12 | const DB_FILE = path.join(BASE_DIR, 'data.json'); 13 | const CONFIG_FILE = path.join(BASE_DIR, 'config.json'); 14 | 15 | this.config = require(CONFIG_FILE); // TODO: Replace with readFileSync 16 | this.db = low(DB_FILE, { 17 | storage: fileAsync, 18 | }); 19 | this.db.defaults({ items: [] }).value(); 20 | this.baseDir = BASE_DIR; 21 | } 22 | 23 | getConfig() { 24 | return this.config; 25 | } 26 | 27 | async getIndex() { 28 | return await new Promise((resolve, reject) => { 29 | fs.readFile(path.join(this.baseDir, 'index.html'), 'utf8', (err, content) => { 30 | if (err) return reject(err); 31 | return resolve(content); 32 | }); 33 | }); 34 | } 35 | 36 | async getItems() { 37 | return this.db.get('items').value(); 38 | } 39 | 40 | async getItem(id) { 41 | const dbitem = this.db.get('items').find({ id }); 42 | const item = Object.assign({}, dbitem.value()); 43 | item.bid.next = (item.bid.highest || item.bid.starting) + item.bid.increment; 44 | return item; 45 | } 46 | 47 | async putItemBid(id, bid) { 48 | const dbitem = this.db.get('items').find({ id }); 49 | const item = Object.assign({}, dbitem.value()); 50 | item.bid.next = (item.bid.highest || item.bid.starting) + item.bid.increment; 51 | 52 | const amount = parseInt(bid.amount, 10); 53 | const name = bid.name; 54 | const phone = bid.phone; 55 | if (!item.bid.bids) { 56 | dbitem.get('bid').set('bids', []).value(); 57 | } 58 | if (amount < item.bid.next) { 59 | throw new Error(`Please bid $${item.bid.next} or higher`); 60 | } 61 | if (!name) { 62 | throw new Error('Please give us your name'); 63 | } 64 | if (!phone || phone.length < 8) { 65 | throw new Error('Please give us a valid phone number'); 66 | } 67 | dbitem.get('bid').get('bids').push({ 68 | amount, 69 | name, 70 | phone, 71 | }).value(); 72 | dbitem.get('bid').set('highest', amount).value(); 73 | } 74 | } 75 | 76 | module.exports = Logic; 77 | -------------------------------------------------------------------------------- /routes.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node, es6 */ 2 | 3 | const path = require('path'); 4 | 5 | const express = require('express'); 6 | const bodyparser = require('body-parser'); 7 | const hbs = require('express-handlebars'); 8 | const Logic = require('./logic'); 9 | 10 | const routes = express(); 11 | routes.engine('hbs', hbs({ 12 | extname: '.hbs', 13 | defaultLayout: 'default', 14 | helpers: { 15 | ifincludes: (a, b, options) => { 16 | if (a && a.includes && a.includes(b)) { 17 | return options.fn(this); 18 | } 19 | return options.inverse(this); 20 | }, 21 | }, 22 | })); 23 | routes.set('view engine', 'hbs'); 24 | routes.use('/jquery', express.static(path.join(__dirname, 'node_modules/jquery/dist'))); 25 | routes.use('/bootstrap', express.static(path.join(__dirname, 'node_modules/bootstrap/dist'))); 26 | routes.use('/imgix.js', express.static(path.join(__dirname, 'node_modules/imgix.js/dist'))); 27 | routes.use('/assets', express.static(path.join(__dirname, 'assets'))); 28 | routes.use(bodyparser.urlencoded({ 29 | extended: true, 30 | })); 31 | 32 | const logic = new Logic(); 33 | const config = logic.getConfig(); 34 | 35 | routes.get('/*', (req, res, next) => { 36 | res.set('Cache-Control', 'no-cache, no-store'); 37 | next(); 38 | }); 39 | 40 | routes.get('/', (req, res, next) => { 41 | logic.getIndex().then((content) => { 42 | res.render('index', { 43 | content, 44 | config, 45 | }); 46 | }).catch((err) => { 47 | next(err); 48 | }); 49 | }); 50 | 51 | routes.get('/items/', (req, res, next) => { 52 | logic.getItems().then((items) => { 53 | switch (req.accepts(['json', 'html'])) { 54 | case 'json': 55 | return res.json(items); 56 | case 'html': 57 | default: 58 | return res.render('items', { 59 | items, 60 | config, 61 | }); 62 | } 63 | }).catch((err) => { 64 | next(err); 65 | }); 66 | }); 67 | 68 | routes.get('/items/:id', (req, res, next) => { 69 | logic.getItem(parseInt(req.params.id, 10)).then((item) => { 70 | switch (req.accepts(['json', 'html'])) { 71 | case 'json': 72 | return res.json(item); 73 | case 'html': 74 | default: 75 | return res.render('item', { 76 | item, 77 | config, 78 | }); 79 | } 80 | }).catch((err) => { 81 | next(err); 82 | }); 83 | }); 84 | 85 | routes.put('/items/:id/bids/', (req, res) => { 86 | logic.putItemBid(parseInt(req.params.id, 10), req.body).then(() => { 87 | res.json({ 88 | success: true, 89 | }); 90 | }).catch((err) => { 91 | res.status(400).json({ 92 | error: true, 93 | message: err.message, 94 | }); 95 | }); 96 | }); 97 | 98 | module.exports = routes; 99 | -------------------------------------------------------------------------------- /views/item.hbs: -------------------------------------------------------------------------------- 1 | {{#if item}} 2 | {{#with item}} 3 |
4 |
5 | 6 | 7 |
8 |
9 |
10 |
11 |
12 | {{#each images}} 13 | 19 | {{/each}} 20 |
21 |
22 |
23 |
Description
24 |

{{description}}

25 | {{#if info}} 26 |

{{info}}

27 | {{/if}} 28 | {{#if sizes}} 29 |
Sizes
30 |

31 | {{#each sizes}} 32 | {{@key}}: {{this}}
33 | {{/each}} 34 |

35 | {{/if}} 36 |
{{#if bid.highest}}Highest{{else}}Starting{{/if}} bid
37 | ${{#if bid.highest}}{{bid.highest}}{{else}}{{bid.starting}}{{/if}} 38 |
39 |
40 |
41 | $ 42 | 43 | 44 | 45 | 46 |
47 |
48 |
49 |
50 |
51 | {{/with}} 52 | {{else}} 53 |
54 |
55 |
56 | Error! Item not found. 57 |
58 |
59 |
60 | {{/if}} 61 | 62 | 63 | 93 | --------------------------------------------------------------------------------