Show a Shopper their Product Reviews with Product Data

In this tutorial we're going to create a page in the my account section so that a shopper can log in and see what products they've reviewed. More than that, though, we're going to do something quite complicated, which is to pull item data that is associated with the items that they've reviewed.

The idea is that a shopper visits your site, buys products and reviews them. As time goes on, they want to be able to quickly look at all of their reviews in a list and see what products they reviewed.

Conceptually, it's very similar to the 'build your first module' tutorial we did ages ago. While we won't be offering them the opportunity to create, update or delete existing reviews through this module, we still want to be able to retrieve those records from the server and display them to the user in the list. Therefore, at least at the beginning, there is significant overlap between the two modules.

There is divergence, however, when we consider the secondary aspect of this project: namely the product data. When a shopper reviews a product, the product ID is tied to the review — this is used by the product review functionality to pull in all reviews related to that product. Similarly, however, it can also be used by us to pull in item data when we look at a review (out of context of the product page).

This presents a challenge: we want the data of the review and the product combined. When the shopper looks at a review, they should see not only what they wrote but also the thing they were writing about. Let's take a look at this.

Module Skeleton

As I said, a lot of the basic stuff is already covered by a previous tutorial so I don't want to dwell on that. So, to aid you, I've included a zipped up module which you can download: MyReviews@1.0.0-basic.zip.

Download those source files and add them to your customizations directory. Then open up distro.json and update the following points:

  • In the modules object, add in "customizations/MyReviews": "1.0.0"
  • In the dependencies for myaccount.js, add in "MyReviews"
  • In the dependencies for ssp-libraries, add in "MyReviews.ServiceController"
  • In the dependencies for myaccount.css, add in "MyReviews"

Deploy these up to your site. Once that's done, you'll need to log in to a shopper account that has some product reviews (or create some if you don't have any yet). Then, in my account, change the end of the URL to "#myreviews". You should see something like this:

Here you can see that we have surfaced, in the most basic way possible, a number of relevant details about each of the reviews the user has submitted.

Take a look at the code. You'll see that in the JavaScript folder, there isn't anything here too crazy — this is basic Backbone stuff. We have an entry point file, a router, a model, a collection, and then a list and a detail view. The list view is a composite view as it's going to load instances of the detail view, one per review.

If you look at the SuiteScript, again nothing too racy. The service controller requires only that the user is logged in (how else would we know the current user's ID?) and calls the list() method of the model. The model itself very closely mirrors the Artist module's model:

  • Specify the author field and current user as the filter (search criteria)
  • Specify a number of columns (fields) related to those reviews as the data we want back
  • Specify the review record type as the one to be searched
  • Perform the search and then return a map of the results

From here you can see that we plug it into our frontend model and collection so it is called when the router calls the listReviews() method in var collection = new CollectionView();. From there, the data is plugged into the frontend and as we progress down the chain (from the list view to the details view) we pass that collection on. Then, with a little bit of code in the context of the details view, we can plug it into our template. Simple stuff.

Plan the Item Data Solution

OK, so let's do some actual development work. But before we do any coding, we need to think about how we're going to approach this problem. As I said previously, the plan is to get some product data and then serve it with the review data.

Having spoken to different people about this, there seems to be a lote of competing ways to achieve with this, which makes me think two things:

  1. NetSuite is really flexible!
  2. There's perhaps no one best practice for this, though I'm sure there will be some NetSuite engineers who'll tell me otherwise.

With that in mind, let's look at how I approached this problem. If you think you can do it better/differently, then let me know.

My approach to this is to use the items API to make a call on the frontend using the item IDs in the collection. This is a neat feature of the item API: rather than specify, say, a keyword, you can tell it to return specific IDs, separated by commas.

Once we have that request, we need to format the data. A golden rule of API requests is to have the APIs return only the data that you need. In NetSuite, we do this by creating field sets. The API can return a lot of data about the items and some of these can be expensive to generate — as it's not important to include some of these fields in our particular module, we can exclude them. We'll create this field set in a moment.

After we make the request and get the data back, the next part is to add it to the collection data. Remember: the collection is all the models we're sending to the views and then to the templates; it's the data of each review used to generate each list item. Seeing as the review data and the item data both come as JSON, it's simply a case of matching the item IDs together and copying from one to the other.

After that, it's just some other small changes to accommodate the new data and we're ready to go!

Create a Field Set

The field set will determine what data is returned from NetSuite when we make the API call.

In the NetSuite backend, go to Setup > SuiteCommerce Advanced > Set Up Website. Edit the record for your site.

In Field Sets add a new one configured this way:

  • Name — My Reviews
  • Field Set ID — myreviews
  • Record Type — Item
  • Fields Included in Field Set — internalid: Internal ID, displayname: Display Name, urlcomponent: URL Component, itemimages_detail: Item Images (Detail)

You can add additional field sets if you wish, but you'll need these ones to get my code to work.

Click Add and save the record. You may need to rebuild the search index, which you can do from the Actions dropdown.

Make the Call to the Items API

As I said, it is the router that first gets the collection data before sending it on its way. This is a prime place to intercept it and make modifications to it.

Above the listReviews method in MyReviews.Router.js, add in a new function:

  // Build a function that requests search information. It takes the collection (which is just review data at this point)
, itemDetails: function (reviews)
  {
    // Build up some useful variables
    var ids = []
    , locale = SC.ENVIRONMENT.currentLanguage.locale.split("_")
    , language = locale[0]
    , country = locale[1]
    , currency = SC.ENVIRONMENT.currentCurrency.code
    , company = SC.ENVIRONMENT.companyId
    , fieldset = 'myreviews'

    // Go through the object passed to it and build an array out of the item IDs
    for (var i = 0; i < reviews.models.length; i++)
    {
      ids.push(reviews.models[i].attributes.itemid)
    }

    ids = ids.toString();

    // Build the search URL and the search request itself
    var search = '/api/items?id=' + ids + '&fieldset=' + fieldset + '&language=' + language + '&country=' + country + '¤cy=' + currency + '&c=' + company
    , itemDetails = jQuery.ajax(search);

    // Perform the search when called
    return itemDetails
  }

Here's what's happening:

  1. Start by building up some variables. A valid search API URL may vary depending on your site configuration, but on my site I need to include a number of mandatory parameters: language, country, currency and the company (account) ID number. So I've created ways of pulling that information out of the SC global variable. Adjust as required.
  2. Next, I'm building up a comma-separated list of the item IDs I want to search for. Here, reviews refers to the parameter that is passed to this function, which is going to be the collection. Note how I iterate through returned model, pluck out the item ID, and then push it to an array. I need it as a string, so I convert it afterwards.
  3. Then we build the search URL used in the request. As I said, it includes a number of mandatory fields required for my site so if it fails for you, check to see what your site needs. The important part is that it includes id= and then the comma-separated list of IDs.
  4. Finally, the search is saved as a variable and then returned when the function is called.

After that, we need to modify the listReviews function so that it calls the new function and retrieves the product data. We'll also need to do some work to merge the two data sets together.

First, add jQuery as a dependency and then replace listReviews with the following:

, listReviews: function ()
  {
    var self = this;
    var collection = new CollectionView();
    var view = new ListView
    ({
      collection: collection
    , application: this.application
    });

    collection.fetch().done(function()
    {
      // Generate an array of item details using itemDetails and the collection
      var arr = self.itemDetails(collection);

      // Create a promise
      jQuery.when(arr).done(function(result)
      {
        // Loop through every item in the collection
        for (var i = 0; i < collection.models.length; i++)
        {
          // Loop through every item in search API request
          for (var j = 0; j < result.items.length; j++)
          {
            // Check to see if it matches
            if (collection.models[i].attributes.itemid == result.items[j].internalid)
            {
              // If it does, merge the search data into the collection data
              jQuery.extend(collection.models[i].attributes, result.items[j]);
            }
          }
        }
      })
      .then(function()
      {
        // Once we've done all that, render the view
        view.showContent();
      });
    });
  }

I added inline comments but let's go through what's happening:

  1. The first part has remained the same: we're fetching the collection so that we can get our review data.
  2. Afterwards, after the fetch is done, we use that collection in our new itemDetails(). Remember, it's going to call the search API and fetch data about each of the items.
  3. We then create a promise that resolves when this call is done.
  4. The resolution of this promise starts a loop, going through every item in the collection.
  5. Nested in this loop is another that goes through each item in the item API result.
  6. Within that loop we perform a check to see if the item ID in the model matches the item ID in the search API result.
  7. If it does, it takes the properties of that item from the search API result and merge them to its corresponding object in the model data (ie, extend the collection object).
  8. This process repeats until both loops are complete.
  9. Finally, we add in a secondary resolution for the promise (ie one that runs only when the first one is complete) that tells the view to show its content.

What we're doing is effectively intercepting the models before it makes it to the view and adding in additional data. The data is simply the data we've specified from the field set; so, in addition to the review data, we're also including each item's name, URL component and its imagery.

Next, we need to do something with this data: let's update our view and template.

Show the Data on the Frontend

So, we have the data ready but if you visit your site, you'll notice that it doesn't show it. At the moment, the view and template are set up to pull only review data from the collection. We need to change that.

Head to MyReviews.Details.View.js and replace the getContext function with the following:

, getContext: function()
  {
    // return a product image, it doesn't matter which one (so just get the first)
    var images = this.model.get('itemimages_detail')
    , firstImageUrl = images.media[Object.keys(images.media)[0]].urls["0"].url;

    return {
      reviewid: this.model.get('reviewid')
    , rating: this.model.get('rating')
    , text: this.model.get('text')
    , itemid: this.model.get('itemid')
    , created: this.model.get('created')
    , displayname: this.model.get('displayname')
    , urlcomponent: this.model.get('urlcomponent')
    , image: firstImageUrl
    }
  }
})

The most significant change here relates to the image details. When we poll the API for image data, it returns an object containing an object with multiple other nested objects (depending on the number of colors). Each of these nested objects contain a URL and a text alternative. As the data about what color the user reviewed is not saved, we can't know which color image to show. In reality, this doesn't really matter.

So, I've written some simple JavaScript that just pulls out the URL the first entry using the Object.keys() method. This isn't supported by IE8 so you'll have to rework it if you want your IE8 users to use it, such as with a loop that stops after the first property (although, perhaps you shouldn't care anyway).

So that exposes the data to the template, so all that's left is to get the template to render it. Replace the contents of myreviews_details.tpl with the following:

<h3>ID: {{reviewid}}</h3>
<p>Rating: {{rating}}</p>
<p>Text: {{text}}</p>
<p>Item: {{itemid}}</p>
<p>Created: {{created}}</p>
<p>Display Name: {{displayname}}</p>
<p>URL Component: {{urlcomponent}}</p>
<p>Img: <img src="{{resizeImage image large}}"></p>

Yeah, for now we're just returning the information without any proper styling — we'll do that later. Save the file and refresh your local site. When it loads, you should see something like this:

And now, finally, we can move on styling.

Styling and Tinkering

I think the most important thing to keep in mind is that you want this information to be kept clean and easily presentatable.

URL Component

However, before we get to that, we need to fix the link to the item. Just having the URL component is not enough as it's effectively on a separate application. We need, therefore, a fully qualified URL. There are numerous ways to do this, and perhaps you know of a better one or one that works for you.

In the getContext function for MyReviews.Details.View.js, remove the entry for urlcomponent and then replace it with:

, url: SC.ENVIRONMENT.siteSettings.touchpoints.home + '&fragment=' + this.model.get('urlcomponent')

This pulls out the base domain for the home page, and then puts a redirect URL on the end so it sends the user to the PDP for the item.

Mark Up the Template

From here, we can rebuild the template so that it's more structured, ready for styling. For example:

<section class="myreviews-detail-container">
    <h3 class="myreviews-detail-subtitle">{{displayname}}</h3>
    <div class="myreviews-detail-side">
        <a class="myreviews-detail-image" href="{{url}}"><img src="{{resizeImage image large}}"></a>
    </div>
    <div class="myreviews-detail-body">
        <p class="myreviews-detail-body-review">{{text}}</p>
        <p class="myreviews-detail-body-rating">{{rating}}</p>
        <p class="myreviews-detail-body-date">{{created}}</p>
    </div>
</section>

Next, create Sass > _myreviews-details.scss and in it put the following:

.myreviews-detail-container {
    @extend .row;
    margin: $sc-medium-margin 0;
}
.myreviews-detail-side {
    @extend .col-xs-3;
    padding-top: $sc-base-padding;
}
.myreviews-detail-body {
    @extend .col-xs-9;
    padding-top: $sc-base-padding;
}
.myreviews-detail-body-date {
    font-weight: $sc-font-weight-bold;
    font-size: $sc-button-small-font-size;
    color: $sc-color-theme;
}

As we added a new file, we'll need to restart your local server in order to see the effects. When you do, you should see something like this:

From here, you get the idea: keep it clean and make it look presentable.

Star Ratings

The final improvement we can make is use the global star ratings view to turn the rating number into glorious, golden stars (or whatever color your stars are).

To do this we need to turn the details view into a composite view and call the global view as a child view.

Switch back to MyReviews.Details.View.js and add in two dependencies: Backbone.CompositeView and GlobalViews.StarRating.View.

Underneath, add two new functions:

, initialize: function initialize() {
    BackboneCompositeView.add(this);
  }

, childViews: {
    'StarRating': function() {
      return new GlobalViewsStarRatingView({
        model: this.model
      , showRatingCount: false
      });
    }
  }

Then update the template to provide a container for the view to be loaded into. Replace the rating paragraph with the following:

<p class="myreviews-detail-body-rating" data-view="StarRating"></p>

Then just add a little bit of CSS to stop the rating clashing with the date, eg:

.myreviews-detail-body-rating {
    display: inline-block;
}

Reload your page and you'll see something like this:

And that's it, really.

The only things left to do would be to add something so that it appears in the menu — after all, you want your customers to be able to find it, right?

You could also use this opportunity to add in a switch that reads data from the configuration that disables/enables the functionality, so that your site administration team can turn it on or off as they wish.

I'll include both of these modifications in the zip file at the end of article (remember to go to the site configuration tool and enable the )

Summary

For this tutorial we started at the same base level: with a basic service that pulls in existing product reviews that the logged-in user has created. But from there we wanted expanded functionality: we wanted to enhance the experience by including relevant product information.

Due to the flexibility of NetSuite, there are a number of ways to accomplish this. I went with one that makes a call to search API using the IDs of the products, returns specific details (as defined by a field set) and then merges these details into the models of the reviews.

From there we moved onto presenting the information and tidying up a couple of issues that arose out of this method. Ultimately it left us with a neat little bit of functionality.

For reference, check out a zip of my files here: MyReviews@1.0.0-final.zip.