<div id="app">
  <!-- This prototype is to demonstrate the behavior of products sold in specific quantity sets. -->
  <!-- Chairs, for example, come in sets of 4. A user should only be able to order chairs in 4s. -->
  <!-- For a variant behavior, where Add to Cart changes, go here: https://codepen.io/xace90/pen/OYLWvR -->
  <!-- To see how this would play out for Chinaware, see here: https://codepen.io/xace90/pen/bybEoK -->
  <!-- To see how this would play out for Glassware, see here: https://codepen.io/xace90/pen/WBepeM -->
</div>
// General Quality of Life Styles
#app { padding: 5rem; }
// Removing base input="number" styles
input[type=number] {
  -moz-appearance: textfield;
  appearance: textfield;
  &::-webkit-inner-spin-button,
  &::-webkit-outer-spin-button {
    -webkit-appearance: none;
    appearance: none;
    margin: 0;
  }
}

.quantity-wrapper {
  display: flex;
  flex-direction: column;
  input {
    width: 100%;
    height: 1.5rem;
    padding-right: 1.25rem;
    text-align: right;
  }
  label {}
  .disclaimer {
    color: gray;
    font-size: 0.75rem;
    margin-top: 0.25em;
  }
}
.quantity-inner-wrapper {
  position: relative;
  width: 3.5rem;
}
.quantity-button {
  display: inline;
  position: absolute;
  font-size: 1.5rem;
  cursor: pointer;
  top: 0;
  &.quantity-down {
    left: 4px;
  }
  &.quantity-up {
    right: -1.25rem;
  }
}
View Compiled
class Quantity extends React.Component {
  constructor(props){
    super(props);
    this.increment = this.increment.bind(this);
    this.decrement = this.decrement.bind(this);
  }
  
  increment() {
    /**
     * Go to the next increment.
     * If the step is 4, and they have a quantity of 6,
     * the next step should be 8, not 10.
     */
    if (this.props.value + this.props.step >= this.props.max) {
      //do not allow value to be greater than the input max
      return false;
    }
    if (this.props.value % this.props.step === 0) {
      //if the value is divisible by the step value, increment by that step
      this.props.onChange(this.props.value + this.props.step);
    } else {
      //if the value is NOT divisible by the step value, round up to the next step
      const val = Math.ceil(this.props.value/this.props.step)*this.props.step;
      this.props.onChange(val);
    }
  }
  
  decrement() {
    /**
     * Go to the last increment.
     * If the step is 4, and they have a quantity of 14,
     * the new value should be 12, not 10.
     */
    if (this.props.value - this.props.step <= this.props.min) {
      //do not allow value to be less than the input min
      return false;
    }
    if (this.props.value % this.props.step === 0) {
      //if the value is divisible by the step value, decrement by that step
      this.props.onChange(this.props.value - this.props.step);
    } else {
      //if the value is NOT divisible by the step value, round down to the last step
      const val = Math.floor(this.props.value / this.props.step) * this.props.step;
      this.props.onChange(val);
    }
  }
  
  handleChange(e) {
    return this.props.onChange(e.target.value);
  }
  
  render() {
    return (
      <div className="quantity-wrapper">
        <label htmlFor="quantity">Quantity</label>
        <div className="quantity-inner-wrapper">
          <div className="quantity-button quantity-down" onClick={this.decrement}>-</div>
          <input 
            id="quantity"
            type="number"
            min={this.props.min}
            max={this.props.max}
            step={this.props.step}
            value={this.props.value}
            onChange={(e) => this.handleChange(e)}
          />
          <div className="quantity-button quantity-up" onClick={this.increment}>+</div>
        </div>
        <span className="disclaimer">Only sold in quantities of {this.props.step}</span>
      </div>
    );
  }
}

class AddToCart extends React.Component {
  render() {
    const styles = { marginTop: '1rem' };
    return (
      <button style={styles} onClick={this.props.onClick} >
        Add to Cart
      </button>
    );
  }
}

class Prototype extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      qty: 1,
      error: false,
    };
    
    this.handleChange = this.handleChange.bind(this);
    this.handleValidation = this.handleValidation.bind(this);
    this.handleAddToCart = this.handleAddToCart.bind(this);
  }
  
  default = { qty: 4, error: false };
  step = 4;

  componentDidMount() {
    if (this.state.qty < this.step) this.setState({ qty: this.step });
  }

  handleChange(qty) {
    this.setState({ qty })
  }

  handleValidation() {
    if (this.state.qty % this.step !== 0) {
      // If the value is NOT divisible by the step, round up to the next step
      const qty = Math.ceil(this.state.qty / this.step) * this.step;
      this.setState({ error: true, qty });
    } else if (this.state.qty === 0) {
      alert('Quantity must be greater than zero.');
    } else {
      this.setState({ error: false });
      this.handleAddToCart();
    }
  }

  handleAddToCart() {
    alert('Added to Cart!');
    this.setState(this.default);
  }

  render() {
    return (
      <div>
        <Quantity 
          value={+this.state.qty}
          step={this.step}
          min={0}
          max={9999}
          onChange={this.handleChange}
        />
        {this.state.error && (
          <p style={{ color: 'red' }}>This item only comes in {this.step}. We've updated your quantity for you!</p>
        )}
        <AddToCart onClick={this.handleValidation} />
      </div>
    );
  }
}

ReactDOM.render(
  <Prototype />,
  document.getElementById("app")
);
View Compiled
Run Pen

External CSS

This Pen doesn't use any external CSS resources.

External JavaScript

  1. https://cdnjs.cloudflare.com/ajax/libs/react/15.4.2/react.min.js
  2. https://cdnjs.cloudflare.com/ajax/libs/react/15.4.2/react-dom.min.js