diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..6b998e2 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,17 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "type": "node", + "request": "launch", + "name": "Launch Program", + "skipFiles": [ + "/**" + ], + "program": "${workspaceFolder}\\index.js" + } + ] +} diff --git a/api/items.js b/api/items.js index 4a3428d..90c2b4a 100644 --- a/api/items.js +++ b/api/items.js @@ -50,11 +50,6 @@ router.delete('/:uuid', async (req, res) => { }) router.post('/:uuid/images', upload.single('image'), async (req, res) => { - // TODO: Use the real user when we have authentication - req.user = { - uuid: '56881ad0-8d80-496a-b036-aed03d0895ce' - } - // Handle either image upload body or JSON body try { if(req.file) diff --git a/api/users.js b/api/users.js index 0d98625..8227718 100644 --- a/api/users.js +++ b/api/users.js @@ -17,6 +17,19 @@ router.post('/', parseJSON, registerValidation, async (req, res) => { req.body.password ) + if(!user){ + return res.status(422).json({errors: [{ + param: 'email', + msg: 'Unable to complete registration' + },{ + param: 'password', + msg: ' ' + },{ + param: 'password2', + msg: ' ' + }]}) + } + const session = await db.session.create( user.uuid, req.ip, diff --git a/components/card/card.js b/components/card/card.js new file mode 100644 index 0000000..bf5ba3f --- /dev/null +++ b/components/card/card.js @@ -0,0 +1,36 @@ +import React from 'react' +import Link from 'next/link' + +import styles from './style.module.css' + +export default function Card({item, numberInCart}) { + let featuredImage = item.images.filter(i=>i.featured)[0] + if(!featuredImage) featuredImage = item.images[0] + + return ( +
+

{item.name}

+
+ {featuredImage && } +
+
{item.description}
+ +
+ ) +} diff --git a/components/card/style.module.css b/components/card/style.module.css new file mode 100644 index 0000000..b005e4e --- /dev/null +++ b/components/card/style.module.css @@ -0,0 +1,138 @@ +.card{ + background:white; + display:inline-block; + position:relative; + margin:20px 5%; + width:90%; + top:0; + box-shadow: 0 1px 3px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.24); + transition: all 0.3s cubic-bezier(.25,.8,.25,1); +} + +.card:last-child { + /* Fixes the odd flickering bug */ + margin-bottom:0px; +} + +.card:hover { + box-shadow: 0 10px 20px rgba(0,0,0,0.19), 0 6px 6px rgba(0,0,0,0.23); + top:-3px; +} + +.card h3{ + font-family:'Cormorant Infant', serif; + font-weight:600; + font-size:22px; + border-bottom:solid 1px black; + text-align:center; + margin:5px; +} + + +.card h3{ + border-bottom:none; +} +.card h3 a{ + color:inherit; + text-decoration:none; + border-bottom:solid 1px black; +} + +.card a:hover{ + color: #760c88; + border-color: #760c88; + transition: .2s ease-in-out; + transition-property: color, border-color; +} + +.card .card-text { + margin:0; + left: 40%; + top: 34px; +} + +.card .card-text p:first-child{ + margin-top:0px; +} + +.card .card-text{ + padding:10px; +} + +.card .card-text:after{ + content:''; + display:block; + clear:both; +} + +.card .card-image{ + width: 40%; + padding: 10px; + position:relative; + float:left; +} + +.card img{ + width:80%; + padding:10px; + background: #d4d4fb; + border: solid 1px #dec8e2; + box-shadow: 0 1px 3px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.24); +} + +.card p, .card a{ + color:black; + opacity:.87; +} + +.card .card-details{ + font-family: 'Cormorant SC', serif; + list-style:none; + text-align:center; + margin-top:0px; + padding:0; +} + +.card .card-details.out-of-stock{ + opacity:.54; +} + +.card .card-details li a.disabled{ + opacity:.54; + text-decoration:none; +} + +.card .card-details.out-of-stock li.numberInStock{ + color:#5d0000 +} + +.card .card-details li{ + display:inline-block; + margin-right:20px; +} + +.card .card-details .numberInStock{ + font-weight:600; +} + +.card .card-details li:last-child{ + margin-right:0; +} +.catalogue{ + max-width:2000px; + column-count: 3; +} + +@media (max-width:400px){ + .card .card-image{ + width:100%; + float:none; + padding:0; + display: block; + margin: 10px auto; + text-align: center; + } + .card .card-image img{ + margin: 10px; + } +} diff --git a/components/form/form.js b/components/form/form.js index 1410303..65bfd36 100644 --- a/components/form/form.js +++ b/components/form/form.js @@ -1,58 +1,80 @@ // TODO: Enable exportDefaultFrom in Babel syntax import React, { useReducer } from 'react' +import axios from 'axios' import styles from './styles.module.css' export const Input = require('./input.js').default export const Button = require('./button.js').default -export const FormController = function FormController({errors, errorDispatch, children, onSubmit}){ - const initialState = { - fields: {} - } +const errorReducer = (errors, action) => { + switch (action.type) { + case 'set_errors': + const newErrors = {} + for(const field of action.errors) + newErrors[field.param] = field.msg + return newErrors - const reducer = (state, action)=>{ - let fields = {...state.fields} - const prevField = state.fields[action.name] + case 'clear_error': + console.log('clear_error', action) + return { + ...errors, + [action.field]: undefined + } - // If no value (onBlur), just mark touched and clear error - if(action.value === undefined){ - errorDispatch({type: 'clear_error', field: action.name}) + default: + return errors + } +} - return { - fields: { - ...fields, - [action.name]: { - ...prevField, - touched: true, - isValid: prevField.validate?prevField.validate(prevField.value, fields):true - } +const formReducer = errorDispatch => (state, action)=>{ + let fields = {...state.fields} + const prevField = state.fields[action.name] + + // If no value (onBlur), just mark touched and clear error + if(action.value === undefined){ + errorDispatch({type: 'clear_error', field: action.name}) + + return { + fields: { + ...fields, + [action.name]: { + ...prevField, + touched: true, + isValid: prevField.validate?prevField.validate(prevField.value, fields):true } } } + } - // Update currently changing field - const updatedField = { - ...prevField, - value: action.value !== undefined ? action.value : prevField.value, - isValid: prevField.validate?prevField.validate(action.value, fields):true - } - fields[action.name] = updatedField + // Update currently changing field + const updatedField = { + ...prevField, + value: action.value !== undefined ? action.value : prevField.value, + isValid: prevField.validate?prevField.validate(action.value, fields):true + } + fields[action.name] = updatedField - // Update other fields where the validate function takes whole state - const fieldNames = Object.keys(fields) - for(const name of fieldNames){ - if(name === action.name) continue + // Update other fields where the validate function takes whole state + const fieldNames = Object.keys(fields) + for(const name of fieldNames){ + if(name === action.name) continue - const field = fields[name] + const field = fields[name] + + if(field.validate && field.validate.length > 1) + fields[name] = { + ...field, + isValid: field.validate(field.value, fields) + } + } + + return {fields} +} - if(field.validate && field.validate.length > 1) - fields[name] = { - ...field, - isValid: field.validate(field.value, fields) - } - } - return {fields} +export const FormController = function FormController({children, url, method = 'POST', afterSubmit = ()=>null}){ + const initialState = { + fields: {} } // Update initial state @@ -68,28 +90,43 @@ export const FormController = function FormController({errors, errorDispatch, c } }) - // Create reducer - const [state, dispatch] = useReducer(reducer, initialState) + // Create reducers + const [errors, errorDispatch] = useReducer(errorReducer, {}) + const [state, dispatch] = useReducer(formReducer(errorDispatch), initialState) - const submitForm = ev=>{ + // Handle submitting form + const handleSubmit = async (ev) => { if(ev) ev.preventDefault(); - const fields = {} + const data = {} for(const name in state.fields){ const field = state.fields[name] - fields[field.name] = field.value + data[field.name] = field.value } - onSubmit(fields) + if(url) + try { + await axios({ method, url, data }) + } catch (err) { + if(!err.response || err.response.status !== 422) throw err; + + return errorDispatch({ + type: 'set_errors', + errors: err.response.data.errors + }) + } + + afterSubmit(data) } + // Map children const _children = React.Children.map(children, child => { if(child.type === Button && child.props.type.toLowerCase() === 'submit') return React.cloneElement(child, { enabled: !Object.values(state.fields).some(field=>!field.isValid), - onClick: submitForm + onClick: handleSubmit }) - + const {name} = child.props; if(!name) return child; return React.cloneElement(child, { @@ -102,7 +139,7 @@ export const FormController = function FormController({errors, errorDispatch, c }) return ( -
+ {_children}
) diff --git a/db/mappings/item.js b/db/mappings/item.js index c58be3b..02035b6 100644 --- a/db/mappings/item.js +++ b/db/mappings/item.js @@ -19,7 +19,8 @@ module.exports = [{ 'description', 'urlslug', 'price_cents', - 'published' + 'published', + 'number_in_stock' ], collections: [ {name: 'images', mapId: 'imageMap', columnPrefix: 'image_'} @@ -42,4 +43,4 @@ module.exports = [{ collections: [ {name: 'items', mapId: 'itemMap', columnPrefix: 'item_'} ] -}] \ No newline at end of file +}] diff --git a/db/models/category.js b/db/models/category.js index 89424a0..b988666 100644 --- a/db/models/category.js +++ b/db/models/category.js @@ -6,7 +6,7 @@ const mappings = require('../mappings') const category = module.exports = {} category.findAll = async () => { - const query = 'select * from v_category' + const query = 'select * from sos.v_category' debug(query); @@ -16,7 +16,7 @@ category.findAll = async () => { category.create = async (name, urlslug, description) => { const query = { - text: 'select * from public.create_category($1::text, $2::citext, $3::text)', + text: 'select * from sos.create_category($1::text, $2::citext, $3::text)', values: [ name, urlslug, @@ -32,7 +32,7 @@ category.create = async (name, urlslug, description) => { category.addItem = async (category_uuid, item_uuid) => { const query = { - text: 'select * from public.add_item_to_category($1::uuid, $2::uuid)', + text: 'select * from sos.add_item_to_category($1::uuid, $2::uuid)', values: [ category_uuid, item_uuid @@ -47,7 +47,7 @@ category.addItem = async (category_uuid, item_uuid) => { category.removeItem = async (category_uuid, item_uuid) => { const query = { - text: 'select * from public.remove_item_from_category($1::uuid, $2::uuid)', + text: 'select * from sos.remove_item_from_category($1::uuid, $2::uuid)', values: [ category_uuid, item_uuid diff --git a/db/models/item.js b/db/models/item.js index 7277198..76369ee 100644 --- a/db/models/item.js +++ b/db/models/item.js @@ -8,7 +8,7 @@ const sharp = require('sharp') const item = module.exports = {} item.findAll = async () => { - const query = 'select * from v_item' + const query = 'select * from sos.v_item' debug(query); @@ -18,7 +18,7 @@ item.findAll = async () => { item.findById = async (item_uuid) => { const query = { - text: 'select * from v_item where item_uuid = $1', + text: 'select * from sos.v_item where item_uuid = $1', values: [ item_uuid ] @@ -32,7 +32,7 @@ item.findById = async (item_uuid) => { item.findBySlug = async (item_slug) => { const query = { - text: 'select * from v_item where item_urlslug = $1', + text: 'select * from sos.v_item where item_urlslug = $1', values: [ item_slug ] @@ -46,7 +46,7 @@ item.findBySlug = async (item_slug) => { item.create = async (name, urlslug, description, price_cents, published) => { const query = { - text: 'select * from public.create_item($1::text, $2::citext, $3::text, $4::integer, $5::boolean)', + text: 'select * from sos..create_item($1::text, $2::citext, $3::text, $4::integer, $5::boolean)', values: [ name, urlslug, @@ -75,7 +75,7 @@ item.addImage = async (item_uuid, image_buffer, uploader_uuid) => { ]) const query = { - text: 'select * from public.add_image_to_item($1, $2, $3, $4, $5)', + text: 'select * from sos.add_image_to_item($1, $2, $3, $4, $5)', values: [ item_uuid, image, @@ -90,8 +90,8 @@ item.addImage = async (item_uuid, image_buffer, uploader_uuid) => { } const imageSizeQueries = { - 'large': 'select * from get_image_large($1)', - 'thumb': 'select * from get_image_thumb($1)' + 'large': 'select * from sos.get_image_large($1)', + 'thumb': 'select * from sos.get_image_thumb($1)' } item.getImage = async (image_uuid, size) => { @@ -109,7 +109,7 @@ item.getImage = async (image_uuid, size) => { item.removeImage = async (image_uuid) => { const query = { - text: 'select * from public.delete_image($1)', + text: 'select * from sos.delete_image($1)', values: [ image_uuid ] @@ -121,7 +121,7 @@ item.removeImage = async (image_uuid) => { item.removeItem = async (item_uuid) => { const query = { - text: 'select * from public.delete_item($1)', + text: 'select * from sos.delete_item($1)', values: [ item_uuid ] diff --git a/db/models/session.js b/db/models/session.js index 2bb5778..ea216b8 100644 --- a/db/models/session.js +++ b/db/models/session.js @@ -7,7 +7,7 @@ const session = module.exports = {} session.create = async (user_uuid, ip_address, user_agent, referer, origin_link_uuid) => { const query = { - text: 'select * from login_user_session($1, $2, $3, $4, $5, $6)', + text: 'select * from sos.login_user_session($1, $2, $3, $4, $5, $6)', values: [ user_uuid, '2 hours', @@ -26,7 +26,7 @@ session.create = async (user_uuid, ip_address, user_agent, referer, origin_link_ session.validate = async (session_uuid) => { const query = { - text: 'select * from validate_session($1)', + text: 'select * from sos.validate_session($1)', values: [ session_uuid ] @@ -40,7 +40,7 @@ session.validate = async (session_uuid) => { session.update = async (session_uuid) => { const query = { - text: 'select * from update_session($1)', + text: 'select * from sos.update_session($1)', values: [ session_uuid ] diff --git a/db/models/user.js b/db/models/user.js index 7c4f862..13c61d4 100644 --- a/db/models/user.js +++ b/db/models/user.js @@ -41,7 +41,7 @@ user.register = async (email, password) => { const hash = await bcrypt.hash(password, saltRounds) const query = { - text: 'select * from register_user($1, $2)', + text: 'select * from sos.register_user($1, $2)', values: [ email, hash diff --git a/db/sql/0-setup.sql b/db/sql/0-setup.sql index 7d542a6..284b954 100644 --- a/db/sql/0-setup.sql +++ b/db/sql/0-setup.sql @@ -7,3 +7,4 @@ alter default privileges for user sos in schema sos grant all privileges on func create extension if not exists "uuid-ossp"; CREATE EXTENSION IF NOT EXISTS citext; + diff --git a/db/sql/2-views.sql b/db/sql/2-views.sql index c5100a0..d578ca6 100644 --- a/db/sql/2-views.sql +++ b/db/sql/2-views.sql @@ -31,10 +31,21 @@ create or replace view sos.v_item as "image".image_featured, "image".image_mime_type, "image".image_date_uploaded, - "user".user_email + "user".user_email, + num_added - num_removed as item_number_in_stock from sos."item" - left join sos."image" on "item".item_uuid = "image".image_item_uuid - left join sos."user" on "image".image_uploader_uuid = "user".user_uuid; + left join sos."image" on item.item_uuid = image.image_item_uuid + left join sos."user" on image.image_uploader_uuid = "user".user_uuid + left join + ( + select + stockchange_item_uuid, + sum(case when stockchange_direction = 'added' then stockchange_change end)::int4 as num_added, + sum(case when stockchange_direction = 'subtracted' then stockchange_change end)::int4 as num_removed + from sos."item_stockchange" + group by stockchange_item_uuid + ) stock_counts + on stock_counts.stockchange_item_uuid = item.item_uuid; create or replace view sos.v_category as select diff --git a/hooks/errorReducer.js b/hooks/errorReducer.js deleted file mode 100644 index 01871dc..0000000 --- a/hooks/errorReducer.js +++ /dev/null @@ -1,25 +0,0 @@ -import {useReducer} from 'react' - -const errorReducer = (errors, action) => { - switch (action.type) { - case 'set_errors': - const newErrors = {} - for(const field of action.errors) - newErrors[field.param] = field.msg - return newErrors - - case 'clear_error': - console.log('clear_error', action) - return { - ...errors, - [action.field]: undefined - } - - default: - return errors - } -} - -export default function useErrorReducer(initialState = {}){ - return useReducer( errorReducer ) -} \ No newline at end of file diff --git a/hooks/useAccountRedirect.js b/hooks/useAccountRedirect.js new file mode 100644 index 0000000..67effc2 --- /dev/null +++ b/hooks/useAccountRedirect.js @@ -0,0 +1,7 @@ +import {useEffect} from 'react' + +export default function useAccountRedirect(user){ + useEffect(()=>{ + if(user) Router.push('/account') + }, [user]) +} diff --git a/pages/_app.js b/pages/_app.js index 5c38e14..48e4495 100644 --- a/pages/_app.js +++ b/pages/_app.js @@ -6,16 +6,26 @@ import Header from '~/components/header' import Footer from '~/components/footer' import "../styles/layout.css" -Layout.getInitialProps = async ({ctx}) => { - const {data: user} = await axios.get(`/api/auth`, { headers: ctx.req ? { cookie: ctx.req.headers.cookie } : undefined }) - return {user} +Layout.getInitialProps = async ({Component, ctx}) => { + // Configure axios instance + ctx.axios = axios.create({ + headers: ctx.req ? {cookie: ctx.req.headers.cookie} : undefined + }) + + const {data: user} = await ctx.axios.get(`/api/auth`) + + let pageProps = {}; + if(Component.getInitialProps) + pageProps = await Component.getInitialProps({ctx}) + + return {pageProps, user} } function Layout({ Component, pageProps, user }){ return ( <>
-
+