diff options
Diffstat (limited to 'finished-application/frontend/components')
43 files changed, 2460 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..8a71cc3 --- /dev/null +++ b/finished-application/frontend/components/AddToCart.js @@ -0,0 +1,36 @@ +import React from 'react'; +import { Mutation } from 'react-apollo'; +import gql from 'graphql-tag'; +import { CURRENT_USER_QUERY } from './User'; + +const ADD_TO_CART_MUTATION = gql` + mutation addToCart($id: ID!) { + addToCart(id: $id) { + id + quantity + } + } +`; + +class AddToCart extends React.Component { + render() { + const { id } = this.props; + return ( + <Mutation + mutation={ADD_TO_CART_MUTATION} + variables={{ + id, + }} + refetchQueries={[{ query: CURRENT_USER_QUERY }]} + > + {(addToCart, { loading }) => ( + <button disabled={loading} onClick={addToCart}> + Add{loading && 'ing'} To Cart 🛒 + </button> + )} + </Mutation> + ); + } +} +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..2a72b38 --- /dev/null +++ b/finished-application/frontend/components/Cart.js @@ -0,0 +1,66 @@ +import React from 'react'; +import { Query, Mutation } from 'react-apollo'; +import gql from 'graphql-tag'; +import { adopt } from 'react-adopt'; +import User from './User'; +import CartStyles from './styles/CartStyles'; +import Supreme from './styles/Supreme'; +import CloseButton from './styles/CloseButton'; +import SickButton from './styles/SickButton'; +import CartItem from './CartItem'; +import calcTotalPrice from '../lib/calcTotalPrice'; +import formatMoney from '../lib/formatMoney'; +import TakeMyMoney from './TakeMyMoney'; + +const LOCAL_STATE_QUERY = gql` + query { + cartOpen @client + } +`; + +const TOGGLE_CART_MUTATION = gql` + mutation { + toggleCart @client + } +`; +/* eslint-disable */ +const Composed = adopt({ + user: ({ render }) => <User>{render}</User>, + toggleCart: ({ render }) => <Mutation mutation={TOGGLE_CART_MUTATION}>{render}</Mutation>, + localState: ({ render }) => <Query query={LOCAL_STATE_QUERY}>{render}</Query>, +}); +/* eslint-enable */ + +const Cart = () => ( + <Composed> + {({ user, toggleCart, localState }) => { + const me = user.data.me; + if (!me) return null; + return ( + <CartStyles open={localState.data.cartOpen}> + <header> + <CloseButton onClick={toggleCart} title="close"> + × + </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> + {me.cart.length && ( + <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..c202d74 --- /dev/null +++ b/finished-application/frontend/components/CartCount.js @@ -0,0 +1,60 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { TransitionGroup, CSSTransition } from 'react-transition-group'; +import styled from 'styled-components'; + +const AnimationStyles = styled.span` + position: relative; + .count { + display: block; + position: relative; + transition: all 0.4s; + backface-visibility: hidden; + } + /* Intial State of the entered Dot */ + .count-enter { + transform: scale(4) rotateX(0.5turn); + } + .count-enter-active { + transform: rotateX(0); + } + .count-exit { + top: 0; + position: absolute; + transform: rotateX(0); + } + .count-exit-active { + transform: scale(4) rotateX(0.5turn); + } +`; + +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 CartCount = ({ count }) => ( + <AnimationStyles> + <TransitionGroup> + <CSSTransition + unmountOnExit + className="count" + classNames="count" + key={count} + timeout={{ enter: 400, exit: 400 }} + > + <Dot>{count}</Dot> + </CSSTransition> + </TransitionGroup> + </AnimationStyles> +); + +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..b0ce62e --- /dev/null +++ b/finished-application/frontend/components/CartItem.js @@ -0,0 +1,53 @@ +import React from 'react'; +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, + p { + margin: 0; + } +`; + +const CartItem = ({ cartItem }) => { + // first check if that item exists + if (!cartItem.item) + return ( + <CartItemStyles> + <p>This Item has been removed</p> + <RemoveFromCart id={cartItem.id} /> + </CartItemStyles> + ); + return ( + <CartItemStyles> + <img width="100" src={cartItem.item.image} alt={cartItem.item.title} /> + <div className="cart-item-details"> + <h3>{cartItem.item.title}</h3> + <p> + {formatMoney(cartItem.item.price * cartItem.quantity)} + {' - '} + <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..c115eac --- /dev/null +++ b/finished-application/frontend/components/CreateItem.js @@ -0,0 +1,142 @@ +import React, { Component } from 'react'; +import { Mutation } from 'react-apollo'; +import gql from 'graphql-tag'; +import Router from 'next/router'; +import Form from './styles/Form'; +import formatMoney from '../lib/formatMoney'; +import Error from './ErrorMessage'; + +const CREATE_ITEM_MUTATION = gql` + mutation CREATE_ITEM_MUTATION( + $title: String! + $description: String! + $price: Int! + $image: String + $largeImage: String + ) { + createItem( + title: $title + description: $description + price: $price + image: $image + largeImage: $largeImage + ) { + id + } + } +`; + +class CreateItem extends Component { + state = { + title: '', + description: '', + image: '', + largeImage: '', + price: 0, + }; + handleChange = e => { + const { name, type, value } = e.target; + const val = type === 'number' ? parseFloat(value) : value; + this.setState({ [name]: val }); + }; + + uploadFile = async e => { + const files = e.target.files; + const data = new FormData(); + data.append('file', files[0]); + data.append('upload_preset', 'sickfits'); + + const res = await fetch('https://api.cloudinary.com/v1_1/wesbostutorial/image/upload', { + method: 'POST', + body: data, + }); + const file = await res.json(); + this.setState({ + image: file.secure_url, + largeImage: file.eager[0].secure_url, + }); + }; + render() { + return ( + <Mutation mutation={CREATE_ITEM_MUTATION} variables={this.state}> + {(createItem, { loading, error }) => ( + <Form + data-test="form" + onSubmit={async e => { + // Stop the form from submitting + e.preventDefault(); + // call the mutation + const res = await createItem(); + // change them to the single item page + console.log(res); + Router.push({ + pathname: '/item', + query: { id: res.data.createItem.id }, + }); + }} + > + <Error error={error} /> + <fieldset disabled={loading} aria-busy={loading}> + <label htmlFor="file"> + Image + <input + type="file" + id="file" + name="file" + placeholder="Upload an image" + required + onChange={this.uploadFile} + /> + {this.state.image && ( + <img width="200" src={this.state.image} alt="Upload Preview" /> + )} + </label> + + <label htmlFor="title"> + Title + <input + type="text" + id="title" + name="title" + placeholder="Title" + required + value={this.state.title} + onChange={this.handleChange} + /> + </label> + + <label htmlFor="price"> + Price + <input + type="number" + id="price" + name="price" + placeholder="Price" + required + value={this.state.price} + onChange={this.handleChange} + /> + </label> + + <label htmlFor="description"> + Description + <textarea + id="description" + name="description" + placeholder="Enter A Description" + required + value={this.state.description} + onChange={this.handleChange} + /> + </label> + <button 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..e5e4752 --- /dev/null +++ b/finished-application/frontend/components/DeleteItem.js @@ -0,0 +1,50 @@ +import React, { Component } from 'react'; +import { Mutation } from 'react-apollo'; +import gql from 'graphql-tag'; +import { ALL_ITEMS_QUERY } from './Items'; + +const DELETE_ITEM_MUTATION = gql` + mutation DELETE_ITEM_MUTATION($id: ID!) { + deleteItem(id: $id) { + id + } + } +`; + +class DeleteItem extends Component { + update = (cache, payload) => { + // manually update the cache on the client, so it matches the server + // 1. Read the cache for the items we want + const data = cache.readQuery({ query: ALL_ITEMS_QUERY }); + console.log(data, payload); + // 2. Filter the deleted itemout of the page + data.items = data.items.filter(item => item.id !== payload.data.deleteItem.id); + // 3. Put the items back! + cache.writeQuery({ query: ALL_ITEMS_QUERY, data }); + }; + render() { + return ( + <Mutation + mutation={DELETE_ITEM_MUTATION} + variables={{ id: this.props.id }} + update={this.update} + > + {(deleteItem, { error }) => ( + <button + onClick={() => { + if (confirm('Are you sure you want to delete this item?')) { + deleteItem().catch(err => { + alert(err.message); + }); + } + }} + > + {this.props.children} + </button> + )} + </Mutation> + ); + } +} + +export default DeleteItem; 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..797eb8e --- /dev/null +++ b/finished-application/frontend/components/Header.js @@ -0,0 +1,75 @@ +import Link from 'next/link'; +import styled from 'styled-components'; +import NProgress from 'nprogress'; +import Router from 'next/router'; +import Nav from './Nav'; +import Cart from './Cart'; +import Search from './Search'; + +Router.onRouteChangeStart = () => { + NProgress.start(); +}; +Router.onRouteChangeComplete = () => { + NProgress.done(); +}; + +Router.onRouteChangeError = () => { + NProgress.done(); +}; + +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; + text-transform: uppercase; + text-decoration: none; + } + @media (max-width: 1300px) { + margin: 0; + text-align: center; + } +`; + +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 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..2741dcf --- /dev/null +++ b/finished-application/frontend/components/Item.js @@ -0,0 +1,50 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import Link from 'next/link'; +import Title from './styles/Title'; +import ItemStyles from './styles/ItemStyles'; +import PriceTag from './styles/PriceTag'; +import formatMoney from '../lib/formatMoney'; +import DeleteItem from './DeleteItem'; +import AddToCart from './AddToCart'; + +export default class Item extends Component { + static propTypes = { + item: PropTypes.object.isRequired, + }; + + render() { + const { item } = this.props; + return ( + <ItemStyles> + {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}>Delete This Item</DeleteItem> + </div> + </ItemStyles> + ); + } +} diff --git a/finished-application/frontend/components/Items.js b/finished-application/frontend/components/Items.js new file mode 100644 index 0000000..9b5426c --- /dev/null +++ b/finished-application/frontend/components/Items.js @@ -0,0 +1,61 @@ +import React, { Component } from 'react'; +import { Query } from 'react-apollo'; +import gql from 'graphql-tag'; +import styled from 'styled-components'; +import Item from './Item'; +import Pagination from './Pagination'; +import { perPage } from '../config'; + +const ALL_ITEMS_QUERY = gql` + query ALL_ITEMS_QUERY($skip: Int = 0, $first: Int = ${perPage}) { + items(first: $first, skip: $skip, orderBy: createdAt_DESC) { + id + title + price + description + image + largeImage + } + } +`; + +const Center = styled.div` + text-align: center; +`; + +const ItemsList = styled.div` + display: grid; + grid-template-columns: 1fr 1fr; + grid-gap: 60px; + max-width: ${props => props.theme.maxWidth}; + margin: 0 auto; +`; + +class Items extends Component { + render() { + return ( + <Center> + <Pagination page={this.props.page} /> + <Query + query={ALL_ITEMS_QUERY} + // fetchPolicy="network-only" + variables={{ + skip: this.props.page * perPage - perPage, + }} + > + {({ data, error, loading }) => { + if (loading) return <p>Loading...</p>; + if (error) return <p>Error: {error.message}</p>; + return ( + <ItemsList>{data.items.map(item => <Item item={item} key={item.id} />)}</ItemsList> + ); + }} + </Query> + <Pagination page={this.props.page} /> + </Center> + ); + } +} + +export default Items; +export { ALL_ITEMS_QUERY }; diff --git a/finished-application/frontend/components/Meta.js b/finished-application/frontend/components/Meta.js new file mode 100644 index 0000000..2b98f92 --- /dev/null +++ b/finished-application/frontend/components/Meta.js @@ -0,0 +1,13 @@ +import Head from 'next/head'; + +const Meta = () => ( + <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> +); + +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..118db5a --- /dev/null +++ b/finished-application/frontend/components/Nav.js @@ -0,0 +1,49 @@ +import Link from 'next/link'; +import { Mutation } from 'react-apollo'; +import { TOGGLE_CART_MUTATION } from './Cart'; +import NavStyles from './styles/NavStyles'; +import User from './User'; +import CartCount from './CartCount'; +import Signout from './Signout'; + +const Nav = () => ( + <User> + {({ data: { me } }) => ( + <NavStyles data-test="nav"> + <Link href="/items"> + <a>Shop</a> + </Link> + {me && ( + <> + <Link href="/sell"> + <a>Sell</a> + </Link> + <Link href="/orders"> + <a>Orders</a> + </Link> + <Link href="/me"> + <a>Account</a> + </Link> + <Signout /> + <Mutation mutation={TOGGLE_CART_MUTATION}> + {(toggleCart) => ( + <button onClick={toggleCart}> + My Cart + <CartCount count={me.cart.reduce((tally, cartItem) => tally + cartItem.quantity, 0)}></CartCount> + </button> + )} + </Mutation> + </> + )} + {!me && ( + <Link href="/signup"> + <a>Sign In</a> + </Link> + + )} + </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..5177c0a --- /dev/null +++ b/finished-application/frontend/components/Order.js @@ -0,0 +1,92 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Query } from 'react-apollo'; +import { format } from 'date-fns'; +import Head from 'next/head'; +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 + description + price + image + quantity + } + } + } +`; + +class Order extends React.Component { + static propTypes = { + id: PropTypes.string.isRequired, + }; + render() { + return ( + <Query query={SINGLE_ORDER_QUERY} variables={{ id: this.props.id }}> + {({ data, error, loading }) => { + if (error) return <Error error={error} />; + if (loading) return <p>Loading...</p>; + const order = data.order; + return ( + <OrderStyles data-test="order"> + <Head> + <title>Sick Fits - Order {order.id}</title> + </Head> + <p> + <span>Order ID:</span> + <span>{this.props.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>{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..e2e22ff --- /dev/null +++ b/finished-application/frontend/components/OrderList.js @@ -0,0 +1,80 @@ +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 Error from './ErrorMessage'; +import formatMoney from '../lib/formatMoney'; +import OrderItemStyles from './styles/OrderItemStyles'; + +const USER_ORDERS_QUERY = gql` + query USER_ORDERS_QUERY { + orders(orderBy: createdAt_DESC) { + id + total + createdAt + 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 <Error erorr={error} />; + console.log(orders); + 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>{order.items.reduce((a, b) => a + b.quantity, 0)} Items</p> + <p>{order.items.length} Products</p> + <p>{formatDistance(order.createdAt, new Date())}</p> + <p>{formatMoney(order.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; diff --git a/finished-application/frontend/components/Page.js b/finished-application/frontend/components/Page.js new file mode 100644 index 0000000..75ab84a --- /dev/null +++ b/finished-application/frontend/components/Page.js @@ -0,0 +1,68 @@ +import React, { Component } from 'react'; +import styled, { ThemeProvider, injectGlobal } from 'styled-components'; +import Header from '../components/Header'; +import Meta from '../components/Meta'; + +const theme = { + red: '#FF0000', + black: '#393939', + grey: '#3A3A3A', + lightgrey: '#E1E1E1', + offWhite: '#EDEDED', + maxWidth: '1000px', + bs: '0 12px 24px 0 rgba(0, 0, 0, 0.09)', +}; + +const StyledPage = styled.div` + background: white; + color: ${props => props.theme.black}; +`; + +const Inner = styled.div` + max-width: ${props => props.theme.maxWidth}; + margin: 0 auto; + padding: 2rem; +`; + +injectGlobal` + @font-face { + font-family: 'radnika_next'; + src: url('/static/radnikanext-medium-webfont.woff2') format('woff2'); + font-weight: normal; + font-style: normal; + } + html { + box-sizing: border-box; + font-size: 10px; + } + *, *:before, *:after { + box-sizing: inherit; + } + body { + padding: 0; + margin: 0; + font-size: 1.5rem; + line-height: 2; + font-family: 'radnika_next'; + } + a { + text-decoration: none; + color: ${theme.black}; + } +`; + +class Page extends Component { + render() { + return ( + <ThemeProvider theme={theme}> + <StyledPage> + <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..b84af76 --- /dev/null +++ b/finished-application/frontend/components/Pagination.js @@ -0,0 +1,67 @@ +import React from 'react'; +import gql from 'graphql-tag'; +import { Query } from 'react-apollo'; +import Head from 'next/head'; +import Link from 'next/link'; +import PaginationStyles from './styles/PaginationStyles'; +import { perPage } from '../config'; + +const PAGINATION_QUERY = gql` + query PAGINATION_QUERY { + itemsConnection { + aggregate { + count + } + } + } +`; + +const Pagination = props => ( + <Query query={PAGINATION_QUERY}> + {({ data, loading, error }) => { + if (loading) return <p>Loading...</p>; + const count = data.itemsConnection.aggregate.count; + const pages = Math.ceil(count / perPage); + const page = props.page; + 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 {props.page} of + <span className="totalPages">{pages}</span>! + </p> + <p>{count} Items Total</p> + <Link + prefetch + href={{ + pathname: 'items', + query: { page: page + 1 }, + }} + > + <a className="next" aria-disabled={page >= pages}> + Next → + </a> + </Link> + </PaginationStyles> + ); + }} + </Query> +); + +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..4b08c39 --- /dev/null +++ b/finished-application/frontend/components/Permissions.js @@ -0,0 +1,131 @@ +import { Query, Mutation } from 'react-apollo'; +import Error from './ErrorMessage'; +import gql from 'graphql-tag'; +import Table from './styles/Table'; +import SickButton from './styles/SickButton'; +import PropTypes from 'prop-types'; + +const possiblePermissions = [ + 'ADMIN', + 'USER', + 'ITEMCREATE', + 'ITEMUPDATE', + 'ITEMDELETE', + 'PERMISSIONUPDATE', +]; + +const UPDATE_PERMISSIONS_MUTATION = gql` + mutation updatePermissions($permissions: [Permission], $userId: ID!) { + updatePermissions(permissions: $permissions, userId: $userId) { + id + permissions + name + email + } + } +`; + +const ALL_USERS_QUERY = gql` + query { + users { + id + name + email + permissions + } + } +`; + +const Permissions = props => ( + <Query query={ALL_USERS_QUERY}> + {({ data, loading, error }) => ( + <div> + <Error error={error} /> + <div> + <h2>Manage Permissions</h2> + <Table> + <thead> + <tr> + <th>Name</th> + <th>Email</th> + {possiblePermissions.map(permission => <th key={permission}>{permission}</th>)} + <th>👇🏻</th> + </tr> + </thead> + <tbody>{data.users.map(user => <UserPermissions user={user} key={user.id} />)}</tbody> + </Table> + </div> + </div> + )} + </Query> +); + +class UserPermissions extends React.Component { + static propTypes = { + user: PropTypes.shape({ + name: PropTypes.string, + email: PropTypes.string, + id: PropTypes.string, + permissions: PropTypes.array, + }).isRequired, + }; + state = { + permissions: this.props.user.permissions, + }; + handlePermissionChange = (e) => { + const checkbox = e.target; + // take a copy of the current permissions + let updatedPermissions = [...this.state.permissions]; + // figure out if we need to remove or add this permission + 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.user; + return ( + <Mutation + mutation={UPDATE_PERMISSIONS_MUTATION} + variables={{ + permissions: this.state.permissions, + userId: this.props.user.id, + }} + > + {(updatePermissions, { loading, error }) => ( + <> + {error && <tr><td colspan="8"><Error error={error} /></td></tr>} + < tr > + <td>{user.name}</td> + <td>{user.email}</td> + {possiblePermissions.map(permission => ( + <td key={permission}> + <label htmlFor={`${user.id}-permission-${permission}`}> + <input + id={`${user.id}-permission-${permission}`} + type="checkbox" + checked={this.state.permissions.includes(permission)} + value={permission} + onChange={this.handlePermissionChange} + /> + </label> + </td> + ))} + <td> + <SickButton type="button" disabled={loading} onClick={updatePermissions}> + Updat{loading ? 'ing' : 'e'} + </SickButton> + </td> + </tr> + </> + ) + } + </Mutation> + ); + } +} + +export default Permissions; diff --git a/finished-application/frontend/components/PleaseSignIn.js b/finished-application/frontend/components/PleaseSignIn.js new file mode 100644 index 0000000..80cfdbf --- /dev/null +++ b/finished-application/frontend/components/PleaseSignIn.js @@ -0,0 +1,22 @@ +import { Query } from 'react-apollo'; +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>; + if (!data.me) { + return ( + <div> + <p>Please Sign In before Continuing</p> + <Signin /> + </div> + ); + } + return props.children; + }} + </Query> +); + +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..025dae5 --- /dev/null +++ b/finished-application/frontend/components/RemoveFromCart.js @@ -0,0 +1,71 @@ +import React 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 React.Component { + static propTypes = { + id: PropTypes.string.isRequired, + }; + // This gets called as soon as we get a response back from the server after a mutation has been performed + update = (cache, payload) => { + // 1. first read the cache + const data = cache.readQuery({ query: CURRENT_USER_QUERY }); + // 2. remove that item from the cart + const cartItemId = payload.data.removeFromCart.id; + data.me.cart = data.me.cart.filter(cartItem => cartItem.id !== cartItemId); + // 3. write it back to the cache + cache.writeQuery({ query: CURRENT_USER_QUERY, data }); + }; + render() { + return ( + <Mutation + mutation={REMOVE_FROM_CART_MUTATION} + variables={{ id: this.props.id }} + update={this.update} + optimisticResponse={{ + __typename: 'Mutation', + removeFromCart: { + __typename: 'CartItem', + id: this.props.id, + }, + }} + > + {(removeFromCart, { loading, error }) => ( + <BigButton + disabled={loading} + onClick={() => { + removeFromCart().catch(err => alert(err.message)); + }} + title="Delete Item" + > + × + </BigButton> + )} + </Mutation> + ); + } +} + +export default RemoveFromCart; +export { REMOVE_FROM_CART_MUTATION }; diff --git a/finished-application/frontend/components/RequestReset.js b/finished-application/frontend/components/RequestReset.js new file mode 100644 index 0000000..be21639 --- /dev/null +++ b/finished-application/frontend/components/RequestReset.js @@ -0,0 +1,60 @@ +import React, { Component } 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 REQUEST_RESET_MUTATION($email: String!) { + requestReset(email: $email) { + message + } + } +`; + +class RequestReset extends Component { + state = { + email: '', + }; + saveToState = e => { + this.setState({ [e.target.name]: e.target.value }); + }; + render() { + return ( + <Mutation mutation={REQUEST_RESET_MUTATION} variables={this.state}> + {(reset, { error, loading, called }) => ( + <Form + method="post" + data-test="form" + onSubmit={async e => { + e.preventDefault(); + await reset(); + this.setState({ email: '' }); + }} + > + <fieldset disabled={loading} aria-busy={loading}> + <h2>Request a password reset</h2> + <Error error={error} /> + {!error && !loading && called && <p>Success! Check your email for a reset link!</p>} + <label htmlFor="email"> + Email + <input + type="email" + name="email" + placeholder="email" + value={this.state.email} + onChange={this.saveToState} + /> + </label> + + <button type="submit">Request Reset!</button> + </fieldset> + </Form> + )} + </Mutation> + ); + } +} + +export default RequestReset; +export { REQUEST_RESET_MUTATION }; diff --git a/finished-application/frontend/components/Reset.js b/finished-application/frontend/components/Reset.js new file mode 100644 index 0000000..a132f03 --- /dev/null +++ b/finished-application/frontend/components/Reset.js @@ -0,0 +1,84 @@ +import React, { Component } from 'react'; +import { Mutation } from 'react-apollo'; +import gql from 'graphql-tag'; +import PropTypes from 'prop-types'; +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 Component { + static propTypes = { + resetToken: PropTypes.string.isRequired, + }; + state = { + password: '', + confirmPassword: '', + }; + saveToState = e => { + this.setState({ [e.target.name]: e.target.value }); + }; + render() { + return ( + <Mutation + mutation={RESET_MUTATION} + variables={{ + resetToken: this.props.resetToken, + password: this.state.password, + confirmPassword: this.state.confirmPassword, + }} + refetchQueries={[{ query: CURRENT_USER_QUERY }]} + > + {(reset, { error, loading, called }) => ( + <Form + method="post" + onSubmit={async e => { + e.preventDefault(); + await reset(); + this.setState({ password: '', confirmPassword: '' }); + }} + > + <fieldset disabled={loading} aria-busy={loading}> + <h2>Reset Your Password</h2> + <Error error={error} /> + <label htmlFor="password"> + Password + <input + type="password" + name="password" + placeholder="password" + value={this.state.password} + onChange={this.saveToState} + /> + </label> + + <label htmlFor="confirmPassword"> + Confirm Your Password + <input + type="password" + name="confirmPassword" + placeholder="confirmPassword" + value={this.state.confirmPassword} + onChange={this.saveToState} + /> + </label> + + <button type="submit">Reset Your Password!</button> + </fieldset> + </Form> + )} + </Mutation> + ); + } +} + +export default Reset; diff --git a/finished-application/frontend/components/Search.js b/finished-application/frontend/components/Search.js new file mode 100644 index 0000000..c29f93e --- /dev/null +++ b/finished-application/frontend/components/Search.js @@ -0,0 +1,94 @@ +import React from 'react'; +import Downshift, { resetIdCounter } from 'downshift'; +import Router from 'next/router'; +import { ApolloConsumer } from 'react-apollo'; +import gql from 'graphql-tag'; +import debounce from 'lodash.debounce'; +import { DropDown, DropDownItem, SearchStyles } from './styles/DropDown'; + +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, + }, + }); +} + +class AutoComplete extends React.Component { + state = { + items: [], + loading: false, + }; + onChange = debounce(async (e, client) => { + console.log('Searching...'); + // turn loading on + this.setState({ loading: true }); + // Manually query apollo client + const res = await client.query({ + query: SEARCH_ITEMS_QUERY, + variables: { searchTerm: e.target.value }, + }); + this.setState({ + items: res.data.items, + loading: false, + }); + }, 350); + render() { + resetIdCounter(); + return ( + <SearchStyles> + <Downshift onChange={routeToItem} itemToString={item => (item === null ? '' : item.title)}> + {({ getInputProps, getItemProps, isOpen, inputValue, highlightedIndex }) => ( + <div> + <ApolloConsumer> + {client => ( + <input + {...getInputProps({ + type: 'search', + placeholder: 'Search For An Item', + id: 'search', + className: this.state.loading ? 'loading' : '', + onChange: e => { + e.persist(); + this.onChange(e, client); + }, + })} + /> + )} + </ApolloConsumer> + {isOpen && ( + <DropDown> + {this.state.items.map((item, index) => ( + <DropDownItem + {...getItemProps({ item })} + key={item.id} + highlighted={index === highlightedIndex} + > + <img width="50" src={item.image} alt={item.title} /> + {item.title} + </DropDownItem> + ))} + {!this.state.items.length && + !this.state.loading && <DropDownItem> Nothing Found {inputValue}</DropDownItem>} + </DropDown> + )} + </div> + )} + </Downshift> + </SearchStyles> + ); + } +} + +export default AutoComplete; diff --git a/finished-application/frontend/components/Signin.js b/finished-application/frontend/components/Signin.js new file mode 100644 index 0000000..4fd3013 --- /dev/null +++ b/finished-application/frontend/components/Signin.js @@ -0,0 +1,76 @@ +import React, { Component } from 'react'; +import { Mutation } from 'react-apollo'; +import gql from 'graphql-tag'; +import Form from './styles/Form'; +import Error from './ErrorMessage'; +import { CURRENT_USER_QUERY } from './User'; + +const SIGNIN_MUTATION = gql` + mutation SIGNIN_MUTATION($email: String!, $password: String!) { + signin(email: $email, password: $password) { + id + email + name + } + } +`; + +class Signin extends Component { + state = { + name: '', + password: '', + email: '', + }; + saveToState = e => { + this.setState({ [e.target.name]: e.target.value }); + }; + render() { + return ( + <Mutation + mutation={SIGNIN_MUTATION} + variables={this.state} + refetchQueries={[{ query: CURRENT_USER_QUERY }]} + > + {(signup, { error, loading }) => ( + <Form + method="post" + onSubmit={async e => { + e.preventDefault(); + await signup(); + this.setState({ name: '', email: '', password: '' }); + }} + > + <fieldset disabled={loading} aria-busy={loading}> + <h2>Sign into your account</h2> + <Error error={error} /> + <label htmlFor="email"> + Email + <input + type="email" + name="email" + placeholder="email" + value={this.state.email} + onChange={this.saveToState} + /> + </label> + <label htmlFor="password"> + Password + <input + type="password" + name="password" + placeholder="password" + value={this.state.password} + onChange={this.saveToState} + /> + </label> + + <button type="submit">Sign In!</button> + </fieldset> + </Form> + )} + </Mutation> + ); + } +} + +export default Signin; diff --git a/finished-application/frontend/components/Signout.js b/finished-application/frontend/components/Signout.js new file mode 100644 index 0000000..f852531 --- /dev/null +++ b/finished-application/frontend/components/Signout.js @@ -0,0 +1,19 @@ +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 + } + } +`; + +const Signout = props => ( + <Mutation mutation={SIGN_OUT_MUTATION} refetchQueries={[{ query: CURRENT_USER_QUERY }]}> + {signout => <button onClick={signout}>Sign Out</button>} + </Mutation> +); +export default Signout; diff --git a/finished-application/frontend/components/Signup.js b/finished-application/frontend/components/Signup.js new file mode 100644 index 0000000..e2f1a3f --- /dev/null +++ b/finished-application/frontend/components/Signup.js @@ -0,0 +1,87 @@ +import React, { Component } from 'react'; +import { Mutation } from 'react-apollo'; +import gql from 'graphql-tag'; +import Form from './styles/Form'; +import Error from './ErrorMessage'; +import { CURRENT_USER_QUERY } from './User'; + +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 = { + name: '', + email: '', + password: '', + }; + saveToState = e => { + this.setState({ [e.target.name]: e.target.value }); + }; + render() { + return ( + <Mutation + mutation={SIGNUP_MUTATION} + variables={this.state} + refetchQueries={[{ query: CURRENT_USER_QUERY }]} + > + {(signup, { error, loading }) => ( + <Form + method="post" + onSubmit={async e => { + e.preventDefault(); + await signup(); + this.setState({ name: '', email: '', password: '' }); + }} + > + <fieldset disabled={loading} aria-busy={loading}> + <h2>Sign Up for An Account</h2> + <Error error={error} /> + <label htmlFor="email"> + Email + <input + type="email" + name="email" + placeholder="email" + value={this.state.email} + onChange={this.saveToState} + /> + </label> + <label htmlFor="name"> + Name + <input + type="text" + name="name" + placeholder="name" + value={this.state.name} + onChange={this.saveToState} + /> + </label> + <label htmlFor="password"> + Password + <input + type="password" + name="password" + placeholder="password" + value={this.state.password} + onChange={this.saveToState} + /> + </label> + + <button type="submit">Sign Up!</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..145b5ae --- /dev/null +++ b/finished-application/frontend/components/SingleItem.js @@ -0,0 +1,70 @@ +import React, { Component } from 'react'; +import gql from 'graphql-tag'; +import { Query } from 'react-apollo'; +import Error from './ErrorMessage'; +import styled from 'styled-components'; +import Head from 'next/head'; + +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 SINGLE_ITEM_QUERY = gql` + query SINGLE_ITEM_QUERY($id: ID!) { + item(where: { id: $id }) { + id + title + description + largeImage + } + } +`; +class SingleItem extends Component { + render() { + return ( + <Query + query={SINGLE_ITEM_QUERY} + variables={{ + id: this.props.id, + }} + > + {({ error, loading, data }) => { + if (error) return <Error error={error} />; + if (loading) return <p>Loading...</p>; + if (!data.item) return <p>No Item Found for {this.props.id}</p>; + const item = data.item; + return ( + <SingleItemStyles> + <Head> + <title>Sick Fits | {item.title}</title> + </Head> + <img src={item.largeImage} alt={item.title} /> + <div className="details"> + <h2>Viewing {item.title}</h2> + <p>{item.description}</p> + </div> + </SingleItemStyles> + ); + }} + </Query> + ); + } +} + +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..ca87832 --- /dev/null +++ b/finished-application/frontend/components/TakeMyMoney.js @@ -0,0 +1,79 @@ +import React from 'react'; +import StripeCheckout from 'react-stripe-checkout'; +import { Mutation } 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 React.Component { + onToken = async (res, createOrder) => { + NProgress.start(); + // manually call the mutation once we have the stripe token + const order = await createOrder({ + variables: { + token: res.id, + }, + }).catch(err => { + alert(err.message); + }); + Router.push({ + pathname: '/order', + query: { id: order.data.createOrder.id }, + }); + }; + render() { + return ( + <User> + {({ data: { me }, loading }) => { + if (loading) return null; + return ( + <Mutation + mutation={CREATE_ORDER_MUTATION} + refetchQueries={[{ query: CURRENT_USER_QUERY }]} + > + {createOrder => ( + <StripeCheckout + amount={calcTotalPrice(me.cart)} + name="Sick Fits" + description={`Order of ${totalItems(me.cart)} items!`} + image={me.cart.length && me.cart[0].item && me.cart[0].item.image} + stripeKey="pk_test_Vtknn6vSdcZWSG2JWvEiWSqC" + currency="USD" + email={me.email} + token={res => this.onToken(res, createOrder)} + > + {this.props.children} + </StripeCheckout> + )} + </Mutation> + ); + }} + </User> + ); + } +} + +export default TakeMyMoney; +export { CREATE_ORDER_MUTATION }; diff --git a/finished-application/frontend/components/UpdateItem.js b/finished-application/frontend/components/UpdateItem.js new file mode 100644 index 0000000..11aef32 --- /dev/null +++ b/finished-application/frontend/components/UpdateItem.js @@ -0,0 +1,117 @@ +import React, { Component } from 'react'; +import { Mutation, Query } from 'react-apollo'; +import gql from 'graphql-tag'; +import Router from 'next/router'; +import Form from './styles/Form'; +import formatMoney from '../lib/formatMoney'; +import Error from './ErrorMessage'; + +const SINGLE_ITEM_QUERY = gql` + query SINGLE_ITEM_QUERY($id: ID!) { + item(where: { id: $id }) { + id + title + description + price + } + } +`; +const UPDATE_ITEM_MUTATION = gql` + mutation UPDATE_ITEM_MUTATION($id: ID!, $title: String, $description: String, $price: Int) { + updateItem(id: $id, title: $title, description: $description, price: $price) { + id + title + description + price + } + } +`; + +class UpdateItem extends Component { + state = {}; + handleChange = e => { + const { name, type, value } = e.target; + const val = type === 'number' ? parseFloat(value) : value; + this.setState({ [name]: val }); + }; + updateItem = async (e, updateItemMutation) => { + e.preventDefault(); + console.log('Updating Item!!'); + console.log(this.state); + const res = await updateItemMutation({ + variables: { + id: this.props.id, + ...this.state, + }, + }); + console.log('Updated!!'); + }; + + render() { + return ( + <Query + query={SINGLE_ITEM_QUERY} + variables={{ + id: this.props.id, + }} + > + {({ data, loading }) => { + if (loading) return <p>Loading...</p>; + if (!data.item) return <p>No Item Found for ID {this.props.id}</p>; + return ( + <Mutation mutation={UPDATE_ITEM_MUTATION} variables={this.state}> + {(updateItem, { loading, error }) => ( + <Form onSubmit={e => this.updateItem(e, updateItem)}> + <Error error={error} /> + <fieldset disabled={loading} aria-busy={loading}> + <label htmlFor="title"> + Title + <input + type="text" + id="title" + name="title" + placeholder="Title" + required + defaultValue={data.item.title} + onChange={this.handleChange} + /> + </label> + + <label htmlFor="price"> + Price + <input + type="number" + id="price" + name="price" + placeholder="Price" + required + defaultValue={data.item.price} + onChange={this.handleChange} + /> + </label> + + <label htmlFor="description"> + Description + <textarea + id="description" + name="description" + placeholder="Enter A Description" + required + defaultValue={data.item.description} + onChange={this.handleChange} + /> + </label> + <button type="submit">Sav{loading ? 'ing' : 'e'} Changes</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..6f8a4fb --- /dev/null +++ b/finished-application/frontend/components/User.js @@ -0,0 +1,41 @@ +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 + } + cart { + id + quantity + item { + id + price + image + title + description + } + } + } + } +`; + +const User = props => ( + <Query {...props} query={CURRENT_USER_QUERY}> + {payload => props.children(payload)} + </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/DropDown.js b/finished-application/frontend/components/styles/DropDown.js new file mode 100644 index 0000000..df86aee --- /dev/null +++ b/finished-application/frontend/components/styles/DropDown.js @@ -0,0 +1,47 @@ +import styled, { keyframes } from 'styled-components'; + +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; + } + } +`; + +export { DropDown, DropDownItem, SearchStyles }; 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..fe4abda --- /dev/null +++ b/finished-application/frontend/components/styles/NavStyles.js @@ -0,0 +1,66 @@ +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; + color: ${props => props.theme.black}; + font-weight: 800; + @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..f461b70 --- /dev/null +++ b/finished-application/frontend/components/styles/OrderStyles.js @@ -0,0 +1,37 @@ +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%; + object-fit: cover; + } + } +`; +export default OrderStyles; diff --git a/finished-application/frontend/components/styles/PaginationStyles.js b/finished-application/frontend/components/styles/PaginationStyles.js new file mode 100644 index 0000000..30753af --- /dev/null +++ b/finished-application/frontend/components/styles/PaginationStyles.js @@ -0,0 +1,27 @@ +import styled from 'styled-components'; + +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; + } +`; + +export default PaginationStyles; 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..b2cd6c4 --- /dev/null +++ b/finished-application/frontend/components/styles/Table.js @@ -0,0 +1,35 @@ +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: 5px; + position: relative; + &:last-child { + border-right: none; + width: 150px; + button { + width: 100%; + } + } + label { + padding: 10px 5px; + display: block; + } + } + 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; |
