Building a Swag Shop: The Landing Page pt. 2

Welcome to another installment of the Reaction Swag Shop project!

Last week, I wrote about on our Landing Page epic, covering several of the first few tickets: how to get started, set up data fixtures, and add images to the database.

This week, I’ll show you how we...

  • Added featured products to the landing page
  • Added fields in Admin
  • Customized Publications and Subscriptions

For those who haven't been following, be sure to check out the live shop and the repository on GitHub.

Adding featured products to the landing page

The Featured Products functionality is the first real feature that we're implementing. The idea is to show shoppers a colored label in the product image’s upper left-hand corner for certain products, specified by the shop admin.

The label's text should be editable through the admin backend. This requires some database schema changes for the products, since we want to store the label text persistently. The necessary changes for the admin backend are covered in #19.

Essentially, we're extending the existing Product database schema with a new field called featuredProductLabel, which holds the label text. This can be seen in /imports/plugins/custom/reaction-swag-shop/lib/collections/schemas/swagProducts.js:

const ExtendedSchema = new SimpleSchema([  
  Product,
  {
    featuredProductLabel: {
      optional: true,
      type: String
    }
  }
]);
Products.attachSchema(ExtendedSchema, { replace: true, selector: { type: "simple" } });  
registerSchema("Product", ExtendedSchema);


const Schemas = getSchemas();

const ExtendedFilters = new SimpleSchema([  
  Schemas.filters,
  {
    featuredProductLabel: {
      optional: true,
      type: String
    }
  }
]);

registerSchema("filters", ExtendedFilters);  

Besides the Product schema, we’ll also need to extend the Filters schema, which is used to pass filter criteria when subscribing to products. For more information on how simple schemas are used in Reaction, visit our docs.

Adding an admin field

Next, let’s provide a new text field in the backend, Featured product label.

Featured product in admin backend

To extend the default product settings form, replace the ProductAdmin component with our customized version. This version contains a text field for our newly-created schema field, featuredProductLabel:

/imports/plugins/custom/reaction-swag-shop/client/components/product-admin/productAdmin.js

class ProductAdmin extends CoreProductAdmin {  
  render() {
    return (

            // -------------- %< --------------------
            //             more stuff 
            // -------------- %< --------------------

            <Components.Divider />
            <div className="row">
              <div className="col-sm-12">
                <Components.TextField
                  i18nKeyLabel="productVariant.featuredProductLabel"
                  i18nKeyPlaceholder=""
                  placeholder=""
                  label="Featured product label"
                  name="featuredProductLabel"
                  ref="featuredProductLabel"
                  value={this.product.featuredProductLabel}
                  onBlur={this.handleFieldBlur}
                  onChange={this.handleFieldChange}
                  onReturnKeyDown={this.handleFieldBlur}
                />
              </div>
            </div>

            // -------------- %< --------------------
            //             more stuff 
            // -------------- %< --------------------
    );
  }
}

replaceComponent("ProductAdmin", ProductAdmin);

export default ProductAdmin;  

In the above snippets, there are two important bits to consider:

  1. We're extending the original ProductAdmin component and overriding the render() method, rendering a new TextField for the featuredProductLabel product property instead.

  2. The Reaction default ProductAdmin React component is replaced with our derived version. This is done via calling replaceComponent. For more information on how the Reaction Component API works, visit our API docs.

Updating the landing page

Now that we have the backend functionality in place, let's move on to the public-facing landing page and render those labels in different colors.

Clone the ProductGridItems component from /imports/plugins/included/product-variant/component/productGridItems.js into our plugin. To keep track of where we originally copied the files from, name the newly-created file the same as your files in core. Also, keep the name of the plugin in its path name. Here, the newly created file becomes:

/imports/plugins/custom/reaction-swag-shop/client/components/product-variant/productGridItems.js

class ProductGridItems extends ProductGridItemsCore {  
  static labelColorPalette = [
    "#2899D3", // blue
    "#40e0d0", // turquoise
    "#F2542F"  // orange
  ];

  renderFeaturedProductLabel() {
    const featuredProductLabel = this.props.product.featuredProductLabel;
    let bgColor;
    if (featuredProductLabel) {
      const hash = featuredProductLabel.split("").reduce((acc, value, i) => {
        const code = featuredProductLabel.charCodeAt(i);
        return code + acc;
      }, 0);
      bgColor = ProductGridItems.labelColorPalette[hash % 3];
    }
    return (
      <div className="grid-item__featured-product-label" style={bgColor ? { backgroundColor: bgColor } : {}}>
        {featuredProductLabel}
      </div>
    );
  }

  render() {
    const productItem = (
      <li
        className={`product-grid-item ${this.renderPinned()} ${this.props.weightClass()} ${this.props.isSelected()}`}
        data-id={this.props.product._id}
        id={this.props.product._id}
      >
        <div className={this.renderHoverClassName()}>
          <span className="product-grid-item-alerts" />

          <a className="product-grid-item-images"
            href={this.props.pdpPath()}
            data-event-category="grid"
            data-event-label="grid product click"
            data-event-value={this.props.product._id}
            onDoubleClick={this.handleDoubleClick}
            onClick={this.handleClick}
          >
            <div className={`product-primary-images ${this.renderVisible()}`}>
              {this.renderFeaturedProductLabel()}
              {this.renderMedia()}
              {this.renderOverlay()}
            </div>

            {this.renderAdditionalMedia()}
          </a>

          {!this.props.isSearch && this.renderNotices()}
          {this.renderGridContent()}
        </div>
      </li>
    );
}

export default ProductGridItems;


replaceComponent("ProductGridItems", ProductGridItems);  

Again, we're extending the original component and enhancing it with a new method called renderFeaturedProductLabel, which is responsible for rendering the colored labels. The colors for the labels get assigned in a pseudo-random fashion: a simple algorithm calculates the sum of the text label's ASCII values and maps them to a fixed color list through the modulo operation:

Screenshot Featured Product

Customizing publications and subscriptions

When the default publication publishes a product for the landing page, it doesn't care if it's a featured product or not. Because the user story requests only publishing featured products (i.e. "Products We Love"), we’ll need to modify the server side publication function. Start with copying /server/publications/collections/products.js verbatim to our plugin and save it to /imports/plugins/custom/reaction-swag-shop/server/publications/collections/products.js:

import { getSchemas } from "@reactioncommerce/reaction-collections";


// Validate the subscription filter against our extended filter schema.
const Schemas = getSchemas();  
const filters = Schemas.filters;

/* Replace stock publication with our custom publication that knows how to filter
 * featured products as well.
 */
Meteor.startup(() => {  
  Meteor.default_server.publish_handlers.Products = publishFeaturedSwagProducts;
});

/**
 * Swag shop products publication. Knows how to filter for featured products.
 * @param {Number} [productScrollLimit] - optional, defaults to 24
 * @param {Array} shops - array of shopId to retrieve product from.
 * @return {Object} return product cursor
 */
function publishFeaturedSwagProducts(productScrollLimit = 24, productFilters, sort = {}, editMode = true) {  
  check(productScrollLimit, Number);
  check(productFilters, Match.OneOf(undefined, Object));
  check(sort, Match.OneOf(undefined, Object));
  check(editMode, Match.Maybe(Boolean));

  // -------------- %< --------------------
  //             more stuff 
  // -------------- %< --------------------

    // BOF: swag shop featuredProduct filter
    if (productFilters.hasOwnProperty("featuredProductLabel")) {
      if (productFilters.featuredProductLabel !== "") {
        _.extend(selector, {
          // Return only featured products that match the label exactly
          featuredProductLabel: productFilters.featuredProductLabel
        });
      } else {
        // Return all featured products, regardless of label
        _.extend(selector, {
          featuredProductLabel: {
            $exists: true
          }
        });
      }
    }
    // EOF: swag shop featuredProduct filter
  } // end if productFilters

  // -------------- %< --------------------
  //             more stuff 
  // -------------- %< --------------------
}

This overridden method adds another filter criteria to the database query, which allows for searching featured products only. The corresponding subscription lives as a part of a higher order component in /imports/plugins/included/product-variant/containers/productsContainer.js. We'll be modifying the filters passed to the subscription, so the file is copied to /imports/plugins/custom/reaction-swag-shop/client/containers/product-variant/productsContainer.js and adapted accordingly:

import { replaceComponent, getHOCs, composeWithTracker } from "@reactioncommerce/reaction-components";  
import ProductsComponent from "../../components/product-variant/products";

/*
 * Customized version of imports/plugins/included/product-variant/containers/productsContainer.js
 * It subscribes to featured products only for landing page section "Products we love"
 */
function composer(props, onData) {  
  window.prerenderReady = false;

  let canLoadMoreProducts = false;

  // -------------- %< --------------------
  //             more stuff 
  // -------------- %< --------------------

  const queryParams = Object.assign({}, tags, Reaction.Router.current().queryParams, shopIds);

  // BOF: swag shop featuredProduct filter
  queryParams.featuredProductLabel = ""; // subscribe to all featured products, regardless of label
  const swagShopScrollLimit = 3; // Only interested in first 3 products for "Products we love" section
  const productsSubscription = Meteor.subscribe("Products", swagShopScrollLimit, queryParams, sort, editMode);
  // EOF: swag shop featuredProduct filter

  // -------------- %< --------------------
  //             more stuff 
  // -------------- %< --------------------

  onData(null, {
    productsSubscription,
    products: stateProducts,
    canLoadMoreProducts
  });
}

const higherOrderFuncs = getHOCs("Products");  
// We are interested in replacing the composer HOC only.
higherOrderFuncs[0] = composeWithTracker(composer);  
replaceComponent("Products", ProductsComponent);  

The important bit here is that we're replacing a higher order component, which is responsible for injecting data from subscriptions into the real Products component. With getHOCs, we obtain a handle to the original HOCs. The base component is wrapped with two of them, but we're only interested in replacing the first with our own. Here’s how it’s done:

higherOrderFuncs[0] = composeWithTracker(composer);  

Next, the original Products component is replaced with our own from ../../components/product-variant/products.js.

Do I really need a custom React component for everything?

The rule is as following: If you’re happy with the rendered markup and you don't need custom behaviour for a component (like overriding event handlers), you can get away with simple CSS changes. CSS changes are done in /imports/plugins/custom/reaction-swag-shop/client/styles. Because CSS files from custom plugins are loaded after the default CSS styles, it's generally sufficient to copy the original CSS selectors and modify their values according to your needs.

Wrapping it up

And that’s it. Hopefully, our process has inspired you to build something of your own. If you feel like something's missing, or you're interested in further details, don't hesitate to reach out to us at our Gitter channel. We're always glad to give you advice wherever we can.

Next we'll be focusing on implementing the category grid and the product detail page (PDP). We'll report about that in our next episode.

comments powered by Disqus