diff options
Diffstat (limited to 'finished-application/frontend/components')
44 files changed, 2722 insertions, 0 deletions
diff --git a/finished-application/frontend/components/AddToCart.js b/finished-application/frontend/components/AddToCart.js new file mode 100644 index 0000000..03242a4 --- /dev/null +++ b/finished-application/frontend/components/AddToCart.js @@ -0,0 +1,68 @@ +import { Component } from 'react'; +import { Mutation } from 'react-apollo'; +import PropTypes from 'prop-types'; +import gql from 'graphql-tag'; +import User, { CURRENT_USER_QUERY } from './User'; + +const ADD_TO_CART_MUTATION = gql` + mutation addToCart($id: ID!) { + addToCart(id: $id) { + id + quantity + item { + id + price + description + image + title + } + } + } +`; + +class AddToCart extends Component { + static propTypes = { + id: PropTypes.string.isRequired, + }; + + update = (cache, payload) => { + const newCartItem = payload.data.addToCart; + const data = cache.readQuery({ query: CURRENT_USER_QUERY }); + + const existingIndex = data.me.cart.findIndex(cartItem => cartItem.id === newCartItem.id); + if (existingIndex >= 0) { + // already in cache, just replace it + data.me.cart = [ + ...data.me.cart.slice(0, existingIndex), + newCartItem, + ...data.me.cart.slice(existingIndex + 1), + ]; + } else { + data.me.cart = [...data.me.cart, newCartItem]; + } + cache.writeQuery({ query: CURRENT_USER_QUERY, data }); + }; + + render() { + const { id } = this.props; + return ( + <User> + {({ data: { me }, loading }) => { + if (!me || loading) return null; + return ( + <Mutation mutation={ADD_TO_CART_MUTATION} variables={{ id }} update={this.update}> + {(addToCart, { loading }) => ( + <button disabled={loading} onClick={addToCart}> + 🛒 Add{loading && 'ing'} To Cart + </button> + )} + </Mutation> + ); + }} + </User> + ); + } +} + +export default AddToCart; +export { ADD_TO_CART_MUTATION }; diff --git a/finished-application/frontend/components/Cart.js b/finished-application/frontend/components/Cart.js new file mode 100644 index 0000000..7e8a9c5 --- /dev/null +++ b/finished-application/frontend/components/Cart.js @@ -0,0 +1,74 @@ +import React from 'react'; +import { Mutation, Query } from 'react-apollo'; +import { adopt } from 'react-adopt'; +import gql from 'graphql-tag'; +import TakeMyMoney from './TakeMyMoney'; +import formatMoney from '../lib/formatMoney'; +import CartItem from './CartItem'; +import { CURRENT_USER_QUERY } from './User'; +import calcTotalPrice from '../lib/calcTotalPrice'; +import Error from './ErrorMessage'; +import CartStyles from './styles/CartStyles'; +import Supreme from './styles/Supreme'; +import CloseButton from './styles/CloseButton'; +import SickButton from './styles/SickButton'; + +const LOCAL_STATE_QUERY = gql` + query { + cartOpen @client + } +`; + +const TOGGLE_CART_MUTATION = gql` + mutation { + toggleCart @client + } +`; + +const Composed = adopt({ + toggleCart: ({ render }) => ( + <Mutation mutation={TOGGLE_CART_MUTATION}> + {(mutate, result) => render({ mutate, result })} + </Mutation> + ), + localState: ({ render }) => <Query query={LOCAL_STATE_QUERY} children={render} />, + currentUser: ({ render }) => ( + <Query children={render} query={CURRENT_USER_QUERY} data-test="cart" /> + ), +}); + +const Cart = () => ( + <Composed> + {({ toggleCart, localState, currentUser }) => { + const { data: { me }, error, loading } = currentUser; + if (loading) return <p>Loading...</p>; + if (error) return <Error error={error} />; + if (!me) return null; + return ( + <CartStyles open={localState.data.cartOpen}> + <header> + <CloseButton title="close" onClick={toggleCart.mutate}> + × + </CloseButton> + + <Supreme>{me.name}'s Cart.</Supreme> + <p> + You have {me.cart.length} item{me.cart.length === 1 ? '' : 's'} in your cart. + </p> + </header> + + <ul>{me.cart.map(cartItem => <CartItem key={cartItem.id} cartItem={cartItem} />)}</ul> + <footer> + <p>{formatMoney(calcTotalPrice(me.cart))}</p> + <TakeMyMoney> + <SickButton>Checkout</SickButton> + </TakeMyMoney> + </footer> + </CartStyles> + ); + }} + </Composed> +); + +export default Cart; +export { LOCAL_STATE_QUERY, TOGGLE_CART_MUTATION }; diff --git a/finished-application/frontend/components/CartCount.js b/finished-application/frontend/components/CartCount.js new file mode 100644 index 0000000..ae51f20 --- /dev/null +++ b/finished-application/frontend/components/CartCount.js @@ -0,0 +1,64 @@ +import styled from 'styled-components'; +import { TransitionGroup, CSSTransition } from 'react-transition-group'; +import PropTypes from 'prop-types'; + +const Dot = styled.div` + background: ${props => props.theme.red}; + color: white; + border-radius: 50%; + padding: 0.5rem; + line-height: 2rem; + min-width: 3rem; + margin-left: 1rem; + font-weight: 100; + font-feature-settings: 'tnum'; + font-variant-numeric: tabular-nums; +`; + +const AnimationStyles = styled.span` + position: relative; + .count { + display: block; + position: relative; + transition: all 0.4s; + backface-visibility: hidden; + } + .count-enter { + transform: rotateX(0.5turn); + } + + .count-enter-active { + transform: rotateX(0); + } + + .count-exit { + top: 0; + position: absolute; + transform: rotateX(0); + } + + .count-exit-active { + transform: rotateX(0.5turn); + } +`; + +const CartCount = ({ count }) => ( + <AnimationStyles> + <TransitionGroup> + <CSSTransition + unmountOnExit + className="count" + classNames="count" + key={count} + timeout={{ enter: 400, exit: 400 }} + > + <Dot>{count}</Dot> + </CSSTransition> + </TransitionGroup> + </AnimationStyles> +); + +CartCount.propTypes = { + count: PropTypes.number.isRequired, +}; +export default CartCount; diff --git a/finished-application/frontend/components/CartItem.js b/finished-application/frontend/components/CartItem.js new file mode 100644 index 0000000..6c6d575 --- /dev/null +++ b/finished-application/frontend/components/CartItem.js @@ -0,0 +1,53 @@ +import styled from 'styled-components'; +import PropTypes from 'prop-types'; +import formatMoney from '../lib/formatMoney'; +import RemoveFromCart from './RemoveFromCart'; + +const CartItemStyles = styled.li` + padding: 1rem 0; + border-bottom: 1px solid ${props => props.theme.lightgrey}; + display: grid; + align-items: center; + grid-template-columns: auto 1fr auto; + img { + margin-right: 10px; + } + h3 { + margin: 0; + } + p { + margin: 0; + } +`; + +const CartItem = ({ cartItem }) => { + if (!cartItem.item) + return ( + <CartItemStyles key={cartItem.id}> + <p>Ack! That Item is gone!</p> + <RemoveFromCart id={cartItem.id} /> + </CartItemStyles> + ); + return ( + <CartItemStyles key={cartItem.id}> + <img width="100" src={cartItem.item.image} alt={cartItem.item.title} /> + <div className="cart-item-details"> + <h3>{cartItem.item.title}</h3> + <p> + {formatMoney(cartItem.quantity * cartItem.item.price)} + {' — '} + <em> + {cartItem.quantity} × {formatMoney(cartItem.item.price)} each + </em> + </p> + </div> + <RemoveFromCart id={cartItem.id} /> + </CartItemStyles> + ); +}; + +CartItem.propTypes = { + cartItem: PropTypes.object.isRequired, +}; + +export default CartItem; diff --git a/finished-application/frontend/components/CreateItem.js b/finished-application/frontend/components/CreateItem.js new file mode 100644 index 0000000..503b3cb --- /dev/null +++ b/finished-application/frontend/components/CreateItem.js @@ -0,0 +1,148 @@ +import React, { Component } from 'react'; +import { Mutation } from 'react-apollo'; +import Router from 'next/router'; +import wait from 'waait'; +import gql from 'graphql-tag'; +import Error from './ErrorMessage'; +import Form from './styles/Form'; +import formatMoney from '../lib/formatMoney'; +import { ALL_ITEMS_QUERY } from './Items'; +import { PAGINATION_QUERY } from './Pagination'; + +const CREATE_ITEM_MUTATION = gql` + mutation CREATE_ITEM_MUTATION( + $description: String! + $title: String! + $price: Int! + $image: String + $largeImage: String + ) { + createItem( + description: $description + title: $title + price: $price + image: $image + largeImage: $largeImage + ) { + id + } + } +`; + +class CreateItem extends Component { + state = { + title: '', + description: '', + image: '', + largeImage: '', + price: 0, + }; + + handleChange = e => { + const { name, value, type } = e.target; + const val = type === 'number' ? parseFloat(value) : value; + this.setState({ [name]: val }); + }; + + uploadFile = async e => { + this.setState({ loading: true }); + const files = e.currentTarget.files; + const data = new FormData(); + data.append('file', files[0]); + data.append('upload_preset', 'sickfits'); + + // use the file endpoint + const res = await fetch('https://api.cloudinary.com/v1_1/wesbos/image/upload', { + method: 'POST', + body: data, + }); + const file = await res.json(); + this.setState({ + image: file.secure_url, + largeImage: file.eager[0].secure_url, + loading: false, + }); + }; + + render() { + return ( + <Mutation + mutation={CREATE_ITEM_MUTATION} + variables={this.state} + refetchQueries={[{ query: ALL_ITEMS_QUERY }, { query: PAGINATION_QUERY }]} + > + {(createItem, { loading, error }) => ( + <Form + data-test + onSubmit={async e => { + e.preventDefault(); + const { data: { createItem: item } } = await createItem(); + // we wait 0 ms so it puts the router push at the end of the call stack. This ensures that refetchQueries runs before we unmount the component :) + await wait(); + Router.push({ + pathname: `/item`, + query: { id: item.id }, + }); + }} + > + <h2>Sell an Item.</h2> + <Error error={error} /> + <fieldset disabled={loading} aria-busy={loading}> + <label htmlFor="file"> + Image + <input + required + id="file" + onChange={this.uploadFile} + type="file" + accept=".png, .jpg, .jpeg" + /> + {this.state.image ? ( + <img src={this.state.image} width="100" alt={this.state.title} /> + ) : null} + </label> + <label htmlFor="title"> + Title + <input + required + value={this.state.title} + onChange={this.handleChange} + type="text" + name="title" + id="title" + placeholder="Title" + /> + </label> + <label htmlFor="price"> + Price {this.state.price && formatMoney(this.state.price)} + <input + required + type="number" + id="price" + name="price" + min="0" + value={this.state.price} + onChange={this.handleChange} + /> + </label> + <textarea + id="description" + required + name="description" + value={this.state.description} + onChange={this.handleChange} + placeholder="The desc for this item" + /> + <button disabled={this.state.loading} type="submit"> + Submit + </button> + </fieldset> + </Form> + )} + </Mutation> + ); + } +} + +export default CreateItem; +export { CREATE_ITEM_MUTATION }; diff --git a/finished-application/frontend/components/DeleteItem.js b/finished-application/frontend/components/DeleteItem.js new file mode 100644 index 0000000..4d68d5e --- /dev/null +++ b/finished-application/frontend/components/DeleteItem.js @@ -0,0 +1,71 @@ +import React from 'react'; +import { Mutation } from 'react-apollo'; +import PropTypes, { number } from 'prop-types'; +import gql from 'graphql-tag'; +import { withRouter } from 'next/router'; +import { ALL_ITEMS_QUERY } from './Items'; +import { PAGINATION_QUERY } from './Pagination'; +import { perPage } from '../config'; + +const DELETE_ITEM_MUTATION = gql` + mutation deleteItem($id: ID!) { + deleteItem(id: $id) { + id + title + description + } + } +`; + +class DeleteItem extends React.Component { + static propTypes = { + id: PropTypes.string.isRequired, + }; + + update = (cache, payload) => { + const deletedItem = payload.data.deleteItem; + let { page = 1 } = this.props.router.query; + page = parseFloat(page); + const skip = page * perPage - perPage; + const variables = { skip }; + const data = cache.readQuery({ query: ALL_ITEMS_QUERY, variables }); + // filter this one out + data.items = data.items.filter(item => item.id !== deletedItem.id); + // write the data back to the cache + console.log(data.items); + cache.writeQuery({ query: ALL_ITEMS_QUERY, data, variables }); + // FYI Pagination is broken with Apollo currently - will make a followup video + }; + + render() { + return ( + <Mutation + mutation={DELETE_ITEM_MUTATION} + variables={{ id: this.props.id }} + refetchQueries={[ + { + query: ALL_ITEMS_QUERY, + variables: { skip: (this.props.router.query.page || 1) * perPage - perPage }, + }, + { query: PAGINATION_QUERY }, + ]} + update={this.update} + > + {(removeItem, { error }) => ( + <button + onClick={() => { + if (confirm('Are you sure you want to delete this item?')) { + removeItem(); + } + }} + > + {error ? error.message : '× Delete Item'} + </button> + )} + </Mutation> + ); + } +} + +export default withRouter(DeleteItem); +export { DELETE_ITEM_MUTATION }; diff --git a/finished-application/frontend/components/Dump.js b/finished-application/frontend/components/Dump.js new file mode 100644 index 0000000..f281281 --- /dev/null +++ b/finished-application/frontend/components/Dump.js @@ -0,0 +1,19 @@ +const Dump = props => ( + <div + style={{ + fontSize: 20, + border: '1px solid #efefef', + padding: 10, + background: 'white', + }} + > + {Object.keys(props).map(prop => ( + <pre key={prop}> + <strong style={{ color: 'white', background: 'red' }}>{prop} 💩</strong> + {JSON.stringify(props[prop], '', ' ')} + </pre> + ))} + </div> +); + +export default Dump; diff --git a/finished-application/frontend/components/EditUser.js b/finished-application/frontend/components/EditUser.js new file mode 100644 index 0000000..1bb8e5b --- /dev/null +++ b/finished-application/frontend/components/EditUser.js @@ -0,0 +1,81 @@ +import React from 'react'; +import { Query, Mutation } from 'react-apollo'; +import gql from 'graphql-tag'; +import Form from './styles/Form'; +import { CURRENT_USER_QUERY } from './User'; +import Error from './ErrorMessage'; +import User from './User'; + +const UPDATE_USER_MUTATION = gql` + mutation updateUser($name: String!) { + updateUser(name: $name) { + name + } + } +`; + +class EditUser extends React.Component { + state = { + changes: {}, + }; + + handleChange = e => { + const { name, value } = e.target; + const changes = { + ...this.state.changes, + [name]: value, + }; + this.setState({ changes }); + }; + + handleSubmit = async (e, updateUser) => { + e.preventDefault(); + // only submit if there are real changes + if (Object.keys(this.state.changes).length === 0) return; + await updateUser(); + this.setState({ changes: {} }); + }; + + render() { + return ( + <User> + {({ data: { me }, loading }) => { + if (loading) return <p>Loading...</p>; + return ( + <Mutation + mutation={UPDATE_USER_MUTATION} + refetchQueries={[{ query: CURRENT_USER_QUERY }]} + variables={this.state.changes} + > + {(updateUser, { error, called }) => ( + <Form onSubmit={e => this.handleSubmit(e, updateUser)}> + <Error error={error} /> + <fieldset disabled={loading} aria-busy={loading}> + {called && !error && <p data-test="updated">Updated!</p>} + <label htmlFor="name"> + Name: + <input + type="text" + name="name" + defaultValue={me.name} + onChange={this.handleChange} + /> + </label> + <button type="submit">Update</button> + <strong>me:</strong> + <pre>{JSON.stringify(me.name)}</pre> + <strong>Change:</strong> + <pre data-test="change">{JSON.stringify(this.state.changes)}</pre> + </fieldset> + </Form> + )} + </Mutation> + ); + }} + </User> + ); + } +} + +export default EditUser; +export { UPDATE_USER_MUTATION }; diff --git a/finished-application/frontend/components/ErrorMessage.js b/finished-application/frontend/components/ErrorMessage.js new file mode 100644 index 0000000..52d59a8 --- /dev/null +++ b/finished-application/frontend/components/ErrorMessage.js @@ -0,0 +1,51 @@ +import styled from 'styled-components'; +import React from 'react'; + +import PropTypes from 'prop-types'; + +const ErrorStyles = styled.div` + padding: 2rem; + background: white; + margin: 2rem 0; + border: 1px solid rgba(0, 0, 0, 0.05); + border-left: 5px solid red; + p { + margin: 0; + font-weight: 100; + } + strong { + margin-right: 1rem; + } +`; + +const DisplayError = ({ error }) => { + if (!error || !error.message) return null; + if (error.networkError && error.networkError.result && error.networkError.result.errors.length) { + return error.networkError.result.errors.map((error, i) => ( + <ErrorStyles key={i}> + <p data-test="graphql-error"> + <strong>Shoot!</strong> + {error.message.replace('GraphQL error: ', '')} + </p> + </ErrorStyles> + )); + } + return ( + <ErrorStyles> + <p data-test="graphql-error"> + <strong>Shoot!</strong> + {error.message.replace('GraphQL error: ', '')} + </p> + </ErrorStyles> + ); +}; + +DisplayError.defaultProps = { + error: {}, +}; + +DisplayError.propTypes = { + error: PropTypes.object, +}; + +export default DisplayError; diff --git a/finished-application/frontend/components/Header.js b/finished-application/frontend/components/Header.js new file mode 100644 index 0000000..bb05c34 --- /dev/null +++ b/finished-application/frontend/components/Header.js @@ -0,0 +1,71 @@ +import React from 'react'; +import NProgress from 'nprogress'; +import Router from 'next/router'; +import styled from 'styled-components'; +import Link from 'next/link'; +import Cart from './Cart'; +import Search from './Search'; +import Nav from './Nav'; + +Router.onRouteChangeStart = () => { + NProgress.start(); +}; +Router.onRouteChangeComplete = () => NProgress.done(); +Router.onRouteChangeError = () => NProgress.done(); + +const StyledHeader = styled.header` + .bar { + border-bottom: 10px solid ${props => props.theme.black}; + display: grid; + grid-template-columns: auto 1fr; + justify-content: space-between; + align-items: stretch; + @media (max-width: 1300px) { + grid-template-columns: 1fr; + justify-content: center; + } + } + .sub-bar { + display: grid; + grid-template-columns: 1fr auto; + border-bottom: 1px solid ${props => props.theme.lightgrey}; + } +`; + +const Logo = styled.h1` + font-size: 4rem; + margin-left: 2rem; + position: relative; + z-index: 2; + transform: skew(-7deg); + a { + padding: 0.5rem 1rem; + background: ${props => props.theme.red}; + color: white; + letter-spacing: -2px; + text-transform: uppercase; + } + @media (max-width: 1300px) { + margin: 0; + text-align: center; + } +`; + +const Header = () => ( + <StyledHeader> + <div className="bar"> + <Logo> + <Link href="/"> + <a>Sick Fits!</a> + </Link> + </Logo> + <Nav /> + </div> + <div className="sub-bar"> + <Search /> + </div> + <Cart /> + </StyledHeader> +); + +export default Header; diff --git a/finished-application/frontend/components/Item.js b/finished-application/frontend/components/Item.js new file mode 100644 index 0000000..d6b339e --- /dev/null +++ b/finished-application/frontend/components/Item.js @@ -0,0 +1,53 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import Link from 'next/link'; +import Title from './styles/Title'; +import AddToCart from './AddToCart'; +import DeleteItem from './DeleteItem'; +import formatMoney from '../lib/formatMoney'; +import ItemStyles from './styles/ItemStyles'; +import PriceTag from './styles/PriceTag'; + +class Item extends React.Component { + static propTypes = { + item: PropTypes.object.isRequired, + }; + + render() { + const item = this.props.item; + return ( + <ItemStyles key={item.id}> + {item.image && <img src={item.image} alt={item.title} />} + <Title> + <Link + href={{ + pathname: '/item', + query: { id: item.id }, + }} + > + <a>{item.title}</a> + </Link> + </Title> + + <PriceTag>{formatMoney(item.price)}</PriceTag> + + <p>{item.description}</p> + + <div className="buttonList"> + <Link + href={{ + pathname: '/update', + query: { id: item.id }, + }} + > + <a>Edit ✏️</a> + </Link> + <AddToCart id={item.id} /> + <DeleteItem id={item.id} /> + </div> + </ItemStyles> + ); + } +} + +export default Item; diff --git a/finished-application/frontend/components/Items.js b/finished-application/frontend/components/Items.js new file mode 100644 index 0000000..5694d0b --- /dev/null +++ b/finished-application/frontend/components/Items.js @@ -0,0 +1,74 @@ +import React from 'react'; +import { Query } from 'react-apollo'; +import styled from 'styled-components'; +import PropTypes from 'prop-types'; +import gql from 'graphql-tag'; +import Pagination from './Pagination'; +import Item from './Item'; +import LoadingItem from './LoadingItem'; +import { perPage } from '../config'; + +const ALL_ITEMS_QUERY = gql` + query ALL_ITEMS_QUERY($skip: Int = 0, $first: Int = ${perPage}) { + items(orderBy: createdAt_DESC, first: $first, skip: $skip) { + __typename + id + title + price + description + image + largeImage + } + } +`; + +const Items = styled.div` + display: grid; + grid-template-columns: 1fr 1fr; + grid-gap: 60px; + max-width: ${props => props.theme.maxWidth}; + margin: 0 auto; +`; + +const Center = styled.div` + text-align: center; +`; + +class ItemList extends React.Component { + static propTypes = { + page: PropTypes.number.isRequired, + }; + render() { + return ( + <Center key={this.props.page}> + <Pagination page={this.props.page} /> + <Query + query={ALL_ITEMS_QUERY} + variables={{ + skip: this.props.page * perPage - perPage, + first: perPage, + }} + // fetchPolicy="network-only" + > + {({ data, error, loading }) => { + if (loading) { + return ( + <Items> + {Array.from({ length: 4 }) + .map((x, id) => ({ id })) + .map(x => <LoadingItem key={x.id} />)} + </Items> + ); + } + if (error) return <div>Error</div>; + return <Items>{data.items.map(item => <Item key={item.id} item={item} />)}</Items>; + }} + </Query> + <Pagination page={this.props.page} /> + </Center> + ); + } +} + +export default ItemList; +export { ALL_ITEMS_QUERY }; diff --git a/finished-application/frontend/components/LoadingItem.js b/finished-application/frontend/components/LoadingItem.js new file mode 100644 index 0000000..5736337 --- /dev/null +++ b/finished-application/frontend/components/LoadingItem.js @@ -0,0 +1,22 @@ +import React from 'react'; +import Title from './styles/Title'; +import ItemStyles from './styles/ItemStyles'; + +class LoadingItem extends React.Component { + render() { + return ( + <ItemStyles> + <img + src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7" + alt="Loading..." + />> + <Title> + <a>Loading...</a> + </Title> + <p>Please Wait</p> + </ItemStyles> + ); + } +} + +export default LoadingItem; diff --git a/finished-application/frontend/components/Meta.js b/finished-application/frontend/components/Meta.js new file mode 100644 index 0000000..fe9c29f --- /dev/null +++ b/finished-application/frontend/components/Meta.js @@ -0,0 +1,16 @@ +import React from 'react'; +import Head from 'next/head'; + +const Meta = () => ( + <div> + <Head> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + <meta charSet="utf-8" /> + <link rel="shortcut icon" href="/static/favicon.png" /> + <link rel="stylesheet" type="text/css" href="/static/nprogress.css" /> + <title>Sick Fits!</title> + </Head> + </div> +); + +export default Meta; diff --git a/finished-application/frontend/components/Nav.js b/finished-application/frontend/components/Nav.js new file mode 100644 index 0000000..b483376 --- /dev/null +++ b/finished-application/frontend/components/Nav.js @@ -0,0 +1,59 @@ +import React, { Fragment } from 'react'; +import Link from 'next/link'; +import { Query, Mutation } from 'react-apollo'; +import { TOGGLE_CART_MUTATION } from './Cart'; +import User from './User'; +import CartCount from './CartCount'; +import Signout from './Signout'; +import NavStyles from './styles/NavStyles'; + +class Nav extends React.Component { + render() { + // below we set the fetchPolicy to network only so it forces re-fetch on the server + return ( + <User> + {({ data: { me }, error }) => ( + <NavStyles data-test="nav"> + <Link href="/items"> + <a>Shop</a> + </Link> + <Link href="/sell"> + <a>Sell</a> + </Link> + + {!me && ( + <Link href="/signup"> + <a>Sign In</a> + </Link> + )} + + {me && ( + <Fragment> + <Link href="/orders"> + <a>Orders</a> + </Link> + <Link href="/me"> + <a>My Account</a> + </Link> + <Signout /> + <Mutation mutation={TOGGLE_CART_MUTATION}> + {toggleCart => ( + <button onClick={toggleCart}> + My Cart + <CartCount + className="cart-count" + count={me.cart.reduce((tally, cartItem) => tally + cartItem.quantity, 0)} + /> + </button> + )} + </Mutation> + </Fragment> + )} + </NavStyles> + )} + </User> + ); + } +} + +export default Nav; diff --git a/finished-application/frontend/components/Order.js b/finished-application/frontend/components/Order.js new file mode 100644 index 0000000..b4a0a0e --- /dev/null +++ b/finished-application/frontend/components/Order.js @@ -0,0 +1,98 @@ +import { Component } from 'react'; +import { Query } from 'react-apollo'; +import { format } from 'date-fns'; +import Head from 'next/head'; +import PropTypes from 'prop-types'; +import gql from 'graphql-tag'; +import formatMoney from '../lib/formatMoney'; +import Error from './ErrorMessage'; +import OrderStyles from './styles/OrderStyles'; + +const SINGLE_ORDER_QUERY = gql` + query SINGLE_ORDER_QUERY($id: ID!) { + order(id: $id) { + id + charge + total + createdAt + user { + id + } + items { + id + title + price + description + image + quantity + } + } + } +`; + +class Order extends Component { + static propTypes = { + id: PropTypes.string.isRequired, + }; + + render() { + return ( + <Query + query={SINGLE_ORDER_QUERY} + variables={{ id: this.props.id }} + fetchPolicy="network-only" + > + {({ data, error, loading, refetch }) => { + if (loading) return <p>Loading...</p>; + if (error) return <Error error={error} refetch={refetch} />; + const order = data.order; + return ( + <OrderStyles data-test="order"> + <Head> + <title>Sick Fits - Order {order.id}</title> + </Head> + <p> + <span>Order Id:</span> + <span>{order.id}</span> + </p> + <p> + <span>Charge</span> + <span>{order.charge}</span> + </p> + <p> + <span>Date</span> + <span>{format(order.createdAt, 'MMMM D, YYYY h:mm A')}</span> + </p> + <p> + <span>Order Total</span> + <span>{formatMoney(order.total)}</span> + </p> + <p> + <span>Item Count</span> + <span>{order.items.length}</span> + </p> + <div className="items"> + {order.items.map(item => ( + <div className="order-item" key={item.id}> + <img src={item.image} alt={item.title} /> + <div className="item-details"> + <h2> {item.title} </h2> + <p>Qty: {item.quantity}</p> + <p>Each: {formatMoney(item.price)}</p> + <p>Subtotal: {formatMoney(item.price * item.quantity)}</p> + <p>SubTotal: {item.description}</p> + <p>{item.description}</p> + </div> + </div> + ))} + </div> + </OrderStyles> + ); + }} + </Query> + ); + } +} + +export default Order; +export { SINGLE_ORDER_QUERY }; diff --git a/finished-application/frontend/components/OrderList.js b/finished-application/frontend/components/OrderList.js new file mode 100644 index 0000000..5886ad7 --- /dev/null +++ b/finished-application/frontend/components/OrderList.js @@ -0,0 +1,93 @@ +import React from 'react'; +import { Query } from 'react-apollo'; +import { formatDistance } from 'date-fns'; +import Link from 'next/link'; +import styled from 'styled-components'; +import gql from 'graphql-tag'; +import formatMoney from '../lib/formatMoney'; +import OrderItemStyles from './styles/OrderItemStyles'; + +const USER_ORDERS_QUERY = gql` + query orders { + orders(orderBy: createdAt_DESC) { + id + total + createdAt + updatedAt + items { + id + title + price + description + quantity + image + } + } + } +`; + +const OrderUl = styled.ul` + display: grid; + grid-gap: 4rem; + grid-template-columns: repeat(auto-fit, minmax(40%, 1fr)); +`; + +class OrderList extends React.Component { + render() { + return ( + <Query query={USER_ORDERS_QUERY}> + {({ data: { orders }, loading, error }) => { + if (loading) return <p>Loading...</p>; + if (error) return <p>Error...</p>; + if (!orders || !orders.length) return <p>No Orders Yet!</p>; + return ( + <div> + <h2>You have {orders.length} Orders</h2> + <OrderUl> + {orders.map(order => ( + <OrderItemStyles key={order.id}> + <Link + href={{ + pathname: '/order', + query: { id: order.id }, + }} + > + <a> + <div className="order-meta"> + <p> + <strong>{order.items.reduce((a, b) => a + b.quantity, 0)}</strong> + Items + </p> + <p> + <strong>{order.items.length}</strong> + Products + </p> + <p> + <strong>{formatDistance(order.createdAt, new Date())}</strong> + ago + </p> + <p> + <strong>{formatMoney(order.total)}</strong> + Total + </p> + </div> + <div className="images"> + {order.items.map(item => ( + <img key={item.id} src={item.image} alt={item.title} /> + ))} + </div> + </a> + </Link> + </OrderItemStyles> + ))} + </OrderUl> + </div> + ); + }} + </Query> + ); + } +} + +export default OrderList; +export { USER_ORDERS_QUERY }; diff --git a/finished-application/frontend/components/Page.js b/finished-application/frontend/components/Page.js new file mode 100644 index 0000000..cc0bc18 --- /dev/null +++ b/finished-application/frontend/components/Page.js @@ -0,0 +1,67 @@ +import React from 'react'; +import styled, { ThemeProvider, injectGlobal } from 'styled-components'; +import PropTypes from 'prop-types'; +import Header from './Header'; +import Meta from './Meta'; + +const theme = { + red: '#FF0000', + black: '#393939', + grey: '#3A3A3A', + lightgrey: '#E1E1E1', + offWhite: '#EDEDED', + maxWidth: '1300px', + bs: '0 12px 24px 0 rgba(0, 0, 0, 0.09)', +}; + +injectGlobal` + html { + box-sizing: border-box; + font-size: 10px; + } + body { + font-family: 'radnika next', sans-serif; + padding: 0; + background-color: #ffffff; + margin: 0; + font-size: 1.5rem; + line-height: 2; + } + *, *:before, *:after { + box-sizing: inherit; + } + a { + color: ${theme.black}; + text-decoration: none; + } +`; + +const Inner = styled.div` + max-width: 1000px; + margin: 0 auto; + padding: 2rem; +`; + +const StyledPage = styled.div` + color: ${props => props.theme.black}; + background: white; +`; + +class Page extends React.Component { + static propTypes = { + children: PropTypes.node.isRequired, + }; + render() { + return ( + <ThemeProvider theme={theme}> + <StyledPage className="main"> + <Meta /> + <Header /> + <Inner>{this.props.children}</Inner> + </StyledPage> + </ThemeProvider> + ); + } +} + +export default Page; diff --git a/finished-application/frontend/components/Pagination.js b/finished-application/frontend/components/Pagination.js new file mode 100644 index 0000000..b77f8f6 --- /dev/null +++ b/finished-application/frontend/components/Pagination.js @@ -0,0 +1,97 @@ +import React from 'react'; +import { Query } from 'react-apollo'; +import styled from 'styled-components'; +import Link from 'next/link'; +import PropTypes from 'prop-types'; +import gql from 'graphql-tag'; +import Head from 'next/head'; +import { perPage } from '../config'; + +const PAGINATION_QUERY = gql` + query itemsConnection($skip: Int = 0, $first: Int = 4) { + itemsConnection(orderBy: createdAt_DESC, first: $first, skip: $skip) { + aggregate { + count + } + } + } +`; + +const PaginationStyles = styled.div` + text-align: center; + display: inline-grid; + grid-template-columns: repeat(4, auto); + align-items: stretch; + justify-content: center; + align-content: center; + margin: 2rem 0; + border: 1px solid ${props => props.theme.lightgrey}; + border-radius: 10px; + & > * { + margin: 0; + padding: 15px 30px; + border-right: 1px solid ${props => props.theme.lightgrey}; + &:last-child { + border-right: 0; + } + } + a[aria-disabled='true'] { + color: grey; + pointer-events: none; + } +`; + +const Pagination = props => ( + <Query query={PAGINATION_QUERY}> + {({ data, loading, error }) => { + if (loading || error) return null; + const { aggregate } = data.itemsConnection; + const { page } = props; + const pages = Math.ceil(aggregate.count / perPage); + return ( + <PaginationStyles data-test="pagination"> + <Head> + <title> + Sick Fits! — Page {page} of {pages} + </title> + </Head> + <Link + prefetch + href={{ + pathname: 'items', + query: { page: page - 1 }, + }} + > + <a className="prev" aria-disabled={page <= 1}> + ←Prev + </a> + </Link> + <p> + Page <strong>{page} </strong> of <strong className="totalPages">{pages}</strong> + </p> + <p> + <strong>{aggregate.count}</strong> Items Total + </p> + <Link + prefetch + href={{ + pathname: 'items', + query: { page: page + 1 }, + }} + > + <a className="next" aria-disabled={page >= pages}> + Next → + </a> + </Link> + </PaginationStyles> + ); + }} + </Query> +); + +Pagination.propTypes = { + page: PropTypes.number.isRequired, +}; + +export default Pagination; +export { PAGINATION_QUERY }; diff --git a/finished-application/frontend/components/Permissions.js b/finished-application/frontend/components/Permissions.js new file mode 100644 index 0000000..52a6acf --- /dev/null +++ b/finished-application/frontend/components/Permissions.js @@ -0,0 +1,133 @@ +import React from 'react'; +import { Query, Mutation } from 'react-apollo'; +import PropTypes from 'prop-types'; +import gql from 'graphql-tag'; +import Error from './ErrorMessage'; +import SickButton from './styles/SickButton'; +import Table from './styles/Table'; + +const ALL_USERS_QUERY = gql` + query { + users { + id + name + email + permissions + } + } +`; + +const UPDATE_PERMISSIONS_MUTATION = gql` + mutation updatePermissions($permissions: [Permission], $userId: ID!) { + updatePermissions(permissions: $permissions, userId: $userId) { + id + permissions + name + email + } + } +`; + +const possiblePermissions = [ + 'ADMIN', + 'USER', + 'ITEMCREATE', + 'ITEMUPDATE', + 'ITEMDELETE', + 'PERMISSIONUPDATE', +]; + +class User extends React.Component { + static propTypes = { + user: PropTypes.shape({ + permissions: PropTypes.array.isRequired, + id: PropTypes.string.isRequired, + }).isRequired, + }; + state = { + permissions: this.props.user.permissions, + }; + handlePermissionsChange = e => { + const checkbox = e.target; + let updatedPermissions = [...this.state.permissions]; + if (checkbox.checked) { + // add it in + updatedPermissions.push(checkbox.value); + } else { + updatedPermissions = updatedPermissions.filter(permission => permission !== checkbox.value); + } + this.setState({ permissions: updatedPermissions }); + }; + render() { + const { user } = this.props; + return ( + <Mutation mutation={UPDATE_PERMISSIONS_MUTATION}> + {(updatePermissions, { loading, error }) => ( + <tr key={user.id} className="user"> + <Error error={error} /> + <td>{user.name}</td> + <td>{user.email}</td> + {possiblePermissions.map(permission => ( + <td> + <label key={permission} htmlFor={`${user.id}-permission-${permission}`}> + <input + type="checkbox" + checked={this.state.permissions.includes(permission)} + name={`permission-${permission}`} + id={`${user.id}-permission-${permission}`} + onChange={this.handlePermissionsChange} + value={permission} + /> + </label> + </td> + ))} + <td> + <SickButton + type="button" + disabled={loading} + onClick={async () => { + await updatePermissions({ + variables: { + permissions: this.state.permissions, + userId: this.props.user.id, + }, + }); + }} + > + Updat{loading ? 'ing' : 'e'} + </SickButton> + </td> + </tr> + )} + </Mutation> + ); + } +} + +const Permissions = () => ( + <Query query={ALL_USERS_QUERY}> + {({ data, error, loading }) => { + if (loading) return <div>Loading</div>; + if (error) return <Error error={error} />; + return ( + <div> + <h1>Manage User Permissions</h1> + <Table> + <thead> + <tr> + <th>Name</th> + <th>Email</th> + {possiblePermissions.map(p => <th>{p}</th>)} + <th>👇🏻</th> + </tr> + </thead> + <tbody>{data.users.map(user => <User key={user.id} user={user} />)}</tbody> + </Table> + </div> + ); + }} + </Query> +); + +export default Permissions; +export { ALL_USERS_QUERY, UPDATE_PERMISSIONS_MUTATION }; diff --git a/finished-application/frontend/components/PleaseSignIn.js b/finished-application/frontend/components/PleaseSignIn.js new file mode 100644 index 0000000..5646749 --- /dev/null +++ b/finished-application/frontend/components/PleaseSignIn.js @@ -0,0 +1,46 @@ +import { Query } from 'react-apollo'; +import PropTypes from 'prop-types'; +import { CURRENT_USER_QUERY } from './User'; +import Signin from './Signin'; + +const PleaseSignIn = props => ( + <Query query={CURRENT_USER_QUERY}> + {({ data, loading }) => { + if (loading) return <p>Loading...</p>; + // check if they are signed in + if (!data.me) { + return ( + <div> + <p>Please sign in before continuing!</p> + <Signin /> + </div> + ); + } + // check if they need permissions + if (props.allowedPermissions) { + // check if they NO permissions, or they don't meet the requmrenets + if ( + !data.me.permissions || + !props.allowedPermissions.some(permission => data.me.permissions.includes(permission)) + ) { + return ( + <p> + Insufficient Permissions. You have: + <strong>{data.me.permissions}</strong> + and you need + <strong>{props.allowedPermissions.join(' OR ')}</strong> + </p> + ); + } + } + return props.children; + }} + </Query> +); + +PleaseSignIn.propTypes = { + allowedPermissions: PropTypes.array, + children: PropTypes.any.isRequired, +}; + +export default PleaseSignIn; diff --git a/finished-application/frontend/components/RemoveFromCart.js b/finished-application/frontend/components/RemoveFromCart.js new file mode 100644 index 0000000..d29cbf1 --- /dev/null +++ b/finished-application/frontend/components/RemoveFromCart.js @@ -0,0 +1,57 @@ +import { Component } from 'react'; +import { Mutation } from 'react-apollo'; +import styled from 'styled-components'; +import PropTypes from 'prop-types'; +import gql from 'graphql-tag'; +import { CURRENT_USER_QUERY } from './User'; + +const REMOVE_FROM_CART_MUTATION = gql` + mutation removeFromCart($id: ID!) { + removeFromCart(id: $id) { + id + } + } +`; + +const BigButton = styled.button` + font-size: 3rem; + background: none; + border: 0; + &:hover { + color: ${props => props.theme.red}; + cursor: pointer; + } +`; + +class RemoveFromCart extends Component { + static propTypes = { + id: PropTypes.string.isRequired, + }; + + update = (cache, payload) => { + const data = cache.readQuery({ query: CURRENT_USER_QUERY }); + // console.log(data.me.cart[0]); + const cartItemId = payload.data.removeFromCart.id; + data.me.cart = data.me.cart.filter(cartItem => cartItem.id !== cartItemId); + cache.writeQuery({ query: CURRENT_USER_QUERY, data }); + }; + + render() { + return ( + <Mutation + mutation={REMOVE_FROM_CART_MUTATION} + variables={{ id: this.props.id }} + update={this.update} + > + {(removeFromCart, { loading }) => ( + <BigButton disabled={loading} title="Remove From Cart" onClick={() => removeFromCart}> + × + </BigButton> + )} + </Mutation> + ); + } +} + +export default RemoveFromCart; +export { REMOVE_FROM_CART_MUTATION }; diff --git a/finished-application/frontend/components/Reset.js b/finished-application/frontend/components/Reset.js new file mode 100644 index 0000000..bb2ece0 --- /dev/null +++ b/finished-application/frontend/components/Reset.js @@ -0,0 +1,85 @@ +import React from 'react'; +import { Mutation } from 'react-apollo'; +import PropTypes from 'prop-types'; +import gql from 'graphql-tag'; +import Form from './styles/Form'; +import Error from './ErrorMessage'; +import { CURRENT_USER_QUERY } from './User'; + +const RESET_MUTATION = gql` + mutation RESET_MUTATION($resetToken: String!, $password: String!, $confirmPassword: String!) { + resetPassword(resetToken: $resetToken, password: $password, confirmPassword: $confirmPassword) { + id + email + name + } + } +`; + +class Reset extends React.Component { + static propTypes = { + resetToken: PropTypes.string.isRequired, + }; + + state = { + confirmPassword: '', + password: '', + }; + + saveToState = e => { + const { name, value } = e.target; + this.setState({ [name]: value }); + }; + + resetPassword = async (e, resetMutation) => { + e.preventDefault(); + await resetMutation(); + }; + + render() { + return ( + <Mutation + mutation={RESET_MUTATION} + variables={{ + resetToken: this.props.resetToken, + password: this.state.password, + confirmPassword: this.state.confirmPassword, + }} + refetchQueries={[{ query: CURRENT_USER_QUERY }]} + > + {(resetMutation, { error, loading }) => ( + <Form onSubmit={e => this.resetPassword(e, resetMutation)}> + <Error error={error} /> + <fieldset disabled={loading} aria-busy={loading}> + <label htmlFor="password"> + password + <input + value={this.state.password} + onChange={this.saveToState} + name="password" + type="password" + id="password" + /> + </label> + <label htmlFor="confirm"> + Confirm: + <input + value={this.state.confirmPassword} + onChange={this.saveToState} + name="confirmPassword" + type="password" + id="confirmPassword" + /> + </label> + + <button type="submit">Request Reset!</button> + </fieldset> + </Form> + )} + </Mutation> + ); + } +} + +export default Reset; +export { RESET_MUTATION }; diff --git a/finished-application/frontend/components/ResetRequest.js b/finished-application/frontend/components/ResetRequest.js new file mode 100644 index 0000000..6aedcc1 --- /dev/null +++ b/finished-application/frontend/components/ResetRequest.js @@ -0,0 +1,55 @@ +import React from 'react'; +import { Mutation } from 'react-apollo'; +import gql from 'graphql-tag'; +import Form from './styles/Form'; +import Error from './ErrorMessage'; + +const REQUEST_RESET_MUTATION = gql` + mutation requestReset($email: String!) { + requestReset(email: $email) { + id + } + } +`; + +class ResetRequest extends React.Component { + state = { + email: '', + }; + + render() { + return ( + <Mutation mutation={REQUEST_RESET_MUTATION} variables={this.state}> + {(resetMutation, { loading, error, called }) => ( + <Form + onSubmit={async e => { + e.preventDefault(); + await resetMutation(); + }} + data-test="ResetRequest" + > + <Error error={error} /> + {!error && called && !loading && <p>Success! Check Your Email!</p>} + <fieldset disabled={loading} aria-busy={loading}> + <label htmlFor="email"> + Email + <input + value={this.state.email} + onChange={e => this.setState({ email: e.target.value })} + name="email" + type="text" + placeholder="email" + /> + </label> + + <button type="submit">Request Reset!</button> + </fieldset> + </Form> + )} + </Mutation> + ); + } +} + +export default ResetRequest; +export { REQUEST_RESET_MUTATION }; diff --git a/finished-application/frontend/components/Search.js b/finished-application/frontend/components/Search.js new file mode 100644 index 0000000..a972db2 --- /dev/null +++ b/finished-application/frontend/components/Search.js @@ -0,0 +1,143 @@ +import React from 'react'; +import Downshift from 'downshift'; +import Router from 'next/router'; +import { ApolloConsumer } from 'react-apollo'; +import gql from 'graphql-tag'; +import styled, { keyframes } from 'styled-components'; +import debounce from 'lodash.debounce'; + +const SEARCH_ITEMS_QUERY = gql` + query SEARCH_ITEMS_QUERY($searchTerm: String!) { + items(where: { OR: [{ title_contains: $searchTerm }, { description_contains: $searchTerm }] }) { + id + image + title + } + } +`; +function routeToItem(item) { + Router.push({ + pathname: '/item', + query: { + id: item.id, + }, + }); +} + +const DropDown = styled.div` + position: absolute; + width: 100%; + z-index: 2; + border: 1px solid ${props => props.theme.lightgrey}; +`; + +const DropDownItem = styled.div` + border-bottom: 1px solid ${props => props.theme.lightgrey}; + background: ${props => (props.highlighted ? '#f7f7f7' : 'white')}; + padding: 1rem; + transition: all 0.2s; + ${props => (props.highlighted ? 'padding-left: 2rem;' : null)}; + display: flex; + align-items: center; + border-left: 10px solid ${props => (props.highlighted ? props.theme.lightgrey : 'white')}; + img { + margin-right: 10px; + } +`; + +const glow = keyframes` + from { + box-shadow: 0 0 0px yellow; + } + + to { + box-shadow: 0 0 10px 1px yellow; + } +`; + +const SearchStyles = styled.div` + position: relative; + input { + width: 100%; + padding: 10px; + border: 0; + font-size: 2rem; + &.loading { + animation: ${glow} 0.5s ease-in-out infinite alternate; + } + } +`; + +class AutoComplete extends React.Component { + state = { + items: [], + loading: false, + }; + onChange = async (e, client) => { + if (!e.target.value) { + return this.setState({ items: [] }); + } + this.setState({ loading: true }); + this.search(client, e.target.value); + }; + + search = debounce(async (client, searchTerm) => { + const res = await client.query({ + query: SEARCH_ITEMS_QUERY, + variables: { searchTerm }, + }); + this.setState({ items: res.data.items, loading: false }); + }, 350); + + render() { + return ( + <SearchStyles> + <Downshift onChange={routeToItem} itemToString={i => (i === null ? '' : i.title)}> + {({ getInputProps, getItemProps, isOpen, inputValue, highlightedIndex }) => ( + <div> + {/* This is the searchInput */} + <ApolloConsumer> + {client => ( + <input + {...getInputProps({ + placeholder: 'Search For Item', + id: 'search', + className: this.state.loading ? 'loading' : '', + onChange: e => { + e.persist(); + this.onChange(e, client); + }, + })} + /> + )} + </ApolloConsumer> + {/* This is the Dropdown */} + {isOpen && ( + <DropDown> + {this.state.items.map((item, index) => ( + <DropDownItem + {...getItemProps({ item })} + key={item.id} + highlighted={highlightedIndex === index} + > + <img width="50" src={item.image} alt={item.title} /> + {item.title} + </DropDownItem> + ))} + {/* Found Nothing State */} + {!this.state.items.length && + !this.state.loading && ( + <DropDownItem>Nothing Found for {inputValue}...</DropDownItem> + )} + </DropDown> + )} + </div> + )} + </Downshift> + </SearchStyles> + ); + } +} + +export default AutoComplete; +export { SEARCH_ITEMS_QUERY }; diff --git a/finished-application/frontend/components/Signin.js b/finished-application/frontend/components/Signin.js new file mode 100644 index 0000000..53cdb76 --- /dev/null +++ b/finished-application/frontend/components/Signin.js @@ -0,0 +1,79 @@ +import React, { Component } from 'react'; +import { Mutation } from 'react-apollo'; +import gql from 'graphql-tag'; +import { CURRENT_USER_QUERY } from './User'; +import Error from './ErrorMessage'; +import Form from './styles/Form'; + +const SIGNIN_MUTATION = gql` + mutation SIGNIN_MUTATION($email: String!, $password: String!) { + signin(email: $email, password: $password) { + id + email + name + } + } +`; + +class Signin extends Component { + state = { + email: '', + password: '', + }; + + saveToState = e => { + const { name, value } = e.target; + this.setState({ [name]: value }); + }; + + render() { + return ( + <Mutation + mutation={SIGNIN_MUTATION} + variables={this.state} + refetchQueries={[{ query: CURRENT_USER_QUERY }]} + > + {(signin, { loading, error }) => ( + <Form + onSubmit={e => { + e.preventDefault(); + signin(); + }} + > + <Error error={error} /> + <fieldset disabled={loading} aria-busy={loading}> + <label htmlFor="email"> + Email + <input + value={this.state.email} + onChange={this.saveToState} + name="email" + type="text" + placeholder="email" + /> + </label> + + <label htmlFor="password"> + Password + <input + type="password" + name="password" + id="password" + className="password" + placeholder="password" + value={this.state.password} + onChange={this.saveToState} + /> + </label> + + <button type="submit">Sign In!</button> + </fieldset> + </Form> + )} + </Mutation> + ); + } +} + +export default Signin; +export { SIGNIN_MUTATION }; diff --git a/finished-application/frontend/components/Signout.js b/finished-application/frontend/components/Signout.js new file mode 100644 index 0000000..ae87631 --- /dev/null +++ b/finished-application/frontend/components/Signout.js @@ -0,0 +1,25 @@ +import React, { Component } from 'react'; +import { Mutation } from 'react-apollo'; +import gql from 'graphql-tag'; +import { CURRENT_USER_QUERY } from './User'; + +const SIGN_OUT_MUTATION = gql` + mutation SIGN_OUT_MUTATION { + signout { + message + } + } +`; + +class Signout extends Component { + render() { + return ( + <Mutation mutation={SIGN_OUT_MUTATION} refetchQueries={[{ query: CURRENT_USER_QUERY }]}> + {signout => <button onClick={signout}>Sign Out</button>} + </Mutation> + ); + } +} + +export default Signout; +export { SIGN_OUT_MUTATION }; diff --git a/finished-application/frontend/components/Signup.js b/finished-application/frontend/components/Signup.js new file mode 100644 index 0000000..9164e67 --- /dev/null +++ b/finished-application/frontend/components/Signup.js @@ -0,0 +1,93 @@ +import React, { Component } from 'react'; +import { Mutation } from 'react-apollo'; +import gql from 'graphql-tag'; +import { CURRENT_USER_QUERY } from './User'; +import Form from './styles/Form'; +import Error from './ErrorMessage'; + +const SIGNUP_MUTATION = gql` + mutation SIGNUP_MUTATION($email: String!, $name: String!, $password: String!) { + signup(email: $email, name: $name, password: $password) { + id + email + name + } + } +`; + +class Signup extends Component { + state = { + email: '', + name: '', + password: '', + }; + + saveToState = e => { + const { name, value } = e.target; + this.setState({ [name]: value }); + }; + + render() { + return ( + <Mutation + mutation={SIGNUP_MUTATION} + variables={this.state} + refetchQueries={[{ query: CURRENT_USER_QUERY }]} + > + {(signup, { loading, error }) => ( + <Form + method="post" + onSubmit={async e => { + e.preventDefault(); + const res = await signup(); + }} + > + <fieldset disabled={loading} aria-busy={loading}> + <Error error={error} /> + <h2>Sign Up for an Account</h2> + <label htmlFor="email"> + Email + <input + value={this.state.email} + onChange={this.saveToState} + name="email" + type="text" + placeholder="email" + /> + </label> + + <label htmlFor="name"> + Name + <input + type="text" + name="name" + placeholder="name" + value={this.state.name} + onChange={this.saveToState} + /> + </label> + + <label htmlFor="signupPassword"> + Password + <input + type="password" + name="password" + id="signupPassword" + className="password" + placeholder="password" + value={this.state.password} + onChange={this.saveToState} + /> + </label> + + <button type="submit">Submit</button> + </fieldset> + </Form> + )} + </Mutation> + ); + } +} + +export default Signup; +export { SIGNUP_MUTATION }; diff --git a/finished-application/frontend/components/SingleItem.js b/finished-application/frontend/components/SingleItem.js new file mode 100644 index 0000000..83ef8a0 --- /dev/null +++ b/finished-application/frontend/components/SingleItem.js @@ -0,0 +1,77 @@ +import { Query } from 'react-apollo'; +import PropTypes from 'prop-types'; +import styled from 'styled-components'; +import Head from 'next/head'; +import Link from 'next/link'; +import gql from 'graphql-tag'; +import Error from './ErrorMessage'; +import AddToCart from './AddToCart'; + +const SINGLE_ITEM_QUERY = gql` + query SINGLE_ITEM_QUERY($id: ID!) { + items(where: { id: $id }) { + id + title + description + largeImage + image + } + } +`; + +const SingleItemStyles = styled.div` + max-width: 1200px; + margin: 2rem auto; + box-shadow: ${props => props.theme.bs}; + display: grid; + grid-auto-columns: 1fr; + grid-auto-flow: column; + min-height: 800px; + img { + width: 100%; + height: 100%; + object-fit: contain; + } + .details { + margin: 3rem; + font-size: 2rem; + } +`; + +const SingleItem = props => ( + <Query query={SINGLE_ITEM_QUERY} variables={{ id: props.id }}> + {({ data, loading, error }) => { + if (loading) return <p>Loading...</p>; + if (error) return <Error error={error} />; + const [item] = data.items; + return ( + <SingleItemStyles data-test="SingleItem"> + <Head> + <title>{item.title}</title> + </Head> + <img src={item.largeImage || item.image} alt={item.title} /> + <div className="details"> + <h2>Viewing {item.title}</h2> + <p>{item.description}</p> + <Link + href={{ + pathname: '/update', + query: { id: item.id }, + }} + > + <a>Edit ✏️</a> + </Link> + <AddToCart id={item.id} /> + </div> + </SingleItemStyles> + ); + }} + </Query> +); + +SingleItem.propTypes = { + id: PropTypes.string.isRequired, +}; + +export default SingleItem; +export { SINGLE_ITEM_QUERY }; diff --git a/finished-application/frontend/components/TakeMyMoney.js b/finished-application/frontend/components/TakeMyMoney.js new file mode 100644 index 0000000..b9e42ea --- /dev/null +++ b/finished-application/frontend/components/TakeMyMoney.js @@ -0,0 +1,83 @@ +import { Component } from 'react'; +import StripeCheckout from 'react-stripe-checkout'; +import { Mutation, Query } from 'react-apollo'; +import Router from 'next/router'; +import NProgress from 'nprogress'; +import PropTypes from 'prop-types'; +import gql from 'graphql-tag'; +import calcTotalPrice from '../lib/calcTotalPrice'; +import Error from './ErrorMessage'; +import User, { CURRENT_USER_QUERY } from './User'; + +const CREATE_ORDER_MUTATION = gql` + mutation createOrder($token: String!) { + createOrder(token: $token) { + id + charge + total + items { + id + title + } + } + } +`; + +function totalItems(cart) { + return cart.reduce((tally, cartItem) => tally + cartItem.quantity, 0); +} + +class TakeMyMoney extends Component { + onToken = async (res, createOrder) => { + NProgress.start(); + const order = await createOrder({ + variables: { + token: res.id, + }, + }); + console.log(order); + // Route them to that order page + const { id } = order.data.createOrder; + Router.push({ + pathname: `/order`, + query: { id }, + }); + }; + render() { + return ( + <User> + {({ data: { me }, error }) => { + if (!me || !me.cart.length) return null; + if (error) return <Error error={error} />; + return ( + <Mutation + mutation={CREATE_ORDER_MUTATION} + refetchQueries={[{ query: CURRENT_USER_QUERY }]} + > + {createOrder => ( + <StripeCheckout + amount={calcTotalPrice(me.cart)} + name="Sick Fits Haul" + description={`Order of ${totalItems(me.cart)} Items From Sick Fits`} + image={me.cart[0].item && me.cart[0].item.image} + token={res => this.onToken(res, createOrder)} + stripeKey="pk_lclTtThFp8CnO3QtEZSd8HA9mFUps" + currency="USD" + email={me.email} + > + {this.props.children} + </StripeCheckout> + )} + </Mutation> + ); + }} + </User> + ); + } +} + +TakeMyMoney.propTypes = { + children: PropTypes.any, +}; + +export default TakeMyMoney; diff --git a/finished-application/frontend/components/UpdateItem.js b/finished-application/frontend/components/UpdateItem.js new file mode 100644 index 0000000..08ba254 --- /dev/null +++ b/finished-application/frontend/components/UpdateItem.js @@ -0,0 +1,105 @@ +import React, { Component } from 'react'; +import { Query, Mutation } from 'react-apollo'; +import PropTypes from 'prop-types'; +import gql from 'graphql-tag'; +import { SINGLE_ITEM_QUERY } from './SingleItem'; +import Form from './styles/Form'; +import Error from './ErrorMessage'; + +const UPDATE_ITEM_MUTATION = gql` + mutation updateItem($id: ID!, $title: String, $description: String, $price: Int) { + updateItem(id: $id, description: $description, title: $title, price: $price) { + id + } + } +`; + +class UpdateItem extends Component { + static propTypes = { + id: PropTypes.string.isRequired, + }; + state = { + item: {}, + }; + + saveToState = e => { + let { name, value, type } = e.target; + if (type === 'number') { + value = parseInt(value); + } + const item = { ...this.state.item }; + item[name] = value; + this.setState({ item }); + }; + + updateItem = async (e, updateItemMutation) => { + console.log('Updating Item'); + e.preventDefault(); + + const res = await updateItemMutation({ + // pass in those variables from state + variables: { + id: this.props.id, + ...this.state.item, + }, + }); + console.log(res); + }; + + render() { + return ( + <Query query={SINGLE_ITEM_QUERY} variables={{ id: this.props.id }}> + {({ data: { items }, loading }) => { + if (loading) return <p>Loading...</p>; + if (!items || !items.length) return <p>Item Not Found</p>; + const [item] = items; + return ( + <Mutation mutation={UPDATE_ITEM_MUTATION}> + {(updateItemMutation, { error }) => ( + <Form onSubmit={e => this.updateItem(e, updateItemMutation)}> + <Error error={error} /> + <h2>Edit {item.title}</h2> + <fieldset disabled={loading} aria-busy={loading}> + <label htmlFor="title"> + Title + <input + id="title" + defaultValue={item.title} + name="title" + onChange={this.saveToState} + type="text" + /> + </label> + + <label htmlFor="description"> + Description + <textarea + defaultValue={item.description} + name="description" + onChange={this.saveToState} + /> + </label> + + <label htmlFor="price"> + Price + <input + type="number" + name="price" + onChange={this.saveToState} + defaultValue={item.price} + /> + </label> + <button type="submit">Save...</button> + </fieldset> + </Form> + )} + </Mutation> + ); + }} + </Query> + ); + } +} + +export default UpdateItem; +export { UPDATE_ITEM_MUTATION }; diff --git a/finished-application/frontend/components/User.js b/finished-application/frontend/components/User.js new file mode 100644 index 0000000..19c568d --- /dev/null +++ b/finished-application/frontend/components/User.js @@ -0,0 +1,45 @@ +import { Query } from 'react-apollo'; +import gql from 'graphql-tag'; +import PropTypes from 'prop-types'; + +const CURRENT_USER_QUERY = gql` + query { + me { + id + email + name + permissions + orders { + id + charge + total + } + cart { + id + quantity + item { + __typename + id + title + price + description + image + largeImage + } + } + } + } +`; + +const User = props => ( + <Query {...props} query={CURRENT_USER_QUERY}> + {result => props.children(result)} + </Query> +); + +User.propTypes = { + children: PropTypes.func.isRequired, +}; + +export default User; +export { CURRENT_USER_QUERY }; diff --git a/finished-application/frontend/components/styles/CartStyles.js b/finished-application/frontend/components/styles/CartStyles.js new file mode 100644 index 0000000..d5ee93a --- /dev/null +++ b/finished-application/frontend/components/styles/CartStyles.js @@ -0,0 +1,47 @@ +import styled from 'styled-components'; + +const CartStyles = styled.div` + padding: 20px; + position: relative; + background: white; + position: fixed; + height: 100%; + top: 0; + right: 0; + width: 40%; + min-width: 500px; + bottom: 0; + transform: translateX(100%); + transition: all 0.3s; + box-shadow: 0 0 10px 3px rgba(0, 0, 0, 0.2); + z-index: 5; + display: grid; + grid-template-rows: auto 1fr auto; + ${props => props.open && `transform: translateX(0);`}; + header { + border-bottom: 5px solid ${props => props.theme.black}; + margin-bottom: 2rem; + padding-bottom: 2rem; + } + footer { + border-top: 10px double ${props => props.theme.black}; + margin-top: 2rem; + padding-top: 2rem; + display: grid; + grid-template-columns: auto auto; + align-items: center; + font-size: 3rem; + font-weight: 900; + p { + margin: 0; + } + } + ul { + margin: 0; + padding: 0; + list-style: none; + overflow: scroll; + } +`; + +export default CartStyles; diff --git a/finished-application/frontend/components/styles/CloseButton.js b/finished-application/frontend/components/styles/CloseButton.js new file mode 100644 index 0000000..69fd55c --- /dev/null +++ b/finished-application/frontend/components/styles/CloseButton.js @@ -0,0 +1,13 @@ +import styled from 'styled-components'; + +const CloseButton = styled.button` + background: black; + color: white; + font-size: 3rem; + border: 0; + position: absolute; + z-index: 2; + right: 0; +`; + +export default CloseButton; diff --git a/finished-application/frontend/components/styles/Form.js b/finished-application/frontend/components/styles/Form.js new file mode 100644 index 0000000..5717130 --- /dev/null +++ b/finished-application/frontend/components/styles/Form.js @@ -0,0 +1,71 @@ +import styled, { keyframes } from 'styled-components'; + +const loading = keyframes` + from { + background-position: 0 0; + /* rotate: 0; */ + } + + to { + background-position: 100% 100%; + /* rotate: 360deg; */ + } +`; + +const Form = styled.form` + box-shadow: 0 0 5px 3px rgba(0, 0, 0, 0.05); + background: rgba(0, 0, 0, 0.02); + border: 5px solid white; + padding: 20px; + font-size: 1.5rem; + line-height: 1.5; + font-weight: 600; + label { + display: block; + margin-bottom: 1rem; + } + input, + textarea, + select { + width: 100%; + padding: 0.5rem; + font-size: 1rem; + border: 1px solid black; + &:focus { + outline: 0; + border-color: ${props => props.theme.red}; + } + } + button, + input[type='submit'] { + width: auto; + background: red; + color: white; + border: 0; + font-size: 2rem; + font-weight: 600; + padding: 0.5rem 1.2rem; + } + fieldset { + border: 0; + padding: 0; + + &[disabled] { + opacity: 0.5; + } + &::before { + height: 10px; + content: ''; + display: block; + background-image: linear-gradient(to right, #ff3019 0%, #e2b04a 50%, #ff3019 100%); + } + &[aria-busy='true']::before { + background-size: 50% auto; + animation: ${loading} 0.5s linear infinite; + } + } +`; + +Form.displayName = 'Form'; + +export default Form; diff --git a/finished-application/frontend/components/styles/ItemStyles.js b/finished-application/frontend/components/styles/ItemStyles.js new file mode 100644 index 0000000..b117d5f --- /dev/null +++ b/finished-application/frontend/components/styles/ItemStyles.js @@ -0,0 +1,39 @@ +import styled from 'styled-components'; + +const Item = styled.div` + background: white; + border: 1px solid ${props => props.theme.offWhite}; + box-shadow: ${props => props.theme.bs}; + position: relative; + display: flex; + flex-direction: column; + img { + width: 100%; + height: 400px; + object-fit: cover; + } + p { + font-size: 12px; + line-height: 2; + font-weight: 300; + flex-grow: 1; + padding: 0 3rem; + font-size: 1.5rem; + } + .buttonList { + display: grid; + width: 100%; + border-top: 1px solid ${props => props.theme.lightgrey}; + grid-template-columns: repeat(auto-fit, minmax(100px, 1fr)); + grid-gap: 1px; + background: ${props => props.theme.lightgrey}; + & > * { + background: white; + border: 0; + font-size: 1rem; + padding: 1rem; + } + } +`; + +export default Item; diff --git a/finished-application/frontend/components/styles/NavStyles.js b/finished-application/frontend/components/styles/NavStyles.js new file mode 100644 index 0000000..41523df --- /dev/null +++ b/finished-application/frontend/components/styles/NavStyles.js @@ -0,0 +1,64 @@ +import styled from 'styled-components'; + +const NavStyles = styled.ul` + margin: 0; + padding: 0; + display: flex; + justify-self: end; + font-size: 2rem; + a, + button { + padding: 1rem 3rem; + display: flex; + align-items: center; + position: relative; + text-transform: uppercase; + font-weight: 900; + font-size: 1em; + background: none; + border: 0; + cursor: pointer; + @media (max-width: 700px) { + font-size: 10px; + padding: 0 10px; + } + &:before { + content: ''; + width: 2px; + background: ${props => props.theme.lightgrey}; + height: 100%; + left: 0; + position: absolute; + transform: skew(-20deg); + top: 0; + bottom: 0; + } + &:after { + height: 2px; + background: red; + content: ''; + width: 0; + position: absolute; + transform: translateX(-50%); + transition: width 0.4s; + transition-timing-function: cubic-bezier(1, -0.65, 0, 2.31); + left: 50%; + margin-top: 2rem; + } + &:hover, + &:focus { + outline: none; + &:after { + width: calc(100% - 60px); + } + } + } + @media (max-width: 1300px) { + border-top: 1px solid ${props => props.theme.lightgrey}; + width: 100%; + justify-content: center; + font-size: 1.5rem; + } +`; + +export default NavStyles; diff --git a/finished-application/frontend/components/styles/OrderItemStyles.js b/finished-application/frontend/components/styles/OrderItemStyles.js new file mode 100644 index 0000000..292c75d --- /dev/null +++ b/finished-application/frontend/components/styles/OrderItemStyles.js @@ -0,0 +1,44 @@ +import styled from 'styled-components'; + +const OrderItemStyles = styled.li` + box-shadow: ${props => props.theme.bs}; + list-style: none; + padding: 2rem; + border: 1px solid ${props => props.theme.offWhite}; + h2 { + border-bottom: 2px solid red; + margin-top: 0; + margin-bottom: 2rem; + padding-bottom: 2rem; + } + + .images { + display: grid; + grid-gap: 10px; + grid-template-columns: repeat(auto-fit, minmax(0, 1fr)); + margin-top: 1rem; + img { + height: 200px; + object-fit: cover; + width: 100%; + } + } + .order-meta { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(20px, 1fr)); + display: grid; + grid-gap: 1rem; + text-align: center; + & > * { + margin: 0; + background: rgba(0, 0, 0, 0.03); + padding: 1rem 0; + } + strong { + display: block; + margin-bottom: 1rem; + } + } +`; + +export default OrderItemStyles; diff --git a/finished-application/frontend/components/styles/OrderStyles.js b/finished-application/frontend/components/styles/OrderStyles.js new file mode 100644 index 0000000..4a2d804 --- /dev/null +++ b/finished-application/frontend/components/styles/OrderStyles.js @@ -0,0 +1,38 @@ +import styled from 'styled-components'; + +const OrderStyles = styled.div` + max-width: 1000px; + margin: 0 auto; + border: 1px solid ${props => props.theme.offWhite}; + box-shadow: ${props => props.theme.bs}; + padding: 2rem; + border-top: 10px solid red; + & > p { + display: grid; + grid-template-columns: 1fr 5fr; + margin: 0; + border-bottom: 1px solid ${props => props.theme.offWhite}; + span { + padding: 1rem; + &:first-child { + font-weight: 900; + text-align: right; + } + } + } + .order-item { + border-bottom: 1px solid ${props => props.theme.offWhite}; + display: grid; + grid-template-columns: 300px 1fr; + align-items: center; + grid-gap: 2rem; + margin: 2rem 0; + padding-bottom: 2rem; + img { + width: 100%; + height: 100%; + object-fit: cover; + } + } +`; +export default OrderStyles; diff --git a/finished-application/frontend/components/styles/PriceTag.js b/finished-application/frontend/components/styles/PriceTag.js new file mode 100644 index 0000000..9116681 --- /dev/null +++ b/finished-application/frontend/components/styles/PriceTag.js @@ -0,0 +1,17 @@ +import styled from 'styled-components'; + +const PriceTag = styled.span` + background: ${props => props.theme.red}; + transform: rotate(3deg); + color: white; + font-weight: 600; + padding: 5px; + line-height: 1; + font-size: 3rem; + display: inline-block; + position: absolute; + top: -3px; + right: -3px; +`; + +export default PriceTag; diff --git a/finished-application/frontend/components/styles/SickButton.js b/finished-application/frontend/components/styles/SickButton.js new file mode 100644 index 0000000..5b5352e --- /dev/null +++ b/finished-application/frontend/components/styles/SickButton.js @@ -0,0 +1,20 @@ +import styled from 'styled-components'; + +const SickButton = styled.button` + background: red; + color: white; + font-weight: 500; + border: 0; + border-radius: 0; + text-transform: uppercase; + font-size: 2rem; + padding: 0.8rem 1.5rem; + transform: skew(-2deg); + display: inline-block; + transition: all 0.5s; + &[disabled] { + opacity: 0.5; + } +`; + +export default SickButton; diff --git a/finished-application/frontend/components/styles/Supreme.js b/finished-application/frontend/components/styles/Supreme.js new file mode 100644 index 0000000..d946ccd --- /dev/null +++ b/finished-application/frontend/components/styles/Supreme.js @@ -0,0 +1,13 @@ +import styled from 'styled-components'; + +const Supreme = styled.h3` + background: ${props => props.theme.red}; + color: white; + display: inline-block; + padding: 4px 5px; + transform: skew(-3deg); + margin: 0; + font-size: 4rem; +`; + +export default Supreme; diff --git a/finished-application/frontend/components/styles/Table.js b/finished-application/frontend/components/styles/Table.js new file mode 100644 index 0000000..e9d0673 --- /dev/null +++ b/finished-application/frontend/components/styles/Table.js @@ -0,0 +1,31 @@ +import styled from 'styled-components'; + +const Table = styled.table` + border-spacing: 0; + width: 100%; + border: 1px solid ${props => props.theme.offWhite}; + thead { + font-size: 10px; + } + td, + th { + border-bottom: 1px solid ${props => props.theme.offWhite}; + border-right: 1px solid ${props => props.theme.offWhite}; + padding: 10px 5px; + position: relative; + &:last-child { + border-right: none; + width: 150px; + button { + width: 100%; + } + } + } + tr { + &:hover { + background: ${props => props.theme.offWhite}; + } + } +`; + +export default Table; diff --git a/finished-application/frontend/components/styles/Title.js b/finished-application/frontend/components/styles/Title.js new file mode 100644 index 0000000..e1901ec --- /dev/null +++ b/finished-application/frontend/components/styles/Title.js @@ -0,0 +1,20 @@ +import styled from 'styled-components'; + +const Title = styled.h3` + margin: 0 1rem; + text-align: center; + transform: skew(-5deg) rotate(-1deg); + margin-top: -3rem; + text-shadow: 2px 2px 0 rgba(0, 0, 0, 0.1); + a { + background: ${props => props.theme.red}; + display: inline; + line-height: 1.3; + font-size: 4rem; + text-align: center; + color: white; + padding: 0 1rem; + } +`; + +export default Title; |
