From 9a65584a1c20b891918d55cb369ce9dcab5e8b78 Mon Sep 17 00:00:00 2001 From: Jimmy Sanford Date: Tue, 27 Nov 2018 15:52:27 -0700 Subject: [PATCH] Product options (#550) * Add product options * Address feedback * Update query * Address PR feedback * Load options dynamically --- .../src/RootComponents/Product/Product.js | 63 +++++++++- .../src/actions/cart/asyncActions.js | 57 +++++---- .../ProductFullDetail/ProductFullDetail.js | 112 ++++++++++++++++-- .../src/components/ProductOptions/option.js | 43 ++++--- .../src/components/ProductOptions/options.js | 28 ++++- .../src/components/ProductOptions/swatch.css | 16 ++- .../src/components/ProductOptions/swatch.js | 73 +++++++++--- .../components/ProductOptions/swatchList.js | 8 +- .../src/components/ProductOptions/tile.css | 9 +- .../src/components/ProductOptions/tile.js | 40 ++++--- .../src/components/ProductOptions/tileList.js | 8 +- .../src/queries/getProductDetail.graphql | 30 +++++ 12 files changed, 387 insertions(+), 100 deletions(-) diff --git a/packages/venia-concept/src/RootComponents/Product/Product.js b/packages/venia-concept/src/RootComponents/Product/Product.js index 3b2d36e25e..339c9196e5 100644 --- a/packages/venia-concept/src/RootComponents/Product/Product.js +++ b/packages/venia-concept/src/RootComponents/Product/Product.js @@ -1,12 +1,71 @@ import React, { Component } from 'react'; import { connect } from 'react-redux'; import { Query } from 'react-apollo'; +import gql from 'graphql-tag'; import { bool, shape, number, arrayOf, string } from 'prop-types'; import { addItemToCart } from 'src/actions/cart'; import ProductFullDetail from 'src/components/ProductFullDetail'; import getUrlKey from 'src/util/getUrlKey'; -import getProductDetail from '../../queries/getProductDetail.graphql'; + +const query = gql` + query productDetail($urlKey: String, $onServer: Boolean!) { + productDetail: products(filter: { url_key: { eq: $urlKey } }) { + items { + sku + name + price { + regularPrice { + amount { + currency + value + } + } + } + description + media_gallery_entries { + label + position + disabled + file + } + ... on ConfigurableProduct { + configurable_options { + attribute_code + attribute_id + id + label + values { + default_label + label + store_label + use_default_value + value_index + } + } + variants { + product { + fashion_color + fashion_size + id + media_gallery_entries { + disabled + file + label + position + } + sku + stock_status + } + } + } + meta_title @include(if: $onServer) + meta_keyword @include(if: $onServer) + meta_description @include(if: $onServer) + } + } + } +`; /** * As of this writing, there is no single Product query type in the M2.3 schema. @@ -59,7 +118,7 @@ class Product extends Component { render() { return ( {({ loading, error, data }) => { diff --git a/packages/venia-concept/src/actions/cart/asyncActions.js b/packages/venia-concept/src/actions/cart/asyncActions.js index fa3b169366..1feb26e22a 100644 --- a/packages/venia-concept/src/actions/cart/asyncActions.js +++ b/packages/venia-concept/src/actions/cart/asyncActions.js @@ -47,8 +47,7 @@ export const createGuestCart = () => }; export const addItemToCart = (payload = {}) => { - const { item, quantity } = payload; - + const { item, options, parentSku, productType, quantity } = payload; const writingImageToCache = writeImageToCache(item); return async function thunk(dispatch, getState) { @@ -57,16 +56,10 @@ export const addItemToCart = (payload = {}) => { const { user } = getState(); if (user.isSignedIn) { - console.warn( - 'Can not currently add items to your cart as a non-guest user' - ); - /////////////////////////////////////////// - // TODO: handle logged-in cart retrieval. // - /////////////////////////////////////////// - // If a user creates a new account - // the guest cart will be transfered to their account. - // Once that happens `/rest/V1/guest-carts` will 400 if it - // is called. + // TODO: handle authed carts + // if a user creates an account, + // then the guest cart will be transferred to their account + // causing `/guest-carts` to 400 return; } @@ -80,7 +73,27 @@ export const addItemToCart = (payload = {}) => { ); missingGuestCartError.noGuestCartId = true; throw missingGuestCartError; - console.log('Missing required information: guestCartId'); + } + + // TODO: change to GraphQL mutation + // for now, manually transform the payload for REST + const itemPayload = { + qty: quantity, + sku: item.sku, + name: item.name, + quote_id: guestCartId + }; + + if (productType === 'ConfigurableProduct') { + Object.assign(itemPayload, { + sku: parentSku, + product_type: 'configurable', + product_option: { + extension_attributes: { + configurable_item_options: options + } + } + }); } const cartItem = await request( @@ -88,12 +101,7 @@ export const addItemToCart = (payload = {}) => { { method: 'POST', body: JSON.stringify({ - cartItem: { - qty: quantity, - sku: item.sku, - name: item.name, - quote_id: guestCartId - } + cartItem: itemPayload }) } ); @@ -192,13 +200,10 @@ export const getCartDetails = (payload = {}) => { const { user } = getState(); if (user.isSignedIn) { - /////////////////////////////////////////// - // TODO: handle logged-in cart retrieval. // - /////////////////////////////////////////// - // If a user creates a new account - // the guest cart will be transfered to their account. - // Once that happens `/rest/V1/guest-carts` will 400 if it - // is called. + // TODO: handle authed carts + // if a user creates an account, + // then the guest cart will be transferred to their account + // causing `/guest-carts` to 400 return; } diff --git a/packages/venia-concept/src/components/ProductFullDetail/ProductFullDetail.js b/packages/venia-concept/src/components/ProductFullDetail/ProductFullDetail.js index d3813bc95a..78fed74c25 100644 --- a/packages/venia-concept/src/components/ProductFullDetail/ProductFullDetail.js +++ b/packages/venia-concept/src/components/ProductFullDetail/ProductFullDetail.js @@ -1,4 +1,4 @@ -import React, { Component } from 'react'; +import React, { Component, Suspense } from 'react'; import { arrayOf, bool, func, number, shape, string } from 'prop-types'; import { Price } from '@magento/peregrine'; @@ -9,6 +9,8 @@ import Quantity from 'src/components/ProductQuantity'; import RichText from 'src/components/RichText'; import defaultClasses from './productFullDetail.css'; +const Options = React.lazy(() => import('../ProductOptions')); + class ProductFullDetail extends Component { static propTypes = { classes: shape({ @@ -18,6 +20,7 @@ class ProductFullDetail extends Component { details: string, detailsTitle: string, imageCarousel: string, + options: string, productName: string, productPrice: string, quantity: string, @@ -49,18 +52,110 @@ class ProductFullDetail extends Component { addToCart: func.isRequired }; - state = { quantity: 1 }; + static getDerivedStateFromProps(props, state) { + const { configurable_options } = props.product; + const optionCodes = new Map(state.optionCodes); + + // if this is a simple product, do nothing + if (!Array.isArray(configurable_options)) { + return; + } + + // otherwise, cache attribute codes to avoid lookup cost later + for (const option of configurable_options) { + optionCodes.set(option.attribute_id, option.attribute_code); + } + + return { optionCodes }; + } + + state = { + optionCodes: new Map(), + optionSelections: new Map(), + quantity: 1 + }; setQuantity = quantity => this.setState({ quantity }); - addToCart = () => - this.props.addToCart({ - item: this.props.product, - quantity: this.state.quantity - }); + addToCart = () => { + const { props, state } = this; + const { optionCodes, optionSelections, quantity } = state; + const { addToCart, product } = props; + const { configurable_options, variants } = product; + const isConfigurable = Array.isArray(configurable_options); + const productType = isConfigurable + ? 'ConfigurableProduct' + : 'SimpleProduct'; + + const payload = { + item: product, + productType, + quantity + }; + + if (productType === 'ConfigurableProduct') { + const options = Array.from(optionSelections, ([id, value]) => ({ + option_id: id, + option_value: value + })); + + const item = variants.find(({ product: variant }) => { + for (const [id, value] of optionSelections) { + const code = optionCodes.get(id); + + if (variant[code] !== value) { + return false; + } + } + + return true; + }); + + Object.assign(payload, { + options, + parentSku: product.sku, + item: Object.assign({}, item.product) + }); + } + + addToCart(payload); + }; + + handleSelectionChange = (optionId, selection) => { + this.setState(({ optionSelections }) => ({ + optionSelections: new Map(optionSelections).set( + optionId, + Array.from(selection).pop() + ) + })); + }; + + get fallback() { + return
Loading...
; + } + + get productOptions() { + const { fallback, handleSelectionChange, props } = this; + const { configurable_options } = props.product; + const isConfigurable = Array.isArray(configurable_options); + + if (!isConfigurable) { + return null; + } + + return ( + + + + ); + } render() { - const { classes, product } = this.props; + const { productOptions, props } = this; + const { classes, product } = props; const { regularPrice } = product.price; return ( @@ -79,6 +174,7 @@ class ProductFullDetail extends Component {
+
{productOptions}

Quantity diff --git a/packages/venia-concept/src/components/ProductOptions/option.js b/packages/venia-concept/src/components/ProductOptions/option.js index 56a3da9d4b..4e81d7ab38 100644 --- a/packages/venia-concept/src/components/ProductOptions/option.js +++ b/packages/venia-concept/src/components/ProductOptions/option.js @@ -1,41 +1,56 @@ import React, { Component } from 'react'; -import PropTypes from 'prop-types'; +import { arrayOf, func, object, shape, string } from 'prop-types'; import classify from 'src/classify'; import SwatchList from './swatchList'; import TileList from './tileList'; import defaultClasses from './option.css'; -const optionTypes = ['color', 'string']; +const getItemKey = ({ value_index }) => value_index; class Option extends Component { static propTypes = { - classes: PropTypes.shape({ - root: PropTypes.string + attribute_id: string, + attribute_code: string.isRequired, + classes: shape({ + root: string, + title: string }), - name: PropTypes.node.isRequired, - type: PropTypes.oneOf(optionTypes).isRequired, - values: PropTypes.arrayOf(PropTypes.object).isRequired + label: string.isRequired, + onSelectionChange: func, + values: arrayOf(object).isRequired }; - static defaultProps = { - type: 'string' + handleSelectionChange = selection => { + const { attribute_id, onSelectionChange } = this.props; + + if (onSelectionChange) { + onSelectionChange(attribute_id, selection); + } }; get listComponent() { - return this.props.type === 'color' ? SwatchList : TileList; + const { attribute_code } = this.props; + + // TODO: get an explicit field from the API + // that identifies an attribute as a swatch + return attribute_code === 'fashion_color' ? SwatchList : TileList; } render() { - const { classes, name, values } = this.props; - const ValueList = this.listComponent; + const { handleSelectionChange, listComponent: ValueList, props } = this; + const { classes, label, values } = props; return (

- {name} + {label}

- +
); } diff --git a/packages/venia-concept/src/components/ProductOptions/options.js b/packages/venia-concept/src/components/ProductOptions/options.js index 7a22c0f54a..084959555e 100644 --- a/packages/venia-concept/src/components/ProductOptions/options.js +++ b/packages/venia-concept/src/components/ProductOptions/options.js @@ -1,17 +1,37 @@ import React, { Component } from 'react'; -import PropTypes from 'prop-types'; +import { arrayOf, func, shape, string } from 'prop-types'; import Option from './option'; class Options extends Component { static propTypes = { - options: PropTypes.arrayOf(PropTypes.object) + onSelectionChange: func, + options: arrayOf( + shape({ + attribute_id: string.isRequired + }) + ) + }; + + handleSelectionChange = (optionId, selection) => { + const { onSelectionChange } = this.props; + + if (onSelectionChange) { + onSelectionChange(optionId, selection); + } }; render() { - const { options } = this.props; + const { handleSelectionChange, props } = this; + const { options } = props; - return options.map(option =>