Promo Cards: Activation (using Shopify meta fields)

#1. Background

Promo cards are non product assets displayed alongside products within the grid.

1562

To read more about use cases and setup, please read here

This section cover how to activate Promo Cards in the front end.
This requires step 1 to be finalized:

👍

Part 1

In order to follow the steps in this guide, you need to set up & populate the required meta fields in Shopify. Please find the full instruction here

To do this requires usage of Findify dev-tool extension.
If you have any challenges, Findify's team for professional services would be happy to assist.
Please get in touch through: [email protected]

📘

Dev Tool Extension

To learn how to work with the Dev Tools, please read more here

#2. Time Estimates

Set up in Platform: n/a
Integration: 50 minutes
Styling: 10 minutes

#3. Integration Steps

Create new component getPromoCards, where document.querySelector('.findify-promo-cards script').text should take a json string.

import React from 'react';
import { fromJS } from 'immutable';

export const setPromoCard = (items, meta, isSmartCollection) => {
  
  const collectionData = isSmartCollection && JSON.parse(document.querySelector('.findify-promo-cards script').text);

  if(collectionData){
    
    const sortedPromos = collectionData.sort( (a,b) => Number(a.position) - Number(b.position) )
    const immutablePromos = fromJS(sortedPromos);
    const offset = meta.get('offset','');
    
    const newItems = immutablePromos.reduce( (acc, item, index) => {
      if(item.get('position') <= offset) return acc;

      return acc.insert(item.get('position') - index + (!index ? -1 : 0), item) 
    }, items)

    return newItems;
  }

  return items;
}

If you use Lazy Loading, open helpers/withLazy.tsx. Here you'll need to edit addItems function.
If you use Pagination, open components/ItemsList/view.tsx. Here you'll need to use the setPromoCard function and put the withPromo prop into the array prop.

import React from 'react';
import { Component, createFactory } from 'react';
import { is, List, Map } from 'immutable';
import { compose, withPropsOnChange, setDisplayName } from 'recompose';
import {setPromoCard} from 'getPromoCards'; //import this function

const isStateEqual = (prev, next) => ['filters', 'q', 'sort'].every(k =>
  is(prev.get(k), next.get(k))
);

const hasRange = (ranges, offset) => !!ranges.find(r => r.get('from') === offset);

const createRange = meta => Map({
  from: meta.get('offset'),
  to: meta.get('offset') + meta.get('limit'),
});

const addItems = ({ ranges, items }, nextItems, meta, config) => {
  const append = ranges.find(r => r.get('from') < meta.get('offset'));
  const newRange = createRange(meta);
  const _items = nextItems.filter(i => !items.includes(i));

  const isSmartCollection = config.get('type') === 'smart-collection';
  //create variable with product items and promo cards
  const withPromo = setPromoCard(_items, meta, isSmartCollection); 

//insert promo cards when click on load more/less
  return {
    ranges: append ? ranges.push(newRange) : ranges.insert(0, newRange),
    items: append ? items.concat(withPromo) : withPromo.concat(items)
  }
}

/**
 * withLazy() returns a HOC for wrapping around component you want to include lazy loading to
 * @returns HOC, accepting a view you want to add lazy loading to
 */
export default () => {
  /**
   * withLazy HOC allows you to add LazyLoading functionality to your Search views, for example.
   * It controls items displaying correct range in the LazyView and automatically requests for more data if needed
   * @param LazyView view you will be adding lazy loading to
   * @returns LazyLoading HOC
   */
  return BaseComponent => {
    const factory = createFactory(BaseComponent);
    return class Lazy extends Component<any, any>{
      container: any;
      autoLoadCount = 0;

      constructor(props) {
        super(props);
        this.autoLoadCount = props.disableAutoLoad ? 0 : props.config.getIn(['loadMore', 'lazyLoadCount'], 2);
        this.state = {
          items: props.items,
          ranges:  List([createRange(props.meta)]),
          columns: props.columns || '3',
          pending: false
        };
      }

      registerContainer = (ref) => {
        if (!ref) return;
        this.container = ref;
      }

      onLoadNext = () => {
        const { update, meta } = this.props;
        const { ranges } = this.state;
        return update('offset', ranges.last().get('to'));
      }

      onLoadPrev = () => {
        const { update, meta } = this.props;
        const { ranges } = this.state;
        return update('offset', ranges.first().get('from') - meta.get('limit'));
      }

      get lessAllowed() {
        const { ranges } = this.state;
        const firstRange = ranges.first();
        return firstRange && firstRange.get('from') > 0
      }

      get moreAllowed() {
        const { meta } = this.props;
        const { ranges } = this.state;
        const lastRange = ranges.last();
        return lastRange && lastRange.get('to') < meta.get('total')
      }

      trackPosition = () =>
        !this.state.pending &&
        !!this.autoLoadCount &&
        window.requestAnimationFrame(() => {
          const offset = 300;
          const { bottom } = this.container.getBoundingClientRect();
          const height = window.innerHeight || document.documentElement.clientHeight;
          const inView = bottom - height <= offset;
          if (!inView || this.state.pending || !this.autoLoadCount || !this.moreAllowed) return;
          this.autoLoadCount -= 1
          this.setState({ pending: true });
          this.onLoadNext();
        })

      componentDidMount() {
        if (this.props.disableAutoLoad) return;
        window.addEventListener('scroll', this.trackPosition);
      }

      componentWillUnmount() {
        if (this.props.disableAutoLoad) return;
        window.removeEventListener('scroll', this.trackPosition);
      }

      UNSAFE_componentWillReceiveProps({ items, meta, config }) {
        // Do nothing if items are equal
        if (items.equals(this.props.items)) return;

        this.setState({ pending: false });

        // Prepend or append new items
        if (isStateEqual(meta, this.props.meta) && !hasRange(this.state.ranges, meta.get('offset'))) {
          return this.setState({ ...addItems(this.state, items, meta, config) });
        }

        // Reset number of loads
        if (!this.props.disableAutoLoad) this.autoLoadCount = config.getIn(['loadMore', 'lazyLoadCount'], 2);

        // Reset items
        const isSmartCollection = config.get('type') === 'smart-collection';
        //insert promo cards by default
        return this.setState({
          items: setPromoCard(items, meta, isSmartCollection),
          ranges: List([createRange(meta)]),
        });
      }

      shouldComponentUpdate(props, state) {
        return (
          this.state.pending !== state.pending ||
          !this.state.items.equals(state.items) ||
          !!Object.keys(props).find(k => !is(this.props[k], props[k]))
        )
      }

      render () {
        const { ranges, items, columns, pending } = this.state;
        const { meta } = this.props;

        const content = factory({
          ...this.props,
          items,
          displayPrevButton: this.lessAllowed,
          displayNextButton: !pending && this.moreAllowed,
          onLoadNext: this.onLoadNext,
          onLoadPrev: this.onLoadPrev,
        });

        return <div ref={this.registerContainer}>{content}</div>
      }
    };
  }
}
/**
 * @module components/ItemsList
 */

import React from 'react'
import ProductCard from 'components/Cards/Product'
import mapArray, { MapArrayProps } from 'components/common/MapArray';
import {setPromoCard} from 'getPromoCards';

// Default item factory is using ProductCard
const ItemFactory = React.createFactory(ProductCard);

/** Props that ItemList view accepts */
export interface IItemsListProps extends MapArrayProps {
  /** Wrapper around mapArray */
  wrapper: React.ComponentType;
  /** Rest props that are passed to wrapper */
  [x: string]: any;
}
 

export default ({ items, wrapper: Wrapper = React.Fragment, ...rest }: IItemsListProps) => {
  const { limit, factory, keyAccessor, ...wrapperProps } = rest;
  const { config, meta } = rest;
  const isSmartCollection = config.get('type') === 'smart-collection';
  const withPromo = setPromoCard(items, meta, isSmartCollection);

  return (
    <Wrapper {...wrapperProps}>
      { mapArray({ keyAccessor, limit, array: withPromo, factory: factory || ItemFactory, ...wrapperProps }) }
    </Wrapper>
  )
}

After all that, you can separate Product/view.

/**
 * @module components/Cards/Product
 */

import React from 'react'
import { IProduct, MJSConfiguration, ThemedSFCProps } from 'types/index';
import {withPropsOnChange} from 'recompose';
import ProductCardView from 'ProductCard';
import PromoCard from 'PromoCard';


export interface IProductCardProps extends ThemedSFCProps {
  item: IProduct;
  config: MJSConfiguration;
}

const ProductCard: React.SFC<IProductCardProps> = ({
  item,
  ...props
}: any) => (
  <div>
    <ProductCardView display-if={!item.get('position')} item={item} {...props}/>
    <PromoCard display-if={item.get('position')} item={item}/>
  </div>
)

export default ProductCard;
/**
 * @module components/Cards/Product
 */

import React from 'react'
import classNames from 'classnames'
import Image from 'components/common/Picture'
import Truncate from 'components/common/Truncate'
import Text from 'components/Text'
import Rating from 'components/Cards/Product/Rating';
import Price from 'components/Cards/Product/Price';
import template from 'helpers/template';
import { DiscountSticker, OutOfStockSticker  } from 'components/Cards/Product/Stickers';
import { List } from 'immutable'
import { IProduct, MJSConfiguration, ThemedSFCProps } from 'types/index';
import BundleAction from 'components/Cards/Product/BundleAction';

const Title: any = ({ text, theme, ...rest }) => (   
  <Text display-if={!!text} className={theme.title} {...rest}>{text}</Text>
);

const Description: any = ({ text, theme, ...rest }) => (
  <p
    display-if={!!text}
    className={theme.description}
    {...rest}
  >
    <Truncate>{text}</Truncate>
  </p>
);


export interface IProductCardProps extends ThemedSFCProps {
  item: IProduct;
  config: MJSConfiguration;
}

const ProductCardView: React.SFC<IProductCardProps> = ({
  item,
  config,
  theme
}: any) => (
  <a
    onClick={item.onClick}
    href={item.get('product_url')}
    className={classNames(
      theme.root,
      config.get('simple') && theme.simple,
      theme.productCard,
    )}
  >
    <div className={classNames(theme.imageWrap)}>
      <BundleAction display-if={config.get('bundle')} item={item} />
      <Image
        className={classNames(theme.image)}
        aspectRatio={config.getIn(['product', 'image', 'aspectRatio'], 1)}
        thumbnail={item.get('thumbnail_url')}
        src={item.get('image_url') || item.get('thumbnail_url')}
        alt={item.get('title')}
      />
      <div display-if={config.getIn(['product', 'stickers', 'display'])}>
        <DiscountSticker
          config={config}
          className={theme.discountSticker}
          discount={item.get('discount')}
          display-if={
            config.getIn(['stickers', 'discount']) &&
            config.getIn(['product', 'stickers', 'display']) &&
            item.get('discount', List()).size &&
            item.getIn(['stickers', 'discount'])
          } />
      </div>
    </div>
    <div
      display-if={
        config.getIn(['product', 'reviews', 'display']) &&
        (!!item.getIn(['reviews', 'count']) || !!item.getIn(['reviews', 'total_reviews']))
      }
      className={theme.rating}>
      <Rating
        value={item.getIn(['reviews', 'average_score'])}
        count={item.getIn(['reviews', 'count']) || item.getIn(['reviews', 'total_reviews'])} />
    </div>
    <div
      className={theme.variants}
      display-if={
        config.getIn(['product', 'variants', 'display']) &&
        item.get('variants', List()).size > 1
      }
      >
      {
        template(config.getIn(['product', 'i18n', 'variants'], 'Available in %s variants'))(
          item.get('variants', List()).size
        )
      }
    </div>
    <div className={theme.content}>
      <Title
        theme={theme}
        display-if={config.getIn(['product', 'title', 'display'])}
        text={item.get('title')}
        config={config.getIn(['product', 'title'])} />
      <Description
        theme={theme}
        display-if={config.getIn(['product', 'description', 'display'])}
        text={item.get('description')}
        config={config.getIn(['product', 'description'])} />
      <Price
        className={theme.priceWrapper}
        display-if={config.getIn(['product', 'price', 'display'])}
        price={item.get('price')}
        oldPrice={item.get('compare_at')}
        discount={item.get('discount')}
        currency={config.get('currency_config').toJS()} />
      <OutOfStockSticker
        display-if={item.getIn(['stickers', 'out-of-stock'])}
        config={config} />
    </div>
  </a>
)

export default ProductCardView;
import React from 'react';

const PromoCard = ({ item }) => (
  <div className="findify-promo_card">
    <div className="findify-promo-card-content-wrapper">
      <div className="findify-promo-card-text-wrapper">
        <div display-if={item.get('top-header')} className="findify-promo-title">{item.get('top-header')}</div>
        <p display-if={item.get('sub-header')} className='findify-promo-card-text'>{item.get('sub-header')}</p>
      </div>
      <a 
        href={item.get('url')} 
        className='findify-promo-card-image-container'
        dangerouslySetInnerHTML={{__html: item.get('image_')}}
      />
      <a 
        display-if={item.get('cta')}
        href={item.get('url')}
        className="findify-promo-card-button"
      >
        {item.get('cta')}
      </a>
      <div 
        display-if={item.get('overlay')} 
        className="findify-promo-card-overlay"
        style={{
          background: item.get('overlay')
        }}
      />
    </div>
  </div>
)

export default PromoCard;