Recommendations

Set up your Placement either using the Experience Hub or Qubit CLI.

For this tutorial, we will be replacing the recommendations element on the PDP (product description page), like the following example:

initial recs

Set up your triggers

Start by finding a way to target exactly the element that you want to replace. Here we can use its Id #shopify-recommendations.

element to replace on page

Make sure you add that element to the 'polling for' section on the triggers page. You should also add the 'page type' trigger to avoid overriding elements by accident and making requests when not necessary. If you have any questions about triggers, there’s a section on them here.

Don’t forget to add a page type trigger for the page your Placement will be running on, product in this case.

simple triggers page

Choose your product settings

Recommendations Placements have a default schema that includes the headline and recs fields.

Note

You can add custom fields to your schema to give merchandisers additional control over the Placement’s design. For more details, see Schemas.

You can click the '*Product settings button and define min and max number of recs that this Placement allows to be served. You can also configure your sample payload here. Once you click 'Save', you should see an example of your payload appear.

schema

This is a short example of a recommendations payload with only one product in the recs array:

"headline": "Best sellers!",
"recs": [
  {
    "details": {
      "categories": [
        "women > accessories > bags > shoulder"
      ],
      "images": [
        "//kg-static-cache.s3.amazonaws.com/catalog/product/cache/thumbnail/1622x1622/7/3/7392398999.jpg"
      ],
      "language": "en-gb",
      "id": "2259209093",
      "views": 3425,
      "name": "kensington beaded bag",
      "stock": 1,
      "size": "NOSZ",
      "unit_sale_price": 44,
      "currency": "gbp",
      "base_currency": "GBP",
      "unit_price": 149,
      "url": "/women/accessories/bags/shoulder/kensington-beaded-bag-pink-others-kurt-geiger-london?istCompanyId=166c308a-e1e7-4c46-9232-e2b92f576b07&istFeedId=ea033aae-0609-44ac-b740-b326f1070dfe&istItemId=ptrmrmtim&istBid=tzxl&gclid=EAIaIQobChMIg46Vsvvh8QIVzd_tCh25HQYNEAQYFiABEgL3dPD_BwE&gclsrc=aw.ds",
      "description": null,
      "category": "bags",
      "subcategory": null,
      "locale": "en-gb-gbp",
      "image_url": "//kg-static-cache.s3.amazonaws.com/catalog/product/cache/thumbnail/1622x1622/7/3/7392398999.jpg"
    }
  }
]
Note

If you’re using Qubit CLI to develop locally, you’ll need to save the Placement in the UI and pull its new structure to your local project at this point.

Write some code!

This is what your placement.js file will look like at first:

module.exports = function renderPlacement ({ content, onImpression, onClickthrough }) {
  if (content) {

  } else {

  }
}

To start with, you have to import preact and @qubit/utils, a library that offers some helpful tools for manipulating the DOM. Next, you should use onRemove and elements (two other arguments available for us in the renderPlacement function) which you can read more about in placement.js arguments.

const React = require('preact')
const {
  style,
  insertBefore,
  onEvent,
  onEnterViewport,
  restoreAll
} = require('@qubit/utils/dom')()

module.exports = function renderPlacement ({
  content,
  onImpression,
  onClickthrough,
  onRemove,
  elements
}) {
  // ...
}

As you can see in the boilerplate code, you will divide our code into two parts, content and no-content. However, there are a couple of things you need to do before getting into that split to avoid creating bias in our test:

// The functions provided by @qubit/utils are all linked to the `restoreAll` function,
// which means that when you remove the Placement on the page with `onRemove` (particularly useful for single-page applications),
// it will clean it up after itself and avoid leaking side effects.
onRemove(restoreAll)

// Appending this element to the same spot on the dom for both control and variant will ensure the test is fair.
// Check out the Impressions and clickthroughs guide in the docs to learn more about this.
const element = document.createElement('div')
insertBefore(target, element)

// Emitting onImpression before branching into control/variant prevents bad splits
onEnterViewport(element, onImpression)

Apart from that, we will want to have access to the element we polled for in the beginning, the element with Id #shopify-recommendations. We can do so by destructuring the array of elements provided to us.

Note

If you had polled for more elements or global objects, they would also appear in this array in the order they are being polled for.

  const [target] = elements

So far, our placement.js code looks like this:

const React = require('preact')
const {
  style,
  insertBefore,
  onEvent,
  onEnterViewport,
  restoreAll
} = require('@qubit/utils/dom')()

module.exports = function renderPlacement ({
  content,
  onImpression,
  onClickthrough,
  onRemove,
  elements
}) {
  onRemove(restoreAll)

  const [target] = elements

  const element = document.createElement('div')
  insertBefore(target, element)

  onEnterViewport(element, onImpression)

  if (content) {

  } else {

  }
}

Content

Now that we’ve set up our Placement code and ensured we’re collecting consistent impression events, we can have a look at actually rendering our new recommendations on the page.

const recsProdIds = recs.map(({details}) => details.id)
// Here we call `onImpression` again, but this time with the product ids available.
// We still recomment calling `onImpression` without any arguments before spliting into content and no-content to ensure we are sending them fairly for both cases.
onEnterViewport(element, () => onImpression('product', recsProdIds))

// Here we use destructuring to get our `headline` and `recs` from the `content` object
const { headline, recs } = content

// Note that we will use these classNames to style our component in placement.css
React.render(
  <div className='RecsContainer'>
    <h2 className='RecsContainer-headline'>{headline}</h2>
    {/* Don't forget to give any clickable element the `onClickthrough` callback */}
    <ul className='RecsContainer-inner'>
      {recs.map(({details}) => <Product key={details.id} item={details}/>)}
    </ul>
  </div>,
  element
)

function Product({ item }) {
  const {
      id,
      name,
      url,
      currency,
      image_url: imageUrl,
      unit_price: unitPrice
    } = item

  const price = new Intl.NumberFormat(navigator.language, {
    style: 'currency',
    currency: currency
  }).format(unitPrice)

  return (
    <div className='RecsContainer-product'>
      <img src={imageUrl} />
      <a
        className='RecsContainer-productName'
        href={url}
        onClick={() => onClickthrough('product', id)}
      >
        {name}
      </a>
      <span className='RecsContainer-productPrice'>{price}</span>
    </div>
  )
}

// After rendering our banner, we want to hide the banner that was already on the page.
style(target, { display: 'none' })
Note

If you want to learn more about when to call onImpression and onClickthrough, visit our guide here.

Now, all you need to do is give it some styles in placement.css. Don’t worry about importing it, it’s all bundled together when you are serving the Placement.

.RecsContainer {
  text-align: center;
  margin: auto;
  max-width: 1200px;
  padding-left: 55px;
  padding-right: 55px;
}

.RecsContainer-inner {
  display: flex;
  margin: 0 30px;
}

.RecsContainer-product {
  display: flex;
  flex-direction: column;
  padding: 15px;
  border: 1px solid #f3d7d7;

  &:not(:last-of-type) {
    margin-right: 15px;
  }
}

.RecsContainer-productName {
  padding: 10px 0;
}

The only thing left to do here is to make sure we are capturing clickthrough events for users that don’t see our personalized content.

No content (control)

  // We find all clickable elements in each of the recs that are already on the page.
  const items = target.querySelectorAll('.product-item')
  forEach(items, item => {
    // Here we assume that the item had a data-product-id attribute. Different pages will have different ways of getting this id
    const prodId = item.dataset.productId
    const image = item.querySelector('img')
    const name = target.querySelector('h2.product-name')

    // Make sure we are calling `onClickthrough` whenever they are clicked.
    onEvent(image, 'click', () => onClickthrough('product', prodId))
    onEvent(name, 'click', () => onClickthrough('product', prodId))
  })
Note

when using Element.querySelectorAll, you get a NodeList and not an Array. For most modern browsers this isn’t a problem, however older versions of IE don’t support calling some Array methods on a NodeList (see here). We omitted this from the code for simplicity, but we recommend you use the slapdash library, since some of our other products also use this package.

So this is what the whole placement.js will look like:

const React = require('preact')
const {
  style,
  insertBefore,
  onEvent,
  onEnterViewport,
  restoreAll
} = require('@qubit/utils/dom')()

module.exports = function renderPlacement ({
  content,
  onImpression,
  onClickthrough,
  onRemove,
  elements
}) {
  onRemove(restoreAll)

  const element = document.createElement('div')
  insertBefore(target, element)

  onEnterViewport(element, onImpression)

  const [target] = elements

  if (content) {
    const recsProdIds = recs.map(({details}) => details.id)
    onEnterViewport(element, () => onImpression('product', recsProdIds))

    const { headline, recs } = content

    React.render(
      <div className='RecsContainer'>
        <h2 className='RecsContainer-headline'>{headline}</h2>
        <ul className='RecsContainer-inner'>
          {recs.map(({details}) => <Product key={details.id} item={details}/>)}
        </ul>
      </div>,
      element
    )

    function Product({ item }) {
      const {
          id,
          name,
          url,
          currency,
          image_url: imageUrl,
          unit_price: unitPrice
        } = item

      const price = new Intl.NumberFormat(navigator.language, {
        style: 'currency',
        currency: currency
      }).format(unitPrice)

      return (
        <div className='RecsContainer-product'>
          <img src={imageUrl} />
          <a
            className='RecsContainer-productName'
            href={url}
            onClick={() => onClickthrough('product', id)}
          >
            {name}
          </a>
          <span className='RecsContainer-productPrice'>{price}</span>
        </div>
      )
    }
  } else {
    const items = target.querySelectorAll('.product-item')
    forEach(items, item => {
      const prodId = item.dataset.productId
      const image = item.querySelector('img')
      const name = target.querySelector('h2.product-name')

      onEvent(image, 'click', () => onClickthrough('product', prodId))
      onEvent(name, 'click', () => onClickthrough('product', prodId))
    })
  }
}

And your new recommendations are on the page:

new recs

After you are done, you can publish your Placement and your merchandiser will be able to target it with some campaigns!