Add 360° Images to Product Detail Pages

I want to preface this entire post with a cautionary note: I don't think my solution is 'production ready'; that is, I'm not quite sure it's elegant and robust enough to withstand use on your live, trading ecommerce site. But why not?

As you'll see as we go through the article, we're adding a jQuery plugin to existing functionality that already has to compete with two other jQuery plugins. While one plugin by itself can add elements of instability and unpredictability to a site, three triples this and then compounds it further. There's no way to guarantee if they'll all play nice with each other.

With that out of the way, let's focus on what we're here today to look at: 360° images for items. While most product imagery is a series of photos of the item from one maybe two angles, 360° imagery is the idea that you take a significantly larger number of photos of the product and then stitch them together to create a nigh seamless complete view of the product.

By the end of the article, we're going to end up with something like this:

Indeed, the types of sites that make the most of this functionality tend to be ones where the shopper could really want to see every angle of the product; cars are a great example of this. However, for example, there's no reason why you couldn't take advantage of this functionality if you run a fashion website. Why not take a series of photos of your model twirling on the spot and then use that for your imagery?

The Plugin: jQuery Reel

With all that in mind, let's take a look at the plugin I'm going to be using for this article: jQuery Reel.

Looking at it, it's quite small and lightweight and contains a lot of configurable options. It supports touchscreen (although we'll see how this is problematic later) and is AMD-compatible, so it should be relatively simple to add to the application.

This is the way it works: after you have the photos, you stitch them together in order into a grid running left to right and then onto the next row. So, for example, if you take 36 images of your product and then stitch them together, it should like this:

 01 02 03 04 05 06
 07 08 09 10 11 12
 13 14 15 16 17 18
 19 20 21 22 23 24
 25 26 27 28 29 30
 31 32 33 34 35 36

Ultimately, this is configurable on an image-by-image basis so it's not too important.

What happens is that as the user cycles through each 'frame' of the image, the image is updated using CSS, specifically two properties: background-position and background-size. The latter property is CSS3, which means that it is not compatible with Internet Explorer 8. Thus, if you still support it (we don't), this solution is not for you.

So after that, let's actually move onto implementing it. Remember: I'm not convinced this a clean implementation.

Add the Library as a New Module

As I said earlier, the plugin we're using is AMD-compatible so we can add it to the application like we would any other module.

Head over to the jQuery Reel site and download a copy of the unminified JavaScript file. Then, in your customizations directory, create jQuery-Reel@1.3.0.

Now seeing as we're going to be adding one file we don't need to create any sub-directories. Put jquery.reel.js into the directory. Create ns.package.json and in it put the following:

{
  "gulp": {
    "javascript": [
      "jquery.reel.js"
    ]
  }
, "jshint": "false"
}

The jshint bit just flags that, should we run the jshint task, the file should be ignored.

The final part of adding it to the application is to add it to distro.json. As normal, we need to add it to the modules object; we don't, however, need to add it to any other application JavaScript or shim it. The plugin is written in such a way that, when initialized, it adds additional methods to jQuery.

Set Up the Module

Next, we need to start preparing the module which is actually going to contain the code that modifies our site. In your customizations directory, create 360Images@1.0.0 with the following structure:

  • Configuration
  • JavaScript
  • Templates
  • ns.package.json

In JavaScript, create 360Images.js and in it put:

define('360Images'
, [
    'ProductDetails.ImageGallery.View'

  , 'jquery.reel'
  ]
, function (
    ImageGalleryView
  )
{
  'use strict';

  _.extend(ImageGalleryView.prototype,
  {
    initialize: _.wrap(ImageGalleryView.prototype.initialize, function(fn)
    {
      fn.apply(this, _.toArray(arguments).slice(1));
      console.log('360 Images initialized');
    })
  });
});

We're taking the view that is used to generate the images on the PDP and injecting our own code. For now, that's just a console log.

Next, we need to add some configuration to the backend. Remember how I explained how the JS works? So what we need to do is add three values: file name (to identify the image), the total frames and the number of columns; after we input that information into the backend, the JS will be able to work out what to do with the image.

Create Configuration > 360Images.json with this in it:

{
  "type": "object",

  "properties": {

    "imagesThreeSixty": {
      "group": "catalog",
      "type": "array",
      "title": "Product 360 Image",
      "description": "Configure the 360 images for the PDP",
      "items": {
        "type": "object",
        "properties": {
          "name": {
            "type": "string",
            "title": "Image file name",
            "description": "",
            "mandatory": true
          },
          "frames": {
            "type": "integer",
            "title": "Total frames",
            "description": "",
            "mandatory": true
          },
          "footage": {
            "type": "integer",
            "title": "Number of columns",
            "description": "",
            "mandatory": true
          }
        }
      }
    }
  }
}

Finally, update ns.package.json so it includes all three folders:

{
  "gulp": {
    "configuration": [
      "Configuration/*"
    ]
  , "javascript": [
      "JavaScript/*"
    ]
  , "templates": [
      "Templates/*"
    ]
  }
}

Great! Let's save and deploy. Once you've done that, visit your site. You should see... a JavaScript error. "Uncaught ReferenceError: jqueryreel is not defined" — what?

The First Snag: jqueryreel is not defined

But we did everything right, so why are we seeing this error message?

The weird thing is, if you run a local server then you don't see the error message. Double what.

I figured out how to fix this, but first let's take a look at all the things that I looked at first (that didn't work):

  1. Renaming the module and file to numerous permutations, in particular jqueryreel
  2. Changing how it's called in 360Images.js — eg, explicitly naming the dependency 'jqueryreel'
  3. Shimming it in the distro file (totally wrong, given that this is AMD-compatible)
  4. Adding it as a dependency in shopping.js in the distro file

There were other things too that I didn't actually think would work (and they didn't). But, hey, I was pretty desperate.

Then I thought about it some more, and I thought about what was different about the local and hosted environments. Then it dawned on me: the name was being changed when it was uploaded because of minification; when it's running on local, the unminified file is being served from my computer — online, it is being served as part of a minified lump of JS. Case in point:

This is the part of jquery.reel.js where the global namespace is defined. Open it up and find it (around line 88). At no point is jqueryreel explicitly named. Let's change that.

//Where it says this
reel= $.reel=

// Change it to this
reel= jqueryreel= $.reel=

You should probably make a note of this — either in the file, your documentation or in 360Images.js so you or future developers don't forget. Strictly speaking, we change the filename of any third-party library file we edit (eg by adding "-custom" to the end).

Save and upload. When you do, no more error and you should see a friendly console log instead. If you type in jQuery.reel, it should return an object with its details and functions.

Prepare the Image and Naming Convention

We need to start thinking about how we're going to get this functionality to work. We know that we're going to need to add new images to the site and that they need to be associated with specific products. We may also want multiple 360° images per product — for example, if the product is a matrix item then we may have a 360° image for each variation. All of this determined by your image naming convention.

On my reasonably default site, I have a file called OM5235.media.orange.01.jpg which is named following this convention:

  • [item identifier].{Category 1}.{Category 2}.[EXT]

You should know your naming convention — if not, you can see and set it in your site setup record (Setup > SuiteCommerce Advanced > Set Up Website > Advanced).

Specifically on my site, we use the colors to associate the images with the color item option the shopper selects. The final bit (the number) is not particularly useful, it's just used for ordering, but this gives us a good opportunity.

What I propose is this: when naming and associating your images, use "360" for the number part of the file. It's unlikely you'll have 360 or more images for a particular item option and it means that it'll make those images easy to identify and handle.

As it would be a lot of effort for me to create an image of an actual product, I'm just going to use the one that the plugin creators provide and add it to one of my existing products. If you like, you can use it too: sampleimage360.jpg. Note that despite the frame size being quite small, the file itself is quite large. This will be something you will need to keep in mind: 360° images will be large and will therefore increase loading times of pages.

Rename the sample image according to your site's convention, using the item identifier of an existing product (alternatively, create a new one — but this will require extra effort) and then upload it to your file cabinet.

Once you've done that, we need to add in some configuration for it. In the backend configuration, go to the new bit for 360° images and enter the file name, total frames (35) and number of columns (6). Save the record.

Check for 360° Images and Update the Images Object

So in order to work with the images, we will need to first check whether the images exist and then associate the configuration data with the images.

In 360Images.js, add SC.Configuration as a dependency. Then add a new method:

, check360: function()
  {
    var config360 = Configuration.imagesThreeSixty
    , images = this.images;

    for (var i in config360)
    {
      for (var j in images)
      {
        if (config360[i].name === images[j].url.split('/').slice(-1).pop())
        {
          _.extend(images[j], config360[i]);
          images[j].is360 = true;
        }
      }
    }
    console.log('360 Images object updated');
  }

So we've got two objects: one containing the config options for the functionality and the other containing the images for the product we're currently looking at. What we're doing is taking the config data and checking the filename against the end of the URL of each product image. If a match is made, take the config data for that image and put it into the object for the that image. We're also adding a little flag to that iamge to make it easier to recognise in our template.

If you refresh your PDP you will see the 360 sample image added to the slider but at the moment it doesn't really do anything. It just shows the whole image and hovering over it launches the zoom function. Not great, but at least we know so far so good.

Preparing the Template

We have the information we need in the images object, so now we just need to do some template work to bring it out.

If you look in ProductDetails > Templates > product_details_image_gallery.tpl, you will see how we generate the images for the slider. Within a couple of conditionals, we loop through each image to generate a list item containing the image. Then, within that loop, we generate the image with the properties we need.

To start, copy the contents of this template and then paste it into a new template in 360Images > Templates > 360_image_gallery.tpl. From here, we can add some new code; replace the {{#each images}} block with the following:

{{#each images}}
    {{#if is360}}
        <li data-zoom class="product-details-image-gallery-container-360">
            <img
                src="{{url}}"
                class="reel"
                id="reelimage"
                height="450px"
                width="450px"
                data-cursor="hand"
                data-frames="{{frames}}"
                data-footage="{{footage}}"
                data-image="{{url}}"
            >
        </li>
    {{else}}
        <li data-zoom class="product-details-image-gallery-container">
            <img
                src="{{resizeImage url ../imageResizeId}}"
                alt="{{altimagetext}}"
                itemprop="image"
                data-loader="false"
            >
        </li>
    {{/if}}
{{/each}}

So what we're doing here is splitting up the 360° images from the others. We're adding a special class to the list item and then creating an image the way we want. Note the attributes we attach, in particular the frames, footage and image: these are vital to ensuring the image loads properly.

Also note that in my solution we are setting the data-image and src attributes to the same values; the creators recommend using the src as a placeholder image until the data-image image has loaded and overwritten it. I thought about this and realised that this would complicate things even more greatly, so I haven't implemented this.

Then we set the width and height of the element. I've hardcoded these to values such that it fits within the container. If you wish to have dynamic values, then it's quite easily to code these into your configuration file but note that you'd have to add some Sass (and probably some JavaScript) to calculate the correct positioning and margins. Again, to keep things simple I've used hardcoded numbers.

Finally, the class and ID are pretty much required to get this functionality to work: it's what causes the plugin to recognize this element as something it needs to work with.

Then we just need to add our template to the code and override the existing template. Open up the module's ns.package.json and add in an override:

, "overrides": {
    "suitecommerce/ProductDetails@1.0.0/Templates/product_details_image_gallery.tpl": "Templates/360_image_gallery.tpl"
  }

Save and restart your local server; you should see the override come up in Gulp. Then refresh the page. It should still look the same, but if you select the image from the slider and then enter jQuery.reel.scan(); into your console, you should see the image initialize and get some feedback in the console. Of course, it doesn't work yet but we're getting there.

Add in Initialization Functions

The .scan() method I just mentioned is something built into the plugin that initializes it by looking through the DOM for marked-up elements. What we need to do is have it run when the page is ready.

Unfortunately, if you just add into the initialize method, it won't work. The page isn't ready when that happens. I also tried adding in an afterViewRender event listener, but that didn't work either. Indeed, it seemed to only work when I told it run after the slider had finished running. This is where things get a little hacky.

We use bxSlider for the slider functionality and if you take a look at their list of callbacks, you can see that they have two that will be of use to us: onSliderLoad and onSlideBefore. These two options let us execute code immediately after the slider is fully loaded and immediately after a slide transitions respectively.

Before we can make use of them, though, let's add an initialization-like function. Add a new method:

, init360: function()
  {
    jQuery.reel.scan();
    console.log('360 Reel scanned');
  }

Nice and easy.

Then we hit the second snag.

The Second Snag: Overwriting the Slider Function

Put simply: we want to use the options of the bxSlider functionality, but we already have code that creates the slider. We can't extend or wrap the code to inject our own. We have to do the unthinkable... overwrite it.

Yes, what I'm suggesting (and this is another reason why I'm unhappy) is that we simply add a method to our JavaScript file that completely replaces the one that initializes the slider functionality. This will give us the opportunity to add the options we want.

So, what we're going to do is do a wholesale copy of the function but then add in two more options. Thus, in 360Images.js, add in:

, initSlider: function()
  {
    var self = this;

    if (self.images.length > 1)
    {
      self.$slider = Utils.initBxSlider(self.$('[data-slider]'), {
          buildPager: _.bind(self.buildSliderPager, self)
        , startSlide: 0
        , adaptiveHeight: true
        , touchEnabled: true
        , nextText: ''
        , prevText: ''
        , controls: true
        , onSliderLoad: _.bind(this.init360, this)
        , onSlideBefore: _.bind(this.init360, this)
      });

      self.$('[data-action="next-image"]').off();
      self.$('[data-action="prev-image"]').off();

      self.$('[data-action="next-image"]').click(_.bind(self.nextImageEventHandler, self));
      self.$('[data-action="prev-image"]').click(_.bind(self.previousImageEventHandler, self));
    }

    console.log('Slider initialized');
  }

As per the rules of Underscore: by naming it exactly the same as an existing function within the extend object (ie the view), it will overwrite it with whatever we specify.

You'll see that we added our two callbacks that bind our new initialization function to those events. Thus, whenever the slider loads or a slide is triggered, we scan for images to make 360.

Before we're done with this, we just need to add in a new dependency as we've mentioned in our code: Utils.

Now when we visit our PDP and scroll to the 360° image, it loads the plugin and correctly applies it to the image. Now all that's left to do is deal with the image zoom functionality. And now we have the third and final snag.

The Third Snag: Preventing Image Zoom

When I first approached this problem, I initially just removed the data-zoom attribute from the template for 360° images. "This is simple" I thought. "If I just remove the parameter, then the zoom plugin won't pick it up and it won't trigger on those images. I was right but oh, how I was wrong.

You see, if you do this (go on, try it) then you'll see that it works as expected. However, when you scroll to an image immediately after it, you'll see that the zoom image for that image is completely wrong: it uses the image from the previous image, cascading down the line, putting everything out of sync. Uh oh.

You see, if you take a look at the initZoom function in the view, you'll see why. It's been written to go through each each element in the view looking for elements marked up with data-zoom attribute; when it finds one, it goes through and does the work to load the zoom image for it. If we remove the parameter from the 360 image, it does not update the index of the slide and puts the whole thing out of sync, associating the wrong images together.

I looked into the docs for the zoom plugin to see if there was something I could do. One idea I had was to use the .destroy method to target the 360° images and remove any zoomed images associated with them. In other words, let it go through the motions but then remove the zoomed images we don't need. The code looked like this:

jQuery('#reelimage').trigger('zoom.destroy')

Run that in your console and then use the functionality. You'll see it works! Hurrah! We're finished! Right? No. Alas.

When I tried putting that simple line of code into my code, I found it worked... sometimes. For example, if I had it in and then went directly to a PDP it worked, but if I went and searched for something and then clicked through from the search results, it didn't. If I went back and forth in my browser history it worked only some of the time. I found the whole thing to be extremely unreliable.

So here's what I'm saying: if you can get the .destroy function to work perfectly for you then please tell me how. In the meantime, I'm suggesting that for the purposes of this tutorial, we commit a cardinal sin and, again, replace the function entirely with some added code.

In 360Images.js, add the following function:

, initZoom: function ()
  {
    if (!SC.ENVIRONMENT.isTouchEnabled)
    {
      var images = this.images
      , self = this;

      this.$('[data-zoom]').each(function (slide_index)
      {
        if (jQuery(this).hasClass('product-details-image-gallery-container-360'))
        {
          console.log('360 image detected - ignoring in zoom');
        } else {
          self.$(this).zoom({
            url: resizeImage(images[slide_index].url, 'zoom')
          , callback: function()
            {
              var $this = self.$(this);

              if ($this.width() <= $this.closest('[data-view="Product.ImageGallery"]').width())
              {
                $this.remove();
              }

              return this;
            }
          });
        }
      });
    }
  }

You'll see we're wrapping the whole thing in an if statement that checks whether the element has the special class we assigned it. We could use any of the element attributes, so if you're itchy about using classes for JavaScript then feel free to swap it out. The point is that we need something to identify the element type.

When the check runs, if it finds an element it basically just presses the clutch and lets it run through without doing any of the work the image zoom functionality requires: in our case, we have it log to the console, but you could rewrite it so that it does literally nothing. This way, we increase the slide index counter and then move on to the next element. Easy peasy.

One final thing you need to do is add in a dependency: Utilities.ResizeImage (resizeImage), which is functionality used by the zoom to get the appropriate image.

Once you've done all that, save and refresh. You should see something like this:

Good job! And we only hit three snags!

Final Thoughts

So we got 360° image functionality to work using the plugin. But, crucially, we had three big problems that we overcame but not satisfactorily:

  1. We modified the plugin source code because we were having with AMD compatibility
  2. We overwrote the slider function so that we could add in callbacks to initialize the functionality because we had an issue running that code directly from our module
  3. We overwrote the zoom function so that we could add in a conditional statement that prevents the functionality from applying to the 360° images

What this all means to me is that what we have today is, at best, a proof of concept. In truth, I had a week to prepare this code for this article: given more time we probably can come up with a better, cleaner version that avoids those three problems.

Building on this, however, I think there's certainly some things you can work on. There's no Sass included in this work, so you can toy around with that to see how it fits into your site. We also didn't use many of the plethora of options available to us from the plugin itself.

Oh and I lied when I said there were only three snags. There's also a fourth: touch devices. Now, this functionality is compatible with touch devices, but if you try it out on your phone, you'll see the problem. For the slider functionality, we already use the swipe inputs to switch between the slides. This makes rotating frustratingly difficult; so much so that I would probably suggest that you add in some code that disables this functionality for touch devices.

Another thing you could do is break this functionality off from the slider and have the 360° functionality as a separate feature on the PDP; rewrite it and set it up as something, say, you have to click, one that launches in a modal, for example. This could help you get around the mobile problem as well as some of the headaches the slider generated.

Let me know what you think.

If you want the full source code for this module, see 360Images@1.0.0.zip.