Promo Cards: Activation (using Shopify meta fields)

Pre-Work

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

To read more about use cases and setup, please [read here](doc:promo-cards-shopify_metafields)To read more about use cases and setup, please [read here](doc:promo-cards-shopify_metafields)

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

Step 4: Integrate Front End Activation Through Findify DevTools

Create new component getPromoCards, where document.querySelector('.findify-promo-cards script').text should take 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 need to edit addItems function.
If you use Pagination open components/ItemsList/view.tsx. Here you need to use the setPromoCard function and put the withPromo prop to 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>
  )
}

Then 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;