diff --git a/.circleci/config.yml b/.circleci/config.yml index da5044df9a..aac9dacfcd 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -12,6 +12,9 @@ jobs: - run: name: Install dependencies command: npm install + - run: + name: Run tslint + command: npm run tslint - run: name: Build application command: npm run build diff --git a/CHANGELOG.md b/CHANGELOG.md index 787700d96b..3a606e5534 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Add add to cart indicator #173 by @piotrgrundas Fix product page tablet view #181 by @piotrgrundas Add collection view, fix cursor pagination for categories, update storefront to use new thumbnail structure #178 by @piotrgrundas Storefront UX improvements, remove signup to newsletter #182 by @piotrgrundas -Fix two line titles breaking fatured carousel, product page improvements #184 by @piotrgrundas +Fix two line titles breaking featured carousel, product page improvements #184 by @piotrgrundas Allow numbers in product, category & collection urls #185 by @piotrgrundas Add OpenGraph and Meta tags, minor UI improvements #191 by @piotrgrundas +Add tslint check in the CI, ability to change cart quantity, fix error after removing item from the cart #194 by @piotrgrundas diff --git a/package.json b/package.json index 2b28170021..81dac6af61 100644 --- a/package.json +++ b/package.json @@ -99,6 +99,7 @@ "watch": "webpack -d --watch", "storybook": "start-storybook -p 9001 -c src/storybook/ -s src/", "codegen": "apollo codegen:generate --target=typescript types", - "codegen-watch": "npm run codegen -- --watch" + "codegen-watch": "npm run codegen -- --watch", + "tslint": "tslint 'src/**/*.ts?(x)'" } } diff --git a/src/components/App/index.tsx b/src/components/App/index.tsx index 9c01f15d4a..1b25d4ed1c 100644 --- a/src/components/App/index.tsx +++ b/src/components/App/index.tsx @@ -2,6 +2,7 @@ import * as React from "react"; import { ApolloConsumer } from "react-apollo"; import { + CartOverlay, Footer, MainMenu, MainMenuNavOverlay, @@ -9,7 +10,6 @@ import { MobileNav, SearchOverlay } from ".."; -import { CartOverlay } from "../CartOverlay"; import CartProvider from "../CartProvider"; import { LoginOverlay } from "../LoginOverlay"; import { NotificationOverlay } from "../NotificationOverlay"; diff --git a/src/components/App/routes.tsx b/src/components/App/routes.tsx index 422179ccee..bcb9818297 100644 --- a/src/components/App/routes.tsx +++ b/src/components/App/routes.tsx @@ -1,8 +1,9 @@ import * as React from "react"; import { Route, Switch } from "react-router-dom"; -import { CartPage, CheckoutLogin } from ".."; +import { CheckoutLogin } from ".."; import { ArticlePage } from "../../views/Article"; +import { CartPage } from "../../views/Cart"; import { CategoryPage } from "../../views/Category"; import { CollectionPage } from "../../views/Collection"; import { HomePage } from "../../views/Home"; diff --git a/src/components/CachedImage/index.tsx b/src/components/CachedImage/CachedImage.tsx similarity index 89% rename from src/components/CachedImage/index.tsx rename to src/components/CachedImage/CachedImage.tsx index 48d4f7b6fc..a04c587113 100644 --- a/src/components/CachedImage/index.tsx +++ b/src/components/CachedImage/CachedImage.tsx @@ -3,6 +3,7 @@ import * as React from "react"; interface CachedImageProps { url: string; url2x?: string; + alt?: string; } interface CachedImageState { @@ -56,12 +57,16 @@ class CachedImage extends React.Component { } render() { - const { url, url2x } = this.props; + const { url, url2x, alt } = this.props; if (this.state.isUnavailable) { return this.props.children || null; } return ( - + {alt} ); } } diff --git a/src/components/CachedImage/CachedThumbnail.tsx b/src/components/CachedImage/CachedThumbnail.tsx new file mode 100644 index 0000000000..3b445c3eb1 --- /dev/null +++ b/src/components/CachedImage/CachedThumbnail.tsx @@ -0,0 +1,32 @@ +import * as React from "react"; + +import { maybe } from "../../core/utils"; +import CachedImage from "./CachedImage"; + +const noPhotoPng = require("../../images/nophoto.png"); + +const CachedThumbnail: React.SFC<{ + source: { + thumbnail: { url: string; alt: string }; + thumbnail2x: { url: string }; + }; + noPhotoDefault?: boolean; + children?: React.ReactNode; +}> = ({ source, noPhotoDefault, children }) => { + const defaultImg = noPhotoDefault ? noPhotoPng : undefined; + return ( + source.thumbnail.url, defaultImg)} + url2x={maybe(() => source.thumbnail2x.url, defaultImg)} + alt={maybe(() => source.thumbnail.alt, "")} + > + {children} + + ); +}; + +CachedThumbnail.defaultProps = { + noPhotoDefault: true +}; + +export default CachedThumbnail; diff --git a/src/components/CachedImage/index.ts b/src/components/CachedImage/index.ts new file mode 100644 index 0000000000..abb3a253e4 --- /dev/null +++ b/src/components/CachedImage/index.ts @@ -0,0 +1,2 @@ +export { default as CachedImage } from "./CachedImage"; +export { default as CachedThumbnail } from "./CachedThumbnail"; diff --git a/src/components/CartOverlay/index.tsx b/src/components/CartOverlay/CartOverlay.tsx similarity index 65% rename from src/components/CartOverlay/index.tsx rename to src/components/CartOverlay/CartOverlay.tsx index 942b24292a..048cf3ee7a 100644 --- a/src/components/CartOverlay/index.tsx +++ b/src/components/CartOverlay/CartOverlay.tsx @@ -6,29 +6,27 @@ import { Link } from "react-router-dom"; import ReactSVG from "react-svg"; import { Button } from ".."; -import { maybe, priceToString } from "../../core/utils"; +import { priceToString } from "../../core/utils"; import { checkoutLoginUrl } from "../App/routes"; import { CartContext } from "../CartProvider/context"; import { Error } from "../Error"; import GoToCart from "../GoToCart"; import { GoToCheckout } from "../GoToCheckout"; import Loader from "../Loader"; +import Offline from "../Offline"; +import OfflinePlaceholder from "../OfflinePlaceholder"; +import Online from "../Online"; import { Overlay } from "../Overlay"; import { OverlayContext, OverlayType } from "../Overlay/context"; import { ShopContext } from "../ShopProvider/context"; import { UserContext } from "../User/context"; - -import CachedImage from "../CachedImage"; -import Offline from "../Offline"; -import OfflinePlaceholder from "../OfflinePlaceholder"; -import Online from "../Online"; +import Empty from "./Empty"; +import ProductList from "./ProductList"; const cartSvg = require("../../images/cart.svg"); const closeSvg = require("../../images/x.svg"); -const noPhotoPng = require("../../images/nophoto.png"); -const removeSvg = require("../../images/garbage.svg"); -export const CartOverlay: React.SFC = () => ( +const CartOverlay: React.SFC = () => ( {overlay => overlay.type === OverlayType.cart ? ( @@ -46,11 +44,13 @@ export const CartOverlay: React.SFC = () => ( ); } + if (errors) { return errors.map(error => ( )); } + return (
@@ -66,45 +66,16 @@ export const CartOverlay: React.SFC = () => (
overlay.hide()} + onClick={overlay.hide} className="overlay__header__close-icon" />
{lines.length ? ( <> -
    - {lines.map(line => ( -
  • - line.variant.product.thumbnail.url, - noPhotoPng - )} - url2x={maybe( - () => line.variant.product.thumbnail2x.url - )} - /> -
    -

    {line.variant.price.localized}

    -

    {line.variant.product.name}

    - - {line.variant.name} - {"Qty: " + line.quantity} - - - cart.remove(line.variant.id) - } - /> -
    -
  • - ))} -
+
Subtotal @@ -152,18 +123,7 @@ export const CartOverlay: React.SFC = () => (
) : ( -
-

Yor bag is empty

-

- You haven’t added anything to your bag. We’re sure - you’ll find something in our store -

-
- -
-
+ )}
); @@ -182,3 +142,5 @@ export const CartOverlay: React.SFC = () => ( }
); + +export default CartOverlay; diff --git a/src/components/CartOverlay/Empty.tsx b/src/components/CartOverlay/Empty.tsx new file mode 100644 index 0000000000..eadfb4e65d --- /dev/null +++ b/src/components/CartOverlay/Empty.tsx @@ -0,0 +1,20 @@ +import * as React from "react"; + +import { Button } from ".."; + +const Empty: React.SFC<{ overlayHide(): void }> = ({ overlayHide }) => ( +
+

Yor bag is empty

+

+ You haven’t added anything to your bag. We’re sure you’ll find something + in our store +

+
+ +
+
+); + +export default Empty; diff --git a/src/components/CartOverlay/ProductList.tsx b/src/components/CartOverlay/ProductList.tsx new file mode 100644 index 0000000000..2203d34697 --- /dev/null +++ b/src/components/CartOverlay/ProductList.tsx @@ -0,0 +1,47 @@ +import * as React from "react"; +import { Link } from "react-router-dom"; +import ReactSVG from "react-svg"; + +import { CachedThumbnail } from ".."; +import { generateProductUrl } from "../../core/utils"; +import { CartLineInterface } from "../CartProvider/context"; + +const removeSvg = require("../../images/garbage.svg"); + +const ProductList: React.SFC<{ + lines: CartLineInterface[]; + removeFromCart(variantId: string): void; +}> = ({ lines, removeFromCart }) => ( + +); + +export default ProductList; diff --git a/src/components/CartOverlay/index.ts b/src/components/CartOverlay/index.ts new file mode 100644 index 0000000000..16984ede66 --- /dev/null +++ b/src/components/CartOverlay/index.ts @@ -0,0 +1 @@ +export { default as CartOverlay } from "./CartOverlay"; diff --git a/src/components/CartPage/index.tsx b/src/components/CartPage/index.tsx deleted file mode 100644 index f3dbfb2ec3..0000000000 --- a/src/components/CartPage/index.tsx +++ /dev/null @@ -1,159 +0,0 @@ -import "./scss/index.scss"; - -import * as React from "react"; -import { ApolloConsumer, Query } from "react-apollo"; -import Media from "react-media"; -import { RouteComponentProps } from "react-router"; -import { Link } from "react-router-dom"; -import ReactSVG from "react-svg"; - -import { Button, Loader } from ".."; -import { maybe } from "../../core/utils"; -import { checkoutLoginUrl } from "../App/routes"; -import { smallScreen } from "../App/scss/variables.scss"; -import CachedImage from "../CachedImage"; -import { CartContext } from "../CartProvider/context"; -import { GET_CHECKOUT } from "../CheckoutApp/queries"; -import { getCheckout } from "../CheckoutApp/types/getCheckout"; -import { Error } from "../Error"; -import { GoToCheckout } from "../GoToCheckout"; -import { UserContext } from "../User/context"; -import { EmptyCart } from "./EmptyCart"; - -const noPhotoPng = require("../../images/nophoto.png"); -const removeSvg = require("../../images/garbage.svg"); - -const canDisplay = (data: getCheckout) => - data && data.checkout && data.checkout.lines && data.checkout.subtotalPrice; - -const CartPage: React.SFC> = ({ - match: { - params: { token = "" } - } -}) => { - return ( -
-

Shopping bag

- - {({ error, data }) => { - if (canDisplay(data)) { - const { checkout } = data; - const lines = checkout ? checkout.lines : []; - if (lines.length > 0) { - return ( - <> - - - - - - {matches => (matches ? : null)} - - - - - - - {lines.map(line => ( - - - - {matches => - matches ? ( - - ) : null - } - - - - - - ))} - - - - - - {matches => (matches ? - - -
ProductsPriceQuantity - - {matches => (matches ? "Total Price" : "Price")} - - -
- ( - line.variant.product.thumbnail.url, - noPhotoPng - )} - url2x={maybe( - () => line.variant.product.thumbnail2x.url - )} - /> - )} - /> - {line.variant.product.name} - {line.variant.name - ? ` (${line.variant.name})` - : null} - {line.variant.price.localized}{line.quantity}{line.totalPrice.gross.localized} - - {({ remove }) => ( - remove(line.variant.id)} - /> - )} - -
Subtotal : null)} - - - {checkout.subtotalPrice.gross.localized} -
-
- - {({ user }) => - user ? ( - - {client => ( - - Checkout{" "} - - )} - - ) : ( - - - - ) - } - -
- - ); - } else { - return ; - } - } - if (error && !data) { - return ; - } - return ; - }} -
-
- ); -}; - -export default CartPage; diff --git a/src/components/CartPage/scss/index.scss b/src/components/CartPage/scss/index.scss deleted file mode 100644 index 141c883e13..0000000000 --- a/src/components/CartPage/scss/index.scss +++ /dev/null @@ -1,50 +0,0 @@ -@import "../../App/scss/variables.scss"; - -.cart-page { - &__header { - margin-top: $spacer * 3; - } - - &__table { - &__subtotal { - color: $gray-dark; - font-weight: $bold-font-weight; - } - } - - &__thumbnail { - img { - width: 50px; - height: auto; - } - } - - &__checkout-action { - text-align: right; - margin: 0 $spacer * 2 $spacer * 3; - - @media (max-width: $small-screen) { - text-align: center; - } - } - - &__empty { - text-align: center; - padding: $spacer * 5 0; - - h4 { - font-weight: $bold-font-weight; - text-transform: uppercase; - margin-bottom: $spacer; - } - - p { - color: $gray; - } - - &__action { - text-align: center; - margin-top: $spacer; - } - } -} diff --git a/src/components/CartProvider/context.ts b/src/components/CartProvider/context.ts index e805c9489d..fb7fa8ab8f 100644 --- a/src/components/CartProvider/context.ts +++ b/src/components/CartProvider/context.ts @@ -10,15 +10,16 @@ export interface CartLineInterface { export interface CartInterface { errors: ApolloError[] | null; - loading: boolean; lines: CartLineInterface[]; + loading: boolean; add(variantId: string, quantity?: number): void; - remove(variantId: string): void; changeQuantity(variantId: string, quantity: number); - fetch(): void; clear(): void; + fetch(): void; getQuantity(): number; getTotal(): { currency: string; amount: number }; + remove(variantId: string): void; + subtract(variantId: string, quantity?: number): void; } /* tslint:disable:no-empty */ @@ -32,7 +33,7 @@ export const CartContext = createContext({ getTotal: () => ({ currency: "USD", amount: 0 }), lines: [], loading: false, - - remove: variantId => {} + remove: variantId => {}, + subtract: (variantId, quantity = 1) => {} }); /* tslint:enable:no-empty */ diff --git a/src/components/CartProvider/index.tsx b/src/components/CartProvider/index.tsx index 5355c720b7..87b9d14e4f 100644 --- a/src/components/CartProvider/index.tsx +++ b/src/components/CartProvider/index.tsx @@ -1,8 +1,23 @@ import * as React from "react"; -import { ApolloClient } from "apollo-client"; +import { ApolloClient, ApolloError } from "apollo-client"; import { productVariatnsQuery } from "../../views/Product/queries"; -import { GET_CHECKOUT, UPDATE_CHECKOUT_LINE } from "../CheckoutApp/queries"; +import { + VariantList, + VariantListVariables +} from "../../views/Product/types/VariantList"; +import { + getCheckoutQuery, + updateCheckoutLineQuery +} from "../CheckoutApp/queries"; +import { + getCheckout, + getCheckoutVariables +} from "../CheckoutApp/types/getCheckout"; +import { + updateCheckoutLine, + updateCheckoutLineVariables +} from "../CheckoutApp/types/updateCheckoutLine"; import { CartContext, CartInterface, CartLineInterface } from "./context"; export default class CartProvider extends React.Component< @@ -11,12 +26,14 @@ export default class CartProvider extends React.Component< > { constructor(props) { super(props); + let lines; try { lines = JSON.parse(localStorage.getItem("cart")) || []; } catch { lines = []; } + this.state = { add: this.add, changeQuantity: this.changeQuantity, @@ -27,40 +44,45 @@ export default class CartProvider extends React.Component< getTotal: this.getTotal, lines, loading: false, - remove: this.remove + remove: this.remove, + subtract: this.subtract }; } + getLine = (variantId: string): CartLineInterface => + this.state.lines.find(line => line.variantId === variantId); + changeQuantity = async (variantId, quantity) => { this.setState({ loading: true }); - const newLine: CartLineInterface = { - quantity, - variantId - }; - this.setState(prevState => { - let lines = prevState.lines.filter(line => line.variantId !== variantId); - if (newLine.quantity > 0) { - lines = [...lines, newLine]; - } - return { lines }; - }); const checkoutToken = localStorage.getItem("checkout"); + let apiError = false; + if (checkoutToken) { const { apolloClient } = this.props; - let data: { [key: string]: any }; - const response = await apolloClient.query({ - query: GET_CHECKOUT, + const { + data: { + checkout: { id: checkoutID } + } + } = await apolloClient.query({ + query: getCheckoutQuery, variables: { token: checkoutToken } }); - data = response.data; - const checkoutID = data.checkout.id; - await apolloClient.mutate({ - mutation: UPDATE_CHECKOUT_LINE, + const { + data: { + checkoutLinesUpdate: { errors } + } + } = await apolloClient.mutate< + updateCheckoutLine, + updateCheckoutLineVariables + >({ + mutation: updateCheckoutLineQuery, update: (cache, { data: { checkoutLinesUpdate } }) => { cache.writeQuery({ - data: { checkout: checkoutLinesUpdate.checkout }, - query: GET_CHECKOUT + data: { + checkout: checkoutLinesUpdate.checkout + }, + query: getCheckoutQuery }); }, variables: { @@ -73,34 +95,61 @@ export default class CartProvider extends React.Component< ] } }); - this.setState({ loading: false }); + apiError = !!errors.length; + if (apiError) { + // TODO Add notificaton after https://github.com/mirumee/saleor/pull/3563 will be resolved + this.setState({ loading: false }); + } + } + + if (!apiError) { + const newLine = { quantity, variantId }; + this.setState(prevState => { + let lines = prevState.lines.filter( + line => line.variantId !== variantId + ); + if (newLine.quantity > 0) { + lines = [...lines, newLine]; + } + return { lines, loading: false }; + }); } }; add = (variantId, quantity = 1) => { - const line = this.state.lines.find(line => line.variantId === variantId); + const line = this.getLine(variantId); const newQuantity = line ? line.quantity + quantity : quantity; this.changeQuantity(variantId, newQuantity); }; + subtract = (variantId, quantity = 1) => { + const line = this.getLine(variantId); + const newQuantity = line ? line.quantity - quantity : quantity; + this.changeQuantity(variantId, newQuantity); + }; + clear = () => this.setState({ lines: [] }); fetch = async () => { const cart = JSON.parse(localStorage.getItem("cart")) || []; + if (cart.length) { this.setState({ loading: true }); - const { apolloClient } = this.props; - let data: { [key: string]: any }; let lines; - const response = await apolloClient.query({ + const { apolloClient } = this.props; + const { data, errors } = await apolloClient.query< + VariantList, + VariantListVariables + >({ query: productVariatnsQuery, - variables: { ids: cart.map(line => line.variantId) } + variables: { + ids: cart.map(line => line.variantId) + } }); const quantityMapping = cart.reduce((obj, line) => { obj[line.variantId] = line.quantity; return obj; }, {}); - data = response.data; lines = data.productVariants ? data.productVariants.edges.map(variant => ({ quantity: quantityMapping[variant.node.id], @@ -108,15 +157,12 @@ export default class CartProvider extends React.Component< variantId: variant.node.id })) : []; - if (data.errors) { - this.setState({ - errors: data.errors, - lines: [], - loading: false - }); - } else { - this.setState({ loading: false, lines, errors: null }); - } + + this.setState({ + errors: errors ? [new ApolloError({ graphQLErrors: errors })] : null, + lines: errors ? [] : lines, + loading: false + }); } }; @@ -140,11 +186,11 @@ export default class CartProvider extends React.Component< localStorage.setItem("cart", JSON.stringify(this.state.lines)); } } - render() { - const { children } = this.props; return ( - {children} + + {this.props.children} + ); } } diff --git a/src/components/CheckoutApp/index.tsx b/src/components/CheckoutApp/index.tsx index a6c35c800f..d895a29d87 100644 --- a/src/components/CheckoutApp/index.tsx +++ b/src/components/CheckoutApp/index.tsx @@ -1,3 +1,6 @@ +import { mediumScreen } from "../App/scss/variables.scss"; +import "./scss/index.scss"; + import { ApolloClient } from "apollo-client"; import * as React from "react"; import { ApolloConsumer } from "react-apollo"; @@ -8,15 +11,12 @@ import ReactSVG from "react-svg"; import { CartSummary, Loader } from ".."; import { baseUrl } from "../App/routes"; -import { CheckoutContext, CheckoutContextInterface } from "./context"; -import { GET_CHECKOUT } from "./queries"; -import { Routes } from "./routes"; - -import { mediumScreen } from "../App/scss/variables.scss"; import Offline from "../Offline"; import OfflinePlaceholder from "../OfflinePlaceholder"; import Online from "../Online"; -import "./scss/index.scss"; +import { CheckoutContext, CheckoutContextInterface } from "./context"; +import { getCheckoutQuery } from "./queries"; +import { Routes } from "./routes"; export class CheckoutProvider extends React.Component< { @@ -45,7 +45,7 @@ export class CheckoutProvider extends React.Component< getCheckout = async () => { this.setState({ loading: true }); const { data } = await this.props.apolloClient.query({ - query: GET_CHECKOUT, + query: getCheckoutQuery, variables: { token: this.props.token } }); this.setState({ ...data, loading: false }); diff --git a/src/components/CheckoutApp/queries.ts b/src/components/CheckoutApp/queries.ts index dbeedf9dbc..799240727a 100644 --- a/src/components/CheckoutApp/queries.ts +++ b/src/components/CheckoutApp/queries.ts @@ -1,6 +1,8 @@ import gql from "graphql-tag"; +import { TypedQuery } from "../../core/queries"; +import { getCheckout, getCheckoutVariables } from "./types/getCheckout"; -export const CHECKOUT_FRAGMENT = gql` +export const checkoutFragment = gql` fragment Checkout on Checkout { token id @@ -111,7 +113,7 @@ export const CHECKOUT_FRAGMENT = gql` url alt } - thumbnail2x: thumbnail(size: 510){ + thumbnail2x: thumbnail(size: 510) { url } } @@ -121,8 +123,8 @@ export const CHECKOUT_FRAGMENT = gql` } `; -export const GET_CHECKOUT = gql` - ${CHECKOUT_FRAGMENT} +export const getCheckoutQuery = gql` + ${checkoutFragment} query getCheckout($token: UUID!) { checkout(token: $token) { ...Checkout @@ -130,8 +132,8 @@ export const GET_CHECKOUT = gql` } `; -export const UPDATE_CHECKOUT_LINE = gql` - ${CHECKOUT_FRAGMENT} +export const updateCheckoutLineQuery = gql` + ${checkoutFragment} mutation updateCheckoutLine($checkoutId: ID!, $lines: [CheckoutLineInput]!) { checkoutLinesUpdate(checkoutId: $checkoutId, lines: $lines) { checkout { @@ -144,3 +146,8 @@ export const UPDATE_CHECKOUT_LINE = gql` } } `; + +export const TypedGetCheckoutQuery = TypedQuery< + getCheckout, + getCheckoutVariables +>(getCheckoutQuery); diff --git a/src/components/CheckoutBilling/queries.ts b/src/components/CheckoutBilling/queries.ts index 811917a8fc..a58ee9f42e 100644 --- a/src/components/CheckoutBilling/queries.ts +++ b/src/components/CheckoutBilling/queries.ts @@ -1,9 +1,9 @@ import gql from "graphql-tag"; -import { CHECKOUT_FRAGMENT } from "../CheckoutApp/queries"; +import { checkoutFragment } from "../CheckoutApp/queries"; export const UPDATE_CHECKOUT_BILLING_ADDRESS = gql` - ${CHECKOUT_FRAGMENT} + ${checkoutFragment} mutation updateCheckoutBillingAddress( $checkoutId: ID! $billingAddress: AddressInput! diff --git a/src/components/CheckoutReview/index.tsx b/src/components/CheckoutReview/index.tsx index 2a4ffa69ad..5b94199c0f 100644 --- a/src/components/CheckoutReview/index.tsx +++ b/src/components/CheckoutReview/index.tsx @@ -6,15 +6,11 @@ import { Mutation } from "react-apollo"; import Media from "react-media"; import { RouteComponentProps } from "react-router"; -import { AddressSummary, Button } from ".."; -import { maybe } from "../../core/utils"; -import CachedImage from "../CachedImage"; +import { AddressSummary, Button, CachedThumbnail } from ".."; import { CheckoutContext } from "../CheckoutApp/context"; import { OverlayContext, OverlayType } from "../Overlay/context"; import { COMPLETE_CHECKOUT } from "./queries"; -const noPhotoPng = require("../../images/nophoto.png"); - class CheckoutReview extends React.Component, {}> { render() { return ( @@ -49,15 +45,7 @@ class CheckoutReview extends React.Component, {}> { ( - line.variant.product.thumbnail.url, - noPhotoPng - )} - url2x={maybe( - () => line.variant.product.thumbnail2x.url - )} - /> + )} /> {line.variant.product.name} diff --git a/src/components/CheckoutShipping/queries.ts b/src/components/CheckoutShipping/queries.ts index 4a5e318c8c..1c481fbfb9 100644 --- a/src/components/CheckoutShipping/queries.ts +++ b/src/components/CheckoutShipping/queries.ts @@ -1,9 +1,9 @@ import gql from "graphql-tag"; -import { CHECKOUT_FRAGMENT } from "../CheckoutApp/queries"; +import { checkoutFragment } from "../CheckoutApp/queries"; export const UPDATE_CHECKOUT_SHIPPING_ADDRESS = gql` - ${CHECKOUT_FRAGMENT} + ${checkoutFragment} mutation updateCheckoutShippingAddress( $checkoutId: ID! $shippingAddress: AddressInput! diff --git a/src/components/CheckoutShippingOptions/queries.ts b/src/components/CheckoutShippingOptions/queries.ts index 77989459b3..d08e446afd 100644 --- a/src/components/CheckoutShippingOptions/queries.ts +++ b/src/components/CheckoutShippingOptions/queries.ts @@ -1,9 +1,9 @@ import gql from "graphql-tag"; -import { CHECKOUT_FRAGMENT } from "../CheckoutApp/queries"; +import { checkoutFragment } from "../CheckoutApp/queries"; export const UPDATE_CHECKOUT_SHIPPING_OPTION = gql` - ${CHECKOUT_FRAGMENT} + ${checkoutFragment} mutation updateCheckoutShippingOptions( $checkoutId: ID! $shippingMethodId: ID! diff --git a/src/components/Debounce.tsx b/src/components/Debounce/DebounceChange.tsx similarity index 66% rename from src/components/Debounce.tsx rename to src/components/Debounce/DebounceChange.tsx index 5da7012f6c..cc9771799c 100644 --- a/src/components/Debounce.tsx +++ b/src/components/Debounce/DebounceChange.tsx @@ -1,6 +1,6 @@ import * as React from "react"; -export interface DebounceProps { +export interface DebounceChangeProps { children: (( props: { change: (event: React.ChangeEvent) => void; @@ -11,32 +11,26 @@ export interface DebounceProps { time?: number; value: TValue; } -export interface DebounceState { +export interface DebounceChangeState { timer: any | null; value: TValue; } -export class Debounce extends React.Component< - DebounceProps, - DebounceState +export class DebounceChange extends React.Component< + DebounceChangeProps, + DebounceChangeState > { static getDerivedStateFromProps( - props: DebounceProps, - state: DebounceState + props: DebounceChangeProps, + state: DebounceChangeState ) { if (props.value !== state.value && state.timer === null) { - return { - ...state, - value: props.value - }; + return { ...state, value: props.value }; } return state; } - state: DebounceState = { - timer: null, - value: this.props.value - }; + state: DebounceChangeState = { timer: null, value: this.props.value }; handleChange = (event: React.ChangeEvent) => { event.persist(); @@ -60,4 +54,4 @@ export class Debounce extends React.Component< }); } } -export default Debounce; +export default DebounceChange; diff --git a/src/components/Debounce/DebouncedTextField.tsx b/src/components/Debounce/DebouncedTextField.tsx new file mode 100644 index 0000000000..375472ae60 --- /dev/null +++ b/src/components/Debounce/DebouncedTextField.tsx @@ -0,0 +1,25 @@ +import * as React from "react"; + +import TextField, { TextFieldProps } from "../TextField"; +import DebounceChange from "./DebounceChange"; + +interface DebouncedTextFieldProps extends TextFieldProps { + time?: number; +} + +const DebouncedTextField: React.SFC = props => { + const { time, value, onChange, ...textFieldProps } = props; + return ( + + {({ change, value }) => ( + + )} + + ); +}; + +DebouncedTextField.defaultProps = { + time: 500 +}; + +export default DebouncedTextField; diff --git a/src/components/Debounce/index.ts b/src/components/Debounce/index.ts new file mode 100644 index 0000000000..40c608e76c --- /dev/null +++ b/src/components/Debounce/index.ts @@ -0,0 +1,2 @@ +export { default as DebounceChange } from "./DebounceChange"; +export { default as DebouncedTextField } from "./DebouncedTextField"; diff --git a/src/components/CartPage/EmptyCart.tsx b/src/components/EmptyCart.tsx similarity index 86% rename from src/components/CartPage/EmptyCart.tsx rename to src/components/EmptyCart.tsx index 0ff40bd93b..e9830c3ed7 100644 --- a/src/components/CartPage/EmptyCart.tsx +++ b/src/components/EmptyCart.tsx @@ -1,8 +1,8 @@ import * as React from "react"; import { Link } from "react-router-dom"; -import { baseUrl } from "../App/routes"; -import Button from "../Button"; +import { baseUrl } from "./App/routes"; +import Button from "./Button"; export const EmptyCart: React.SFC<{}> = () => (
diff --git a/src/components/GoToCheckout/index.tsx b/src/components/GoToCheckout/index.tsx index bfb455b9ee..18147cf84e 100644 --- a/src/components/GoToCheckout/index.tsx +++ b/src/components/GoToCheckout/index.tsx @@ -5,7 +5,7 @@ import { Redirect } from "react-router"; import { ButtonProps, default as Button } from "../Button"; import { CartInterface } from "../CartProvider/context"; import { CheckoutContext } from "../CheckoutApp/context"; -import { GET_CHECKOUT } from "../CheckoutApp/queries"; +import { getCheckoutQuery } from "../CheckoutApp/queries"; import { checkoutBaseUrl, checkoutBillingUrl, @@ -13,7 +13,15 @@ import { checkoutShippingOptionsUrl } from "../CheckoutApp/routes"; import { Checkout } from "../CheckoutApp/types/Checkout"; -import { CREATE_CHECKOUT } from "./queries"; +import { + getCheckout, + getCheckoutVariables +} from "../CheckoutApp/types/getCheckout"; +import { createCheckoutQuery } from "./queries"; +import { + createCheckout, + createCheckoutVariables +} from "./types/createCheckout"; export interface GoToCheckoutState { checkout?: Checkout; @@ -51,15 +59,17 @@ export class GoToCheckout extends React.Component< const checkoutToken = localStorage.getItem("checkout"); if (checkoutToken) { localStorage.setItem("checkout", checkoutToken); + const { apolloClient } = this.props; - let data: { [key: string]: any }; - const response = await apolloClient.query({ - query: GET_CHECKOUT, + const { data } = await apolloClient.query< + getCheckout, + getCheckoutVariables + >({ + query: getCheckoutQuery, variables: { token: checkoutToken } }); - data = response.data; this.setState({ checkout: data.checkout, checkoutToken, @@ -72,11 +82,14 @@ export class GoToCheckout extends React.Component< cart: { lines } } = this.props; this.setState({ loading: true }); - const { data } = await apolloClient.mutate({ - mutation: CREATE_CHECKOUT, + const { data } = await apolloClient.mutate< + createCheckout, + createCheckoutVariables + >({ + mutation: createCheckoutQuery, variables: { checkoutInput: { - lines: lines.map(line => ({ + lines: lines.map((line: { quantity; variantId }) => ({ quantity: line.quantity, variantId: line.variantId })) @@ -96,6 +109,7 @@ export class GoToCheckout extends React.Component< getRedirection() { const { checkout } = this.state; let pathname; + if (checkout.billingAddress) { pathname = checkoutPaymentUrl(this.state.checkoutToken); } else if (checkout.shippingMethod) { @@ -105,6 +119,7 @@ export class GoToCheckout extends React.Component< } else { pathname = checkoutBaseUrl(this.state.checkoutToken); } + if (pathname) { return ( @@ -117,15 +132,23 @@ export class GoToCheckout extends React.Component< } } + componentDidUpdate() { + if (this.state.checkoutToken && this.state.redirect) { + this.setState({ redirect: false }); + } + } + render = () => { const { children, cart, apolloClient, ...buttonProps } = this.props; + if (this.state.loading) { return ; } + if (this.state.checkoutToken && this.state.redirect) { - this.setState({ redirect: false }); return this.getRedirection(); } + return ( + + ) + } + +
+ + )} + + ); + } else { + return ; + } +}; + +export default Page; diff --git a/src/views/Cart/ProductsTable.tsx b/src/views/Cart/ProductsTable.tsx new file mode 100644 index 0000000000..ac0108c970 --- /dev/null +++ b/src/views/Cart/ProductsTable.tsx @@ -0,0 +1,129 @@ +import { smallScreen } from "../../components/App/scss/variables.scss"; + +import classNames from "classnames"; +import * as React from "react"; +import Media from "react-media"; +import { Link } from "react-router-dom"; +import ReactSVG from "react-svg"; + +import { CachedThumbnail, DebouncedTextField } from "../../components"; +import { getCheckout_checkout } from "../../components/CheckoutApp/types/getCheckout"; +import { generateProductUrl } from "../../core/utils"; + +const cartRemoveSvg = require("../../images/cart-remove.svg"); +const cartAddSvg = require("../../images/cart-add.svg"); +const cartSubtractSvg = require("../../images/cart-subtract.svg"); + +const ProductsTable: React.SFC<{ + checkout: getCheckout_checkout; + processing: boolean; + addToCart(variantId: string): void; + changeQuantityInCart(variantId: string, quantity: number): void; + removeFromCart(variantId: string): void; + subtractToCart(variantId: string): void; +}> = ({ + addToCart, + changeQuantityInCart, + checkout, + processing, + removeFromCart, + subtractToCart +}) => { + const { lines } = checkout; + return ( + + {isMediumScreen => ( + + + + + {isMediumScreen && } + + + + + + {lines + .sort((a, b) => + b.id.toLowerCase().localeCompare(a.id.toLowerCase()) + ) + .map(line => { + const productUrl = generateProductUrl( + line.variant.product.id, + line.variant.product.name + ); + return ( + + + {isMediumScreen && } + + + + + ); + })} + + + + + + + +
ProductsPriceQuantity{isMediumScreen ? "Total Price" : "Price"}
+ {isMediumScreen && ( + + + + )} + + {line.variant.product.name} + {line.variant.name && ` (${line.variant.name})`} + + {line.variant.price.localized} + {isMediumScreen ? ( +
+ addToCart(line.variant.id)} + /> +

{line.quantity}

+ subtractToCart(line.variant.id)} + /> +
+ ) : ( + + changeQuantityInCart( + line.variant.id, + parseInt(value, 10) + ) + } + disabled={processing} + value={line.quantity} + type="number" + /> + )} +
{line.totalPrice.gross.localized} + removeFromCart(line.variant.id)} + /> +
+ Subtotal + {checkout.subtotalPrice.gross.localized}
+ )} +
+ ); +}; + +export default ProductsTable; diff --git a/src/views/Cart/View.tsx b/src/views/Cart/View.tsx new file mode 100644 index 0000000000..ad5d62f22a --- /dev/null +++ b/src/views/Cart/View.tsx @@ -0,0 +1,35 @@ +import "./scss/index.scss"; + +import * as React from "react"; +import { RouteComponentProps } from "react-router"; + +import { Loader } from "../../components"; +import { TypedGetCheckoutQuery } from "../../components/CheckoutApp/queries"; +import { getCheckout_checkout } from "../../components/CheckoutApp/types/getCheckout"; +import { maybe } from "../../core/utils"; +import Page from "./Page"; + +const canDisplay = (checkout: getCheckout_checkout) => + maybe(() => checkout.lines && checkout.subtotalPrice); + +const View: React.SFC> = ({ + match: { + params: { token = "" } + } +}) => { + return ( +
+

Shopping bag

+ + {({ data: { checkout } }) => { + if (canDisplay(checkout)) { + return ; + } + return ; + }} + +
+ ); +}; + +export default View; diff --git a/src/views/Cart/index.ts b/src/views/Cart/index.ts new file mode 100644 index 0000000000..9494d6f214 --- /dev/null +++ b/src/views/Cart/index.ts @@ -0,0 +1 @@ +export { default as CartPage } from "./View"; diff --git a/src/views/Cart/scss/index.scss b/src/views/Cart/scss/index.scss new file mode 100644 index 0000000000..198500157f --- /dev/null +++ b/src/views/Cart/scss/index.scss @@ -0,0 +1,86 @@ +@import "../../../components/App/scss/variables.scss"; + +.cart-page { + &__header { + margin-top: $spacer * 3; + } + + &__table { + &-row--processing td { + position: relative; + + &::after { + background-color: rgba($white, 0.65); + position: absolute; + content: ""; + width: 100%; + height: 100%; + left: 0; + top: 0; + } + } + + &__subtotal { + color: $gray-dark; + font-weight: $bold-font-weight; + } + + &__quantity { + &-header { + text-align: center; + } + + &-cell { + div { + align-items: center; + display: flex; + justify-content: space-around; + } + + & > div { + padding: $spacer / 2; + } + } + } + + svg:hover { + cursor: pointer; + } + } + + &__thumbnail { + img { + width: 50px; + height: auto; + } + } + + &__checkout-action { + text-align: right; + margin: 0 $spacer * 2 $spacer * 3 0; + + @media (max-width: $small-screen) { + text-align: center; + } + } + + &__empty { + text-align: center; + padding: $spacer * 5 0; + + h4 { + font-weight: $bold-font-weight; + text-transform: uppercase; + margin-bottom: $spacer; + } + + p { + color: $gray; + } + + &__action { + text-align: center; + margin-top: $spacer; + } + } +} diff --git a/src/views/Product/Page.tsx b/src/views/Product/Page.tsx index b1ce197dba..c7ad63036e 100644 --- a/src/views/Product/Page.tsx +++ b/src/views/Product/Page.tsx @@ -9,7 +9,7 @@ import { CartContext } from "../../components/CartProvider/context"; import { generateCategoryUrl, generateProductUrl } from "../../core/utils"; import GalleryCarousel from "./GalleryCarousel"; import OtherProducts from "./Other"; -import { ProductDetails, ProductDetails_product } from "./types/ProductDetails"; +import { ProductDetails_product } from "./types/ProductDetails"; const noPhoto = require("../../images/nophoto.png"); @@ -118,7 +118,8 @@ class Page extends React.PureComponent<{ product: ProductDetails_product }> {
diff --git a/src/views/Search/index.tsx b/src/views/Search/index.tsx index a73e180020..27b6ba8e0a 100644 --- a/src/views/Search/index.tsx +++ b/src/views/Search/index.tsx @@ -5,7 +5,7 @@ import * as React from "react"; import { RouteComponentProps } from "react-router"; import { - Debounce, + DebounceChange, Loader, ProductsFeatured, ProductsList @@ -104,7 +104,7 @@ export const SearchView: React.SFC = ({ ); return ( - = ({ ); }} - + ); }} diff --git a/tsconfig.json b/tsconfig.json index 6038d4d066..70a1e82d74 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,6 +5,7 @@ "jsx": "react", "lib": ["es2017", "dom", "esnext"], "sourceMap": true, - "target": "es6" + "target": "es6", + "noUnusedLocals": false } } diff --git a/tslint.json b/tslint.json index 3cf3c2ba7f..4186e4fb88 100644 --- a/tslint.json +++ b/tslint.json @@ -8,7 +8,6 @@ "no-implicit-dependencies": [true, "dev"], "no-shadowed-variable": false, "no-submodule-imports": [true], - "no-unused-variable": true, "no-var-requires": false } }