<div class="utility-10 p-2" id="js-utility-10">
    <div class="field has-addons">
        <div class="control"><input class="input" id="js-utility-10-form" type="text" placeholder="例:タオル" /></div>
        <div class="control"><button class="button is-primary" id="js-utility-10-form-btn">検索</button></div>
    </div>
    <div id="js-utility-10-items"></div>
</div><!-- 商品詳細モーダル-->
<div class="modal" id="js-utility-10-modal">
    <div class="modal-background js-utility-10-modal-close"></div>
    <div class="modal-card">
        <header class="modal-card-head">
            <p class="modal-card-title" id="js-utility-10-modal-title"></p><button class="js-utility-10-modal-close delete" aria-label="close"></button>
        </header>
        <section class="modal-card-body content mb-0">
            <figure class="image is-2by1"><img id="js-utility-10-modal-img" /></figure>
            <p id="js-utility-10-modal-detail"></p>
            <p><span class="is-size-3" id="js-utility-10-modal-price"></span></p>
        </section>
        <footer class="modal-card-foot">
            <div class="field has-addons">
                <div class="control"><input class="input" id="js-utility-10-modal-input" type="number" value="0" step="1" min="0" /></div>
                <div class="control"><button class="button is-primary" id="js-utility-10-modal-btn">カートに入れる</button></div>
            </div>
        </footer>
    </div>
</div><!-- 商品カートボタン--><button class="utility-10__cart-btn button is-medium" id="js-utility-10-cart-btn"><span class="icon"><i class="fas fa-shopping-cart fa-lg"></i></span></button><!-- 商品カートモーダル-->
<div class="modal" id="js-utility-10-cart-modal">
    <div class="modal-background js-utility-10-cart-modal-close"></div>
    <div class="modal-card">
        <header class="modal-card-head">
            <p class="modal-card-title">商品カート</p><button class="js-utility-10-cart-modal-close delete" aria-label="close"></button>
        </header>
        <section class="modal-card-body content mb-0" id="js-utility-10-cart-modal-body"></section>
        <footer class="modal-card-foot">合計料金<span class="pl-4" id="js-utility-10-cart-modal-total"></span></footer>
    </div>
</div>
#js-utility-10.utility-10.p-2
  .field.has-addons
    .control
      input#js-utility-10-form.input(type='text', placeholder='例:タオル')
    .control
      button#js-utility-10-form-btn.button.is-primary 検索
  #js-utility-10-items

// 商品詳細モーダル
#js-utility-10-modal.modal
  .modal-background.js-utility-10-modal-close
  .modal-card
    header.modal-card-head
      p#js-utility-10-modal-title.modal-card-title
      button.js-utility-10-modal-close.delete(aria-label='close')
    section.modal-card-body.content.mb-0
      figure.image.is-2by1
        img#js-utility-10-modal-img
      p#js-utility-10-modal-detail
      p
        span#js-utility-10-modal-price.is-size-3
        | 円
    footer.modal-card-foot
      .field.has-addons
        .control
          input#js-utility-10-modal-input.input(type='number', value='0', step='1', min='0')
        .control
          button#js-utility-10-modal-btn.button.is-primary カートに入れる

// 商品カートボタン
button#js-utility-10-cart-btn.utility-10__cart-btn.button.is-medium
  span.icon
    i.fas.fa-shopping-cart.fa-lg

// 商品カートモーダル
#js-utility-10-cart-modal.modal
  .modal-background.js-utility-10-cart-modal-close
  .modal-card
    header.modal-card-head
        p.modal-card-title 商品カート
        button.js-utility-10-cart-modal-close.delete(aria-label='close')
    section#js-utility-10-cart-modal-body.modal-card-body.content.mb-0
    footer.modal-card-foot
      | 合計料金
      span#js-utility-10-cart-modal-total.pl-4
      | 円
/* No context defined. */
  • Content:
    $BLOCK_NAME: '.utility-10';
    
    // 変数
    
    #{ $BLOCK_NAME } {
      &__cart-btn {
        position: fixed;
        bottom: 8px;
        right: 8px;
        z-index: 10;
      }
    }
    
  • URL: /components/raw/utility10/utility10.scss
  • Filesystem Path: src/components/utilities/utility10/utility10.scss
  • Size: 164 Bytes
  • Content:
    'use strict';
    
    export const utility10 = () => {
      const utility = new Utility10();
      utility.init();
    }
    
    type Product = {
      uid: string,
      name: string,
      price: number,
      tags: string[],
      detail: string,
      imgUrl: string,
      thumbUrl: string,
    }
    
    type CartItem = {
      uid: string,
      name: string,
      number: number,
      price: number,
    }
    
    class Utility10 {
      el: HTMLElement;
      formEl: HTMLInputElement;
      formBtnEl: HTMLElement;
      itemsEl: HTMLElement;
      modalEl: HTMLElement;
      modalImgEl: HTMLElement;
      modalTitleEl: HTMLElement;
      modalDetailEl: HTMLElement;
      modalPriceEl: HTMLElement;
      modalInputEl: HTMLInputElement;
      modalBtnEl: HTMLElement;
      modalCloseEls: HTMLCollection;
      cartBtnEl: HTMLElement;
      cartModalEl: HTMLElement;
      cartModalBodyEl: HTMLElement;
      cartModalTotalEl: HTMLElement;
      cartModalCloseEls: HTMLCollection;
      searchUrl: string;
      cartItems: CartItem[];
      constructor() {
        this.el = document.getElementById('js-utility-10');
        this.formEl = <HTMLInputElement>document.getElementById('js-utility-10-form');
        this.formBtnEl = document.getElementById('js-utility-10-form-btn');
        this.itemsEl = document.getElementById('js-utility-10-items');
        this.modalEl = document.getElementById('js-utility-10-modal');
        this.modalImgEl = document.getElementById('js-utility-10-modal-img');
        this.modalTitleEl = document.getElementById('js-utility-10-modal-title');
        this.modalDetailEl = document.getElementById('js-utility-10-modal-detail');
        this.modalPriceEl = document.getElementById('js-utility-10-modal-price');
        this.modalInputEl = <HTMLInputElement>document.getElementById('js-utility-10-modal-input');
        this.modalBtnEl = document.getElementById('js-utility-10-modal-btn');
        this.modalCloseEls = document.getElementsByClassName('js-utility-10-modal-close');
        this.cartBtnEl = document.getElementById('js-utility-10-cart-btn');
        this.cartModalEl = document.getElementById('js-utility-10-cart-modal');
        this.cartModalBodyEl = document.getElementById('js-utility-10-cart-modal-body');
        this.cartModalTotalEl = document.getElementById('js-utility-10-cart-modal-total');
        this.cartModalCloseEls = document.getElementsByClassName('js-utility-10-cart-modal-close');
        if (location.origin === 'https://zakzakst.github.io') {
          // GitHubの場合
          this.searchUrl = '/parts/data/utility10.json';
        } else {
          // ローカル環境の場合
          this.searchUrl = '/data/utility10.json';
        }
        this.cartItems = [];
      }
    
      /**
       * 初期化
       */
      init(): void {
        if (!this.el) return;
        this.onClickFormBtn();
        this.onClickItems();
        this.onClickModalBtn();
        this.onClickModalClose();
        this.onClickCartBtn();
        this.onClickCartModalClose();
      }
    
      /**
       * 検索結果データの取得
       * @param keyword 検索キーワード
       * @returns 検索にヒットした商品データ
       */
      getSearchResult(keyword: string): Promise<Product[] | null> {
        return new Promise(resolve => {
          fetch(this.searchUrl)
            .then((res) => {
              return res.json();
            })
            .then((data) => {
              // 検索キーワードに一致するデータを取得
              const filteredData = data.filter((item: Product) => {
                // 型番が一致するかの判定(完全一致)
                if (item.uid === keyword) return true;
                // 商品名が一致するかの判定(部分一致)
                if (item.name.indexOf(keyword) !== -1) return true;
                // タグが一致するかの判定(部分一致)
                if (item.tags.length) {
                  const filteredTags = item.tags.filter(tag => {
                    return tag.indexOf(keyword) !== -1;
                  });
                  if (filteredTags.length) return true;
                }
                // 検索に一致しない場合
                return false;
              });
              resolve(filteredData);
            })
            .catch(error => {
              console.log(error);
              resolve(null);
            });
        });
      }
    
      /**
       * 検索結果の表示
       * @param data 表示する商品データ
       */
      showSearchResult(data: Product[]): void {
        // 検索結果の初期化
        this.itemsEl.innerHTML = '';
        if (data.length) {
          // 検索結果がある場合
          data.forEach(item => {
            const imgUrl = item.thumbUrl || 'https://bulma.io/images/placeholders/128x128.png';
            const markup = `
              <a class="box utility-10__item" data-uid="${item.uid}">
                <article class="media">
                  <div class="media-left">
                    <figure class="image is-64x64">
                      <img src="${imgUrl}" alt="${item.name} サムネイル画像">
                    </figure>
                  </div>
                  <div class="media-content">
                    <p class="is-size-4">${item.name}</p>
                    <p>${item.price}円</p>
                  </div>
                </article>
              </a>
            `;
            this.itemsEl.insertAdjacentHTML('beforeend', markup);
          });
        } else {
          // 検索結果がない場合
          this.itemsEl.textContent = 'キーワードに対応する商品が見つかりませんでした。';
        }
      }
    
      /**
       * 商品詳細の表示
       * @param uid 商品UID
       */
      async showItemDetail(uid: string): Promise<void> {
        // 商品データを反映
        const data = await this.getItemDetail(uid);
        const imgUrl = data.imgUrl || 'https://bulma.io/images/placeholders/640x320.png';
        this.modalEl.dataset.uid = data.uid;
        this.modalEl.dataset.name = data.name;
        this.modalEl.dataset.price = String(data.price);
        this.modalImgEl.setAttribute('src', imgUrl);
        this.modalTitleEl.textContent = data.name;
        this.modalDetailEl.textContent = data.detail || '詳細情報は登録されていません。';
        this.modalPriceEl.textContent = String(data.price);
        // 既にカートに入っている商品の場合、商品数を反映
        const cartTarget = this.cartItems.find(item => {
          return item.uid === data.uid;
        });
        const targetCartNumber = cartTarget ? cartTarget.number : 0;
        this.modalInputEl.value = String(targetCartNumber);
        // モーダルを表示
        this.modalEl.classList.add('is-active');
      }
    
      /**
       * 商品詳細の非表示
       */
      hideItemDetail(): void {
        // モーダルを非表示
        this.modalEl.classList.remove('is-active');
        // 商品データを初期化
        this.modalEl.dataset.uid = null;
        this.modalEl.dataset.name = null;
        this.modalEl.dataset.price = null;
        this.modalImgEl.removeAttribute('src');
        this.modalTitleEl.textContent = '';
        this.modalDetailEl.textContent = '';
        this.modalPriceEl.textContent = '';
        this.modalInputEl.value = String(0);
      }
    
      /**
       * 商品詳細データの取得
       * @param uid 商品UID
       * @returns 対象商品の詳細データ
       */
      getItemDetail(uid: string): Promise<Product | null> {
        return new Promise(resolve => {
          fetch(this.searchUrl)
            .then((res) => {
              return res.json();
            })
            .then((data) => {
              // 検索キーワードに一致するデータを取得
              const targetData = data.find((item: Product) => {
                return item.uid === uid;
              });
              resolve(targetData);
            })
            .catch(error => {
              console.log(error);
              resolve(null);
            });
        });
      }
    
      /**
       * カートの表示
       */
      showCart(): void {
        if (this.cartItems.length) {
          // カートに商品が登録されている場合
          let tableBodyMarkup = '';
          let total = 0;
          this.cartItems.forEach(item => {
            tableBodyMarkup += `
              <tr>
                <th>${item.name}</th>
                <td class="has-text-right">× ${item.number}</td>
              </tr>
            `;
            total += item.price * item.number;
          });
          const markup = `
            <table class="table is-striped">
              <tbody>
                ${tableBodyMarkup}
              </tbody>
            </table>
          `;
          this.cartModalBodyEl.insertAdjacentHTML('beforeend', markup);
          this.cartModalTotalEl.innerHTML = String(total);
        } else {
          // カートに商品が登録されていない場合
          this.cartModalBodyEl.innerHTML = 'カートに登録した商品はありません。';
          this.cartModalTotalEl.innerHTML = '---';
        }
        this.cartModalEl.classList.add('is-active');
      }
    
      /**
       * カートの非表示
       */
      hideCart(): void {
        this.cartModalEl.classList.remove('is-active');
        this.cartModalBodyEl.innerHTML = '';
        this.cartModalTotalEl.innerHTML = '';
      }
    
      /**
       * フォームボタンクリック時のイベント設定
       */
      onClickFormBtn(): void {
        this.formBtnEl.addEventListener('click', async () => {
          const formVal = this.formEl.value;
          const searchResult: Product[] = await this.getSearchResult(formVal);
          this.showSearchResult(searchResult);
        });
      }
    
      /**
       * 商品クリック時のイベント設定
       */
      onClickItems(): void {
        this.itemsEl.addEventListener('click', e => {
          e.preventDefault();
          const target = <HTMLElement>e.target;
          const targetItem = <HTMLElement>target.closest('.utility-10__item');
          // 商品以外の要素がクリックされた場合、処理を終了
          if (!targetItem) return;
          // 商品UIDを取得
          const uid = targetItem.dataset.uid;
          // 商品詳細を表示
          this.showItemDetail(uid);
        });
      }
    
      /**
       * カートに入れるボタンクリック時のイベント設定
       */
      onClickModalBtn(): void {
        this.modalBtnEl.addEventListener('click', () => {
          const number = Number(this.modalInputEl.value);
          const uid = this.modalEl.dataset.uid;
          const name = this.modalEl.dataset.name;
          const price = Number(this.modalEl.dataset.price);
          if (number > 0) {
            // 商品数が0より大きい場合、対応する商品をカートに追加
            this.cartItems.push({
              uid,
              name,
              number,
              price,
            });
          } else {
            // 商品数が0以下の場合、対応する商品をカートから削除
            const targetCartItem = this.cartItems.find(item => {
              return item.uid === uid;
            });
            this.cartItems.splice(this.cartItems.indexOf(targetCartItem), 1);
          }
          this.hideItemDetail();
        });
      }
    
      /**
       * モーダルを閉じる要素クリック時のイベント設定
       */
      onClickModalClose(): void {
        [...this.modalCloseEls].forEach(el => {
          el.addEventListener('click', e => {
            e.preventDefault();
            this.hideItemDetail();
          });
        });
      }
    
      /**
       * カートを表示ボタンクリック時のイベント設定
       */
      onClickCartBtn(): void {
        this.cartBtnEl.addEventListener('click', e => {
          e.preventDefault();
          this.showCart();
        });
      }
    
      /**
       * カートを閉じる要素クリック時のイベント設定
       */
      onClickCartModalClose(): void {
        [...this.cartModalCloseEls].forEach(el => {
          el.addEventListener('click', e => {
            e.preventDefault();
            this.hideCart();
          });
        });
      }
    }
    
  • URL: /components/raw/utility10/utility10.ts
  • Filesystem Path: src/components/utilities/utility10/utility10.ts
  • Size: 11.6 KB

検索フォームに入力されたキーワードに対応するデータを JSON データ( https://zakzakst.github.io/parts/data/utility10.json )から取得して
ショッピングカートを表示するスクリプト。

補足

スクリプト内では都度「utility10.json」を読み込んでいる。こちらはデータ量が少ないため、「一度読み込んで変数に入れて置き、そのデータを利用する」ほうが効率がいい。
しかし現実のショッピングカートは商品データ全体を読み込むとデータ量が大きくなりすぎる。
今回はデータ量が多い場合を想定して、都度データを読み込む方法で実装した。

※データの読み込み関数は全て以下の流れで行っているが、現実ではデータの絞り込みはそれぞれ API にパラメータを渡してサーバー側で行う想定。

  1. utility10.json を読み込む
  2. その時に必要なデータを絞り込んで resolve に渡す