Merge branch 'master' of gitlab.com:pawnstar/sos-nextjs

main
Ashelyn Dawn 5 years ago
commit 3140b5d315

@ -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": [
"<node_internals>/**"
],
"program": "${workspaceFolder}\\index.js"
}
]
}

@ -50,11 +50,6 @@ router.delete('/:uuid', async (req, res) => {
}) })
router.post('/:uuid/images', upload.single('image'), 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 // Handle either image upload body or JSON body
try { try {
if(req.file) if(req.file)

@ -17,6 +17,19 @@ router.post('/', parseJSON, registerValidation, async (req, res) => {
req.body.password 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( const session = await db.session.create(
user.uuid, user.uuid,
req.ip, req.ip,

@ -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 (
<div className={styles.card}>
<h3><Link href={`/store/sock/${item.urlslug}`}><a>{item.name}</a></Link></h3>
<div className={styles['card-image']}>
{featuredImage && <img src={`/api/images/${featuredImage.uuid}/thumb`} />}
</div>
<div className={styles['card-text']}>{item.description}</div>
<ul className={styles['card-details'] + (item.number_in_stock > 0 ? '' : ' ' + styles['out-of-stock'])}>
<li className={styles['number-in-stock']}>
{
item.number_in_stock > 0
? `${item.number_in_stock} pairs in stock`
: 'Currently out of stock'
}
</li>
<li><Link href={`/store/sock/${item.urlSlug}`}><a>Details</a></Link></li>
{
item.number_in_stock > 0 && (
<li>
<a disabled={!(item.number_in_stock > 0)}>Add to Cart</a>
</li>
)
}
</ul>
</div>
)
}

@ -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;
}
}

@ -1,58 +1,80 @@
// TODO: Enable exportDefaultFrom in Babel syntax // TODO: Enable exportDefaultFrom in Babel syntax
import React, { useReducer } from 'react' import React, { useReducer } from 'react'
import axios from 'axios'
import styles from './styles.module.css' import styles from './styles.module.css'
export const Input = require('./input.js').default export const Input = require('./input.js').default
export const Button = require('./button.js').default export const Button = require('./button.js').default
export const FormController = function FormController({errors, errorDispatch, children, onSubmit}){ const errorReducer = (errors, action) => {
const initialState = { switch (action.type) {
fields: {} case 'set_errors':
} const newErrors = {}
for(const field of action.errors)
newErrors[field.param] = field.msg
return newErrors
const reducer = (state, action)=>{ case 'clear_error':
let fields = {...state.fields} console.log('clear_error', action)
const prevField = state.fields[action.name] return {
...errors,
[action.field]: undefined
}
// If no value (onBlur), just mark touched and clear error default:
if(action.value === undefined){ return errors
errorDispatch({type: 'clear_error', field: action.name}) }
}
return { const formReducer = errorDispatch => (state, action)=>{
fields: { let fields = {...state.fields}
...fields, const prevField = state.fields[action.name]
[action.name]: {
...prevField, // If no value (onBlur), just mark touched and clear error
touched: true, if(action.value === undefined){
isValid: prevField.validate?prevField.validate(prevField.value, fields):true 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 // Update currently changing field
const updatedField = { const updatedField = {
...prevField, ...prevField,
value: action.value !== undefined ? action.value : prevField.value, value: action.value !== undefined ? action.value : prevField.value,
isValid: prevField.validate?prevField.validate(action.value, fields):true isValid: prevField.validate?prevField.validate(action.value, fields):true
} }
fields[action.name] = updatedField fields[action.name] = updatedField
// Update other fields where the validate function takes whole state // Update other fields where the validate function takes whole state
const fieldNames = Object.keys(fields) const fieldNames = Object.keys(fields)
for(const name of fieldNames){ for(const name of fieldNames){
if(name === action.name) continue 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 // Update initial state
@ -68,28 +90,43 @@ export const FormController = function FormController({errors, errorDispatch, c
} }
}) })
// Create reducer // Create reducers
const [state, dispatch] = useReducer(reducer, initialState) 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(); if(ev) ev.preventDefault();
const fields = {} const data = {}
for(const name in state.fields){ for(const name in state.fields){
const field = state.fields[name] 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 => { const _children = React.Children.map(children, child => {
if(child.type === Button && child.props.type.toLowerCase() === 'submit') if(child.type === Button && child.props.type.toLowerCase() === 'submit')
return React.cloneElement(child, { return React.cloneElement(child, {
enabled: !Object.values(state.fields).some(field=>!field.isValid), enabled: !Object.values(state.fields).some(field=>!field.isValid),
onClick: submitForm onClick: handleSubmit
}) })
const {name} = child.props; const {name} = child.props;
if(!name) return child; if(!name) return child;
return React.cloneElement(child, { return React.cloneElement(child, {
@ -102,7 +139,7 @@ export const FormController = function FormController({errors, errorDispatch, c
}) })
return ( return (
<form onSubmit={onSubmit} className={styles.formContainer}> <form autocomplete="off" onSubmit={handleSubmit} className={styles.formContainer}>
{_children} {_children}
</form> </form>
) )

@ -19,7 +19,8 @@ module.exports = [{
'description', 'description',
'urlslug', 'urlslug',
'price_cents', 'price_cents',
'published' 'published',
'number_in_stock'
], ],
collections: [ collections: [
{name: 'images', mapId: 'imageMap', columnPrefix: 'image_'} {name: 'images', mapId: 'imageMap', columnPrefix: 'image_'}
@ -42,4 +43,4 @@ module.exports = [{
collections: [ collections: [
{name: 'items', mapId: 'itemMap', columnPrefix: 'item_'} {name: 'items', mapId: 'itemMap', columnPrefix: 'item_'}
] ]
}] }]

@ -6,7 +6,7 @@ const mappings = require('../mappings')
const category = module.exports = {} const category = module.exports = {}
category.findAll = async () => { category.findAll = async () => {
const query = 'select * from v_category' const query = 'select * from sos.v_category'
debug(query); debug(query);
@ -16,7 +16,7 @@ category.findAll = async () => {
category.create = async (name, urlslug, description) => { category.create = async (name, urlslug, description) => {
const query = { 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: [ values: [
name, name,
urlslug, urlslug,
@ -32,7 +32,7 @@ category.create = async (name, urlslug, description) => {
category.addItem = async (category_uuid, item_uuid) => { category.addItem = async (category_uuid, item_uuid) => {
const query = { 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: [ values: [
category_uuid, category_uuid,
item_uuid item_uuid
@ -47,7 +47,7 @@ category.addItem = async (category_uuid, item_uuid) => {
category.removeItem = async (category_uuid, item_uuid) => { category.removeItem = async (category_uuid, item_uuid) => {
const query = { 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: [ values: [
category_uuid, category_uuid,
item_uuid item_uuid

@ -8,7 +8,7 @@ const sharp = require('sharp')
const item = module.exports = {} const item = module.exports = {}
item.findAll = async () => { item.findAll = async () => {
const query = 'select * from v_item' const query = 'select * from sos.v_item'
debug(query); debug(query);
@ -18,7 +18,7 @@ item.findAll = async () => {
item.findById = async (item_uuid) => { item.findById = async (item_uuid) => {
const query = { const query = {
text: 'select * from v_item where item_uuid = $1', text: 'select * from sos.v_item where item_uuid = $1',
values: [ values: [
item_uuid item_uuid
] ]
@ -32,7 +32,7 @@ item.findById = async (item_uuid) => {
item.findBySlug = async (item_slug) => { item.findBySlug = async (item_slug) => {
const query = { const query = {
text: 'select * from v_item where item_urlslug = $1', text: 'select * from sos.v_item where item_urlslug = $1',
values: [ values: [
item_slug item_slug
] ]
@ -46,7 +46,7 @@ item.findBySlug = async (item_slug) => {
item.create = async (name, urlslug, description, price_cents, published) => { item.create = async (name, urlslug, description, price_cents, published) => {
const query = { 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: [ values: [
name, name,
urlslug, urlslug,
@ -75,7 +75,7 @@ item.addImage = async (item_uuid, image_buffer, uploader_uuid) => {
]) ])
const query = { 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: [ values: [
item_uuid, item_uuid,
image, image,
@ -90,8 +90,8 @@ item.addImage = async (item_uuid, image_buffer, uploader_uuid) => {
} }
const imageSizeQueries = { const imageSizeQueries = {
'large': 'select * from get_image_large($1)', 'large': 'select * from sos.get_image_large($1)',
'thumb': 'select * from get_image_thumb($1)' 'thumb': 'select * from sos.get_image_thumb($1)'
} }
item.getImage = async (image_uuid, size) => { item.getImage = async (image_uuid, size) => {
@ -109,7 +109,7 @@ item.getImage = async (image_uuid, size) => {
item.removeImage = async (image_uuid) => { item.removeImage = async (image_uuid) => {
const query = { const query = {
text: 'select * from public.delete_image($1)', text: 'select * from sos.delete_image($1)',
values: [ values: [
image_uuid image_uuid
] ]
@ -121,7 +121,7 @@ item.removeImage = async (image_uuid) => {
item.removeItem = async (item_uuid) => { item.removeItem = async (item_uuid) => {
const query = { const query = {
text: 'select * from public.delete_item($1)', text: 'select * from sos.delete_item($1)',
values: [ values: [
item_uuid item_uuid
] ]

@ -7,7 +7,7 @@ const session = module.exports = {}
session.create = async (user_uuid, ip_address, user_agent, referer, origin_link_uuid) => { session.create = async (user_uuid, ip_address, user_agent, referer, origin_link_uuid) => {
const query = { 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: [ values: [
user_uuid, user_uuid,
'2 hours', '2 hours',
@ -26,7 +26,7 @@ session.create = async (user_uuid, ip_address, user_agent, referer, origin_link_
session.validate = async (session_uuid) => { session.validate = async (session_uuid) => {
const query = { const query = {
text: 'select * from validate_session($1)', text: 'select * from sos.validate_session($1)',
values: [ values: [
session_uuid session_uuid
] ]
@ -40,7 +40,7 @@ session.validate = async (session_uuid) => {
session.update = async (session_uuid) => { session.update = async (session_uuid) => {
const query = { const query = {
text: 'select * from update_session($1)', text: 'select * from sos.update_session($1)',
values: [ values: [
session_uuid session_uuid
] ]

@ -41,7 +41,7 @@ user.register = async (email, password) => {
const hash = await bcrypt.hash(password, saltRounds) const hash = await bcrypt.hash(password, saltRounds)
const query = { const query = {
text: 'select * from register_user($1, $2)', text: 'select * from sos.register_user($1, $2)',
values: [ values: [
email, email,
hash hash

@ -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 "uuid-ossp";
CREATE EXTENSION IF NOT EXISTS citext; CREATE EXTENSION IF NOT EXISTS citext;

@ -31,10 +31,21 @@ create or replace view sos.v_item as
"image".image_featured, "image".image_featured,
"image".image_mime_type, "image".image_mime_type,
"image".image_date_uploaded, "image".image_date_uploaded,
"user".user_email "user".user_email,
num_added - num_removed as item_number_in_stock
from sos."item" from sos."item"
left join sos."image" on "item".item_uuid = "image".image_item_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 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 create or replace view sos.v_category as
select select

@ -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 )
}

@ -0,0 +1,7 @@
import {useEffect} from 'react'
export default function useAccountRedirect(user){
useEffect(()=>{
if(user) Router.push('/account')
}, [user])
}

@ -6,16 +6,26 @@ import Header from '~/components/header'
import Footer from '~/components/footer' import Footer from '~/components/footer'
import "../styles/layout.css" import "../styles/layout.css"
Layout.getInitialProps = async ({ctx}) => { Layout.getInitialProps = async ({Component, ctx}) => {
const {data: user} = await axios.get(`/api/auth`, { headers: ctx.req ? { cookie: ctx.req.headers.cookie } : undefined }) // Configure axios instance
return {user} 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 }){ function Layout({ Component, pageProps, user }){
return ( return (
<> <>
<Header user={user} /> <Header user={user} />
<main><Component {...pageProps} /></main> <main><Component {...{user, ...pageProps}} /></main>
<Footer/> <Footer/>
</> </>
) )

@ -1,11 +1,22 @@
import React from 'react' import React from 'react'
import Hero from '~/components/hero' import Hero from '~/components/hero'
import Card from '~/components/card'
const Index = () => ( Index.getInitialProps = async ({ctx})=>{
<> const {data: items} = await ctx.axios.get('/api/items')
<Hero/> return {items}
<p>Homepage</p> }
</>
) export default function Index({items}){
export default Index return (
<>
<Hero/>
<div className="cardContainer">
{items.map(item=>
<Card item={item}/>
)}
</div>
</>
)
}

@ -2,30 +2,15 @@ import React from 'react'
import Link from 'next/link' import Link from 'next/link'
import Router from 'next/router' import Router from 'next/router'
import isEmail from 'validator/lib/isEmail' import isEmail from 'validator/lib/isEmail'
import axios from 'axios'
import {FormController, Input, Button} from '~/components/form' import {FormController, Input, Button} from '~/components/form'
import useErrorReducer from '../hooks/errorReducer' import useAccountRedirect from '../hooks/useAccountRedirect'
export default function Login(){ export default function Login({user}){
const [errors, dispatch] = useErrorReducer() useAccountRedirect(user)
const submit = async (values)=>{
try {
await axios.post(`/api/auth`, values)
Router.push('/')
} catch (err) {
if(!err.response || err.response.status !== 422) throw err;
dispatch({
type: 'set_errors',
errors: err.response.data.errors
})
}
}
return ( return (
<FormController errors={errors} errorDispatch={dispatch} onSubmit={submit}> <FormController url="/api/auth" afterSubmit={()=>Router.push('/account')}>
<h1>Login</h1> <h1>Login</h1>
<Input label="Email" type="text" name="email" validate={value=>isEmail(value)} hint="Enter a valid email address" /> <Input label="Email" type="text" name="email" validate={value=>isEmail(value)} hint="Enter a valid email address" />
<Input label="Password" type="password" name="password" validate={value=>(value.length >= 8)} hint="Password must be at least 8 characters long" /> <Input label="Password" type="password" name="password" validate={value=>(value.length >= 8)} hint="Password must be at least 8 characters long" />

@ -1,30 +1,16 @@
import React from 'react' import React from 'react'
import Link from 'next/link' import Link from 'next/link'
import Router from 'next/router'
import isEmail from 'validator/lib/isEmail' import isEmail from 'validator/lib/isEmail'
import axios from 'axios'
import {FormController, Input, Button} from '~/components/form' import {FormController, Input, Button} from '~/components/form'
import useErrorReducer from '../hooks/errorReducer' import useAccountRedirect from '../hooks/useAccountRedirect'
export default function Register(){ export default function Register({user}){
const [errors, dispatch] = useErrorReducer() useAccountRedirect(user)
const submit = async (values)=>{
try {
const {data} = await axios.post(`/api/users`, values)
console.log(data)
} catch (err) {
if(!err.response || err.response.status !== 422) throw err;
dispatch({
type: 'set_errors',
errors: err.response.data.errors
})
}
}
return ( return (
<FormController errors={errors} errorDispatch={dispatch} onSubmit={submit}> <FormController url="/api/users" afterSubmit={()=>Router.push('/account')}>
<h1>Register</h1> <h1>Register</h1>
<Input label="Email" type="text" name="email" validate={value=>isEmail(value)} hint="Enter a valid email address" /> <Input label="Email" type="text" name="email" validate={value=>isEmail(value)} hint="Enter a valid email address" />
<Input label="Password" type="password" name="password" validate={value=>(value.length >= 8)} hint="Password must be at least 8 characters long" /> <Input label="Password" type="password" name="password" validate={value=>(value.length >= 8)} hint="Password must be at least 8 characters long" />

@ -20,3 +20,33 @@ main {
color: black; color: black;
padding: 30px 0; padding: 30px 0;
} }
.cardContainer{
margin-left: auto;
margin-right: auto;
margin-bottom: 30px;
padding-left: 50px;
padding-right: 50px;
max-width:2000px;
column-count: 3;
}
@media (max-width:1200px){
.cardContainer{
padding:0 2%;
}
}
@media (max-width: 1000px) {
.cardContainer {
column-count: 2;
}
}
@media (max-width: 600px) {
.cardContainer {
margin-top: 20px;
column-count: 1;
}
}

Loading…
Cancel
Save