Dua bab terakhir soal satu produk: variannya di 39 Produk Variants dan High-variant Products, medianya di 40 Produk Media. Bab ini soal produk yang berhubungan dengan produk itu, plus dua fitur produk khusus: gift card dan bundle.

Rekomendasi Produk

Nampilin produk rekomendasi bikin pembeli lebih gampang nemuin produk baru dan bisa naikin penjualan. Sebelum masang, ada dua hal yang perlu dipahami: intent rekomendasi dan cara ngelacaknya.

1. Dua Intent

Shopify nyediain dua strategi rekomendasi:

  1. Related products (produk terkait): produk yang mirip sama produk yang lagi dilihat, alias pengganti. Tampil di section “You might also like”. Ini dibikin otomatis oleh Shopify.
  2. Complementary products (produk pelengkap): produk yang melengkapi produk yang lagi dilihat, alias add-on. Tampil di section “Pair it with”. Ini harus diatur manual merchant lewat app Search & Discovery.

Merchant bisa ngustomisasi produk rekomendasi buat kedua intent lewat Search & Discovery, yang juga ngaruh ke hasil pencarian di 38 Search dan Predictive Search.

Algoritmanya nebak produk paling relevan berdasarkan produk yang lagi dilihat. Faktornya:

  1. Riwayat pembelian: produk yang secara historis dibeli bareng.
  2. Deskripsi produk: produk dengan deskripsi mirip.
  3. Koleksi terkait: produk dari koleksi yang sama, kecuali koleksi all dan frontpage.

Riwayat pembelian dan deskripsi dipakai buat semua merchant. Koleksi terkait cuma cadangan waktu dua yang pertama gak tersedia (toko baru tanpa riwayat).

3. Batasan

  1. Algoritmanya gak bisa dikustomisasi buat ngecualiin produk tertentu. Tapi kamu bisa milih mana yang ditampilin lewat JavaScript.
  2. Pesanan yang diimpor dari toko atau platform lain gak dipakai.
  3. Yang gak pernah direkomendasiin: produk habis stok, produk harga 0, gift card, dan produk yang udah ada di cart pengunjung.

Poin terakhir itu bagus: pembeli gak bakal direkomendasiin barang yang udah mereka masukin keranjang.

  1. Algoritma ngasosiasiin sampai 10 produk per produk, urut relevansi (yang pertama paling relevan). Jadi batasi ke 4 per halaman produk biar cuma yang paling relevan yang tampil.
  2. Load ukuran gambar yang tepat buat kartu produk (bahasannya di 59 Performance Gambar dan LCP).
  3. Karena rekomendasi dimuat asinkron lewat JavaScript, sediain empty state atau jangan tampilin section-nya sama sekali sampai datanya datang. Placeholder-nya ditukar sama produk asli setelah dimuat.
  4. Pakai frasa kaya “You might also like” di heading biar pembeli paham kenapa produk ini ditampilin.

5. Pedoman UI Complementary

  1. Ditaruh dekat atas halaman produk, biasanya di section info produk dekat gambar utama, karena ini add-on yang mau dibeli bareng.
  2. API ngasih sampai 10 produk pelengkap, tampilin 2–3 secara default dan paginasi sisanya.
  3. Ukuran gambar yang tepat, empty state, dan heading “Pairs well with”.

6. Implementasi

Polanya persis predictive search di 38 Search dan Predictive Search > Predictive Search: section ngeloop recommendations.products, tapi object recommendations (dari 14 Objects Produk dan Koleksi > recommendations) gak terisi waktu section dirender pertama kali. Jadi JavaScript ngambil section yang udah terisi lewat section response Product Recommendations API. Satu section buat dua intent:

<div
  class="product-recommendations"
  data-url="{{ routes.product_recommendations_url }}?section_id={{ section.id }}&product_id={{ product.id }}&limit=4&intent=related"
>
  {%- if recommendations.performed? and recommendations.products_count > 0 -%}
    {% if recommendations.intent == 'related' %}
      <h2>You may also like</h2>
    {% elsif recommendations.intent == 'complementary' %}
      <h2>Pair it with</h2>
    {% endif %}
    <ul>
      {%- for product in recommendations.products -%}
        <li class="product">
          <a href="{{ product.url }}">
            <img
              class="product__img"
              src="{{ product.featured_image | image_url: width: 300, height: 300 }}"
              alt="{{ product.featured_image.alt }}"
            />
            <p class="product__title">{{ product.title }}</p>
            <p class="product__price">{{ product.price | money}}</p>
          </a>
        </li>
      {%- endfor -%}
    </ul>
  {%- endif -%}
</div>
{% javascript %}
  const handleIntersection = (entries, observer) => {
    if (!entries[0].isIntersecting) return;
    observer.unobserve(productRecommendationsSection);
    const url = productRecommendationsSection.dataset.url;
    fetch(url)
      .then(response => response.text())
      .then(text => {
        const html = document.createElement('div');
        html.innerHTML = text;
        const recommendations = html.querySelector('.product-recommendations');
        if (recommendations && recommendations.innerHTML.trim().length) {
          productRecommendationsSection.innerHTML = recommendations.innerHTML;
        }
      })
      .catch(e => {
        console.error(e);
      });
  };
  const productRecommendationsSection = document.querySelector('.product-recommendations');
  const observer = new IntersectionObserver(handleIntersection, {rootMargin: '0px 0px 200px 0px'});
  observer.observe(productRecommendationsSection);
{% endjavascript %}
{% schema %}
  {
    "name": "Product recommendations",
    "settings": []
  }
{% endschema %}

Bedahnya:

  1. data-url dirakit dari routes.product_recommendations_url (sadar locale, dari 13 Objects Global > routes) plus section_id, product_id, limit=4, dan intent. URL ini dibikin di Liquid biar JavaScript gak perlu tau apa-apa soal produk.
  2. recommendations.performed? (perhatiin tanda tanya, ini properti boolean bergaya Ruby) true cuma waktu dirender lewat API. Di render normal, <div>-nya kosong.
  3. recommendations.intent ngasih tau section ini dirender buat intent apa, jadi heading-nya bisa beda. Satu file section bisa dipakai dua kali di halaman produk dengan intent beda di data-url-nya (jadiin intent sebagai setting section).
  4. IntersectionObserver dengan rootMargin 200px: fetch cuma waktu section hampir kelihatan (200px sebelum masuk viewport). Rekomendasi ada di bawah halaman, jadi gak perlu ngebebanin load awal. Setelah fetch, unobserve biar gak fetch dua kali.
  5. Respons di-parse, ambil .product-recommendations, dan cuma ganti kalau ada isinya. Kalau kosong (produk gak punya rekomendasi), section tetap kosong dan gak keliatan.

❌ fetch langsung waktu halaman dimuat, ngebebanin LCP

fetch(productRecommendationsSection.dataset.url).then(...)

✅ fetch waktu hampir kelihatan

const observer = new IntersectionObserver(handleIntersection, {rootMargin: '0px 0px 200px 0px'});
observer.observe(productRecommendationsSection);

Contoh lengkap Dawn di sections/related-products.liquid.

7. Product Recommendations API

Bagian dari Ajax API (51 Ajax API dan Section Rendering API), selalu pakai URL sadar locale. Dua endpoint:

  1. GET /{locale}/recommendations/products.json, ngembaliin JSON produk. Parameter: product_id (wajib), limit (1–10, default 10), intent (related default, atau complementary).
fetch(window.Shopify.routes.root + "recommendations/products.json?product_id=1234567890123&limit=4&intent=related")
  .then(response => response.json())
  .then(({ products }) => {
    if (products.length > 0) {
      const firstRecommendedProduct = products[0];
      alert(
        `The title of the first recommended product is: ${firstRecommendedProduct.title}`
      );
    }
  }
);

Responsnya { "intent": "related", "products": [ ... ] } dengan object produk lengkap (harga dalam subunit, misalnya 380000 = $3.800,00, ingat 14 Objects Produk dan Koleksi > Soal Harga: Subunit), varian, gambar, opsi, dan url. 2. GET /{locale}/recommendations/products (tanpa .json), ngembaliin HTML section. Parameter sama plus section_id (wajib). Ini yang dipakai contoh di atas.

Error: 422 kalau product_id gak ada atau intent bukan related/complementary, 404 kalau produk gak ada atau gak dipublikasi di Online Store, atau section gak ketemu.

8. Ngelacak Konversi

Properti url tiap produk rekomendasi (dari API maupun dari recommendations.products di Liquid) bawa parameter pelacakan:

/products/gorgeous-wooden-computer?pr_choice=default&pr_prod_strat=description&pr_rec_pid=13&pr_ref_pid=17&pr_seq=alternating

pr_prod_strat strateginya (copurchase, description), pr_rec_pid produk yang direkomendasiin, pr_ref_pid produk asalnya. Selalu pakai product.url apa adanya, jangan dirakit sendiri dari handle, kalau gak, laporan “Product recommendation conversion over time” di Analytics admin gak dapat data.

❌ ngilangin pelacakan

<a href="/products/{{ product.handle }}">

✅ pelacakan utuh

<a href="{{ product.url }}">

Gift Card: Form Penerima

Template halaman gift card-nya sendiri (QR code, Apple Wallet) udah dibahas di 24 Template per Halaman > gift_card.liquid. Bagian ini soal halaman produk gift card: form buat pembeli ngisi siapa penerimanya, biar gift card dikirim langsung ke orang itu.

Object yang dipakai: product, form, dan section. Tiga tugasnya: bikin form penerima, masukin ke form produk, dan tampilin propertinya di cart.

1. Line Item Properties

Info penerima dikirim sebagai line item properties (properti tambahan di item cart, dibahas di 23 Templates dan 16 Objects Cart, Checkout, Order, dan Customer) dengan nama yang udah ditentuin Shopify:

  1. Recipient email, email tujuan gift card.
  2. Recipient name (opsional), nama penerima.
  3. Message (opsional), pesan di email gift card.
  4. Send on (opsional), tanggal kirim terjadwal, format ISO 8601 yyyy-mm-dd. Tanpa ini, dikirim langsung.
  5. __shopify_send_gift_card_to_recipient, wajib bernilai true biar Shopify memvalidasi dan memproses info penerima. Tanpa ini, properti lain cuma dianggap catatan biasa.
  6. __shopify_offset (opsional), offset zona waktu pembeli dalam menit, hasil new Date().getTimezoneOffset() di JavaScript, biar “Send on” dijadwalin relatif ke zona waktu penerima. Tanpa ini, dipakai zona waktu toko.

Bentuk minimalnya:

<input type="hidden" name="properties[__shopify_send_gift_card_to_recipient]" value="true">
<label for="Recipient-email">Recipient email</label>
<input type="email" id="Recipient-email" name="properties[Recipient email]" required>
<label for="Recipient-name">Recipient name</label>
<input type="text" id="Recipient-name" name="properties[Recipient name]">
<label for="Recipient-message">Message</label>
<textarea id="Recipient-message" name="properties[Message]" maxlength="200"></textarea>
<label for="Recipient-send-on">Send on</label>
<input type="date" id="Recipient-send-on" name="properties[Send on]">
<input type="hidden" name="properties[__shopify_offset]" value="" id="Recipient-offset">

Form-nya harus nampilin error yang dikembaliin waktu submit (email gak valid, tanggal terlalu jauh), biar pembeli bisa benerin. Contoh lengkap Dawn: snippets/gift-card-recipient-form.liquid, assets/recipient-form.js, dan CSS-nya di section-main-product.css.

2. Masukin ke Form Produk

Render snippet-nya di dalam {% form 'product' %}, cuma kalau produknya gift card (product.gift_card?):

{% form 'product' %}
     <!-- form content -->
     {%- if product.gift_card? -%}
          {%- render 'gift-card-recipient-form', product: product, form: form, section: section -%}
     {%- endif -%}
     <!-- buy buttons -->
{% endform %}

Catatan penting: validasi form penerima gak didukung tombol accelerated checkout (Shop Pay, Apple Pay, dari 43 Accelerated Checkout). Form ini cuma buat tombol Add to Cart. Jadi di halaman produk gift card, sembunyiin tombol dynamic checkout atau pembeli bisa lewat validasi.

3. Tampilin di Cart

Properti yang dikumpulin tampil di cart kaya line item property lain, lewat item.properties, dengan aturan: properti yang diawali _ (termasuk __shopify_send_gift_card_to_recipient dan __shopify_offset) disembunyiin dari pembeli. Pola tampilannya di 24 Template per Halaman.

4. Batasan

  1. Nama penerima maksimal 255 karakter.
  2. Pesan maksimal 200 karakter.
  3. Penjadwalan maksimal 90 hari ke depan.

Bundles di Email Transaksional

Bagian ini sebenarnya bukan buat theme, tapi buat partner email transaksional (app yang ngirim email konfirmasi pesanan dan update pengiriman pengganti bawaan Shopify). Ada di dokumentasi theme karena berkaitan sama tampilan pesanan, dan berguna dipahami kalau kamu nampilin bundle di halaman order atau cart theme.

Bundle itu produk yang terdiri dari beberapa komponen (kaos + celana dalam satu paket). Dulu email Shopify nampilin semua line item sebagai daftar datar. Sekarang bisa bergrup: komponen disarangin di bawah induk bundle-nya. Datanya lewat GraphQL Admin API, tiga langkah.

1. Query lineItemGroup

Query lineItems di order, minta field lineItemGroup:

query GetOrder {
  order(id: "gid://shopify/Order/1") {
    lineItems(first: 100) {
      nodes {
        title
        quantity
        discountedTotalSet {
          shopMoney {
            amount
            currencyCode
          }
        }
        lineItemGroup {
          id
          title
          quantity
        }
      }
    }
  }
}

Responsnya, tiap line item punya lineItemGroup (induk bundle-nya: id, title, quantity) atau null kalau bukan bagian bundle:

{
  "data": {
    "order": {
      "lineItems": {
        "nodes": [
          {
            "title": "Short Sleeve T-Shirt - Green",
            "quantity": 1,
            "discountedTotalSet": {
              "shopMoney": {
                "amount": "22.0",
                "currencyCode": "USD"
              }
            },
            "lineItemGroup": {
              "id": "gid://shopify/LineItemGroup/1",
              "title": "Green Shirt and Shorts Bundle",
              "quantity": 1
            }
          },
          {
            "title": "Shorts - Green",
            "quantity": 1,
            "discountedTotalSet": {
              "shopMoney": {
                "amount": "0.0",
                "currencyCode": "USD"
              }
            },
            "lineItemGroup": {
              "id": "gid://shopify/LineItemGroup/1",
              "title": "Green Shirt and Shorts Bundle",
              "quantity": 1
            }
          },
          {
            "title": "Sneakers",
            "quantity": 1,
            "discountedTotalSet": {
              "shopMoney": {
                "amount": "100.0",
                "currencyCode": "USD"
              }
            },
            "lineItemGroup": null
          }
        ]
      }
    }
  }
}

Perhatiin discountedTotalSet “Shorts - Green” itu 0.0: harga bundle udah dialokasiin ke komponen tertentu, jadi komponen lain bisa 0. Ini kenapa tampilan bergrup penting: pembeli lihat “Bundle 22, Celana $0”.

Di Liquid theme, padanannya adalah line_item.item_components dan line_item.parent di object line_item dari 16 Objects Cart, Checkout, Order, dan Customer.

2. Ngelompokkin

Item dengan lineItemGroup null tampil mandiri. Sisanya dikelompokkin per id grup:

function groupBundles(response) {
  const lineItems = response.data.order.lineItems.nodes;
  const bundles = new Map();
  const result = [];
  // First pass: Identify bundles and group items by productId
  lineItems.forEach((item) => {
    if (item.lineItemGroup) {
      const { id, title } = item.lineItemGroup;
      if (!bundles.has(id)) {
        bundles.set(id, {
          bundleTitle: title,
          quantity: item.quantity,
          components: [],
        });
      }
      bundles.get(id).components.push({
        title: item.title,
        quantity: item.quantity,
        price: item.discountedTotalSet.shopMoney.amount,
      });
    } else {
      // Non-bundle items would be handled here
      result.push(item);
    }
  });
  // Convert bundles map to array format
  bundles.forEach((bundle) => {
    result.unshift({
      title: bundle.bundleTitle,
      quantity: bundle.quantity,
      components: bundle.components,
      lineItemGroup: {
        title: bundle.bundleTitle,
      },
    });
  });
  return result;
}

Hasilnya satu entri bundle dengan array components, disusul item mandiri:

const result = [
  {
    "title": "Green Shirt and Shorts Bundle",
    "quantity": 1,
    "components": [
      {
        "title": "Short Sleeve T-Shirt - Green",
        "quantity": 1,
        "price": "22.0"
      },
      {
        "title": "Shorts - Green",
        "quantity": 1,
        "price": "0.0"
      },
    ],
    lineItemGroup: { title: 'Green Shirt and Shorts Bundle' }
  },
  {
    "title": "Sneakers",
    "quantity": 1,
    "discountedTotalSet": {
      "shopMoney": {
        "amount": "100.0",
        "currencyCode": "USD"
      }
    },
    "lineItemGroup": null
  }
]

3. Harga Bundle

Jumlahin harga komponennya:

function calculateBundlePrice(bundle) {
  return bundle.components.reduce((acc, component) => {
    return acc + parseFloat(component.price);
  }, 0);
}
calculateBundlePrice(result[0]); // 22.0

Kalau yang di-query discountedTotalSet, itu total setelah diskon dan dikali kuantitas. Kalau discountedUnitPriceSet, itu per unit, jadi harus dikali kuantitas sendiri.

Ringkasan

  1. Dua intent rekomendasi: related (mirip, otomatis, “You might also like”) dan complementary (pelengkap, manual lewat Search & Discovery, “Pair it with”).
  2. Algoritma related: riwayat pembelian bareng dan kemiripan deskripsi, koleksi terkait sebagai cadangan. Gak bisa ngecualiin produk, gak pakai pesanan impor, dan gak pernah ngerekomendasiin produk habis, harga 0, gift card, atau yang udah di cart.
  3. UI: sampai 10 produk urut relevansi, tampilin 4 (related) atau 2–3 (complementary, di dekat atas halaman), gambar ukuran tepat, empty state, heading yang ngejelasin.
  4. Implementasi: section dengan data-url dari routes.product_recommendations_url + section_id + product_id + limit + intent, cek recommendations.performed?, JavaScript fetch waktu IntersectionObserver hampir kelihatan, ganti isi kalau ada.
  5. API: recommendations/products.json (JSON, harga subunit) dan recommendations/products (HTML section, butuh section_id), parameter product_id, limit 1–10, intent. Error 422/404.
  6. Pakai product.url apa adanya karena bawa parameter pr_* buat laporan konversi.
  7. Gift card recipient form: line item properties Recipient email, Recipient name, Message, Send on (yyyy-mm-dd), __shopify_send_gift_card_to_recipient=true (wajib), __shopify_offset. Dirender di dalam form 'product' kalau product.gift_card?, cuma buat Add to Cart (bukan accelerated checkout). Batas 255/200 karakter, 90 hari.
  8. Bundle di email: query lineItems.lineItemGroup lewat Admin GraphQL, kelompokkin per id grup (null = mandiri), jumlahin harga komponen. Di Liquid padanannya line_item.item_components.

Bab selanjutnya kita bahas semua yang berkaitan harga: nampilin harga coret dan diskon, harga per unit, cicilan (installments), dan harga khusus B2B dengan quantity rules dan price breaks.