はじめに このカスタマイズ方法はDawnベースで解説しております。掲載のJavaScriptのコードはChatGPTで作っており、Dawnにて動作確認済みですが他テーマでは検証しておりません。 Shopify2.0未対応のテーマにも実装可能ですが、構造が異なりますのでご注意ください。 また、バリエーションに「サイズ」と「カラー」など2バリエーションまでが正しく登録されている商品が対象ですので、3バリエーションの場合は別途分岐が必要になります。 また、Shopifyで登録できるバリエーション数は100という上限があり、それを超える商品はカラー別で商品ページを設けているショップもあります。それらをまとめる一括購入フォームの作り方もございますが、本件のカスタマイズ方法では対応しておりません。 スニペットファイルを作成 「新しいスニペットを追加する」より「bulk-order-form-block.liquid」を作成し以下のコードを順番にコピペしてください。 バリエーションの配列を定義 {%- liquid assign sizes = product.options_with_values[0].values assign colors = product.options_with_values[1].values -%} 「sizes」と「colors」で商品の各バリエーションの値を{% assign %}で定義しています。 例えばサイズバリエーションがS、M、Lだった場合は「sizes」の値は「"S","M","L"」という配列になります。 これをベースにテーブルの行と列が出力されます。 数量セレクターを定義 {%- capture quantity_input -%} <label class="visually-hidden" for="Quantity-variant_id"> {{ 'products.product.quantity.label' | t }} </label> <quantity-input class="quantity"> <button class="quantity__button no-js-hidden" name="minus" type="button"> <span class="visually-hidden"> {{- 'products.product.quantity.decrease' | t: product: product.title | escape -}} </span> {% render 'icon-minus' %} </button> <input class="quantity__input" type="number" name="variant_id" id="Quantity-variant_id" min="0" max="inventory_quantity" value="" placeholder="0" /> <button class="quantity__button no-js-hidden" name="plus" type="button"> <span class="visually-hidden"> {{- 'products.product.quantity.increase' | t: product: product.title | escape -}} </span> {% render 'icon-plus' %} </button> </quantity-input> {%- endcapture -%} Dawnの数量セレクターを一括購入用にカスタマイズしたものを{% capture %}で「quantity_input」として定義しています。 「variant_id」と「inventory_quantity」は置換用の文字列で、出力の際に置換されます。 {% form %}〜{% endform %}を出力 {% form 'product', product, class: 'bulk-order-form' %} <table class="bulk-order-form-table"> {%- if product.variants.size > 1 -%} <thead> <tr> {% for size in sizes %} {%- if forloop.first and colors != blank -%} <th></th> {%- endif -%} <th> {{ size }} </th> {%- endfor -%} </tr> </thead> {%- endif -%} <tbody> {%- if colors != blank -%} {% for color in colors %} <tr> {%- for size in sizes -%} {%- liquid assign variant_map = product.variants | map: 'title' assign variant_title = size | append: ' / ' | append: color -%} {%- if forloop.first -%} <th> {%- if block.settings.show_thumbnail -%} <figure class="color-name"> {%- liquid for variant in product.variants if variant.option1 == size and variant.option2 == color and variant.image != blank assign variant_image_1x = variant.image.src | image_url: width: 30, height: 30 assign variant_image_2x = variant.image.src | image_url: width: 60, height: 60 echo '<img src="variant_image_1x" srcset="variant_image_2x 2x" width="30" height="30" alt="color">' | replace: 'variant_image_1x', variant_image_1x | replace: 'variant_image_2x', variant_image_2x | replace: 'color', color endif endfor -%} <figcaption>{{ color }}</figcaption> </figure> {%- else -%} <span>{{ color }}</span> {%- endif -%} </th> {%- endif -%} {%- if variant_map contains variant_title -%} {%- for variant in product.variants -%} {%- if variant.title == variant_title -%} {%- liquid assign inventory_management = variant.inventory_management assign inventory_quantity = variant.inventory_quantity assign price = variant.price | money capture td_class if inventory_quantity != 0 or inventory_management != 'shopify' echo 'item' else echo 'item no-stock' endif endcapture capture max_quantity if inventory_management != 'shopify' echo 9999 else echo inventory_quantity endif endcapture -%} <td class="{{ td_class }}"> {{ quantity_input | replace: 'variant_id', variant.id | replace: 'inventory_quantity', max_quantity }} <div class="bulk-order-form-item"> {{ price }} {% if inventory_management %} {%- if inventory_quantity == 0 -%} {{ '/ 入荷待ち' }} {%- else -%} {{ inventory_quantity | prepend: '/ 在庫:' }} {%- endif -%} {% endif %} </div> </td> {%- endif -%} {%- endfor -%} {%- else -%} <td class="is-none"></td> {%- endif -%} {%- endfor -%} </tr> {%- endfor -%} {%- else -%} <tr> {% for size in sizes %} {%- for variant in product.variants -%} {%- if variant.option1 == size -%} {%- liquid assign inventory_management = variant.inventory_management assign inventory_quantity = variant.inventory_quantity assign price = variant.price | money capture td_class if inventory_quantity != 0 or inventory_management != 'shopify' echo 'item' else echo 'item no-stock' endif endcapture capture max_quantity if inventory_management != 'shopify' echo 9999 else echo inventory_quantity endif endcapture -%} <td class="item"> {{ quantity_input | replace: 'variant_id', variant.id }} <div class="bulk-order-form-item"> {{ price }} {% if inventory_management %} {%- if inventory_quantity == 0 -%} {{ '/ 入荷待ち' }} {%- else -%} {{ inventory_quantity | prepend: '/ 在庫:' }} {%- endif -%} {% endif %} </div> </td> {%- endif -%} {%- endfor -%} {%- endfor -%} </tr> {%- endif -%} </tbody> </table> <div class="bulk-order-form-action"> <button type="submit" name="add" class="bulk-order-form-button product-form__submit" disabled> <span>カートに追加</span> </button> </div> <div class="bulk-order-form-error product-form__error-message-wrapper" role="alert" hidden> <span class="product-form__error-message"> {{- '入力した数値が在庫数を超えています。' -}} </span> </div> {% endform %} 分岐が複雑なので、順を追って説明します。 <thead>内は「sizes」の値を{% for %}でループさせることで、サイズの値を出力しています。最初のループで空の<th>タグが出力されます。お好みで「カラー\サイズ」などテキストを入れても良いかと思います。また、バリエーションが登録されていない場合は値が出力されず「Default Title」と出力されてしまうので、{% if %}分岐させ出力しないようにしています。 <tbody>内は「colors」の値を{% for %}ループでカラー数分の<tr>タグを出力します。カラーバリエーションがない場合の出力も考慮し、{% if %}でシングルの場合と分岐しています。その際はカラー名の値も存在しませんので、<th>タグは出力されません。ただ、見た目が悪くなるので、カラーバリエーションがなくてもサイズとカラーの2つのバリエーションの登録をおすすめします。 ループの最初でバリエーションタイトルの配列を「variant_map」として定義します。続いてループ内で「size / color」とバリエーションタイトルのフォーマットに合わせた文字列を「variant_title」として定義します。これは以下のようにバリエーションのないセルがあった場合の歯抜け対策に必要となります。 そして<tbody>の<tr>内で再び「sizes」をループさせ、<thead>内の<th>タグとと同数の<td>タグを出力させます。こちらも最初のループでカラー名を出力させる<th>タグを出力しています。バリエーション画像が登録されている場合は、サムネイルも出力されます。(ブロック設定で出力は任意にしています) {%- if variant_map contains variant_title -%}で分岐させることでバリエーションの有無を判断し、バリエーションの無いセルは空の<td>タグが出力されます。 さらに{%- for variant in product.variants -%}でループさせて「variant_title」と一致した場合のみ出力させています。 この中は構造が複雑なので、簡単に説明します。 「td_class」では「在庫が0以上」の場合か「在庫を追跡しない」場合以外は<td>タグに「no-stock」のクラスを出力します。 「max_quantity」では「在庫を追跡しない」場合は「9999」、それ以外はバリエーションの「在庫数」を出力します。また、「在庫を追跡しない」場合は数量セレクター下に在庫数は非表示、さらに「在庫が0」の場合は「入荷待ち」と出力されるようになっています。 本来は言語ファイルで定義した方が良いのですが、編集ファイルが増えるので省略させていただきます。 <td>内には{{ quantity_input }}で数量セレクターを出力し、replaceで「variant_id(バリアントID)」と「inventory_quantity(最大入力数)」を置換させています。 「カートに追加」ボタンはデフォルトで「disabled」属性をつけており、数量を入力しないと解除されない仕様です。 「アラート」部分は「hidden」属性で非表示になっていますが、在庫数を上回る数値が入力された場合に表示され、「カートに追加」ボタンも「disabled」が付与されます。こちらも言語ファイルで文章を定義したり、アイコンを付けたりしてカスタマイズしてください。 Javascriptを追加 <script> document.addEventListener('DOMContentLoaded', () => { const form = document.querySelector('.bulk-order-form'); const inputs = form.querySelectorAll('.quantity__input'); const buttons = form.querySelectorAll('.quantity__button'); const error = form.querySelector('.bulk-order-form-error'); const submitButton = form.querySelector('.bulk-order-form-button'); function checkInputs() { const disabled = Array.from(inputs).every((input) => { const value = input.value.trim(); return value === '' || value === '0'; }); form.querySelector('.bulk-order-form-button').disabled = disabled; const event = new Event('change'); inputs.forEach((input) => { input.dispatchEvent(event); }); } const bulkInputs = Array.from(inputs); bulkInputs.forEach((input) => { input.addEventListener('input', checkInputs); var max = parseInt(input.getAttribute('max')); input.addEventListener('change', function () { if (input.value > max) { input.parentNode.classList.add('is-over'); input.dataset.error = 'true'; } else { input.parentNode.classList.remove('is-over'); input.dataset.error = 'false'; } if (input.value == 0) { input.parentNode.classList.remove('is-true'); } else { input.parentNode.classList.add('is-true'); } const hasErrors = Array.from(inputs).some((input) => input.dataset.error === 'true'); if (hasErrors) { submitButton.classList.add('is-disabled'); error.hidden = false; } else { submitButton.classList.remove('is-disabled'); error.hidden = true; } }); }); buttons.forEach((button) => { button.addEventListener('click', () => { setTimeout(checkInputs, 0); }); }); submitButton.addEventListener('click', (event) => { event.preventDefault(); let formData = { items: [] }; let inputElements = form.querySelectorAll('.quantity__input'); inputElements.forEach(function (inputElement) { let quantity = parseInt(inputElement.value); if (quantity > 0) { let variantId = inputElement.getAttribute('name'); formData.items.push({ id: variantId, quantity: quantity }); } }); fetch(window.Shopify.routes.root + 'cart/add.js', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(formData), }) .then((response) => { return response.json(); }) .then((json) => { window.location.href = '/cart'; }) .catch((error) => { console.error('Error:', error); }); }); }); </script> いろんなイベントを増やし長くなってしまいましたが、機能的にはシンプルですので、箇条書きで説明します。 在庫数を上回った数値が入力された場合 <quantity-input>タグにクラス「is-over」を付与 「カートに追加」ボタンが押せない(disabled属性を付与) 「アラート」を表示(hidden属性を解除) 正常な値が入力された場合 <quantity-input>タグにクラス「is-true」を付与(is-overを削除) 「カートに追加」ボタンが押せる(disabled属性を削除) 「アラート」を非表示(hidden属性を付与) 数値を0に戻した場合 <quantity-input>タグにクラス「is-true」を削除 「カートに追加」ボタンが押せない(disabled属性を付与) 「カートに追加」ボタンをクリックした場合 <input>要素のname属性からバリアントIDを取得 <input>要素のvalue属性から入力数値を取得 fetchで「cart/add.js」にアクセスして更新 カートページ(/cart)に移動 冒頭にもありますが、ChatGTPで作ったコードなので、本格的なプログラマーから見るとおかしな箇所があるかもしれませんが、その辺は大目に見てください。 CSSファイルを作成 「アセットを追加する」より「bulk-order-form.css」を作成し、以下のコードをコピペします。 .bulk-order-form { margin: 3em 0; } .bulk-order-form-table { width: 100%; table-layout: fixed; border-collapse: collapse; border-spacing: 0; } .bulk-order-form-table thead { background: #999; color: #fff; } .bulk-order-form-table tr { transition: .2s linear; } .bulk-order-form-table th, .bulk-order-form-table td { padding: .5em; border: 1px solid #ddd; vertical-align: middle; text-align: center; } .bulk-order-form-table thead th { white-space: nowrap; } .bulk-order-form-table .color-name { display: flex; justify-content: center; align-items: center; flex-direction: column; margin: 0 auto; } .bulk-order-form-table .color-name figcaption { font-size: 1.2rem; line-height: 1.4; } .bulk-order-form-table .no-stock { background: rgb(0 0 0 /.05); pointer-events: none; } .bulk-order-form-table .is-none { background: linear-gradient(to top left, transparent calc(50% - .5px), #eee calc(50% - .5px), #eee calc(50% + .5px), transparent calc(50% + .5px)) center center / 100% 100% no-repeat; } .bulk-order-form-item { font-size: 1rem; line-height: 1; margin-top: .5rem; } .bulk-order-form-table .quantity { width: 10rem; max-width: 100%; min-height: 4rem; margin: 0 auto; border: 1px solid #ddd; border-radius: 3px; transition: .2s linear; } .bulk-order-form-table .quantity:hover { background: #fff; } .bulk-order-form-table .quantity.is-over { border-color: #c00 !important; } .bulk-order-form-table .quantity.is-true { background: #fff; border-color: #666; } .bulk-order-form-table .quantity::before, .bulk-order-form-table .quantity::after { content: none; } .bulk-order-form-table .quantity__button { width: 3rem; } .bulk-order-form-table .quantity__input { font-size: 1.2rem; } .bulk-order-form-table .quantity.is-over .quantity__button, .bulk-order-form-table .quantity.is-over .quantity__input { color: #c00; } .bulk-order-form-table .quantity__input::placeholder { opacity: .5; transition: .2s linear; } .bulk-order-form-table .quantity__input:focus::placeholder { opacity: 0; } .bulk-form-input { display: block; background: #fff; width: 8rem; max-width: 80%; text-align: center; padding: .5em; margin: 0 auto 1rem; border: 1px solid #ddd; } .bulk-order-form-action { padding: 2em 0; } .bulk-order-form-button { display: block; width: 100%; max-width: 30rem; font-size: 1.1em; font-weight: 700; background: #fff; color: #e32c2b; padding: 1em; margin: 0 auto; border: 2px solid #e32c2b; border-radius: 5px; outline: none; -webkit-appearance: none; -moz-appearance: none; appearance: none; cursor: pointer; } .bulk-order-form-button:hover { background: #e32c2b; color: #fff; } .bulk-order-form-button[disabled] { opacity: .5; pointer-events: none; } .bulk-order-form-error { justify-content: center; color: #c00; } Dawnベースのシンプルなスタイリングですので、編集してご利用ください。テーマやバージョンによって、アラートのhidden属性など追記する必要があるかもしれません。 セクションファイルを編集 商品ページを出力するファイルを開きます。Dawnの場合は「sections/main-product.liquid」が該当します。 スキーマにブロックを追加 {% schema %}内の「blocks」に以下のブロックを追加します。 { "type": "bulk_order_form", "name": "一括購入フォーム", "limit": 1, "settings": [ { "type": "checkbox", "id": "show_thumbnail", "label": "カラーサムネイルを表示", "default": true } ] } 「カラーサムネイルを表示」するブロック設定のみ実装しています。お好みに合わせて設定を増やしてください。 2.0に未対応のテーマに実装する場合は、ブロック設定をセクション設定に置き換えるなどしてください。 スニペットファイルを読み込む {% case block.type %}〜{% endcase %}内の最後の方に以下のコードをコピペしてください。 {% when 'bulk_order_form' %} {{ 'bulk-order-form.css' | asset_url | stylesheet_tag }} {% render 'bulk-order-form-block', product: product, block: block %} 2.0に未対応のテーマをご利用の際は{% when %}以外の2行を、購入ボタン付近に分岐などして実装してください。 以上でテーマの編集は終わりです。 設定 カスタマイザーより商品ページを開き、「ブロックを追加」で「一括購入フォーム」ブロックを追加し、お好きな位置にドラッグしてください。 2.0に未対応のテーマをご利用の際は不要です。 また、カラーサムネイルを表示させたくない場合は、ブロック設定の「カラーサムネイルを表示」のチェックを外してください。 「バリエーションピッカー」「数量セレクター」「購入ボタン」ブロックは不要になりますので、非表示にするか削除してください。