Shopify商品閲覧履歴の実装方法

Shopifyのテーマ開発で、基本必須化してきている商品閲覧履歴のスライダー。
ほぼ１００％使うけど…毎回前書いたコードをコピペしながら、「ここどうだったかな？」となって面倒なので、メモ的な意味で記事にしました！
基本コピペで使えるようにはなっていますが、スタイルシートの変更があるのでノーコードでの実装は難しいかと思われます。
なので、エンジニア向けの記事になります。


## 実装方法
元のネタはこちらです。
<a href="https://medium.com/gobeyond-ai/showing-recently-viewed-products-in-shopify-eff00309642c">https://medium.com/gobeyond-ai/showing-recently-viewed-products-in-shopify-eff00309642c</a>
<a href="https://github.com/carolineschnapp/recently-viewed">https://github.com/carolineschnapp/recently-viewed</a>
こちらを元に、少しずつ必要なものを継ぎ足し、継ぎ足し…秘伝のコードになっております。

### assetsフォルダにjquery.products.jsを作成し、以下のコードを入力
```
/**
 * Cookie plugin
 *
 * Copyright (c) 2006 Klaus Hartl (stilbuero.de)
 * Dual licensed under the MIT and GPL licenses:
 * http://www.opensource.org/licenses/mit-license.php
 * http://www.gnu.org/licenses/gpl.html
 *
 */

jQuery.cookie=function(b,j,m){if(typeof j!="undefined"){m=m||{};if(j===null){j="";m.expires=-1}var e="";if(m.expires&&(typeof m.expires=="number"||m.expires.toUTCString)){var f;if(typeof m.expires=="number"){f=new Date();f.setTime(f.getTime()+(m.expires*24*60*60*1000))}else{f=m.expires}e="; expires="+f.toUTCString()}var l=m.path?"; path="+(m.path):"";var g=m.domain?"; domain="+(m.domain):"";var a=m.secure?"; secure":"";document.cookie=[b,"=",encodeURIComponent(j),e,l,g,a].join("")}else{var d=null;if(document.cookie&&document.cookie!=""){var k=document.cookie.split(";");for(var h=0;h<k.length;h++){var c=jQuery.trim(k[h]);if(c.substring(0,b.length+1)==(b+"=")){d=decodeURIComponent(c.substring(b.length+1));break}}}return d}};

/**
 * Module to show Recently Viewed Products
 *
 * Copyright (c) 2014 Caroline Schnapp (11heavens.com)
 * Dual licensed under the MIT and GPL licenses:
 * http://www.opensource.org/licenses/mit-license.php
 * http://www.gnu.org/licenses/gpl.html
 *
 */

 Shopify.Products = (function() {

   var config = {
     howManyToShow: 3,
     howManyToStoreInMemory: 10,
     wrapperId: 'recently-viewed-products',
     templateId: 'recently-viewed-product-template',
     onComplete: null
   };

   var productHandleQueue = [];
   var wrapper = null;
   var template = null;
   var shown = 0;

   var cookie = {
     configuration: {
       expires: 90,
       path: '/',
       domain: window.location.hostname
     },
     name: 'shopify_recently_viewed',
     write: function(recentlyViewed) {
       jQuery.cookie(this.name, recentlyViewed.join(' '), this.configuration);
     },
     read: function() {
       var recentlyViewed = [];
       var cookieValue = jQuery.cookie(this.name);
       if (cookieValue !== null) {
         recentlyViewed = cookieValue.split(' ');
       }
       return recentlyViewed;
     },
     destroy: function() {
       jQuery.cookie(this.name, null, this.configuration);
     },
     remove: function(productHandle) {
       var recentlyViewed = this.read();
       var position = jQuery.inArray(productHandle, recentlyViewed);
       if (position !== -1) {
         recentlyViewed.splice(position, 1);
         this.write(recentlyViewed);
       }
     }
   };

   var finalize = function() {
     wrapper.show();
     // If we have a callback.
     if (config.onComplete) {
       try { config.onComplete() } catch (error) { }
     }
   };

   var moveAlong = function() {
     if (productHandleQueue.length && shown < config.howManyToShow) {
       jQuery.ajax({
         dataType: 'json',
         url: '/products/' + productHandleQueue[0] + '.js',
         cache: false,
         success: function(product) {
           template.tmpl(product).appendTo(wrapper);
           productHandleQueue.shift();
           shown++;
           moveAlong();
         },
         error: function() {
           cookie.remove(productHandleQueue[0]);
           productHandleQueue.shift();
           moveAlong();
         }
       });
     }
     else {
       finalize();
     }

   };

   return {

     resizeImage: function(src, size) {
       if (size == null) {
         return src;
       }

       if (size == 'master') {
         return src.replace(/http(s)?:/, "");
       }

       var match  = src.match(/\.(jpg|jpeg|gif|png|bmp|bitmap|tiff|tif)(\?v=\d+)?/i);

       if (match != null) {
         var prefix = src.split(match[0]);
         var suffix = match[0];

         return (prefix[0] + "_" + size + suffix).replace(/http(s)?:/, "")
       } else {
         return null;
       }
     },

     showRecentlyViewed: function(params) {

       var params = params || {};

       // Update defaults.
       jQuery.extend(config, params);

       // Read cookie.
       productHandleQueue = cookie.read();

       // Template and element where to insert.
       template = jQuery('#' + config.templateId);
       wrapper = jQuery('#' + config.wrapperId);

       // How many products to show.
       config.howManyToShow = Math.min(productHandleQueue.length, config.howManyToShow);

       // If we have any to show.
       if (config.howManyToShow && template.length && wrapper.length) {
         // Getting each product with an Ajax call and rendering it on the page.
         moveAlong();
       }

     },

     getConfig: function() {
       return config;
     },

     clearList: function() {
       cookie.destroy();
     },

     recordRecentlyViewed: function(params) {

       var params = params || {};

       // Update defaults.
       jQuery.extend(config, params);

       // Read cookie.
       var recentlyViewed = cookie.read();

       // If we are on a product page.
       if (window.location.pathname.indexOf('/products/') !== -1) {

         // What is the product handle on this page.
         var productHandle = window.location.pathname.match(/\/products\/([a-z0-9\-]+)/)[1];
         // In what position is that product in memory.
         var position = jQuery.inArray(productHandle, recentlyViewed);
         // If not in memory.
         if (position === -1) {
           // Add product at the start of the list.
           recentlyViewed.unshift(productHandle);
           // Only keep what we need.
           recentlyViewed = recentlyViewed.splice(0, config.howManyToStoreInMemory);
         }
         else {
           // Remove the product and place it at start of list.
           recentlyViewed.splice(position, 1);
           recentlyViewed.unshift(productHandle);
         }

         // Update cookie.
         cookie.write(recentlyViewed);

       }

     }

   };

 })();
```
 
### theme.liquidに以下のコードを追加し、ファイルを読み込む
jqueryとjqueryのtmplを使用するために読み込みが必要になってくるコードです。
最後の１行 {{ "jquery.products.js" | asset_url | script_tag }} を {{ content_for_header }} より上で読み込むとうまくいかない時があるのでご注意！

```
    {%- comment -%} 最近チェックした商品 読み込み {%- endcomment -%}
    <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script>
    {{ '//ajax.aspnetcdn.com/ajax/jquery.templates/beta1/jquery.tmpl.min.js' | script_tag }}
    {{ "jquery.products.js" | asset_url | script_tag }}
```
### main-product.liquidに以下のコードを書き込む

```
{%- comment -%} 最近チェックした商品 書き込み {%- endcomment -%}
<script type="text/javascript">
Shopify.Products.recordRecentlyViewed();
</script>
```
商品ページが読み込まれた時に、商品データをクッキーに入力するコードになります。
GitHubのコードにはtheme.liquidに下記のコードを書き込むように書かれていますが、個人的にはtheme.liquidをごちゃつかせたくないので商品詳細ページに書き込むようにしています。

```
{% if template contains 'product' %}

<script>
Shopify.Products.recordRecentlyViewed();
</script>

{% endif %}
```

### sectionsフォルダにrecently-prouct-slider.liquidを作成するし以下のコードを書き込む
```
{% comment -%}
------------------------------------
最近チェックした商品
------------------------------------
{%- endcomment -%}

<!-- product-recently-viewed -->
<div class="product-recently-viewed collection-slider">
  <div class="container">
    <h2 class="product-recently-viewed__title section__title">最近チェックした商品</h2>

    <ul id="js-recentlySliderBody" class="js-recentlySlider collection-slider__list product-recently-viewed__list">

      {% unless customer %}
        {%- comment -%} 未ログイン時 {%- endcomment -%}
        <p id="wishlist-empty-text"
          style="padding: 60px 0; text-align: center;min-height: 320px; display: flex; align-items: center;">
          まだ最近チェックした商品がありません
        </p>
      {% endunless %}

    </ul>
  </div>
</div>

{%- comment -%} ログイン時のみ発動 {%- endcomment -%}
{% if customer %}
  {%- comment -%} 商品カード {%- endcomment -%}
  {% raw %}
    <script id="recently-viewed-product-template"  type="text/x-jquery-tmpl">
      <li class="featured-collection-slider__item collection-slider__item">
        <div class="product-card">
          <a href="${url}" class="product-card__link">
            <div class="product-card__head">
              <img src="${Shopify.Products.resizeImage(featured_image, "master")}" alt="${title}">
            </div>
            <div class="product-card__body">
              <h3 class="product-card__title">${title}</h3>
              <p class="product-card__price">
                ${(price / 100).toLocaleString()}
                <span class="tax">（税込）</span>
              </p>
            </div>
          </a>
        </div>
      </li>
    </script>
  {% endraw %}


  {%- comment -%} コントロールjs {%- endcomment -%}
  <script>
    window.addEventListener( 'load', function(){
      const recentlyViewController = (function(){

        // クッキーからデータを取得する
        let recentlyItems = jQuery.cookie('shopify_recently_viewed');

        // 閲覧履歴がある時 クッキーにデータがある時
        if(recentlyItems != null ) {
          Shopify.Products.showRecentlyViewed({
            howManyToShow: 8,// 表示する商品数を変更できます。
            wrapperId: 'js-recentlySliderBody',
            onComplete: function() {
              // データが読み込まれた後発火させたい時
              $('#js-recentlySliderBody').slick({
                autoplay: false,
                dots: false,
                arrows: true,
                slidesToShow: 4,
                slidesToScroll: 1,
                infinite: false,
                prevArrow: '<button class="slick-prev"><span class="slick-arrow__inner"></span></button>',
                nextArrow: '<button class="slick-next"><span class="slick-arrow__inner"></span></button>',
                responsive: [
                  {
                    breakpoint: 480,
                    settings: {
                      slidesToShow: 2,
                    },
                  },
                ],
              });

              // 非表示にしているアイテムを表示させる
              let lists = Array.from(document.querySelectorAll('li.recently-slider__item'));
              lists.forEach((item) => {
                item.style.display = "block";
              });
            }
          });
        } else {
          // 閲覧履歴がない時の処理 クッキーにデータがない時
          document.getElementById('js-recentlySliderBody').insertAdjacentHTML("afterbegin",`<p id="wishlist-empty-text" style="padding: 60px 0; text-align: center;min-height: 320px; display: flex; align-items: center;">まだ最近チェックした商品がありません</p>`);
        }

      })();
    }, false);
  </script>
{% endif %}

{% schema %}
{
  "name": "Product Recently Viewed",
  "settings": [
  ],
  "presets": [
    {
      "name": "Product Recently Viewed"
    }
  ]
}
{% endschema %}
```
上記のファイルを作成するとOS.2.0であれば、任意の場所に読み込まれるようになりますが、2.0以前のテーマでTOPページに呼び出したい時は任意の場所に{% section 'recently-prouct-slider.liquid' %}で呼び出してください。
　
## まとめ
コードばかりで解説が少なくなりましたが、それはまた別の機会に記事で紹介したいと思います。
一つ注意点があり、商品のハンドル名に日本語が含まれる場合は、クッキーへの書き込みがうまくいかないので注意が必要になります！
（ハンドルとは…SHOP_URL/products/ハンドル です！）

ちょっとだけ告知を
ARCHETYPでは、Shopifyのテーマ開発やアプリ開発をやりたいエンジニアを募集しております！
気になった方はお気軽にエントリーをお待ちしております！
<a href="https://www.archetyp.jp/recruit/">https://www.archetyp.jp/recruit/</a>