Object selling plan udah dibedah di 14 Objects Produk dan Koleksi > Object Selling Plan. Bab ini soal ngerakitnya jadi fitur langganan yang jalan: dari pemilih di halaman produk sampai tampilan di cart dan order, plus pedoman UX yang panjang dari Shopify.

Merchant bisa nawarin opsi pembelian selain beli sekali lewat app langganan: kirim tiap bulan diskon 10%, tiap minggu diskon 20%. Buat pakai Shopify Subscriptions, merchant harus memenuhi syarat kelayakan tertentu, tapi dari sisi theme itu urusan mereka.

Cara Kerjanya

App bikin selling plan group, di dalamnya selling plan, yang dihubungin ke produk dan varian. Contoh grup “Subscribe & Save” yang punya beberapa plan dengan frekuensi pengiriman dan penagihan yang bisa dipilih terpisah. Merchant bisa nawarin banyak plan opsional, atau mewajibkan plan tertentu buat produk (“subscription-only”).

Analoginya: selling plan group itu kaya produk, selling plan itu kaya varian. Grup punya opsi (“Delivery every”), kombinasi nilai opsi jadi satu plan (“every 2 weeks”). Persis struktur produk → opsi → varian dari 39 Produk Variants dan High-variant Products.

Sumber datanya tergantung konteks: di product/collection lewat variant (variant.selling_plan_allocations), di cart/order lewat line_item (line_item.selling_plan_allocation). Endpoint yang dipakai buat ngubah plan di cart: /{locale}/cart/change.js dari Cart Ajax API.

Lima Komponen

  1. Pemilih selling plan di halaman produk, buat milih plan.
  2. JavaScript, buat ngupdate plan yang tersedia waktu varian berganti, dan ngisi hidden input waktu plan dipilih.
  3. Tampilan selling plan di cart, nandain item yang pakai plan, opsional dengan pemilih buat nambah/ganti/hapus plan.
  4. Checkout charge di cart, jumlah yang harus dibayar saat checkout.
  5. Tampilan di halaman order pelanggan.

Pemilih di Halaman Produk

Pemilih bisa ditaruh di manapun ada form produk. Yang harus ada di form:

  1. Buat tiap selling_plan_group di product.selling_plan_groups, output opsinya.
  2. Input name="selling_plan" yang nilainya ID plan yang dipilih, atau kosong kalau gak ada (beli sekali). Ini yang dibaca Shopify waktu add to cart.
  3. Simpan object produk biar bisa diakses JavaScript.

Contoh implementasi Shopify (bisa juga dipakai sebagai theme app block dari 28 Blocks). Kodenya ngerjain: nentuin varian sekarang, loop tiap grup dan plan-nya, gak nampilin opsi beli sekali kalau produk subscription-only, dan nyiapin badge Subscription:

{%- assign current_variant = product.selected_or_first_available_variant | default: product.variants.first -%}
{% if product.selling_plan_groups.size > 0 %}
  <div class="selling_plan_app_container" data-section-id='{{ section.id }}'>
    <script src="{{ 'selling-plans-integration.js' | asset_url }}" defer></script>
    <style>.selling_plan_theme_integration--hidden {display: none;}</style>
    {% for variant in product.variants %}
      {%liquid
        assign variantPrice = variant.price | money_with_currency | escape
        assign variantComparedAtPrice = variant.compare_at_price | money_with_currency | escape
      %}
      {% if variant.selling_plan_allocations.size > 0 %}
        <section data-variant-id='{{ variant.id }}' class='selling_plan_theme_integration {% if variant.id != current_variant.id %}selling_plan_theme_integration--hidden{% endif %}'>
          <fieldset>
            <legend>
              {{ block.settings.supporting_text_title }}
            </legend>
            <div>
              {% unless product.requires_selling_plan %}
                <div>
                  <label>
                    <input
                      aria-label='One-time purchase. Product price {{ variantPrice }}'
                      type='radio'
                      name="purchaseOption_{{ section.id }}_{{ variant.id }}"
                      {% if variant.available == false %}disabled{% endif %}
                      id='{{ section.id }}_one_time_purchase'
                      data-radio-type='one_time_purchase'
                      data-variant-id='{{ variant.id }}'
                      data-variant-price='{{ variantPrice }}'
                      data-variant-compare-at-price='{{ variantComparedAtPrice }}'
                      checked
                    />
                    One-time purchase
                  </label>
                </div>
              {% endunless %}
              {% assign group_ids = variant.selling_plan_allocations | map: 'selling_plan_group_id' | uniq %}
              {% for group_id in group_ids %}
                {%liquid
                   assign group = product | map: 'selling_plan_groups' | where: 'id', group_id | first
                   assign allocations = variant | map: 'selling_plan_allocations' | where: 'selling_plan_group_id', group_id
                   if forloop.first
                    assign first_selling_plan_group = true
                  else
                    assign first_selling_plan_group = false
                  endif
                %}
                <div>
                  <div>
                    <label>{{ group.name }}</label>
                  </div>
                  <ul>
                    {% for allocation in allocations %}
                      {%liquid
                        if forloop.first and product.requires_selling_plan and first_selling_plan_group
                          assign plan_checked = 'checked'
                        else
                          assign plan_checked = nil
                        endif
                        assign allocationPrice = allocation.price | money_with_currency | escape
                        assign allocationComparedAtPrice = allocation.compare_at_price | money_with_currency | escape
                      %}
                      <li>
                        <label>
                          <input
                            type='radio'
                            {% if variant.available == false %}disabled{% endif %}
                            aria-label='{{ allocation.selling_plan.name }}. Product price {{ allocationPrice }}'
                            name="purchaseOption_{{ section.id }}_{{ variant.id }}"
                            data-radio-type='selling_plan'
                            data-selling-plan-id='{{ allocation.selling_plan.id }}'
                            data-selling-plan-group-id='{{ section.id }}_{{ group_id }}_{{ variant.id }}'
                            data-selling-plan-adjustment='{{ allocation.selling_plan.price_adjustments.size }}'
                            data-variant-price='{{ allocationPrice }}'
                            data-variant-compare-at-price='{{ allocationComparedAtPrice }}'
                            {{ plan_checked }} />
                          {{ allocation.selling_plan.name }}
                        </label>
                      </li>
                    {% endfor %}
                  </ul>
                </div>
              {% endfor %}
            </div>
          </fieldset>
        </section>
      {% endif %}
    {% endfor %}
  </div>
  <input
    name='selling_plan'
    class='selected-selling-plan-id'
    type='hidden' />
{% endif %}

Bedahnya, karena ini padat:

  1. Satu <section> per varian yang punya alokasi, disembunyiin kecuali varian sekarang. Kenapa per varian? Karena gak semua varian ndukung plan yang sama, dan harga alokasinya beda per varian. Lebih gampang ngerender semua di Liquid dan nyembunyiin/nampilin lewat JavaScript daripada ngerakit ulang.
  2. Radio “One-time purchase” cuma kalau bukan product.requires_selling_plan, dan checked bawaannya (pedoman UX: beli sekali dipilih default).
  3. group_ids dirakit dari alokasi varian pakai map: 'selling_plan_group_id' | uniq, terus tiap grup dicari pakai where: 'id', group_id | first. Ini trik filter array dari 18 Filters String, Math, dan Array buat “join” dua array tanpa loop bersarang.
  4. Buat produk subscription-only, plan pertama di grup pertama yang checked.
  5. Tiap radio bawa data-* berisi ID plan, harga, dan harga coret yang udah diformat (money_with_currency | escape), jadi JavaScript tinggal nyalin string ke elemen harga, gak perlu ngeformat uang sendiri.
  6. Di luar semua itu, hidden input selling_plan dengan class selected-selling-plan-id, yang bakal diklon ke tiap form add to cart oleh JavaScript.

JavaScript

File assets/selling-plans-integration.js:

const hiddenClass = 'selling_plan_theme_integration--hidden';
class SellingPlansWidget {
  constructor(sellingPlansWidgetContainer) {
    this.enablePerformanceObserver();
    this.sellingPlansWidgetContainer = sellingPlansWidgetContainer;
    this.appendSellingPlanInputs();
    this.updateSellingPlanInputsValues();
    this.listenToVariantChange();
    this.listenToSellingPlanFormRadioButtonChange();
    this.updatePrice();
  }
  get sectionId() {
    return this.sellingPlansWidgetContainer.getAttribute('data-section-id');
  }
  get shopifySection() {
    return document.querySelector(`#shopify-section-${this.sectionId}`);
  }
  /*
    We are careful to target the correct form, as there are instances when we encounter an installment form that we specifically aim to avoid interacting with.
  */
  get variantIdInput() {
    return (
      this.addToCartForms[1]?.querySelector(`input[name="id"]`) ||
      this.addToCartForms[1]?.querySelector(`select[name="id"]`) ||
      this.addToCartForms[0].querySelector(`input[name="id"]`) ||
      this.addToCartForms[0].querySelector(`select[name="id"]`)
    );
  }
  get priceElement() {
    return this.shopifySection.querySelector('.price');
  }
  get comparedAtPrice() {
    return this.shopifySection.querySelector('.price__sale');
  }
  get visibleSellingPlanForm() {
    return this.shopifySection.querySelector(
      `section[data-variant-id^="${this.variantIdInput.value}"]`,
    );
  }
  get isVariantAvailable() {
    return this.selectedPurchaseOption.getAttributeNames().includes('disabled');
  }
  get sellingPlanInput() {
    return this.shopifySection.querySelector('.selected-selling-plan-id');
  }
  get addToCartForms() {
    return this.shopifySection.querySelectorAll('[action*="/cart/add"]');
  }
  /*
    To enable the addition of a selling plan to a cart, it's necessary to include an input with the name "selling_plan", which will carry the selling ID as its value. When a buyer clicks on 'add to cart', the appropriate selling plan ID is added to their cart.
  */
  appendSellingPlanInputs() {
    this.addToCartForms.forEach((addToCartForm) => {
      addToCartForm.appendChild(this.sellingPlanInput.cloneNode());
    });
  }
  showSellingPlanForm(sellingPlanFormForSelectedVariant) {
    sellingPlanFormForSelectedVariant?.classList?.remove(hiddenClass);
  }
  hideSellingPlanForms(sellingPlanFormsForUnselectedVariants) {
    sellingPlanFormsForUnselectedVariants.forEach((element) => {
      element.classList.add(hiddenClass);
    });
  }
  /*
    Each product variant comes with a selling plan selection box that the buyer can interact with.
    When a buyer chooses a different variant, we ensure that only the relevant selling plan selection box is displayed.
    This guarantees that only the selling plan associated with the selected variant is shown.
  */
  handleSellingPlanFormVisibility() {
    const sellingPlanFormForSelectedVariant = this.shopifySection.querySelector(
      `section[data-variant-id="${this.variantIdInput.value}"]`,
    );
    const sellingPlanFormsForUnselectedVariants =
      this.shopifySection.querySelectorAll(
        `.selling_plan_theme_integration:not([data-variant-id="${this.variantIdInput.value}"])`,
      );
    this.showSellingPlanForm(sellingPlanFormForSelectedVariant);
    this.hideSellingPlanForms(sellingPlanFormsForUnselectedVariants);
  }
  handleVariantChange() {
    this.handleSellingPlanFormVisibility();
    this.updateSellingPlanInputsValues();
    this.listenToSellingPlanFormRadioButtonChange();
  }
  /*
    The functions listenToVariantChange() and listenToAddToCartForms() are implemented to track when a product variant is altered or when the product form is updated.
    The identification of the variant is crucial as it dictates which selling plan box should be displayed.
  */
  listenToVariantChange() {
    this.listenToAddToCartForms();
    if (this.variantIdInput.tagName === 'INPUT') {
      const variantIdObserver = new MutationObserver((mutationList) => {
        mutationList.forEach((mutationRecord) => {
          this.handleVariantChange(mutationRecord.target.value);
        });
      });
      variantIdObserver.observe(this.variantIdInput, {
        attributes: true,
      });
    }
  }
  listenToAddToCartForms() {
    this.addToCartForms.forEach((addToCartForm) => {
      addToCartForm.addEventListener('change', () => {
        this.handleVariantChange();
      });
    });
  }
  get regularPriceElement() {
    return this.shopifySection.querySelector('.price__regular');
  }
  get salePriceElement() {
    return this.shopifySection.querySelector('.price__sale');
  }
  get salePriceValue() {
    return this.salePriceElement.querySelector('.price-item--sale');
  }
  get regularPriceValue() {
    return this.salePriceElement.querySelector('.price-item--regular');
  }
  get sellingPlanAllocationPrice() {
    return document.getElementById(
      `${this.selectedPurchaseOption.dataset.sellingPlanGroupId}_allocation_price`,
    );
  }
  get selectedPurchaseOptionPrice() {
    return this.selectedPurchaseOption.dataset.variantPrice;
  }
  get selectedPurchaseOptionComparedAtPrice() {
    return this.selectedPurchaseOption.dataset.variantCompareAtPrice;
  }
  get price() {
    return this.sellingPlanAllocationPrices.price ?? null;
  }
  /*
    We aim to ascertain whether a compared price exists, which would indicate that the currently selected input has a discount applied to it.
    If a discount is detected, the discounted price is displayed; otherwise, the regular price is shown.
  */
  updatePrice() {
    if (
      !this.selectedPurchaseOptionComparedAtPrice ||
      this.selectedPurchaseOptionComparedAtPrice ===
        this.selectedPurchaseOptionPrice
    ) {
      this.showRegularPrice();
      this.hideSalePrice();
      this.priceElement.classList.remove('price--on-sale');
    } else {
      this.showSalePrice();
      this.hideRegularPrice();
      this.priceElement.classList.add('price--on-sale');
    }
  }
  hideSalePrice() {
    this.salePriceElement.style.display = 'none';
  }
  hideRegularPrice() {
    this.regularPriceElement.style.display = 'none';
  }
  showRegularPrice() {
    this.regularPriceElement.style.display = 'block';
    this.shopifySection.querySelector('.price__sale').style.display = 'none';
  }
  showSalePrice() {
    this.salePriceElement.style.display = 'block';
    this.regularPriceValue.innerHTML =
      this.selectedPurchaseOptionComparedAtPrice;
    this.salePriceValue.innerHTML = this.selectedPurchaseOptionPrice;
  }
  get sellingPlanInputs() {
    return this.shopifySection.querySelectorAll('.selected-selling-plan-id');
  }
  updateSellingPlanInputsValues() {
    this.sellingPlanInputs.forEach((sellingPlanInput) => {
      sellingPlanInput.value = this.sellingPlanInputValue;
    });
  }
  get sellingPlanInputValue() {
    return this.selectedPurchaseOption?.dataset.sellingPlanId ?? null;
  }
  get selectedPurchaseOption() {
    return this.visibleSellingPlanForm?.querySelector(
      'input[type="radio"]:checked',
    );
  }
  set selectedPurchaseOption(selectedPurchaseOption) {
    this._selectedPurchaseOption = selectedPurchaseOption;
  }
  handleRadioButtonChange(selectedPurchaseOption) {
    this.selectedPurchaseOption = selectedPurchaseOption;
    this.updateSellingPlanInputsValues();
    this.updatePrice();
  }
  listenToSellingPlanFormRadioButtonChange() {
    this.visibleSellingPlanForm
      ?.querySelectorAll('input[type="radio"]')
      .forEach((radio) => {
        radio.addEventListener('change', (event) => {
          this.handleRadioButtonChange(event.target);
        });
      });
  }
  enablePerformanceObserver() {
    const performanceObserver = new PerformanceObserver((list) => {
      list.getEntries().forEach((entry) => {
        if (entry.initiatorType !== 'fetch') return;
        const url = new URL(entry.name);
        /*
          When a buyer selects a product variant, a fetch request is initiated.
          Upon completion of this fetch request, we update the price to reflect the correct value.
        */
        if (url.search.includes('variant') || url.search.includes('variants')) {
          this.updatePrice();
        }
      });
    });
    performanceObserver.observe({entryTypes: ['resource']});
  }
}
document
  .querySelectorAll('.selling_plan_app_container')
  .forEach((sellingPlansWidgetContainer) => {
    new SellingPlansWidget(sellingPlansWidgetContainer);
  });

Yang dilakuin, urut:

  1. appendSellingPlanInputs: nyari semua form dengan action mengandung /cart/add di section, dan nyalin hidden input selling_plan ke masing-masing. Inilah kenapa input aslinya di luar form: biar bisa diklon ke form manapun (termasuk form kedua buat installments).
  2. variantIdInput: nyari input/select name="id". Perhatiin dia nyoba form kedua dulu (addToCartForms[1]), karena form pertama bisa jadi form installments yang gak boleh diganggu.
  3. listenToVariantChange: dengerin event change di form, dan MutationObserver di atribut input id, karena theme biasanya ngubah value hidden input itu secara programatik (yang gak nembak event change).
  4. handleVariantChange: tampilin <section> varian yang aktif, sembunyiin sisanya, update nilai hidden input, pasang ulang listener radio.
  5. handleRadioButtonChange: waktu plan dipilih, update hidden input (ID plan, atau null buat beli sekali) dan updatePrice.
  6. updatePrice: kalau ada harga coret dan beda dari harga, tampilin blok sale (.price__sale dengan .price-item--regular dicoret dan .price-item--sale), kalau gak, blok reguler. Selector-nya ngikutin struktur harga Dawn, sesuaikan sama theme kamu.
  7. enablePerformanceObserver: dengerin request fetch yang URL-nya mengandung variant (Section Rendering API waktu varian berubah), dan update harga setelah selesai. Ini cara “nyadap” perubahan varian tanpa nyentuh kode theme.

Ini contoh yang ditulis buat app block yang nempel di theme pihak ketiga, makanya defensif banget. Kalau kamu bikin theme sendiri, integrasinya bisa jauh lebih langsung: satu fungsi yang dipanggil dari pemilih varian kamu.

Di Cart

1. Nampilin Plan

Kalau pembeli milih plan, harus kelihatan di cart lewat line_item.selling_plan_allocation:

{% if line_item.selling_plan_allocation %}
  <p class="selling-plan">{{ line_item.selling_plan_allocation.selling_plan.name }}</p>
{% endif %}

Pakai selling_plan.name apa adanya, teks yang sama tampil di checkout dan order.

2. Pemilih di Cart

Daripada cuma nampilin, kasih opsi nambah, ganti, atau hapus plan. Loop line_item.variant.selling_plan_allocations dan tandai yang cocok sama plan sekarang:

<select name="selling-plan" data-line="{{ forloop.index }}" data-quantity="{{ line_item.quantity }}">
  <option value="">One-time purchase</option>
  {% for selling_plan_allocation in line_item.variant.selling_plan_allocations %}
    <option
      value="{{ selling_plan_allocation.selling_plan.id }}"
      {% if line_item.selling_plan_allocation.selling_plan.id == selling_plan_allocation.selling_plan.id %}selected="selected"{% endif %}
    >
      {{ selling_plan_allocation.selling_plan.name }}
    </option>
  {% endfor %}
</select>

Perubahannya dikirim ke /cart/change.js (Cart Ajax API, dibahas di 51 Ajax API dan Section Rendering API) dengan nomor baris, kuantitas, dan selling_plan baru. Contoh konsepnya (belum lengkap):

const sellingPlanSelectors = document.querySelectorAll('[name="selling-plan"]');
sellingPlanSelectors.forEach(function(element) {
  element.addEventListener('change', function(event) {
    const data = {
      'line': event.target.dataset.line,
      'quantity': event.target.dataset.quantity,
      'id': event.target.value
    }
    fetch('/cart/change.js', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json'
      },
      body: JSON.stringify(data)
    })
    .then(response => {
      // Refresh page, or re-render cart
      console.log(response);
    })
    .catch((error) => {
      console.error('Error:', error);
    });
  });
});

Catatan: buat ngubah selling plan lewat /cart/change.js, kunci yang dipakai di request adalah selling_plan (bukan id, yang buat varian). Contoh di atas nyederhanain, di implementasi nyata kirim { line, quantity, selling_plan: value } dan kosongin selling_plan buat ngehapus plan. Dan pakai window.Shopify.routes.root biar sadar locale.

3. Checkout Charge

Karena pre-order dan try before you buy (bab berikutnya) bisa ngubah berapa yang dibayar di muka, tampilin ke pembeli berapa yang bakal ditagih saat checkout, lewat selling_plan.checkout_charge yang diakses dari selling_plan_allocation. Dua tipenya:

  1. percentage: persen dari harga penuh yang dibayar di muka.
  2. price: harga tetap yang dibayar di muka, dalam subunit.

Buat langganan, checkout charge gak bisa dikonfigurasi, jadi selalu percentage dengan value 100 (bayar penuh). Contoh yang nampilin harga baris sesuai tipenya:

{% if item.selling_plan_allocation %}
  {%- assign checkout_charge = item.selling_plan_allocation.selling_plan.checkout_charge -%}
  {% if checkout_charge.value_type == 'percentage' %}
    {{ item.original_price | times: checkout_charge.value | divided_by: 100 | money }}
  {% else %}
    {{ checkout_charge.value | money }}
  {% endif %}
{% else %}
  {{ item.original_price | money }}
{% endif %}

Cara yang lebih singkat: selling_plan_allocation.checkout_charge_amount udah ngasih angkanya langsung.

4. Di Halaman Order

Sama kaya cart, di customers/order.liquid:

{% if line_item.selling_plan_allocation %}
  <p class="selling-plan">{{ line_item.selling_plan_allocation.selling_plan.name }}</p>
{% endif %}

Pedoman UX Langganan

Ini pedoman yang juga jadi syarat Built for Shopify buat app langganan, jadi theme yang ngikutinnya bakal cocok sama app manapun.

1. Prinsip

  1. Hierarki visual yang jelas: pembeli langsung lihat penghematan langganan, opsi plan, dan syaratnya.
  2. Ungkap informasi bertahap dan logis: tiap keputusan pembeli ngaruh ke pilihan berikutnya, tampilin yang relevan aja.
  3. Integrasi mulus sama design system theme.
  4. Ikutin alur kerja merchant yang udah ada di admin.
  5. Jangan duplikasi sumber daya Shopify (pelanggan, diskon, produk) di app.

Info langganan sebaiknya tampil di banyak tempat: halaman produk, koleksi, hasil pencarian, featured product, quick view. Nunjukin penghematan di kartu produk aja udah ngedorong orang buat langganan.

2. Harga di Form Produk

Elemen dan sumber datanya:

  1. Harga: selling_plan_allocation.price, harus mencerminkan plan yang dipilih.
  2. Harga coret: selling_plan_allocation.compare_at_price.
  3. Harga per pengiriman: selling_plan_allocation.per_delivery_price, tampil cuma kalau beda dari price, yaitu waktu plan-nya prabayar (bayar 3 bulan di muka).
  4. Harga per unit: selling_plan_allocation.unit_price, bisa beda antar alokasi, satuannya dari variant.
  5. Badge langganan: tampil kalau selling_plan.recurring_deliveries. Tapi jangan tampilin badge kalau produknya subscription-only, biar gak berantakan.
  6. Penyesuaian harga: selling_plan.price_adjustments (persen atau nominal), dipakai buat teks “Subscription - Save 10%”. Kalau harganya berubah seiring waktu, tampilin penghematan terbesar: “Save up to 30%”.

Dua pola tampilan harga:

  1. Komponen harga utama yang diupdate waktu plan/varian berubah. Tampilin harga plan dan penghematannya dibanding beli sekali, plus badge biar jelas penghematannya bersyarat.
  2. Harga inline di sebelah tiap opsi plan. Asosiasinya lebih kuat, lebih enak di mobile (harga utama mungkin di luar layar), dan lebih aman buat app yang nempel di theme pihak ketiga karena semuanya ada di dalam pemilih yang app kontrol, gak bentrok sama script theme.

Apapun polanya: harga plan jelas kelihatan setelah dipilih, perubahan harga per unit ikut ditampilin, dan buat item prabayar tampilin harga per pengiriman biar bisa dibandingin sama beli sekali.

3. Pemilih Plan

  1. Label “subscriptions” (product.requires_selling_plan dan product.selling_plan_groups): tampil kalau ada beli sekali + minimal satu grup, atau gak ada beli sekali tapi ada banyak grup. Gak tampil kalau gak ada grup, atau subscription-only dengan satu grup (nama grupnya tetap di posisinya, tanpa radio).
  2. Beli sekali (requires_selling_plan == false): dipilih default waktu load pertama. Waktu pembeli berinteraksi, pertimbangkan nglipat grup yang gak dipilih. Nonaktifin grup yang gak tersedia buat varian itu. Layout vertikal (berjajar horizontal sempit di layar kecil). Pakai radio, bukan tombol, biar gak bersaing sama tombol submit.
  3. Nama grup (selling_plan_group.name): selalu tampil, harus nyatain manfaatnya (“Subscribe and save 10%”).
  4. Harga inline (per_delivery_price): tambahin “each” di beli sekali dan grup biar konsisten, dan “from” kalau harganya berubah seiring waktu (“from $7.00”).
  5. Nama opsi plan (selling_plan_option.name): jangan pernah disembunyiin. Nilainya sering ditulis dengan asumsi namanya kelihatan: nama “Delivery every”, nilai “Month”/“Week”. Tanpa nama, “Month” gak ada konteksnya.
  6. Nilai opsi (selling_plan_option.value): jangan tulis harga pasti (“Save $5 a week”), karena salah waktu mata uang berganti. Persen boleh.

Subscription-only: nama grup dan harga inline tetap di dalam wadah pemilih, biar hubungan pilihan dan per_delivery_price tetap kuat.

Jumlah opsi: 4 atau kurang → radio (semua kelihatan), lebih dari 4 → dropdown.

Waktu varian berganti dan opsi yang dipilih jadi gak tersedia, tiga hal harus terjadi:

  1. Opsi yang gak tersedia dilepas pilihannya, pembeli harus milih lagi.
  2. Opsi yang gak tersedia buat varian itu dinonaktifin.
  3. Tombol submit tetap aktif. Nonaktifin tombol ngilangin kesempatan nampilin error dan bikin produk kelihatan “gak tersedia”, yang salah. Biarin pembeli klik, cegah add to cart, scroll ke bagian yang bermasalah, dan tampilin pesan kenapa dan apa yang harus dilakuin.

4. Detail Plan

  1. Baris harga berulang (price_adjustments): nyatain jumlah siklus di harga sekarang dan harga berikutnya: “first payment 9.00”. Pakai “each” kalau siklusnya lebih dari satu: “first 3 payments 9.00”. Pakai “free” buat $0.00.
  2. Deskripsi plan (selling_plan.description): teks promosi merchant, syarat, kebijakan pembatalan. Jangan tulis harga pasti. Pertimbangkan link ke kebijakan langganan: shop.subscription_policy nyediain halaman /policies/subscription-policy yang diedit merchant di Settings > Legal, dan isinya juga tampil di checkout.

Transparansi soal perubahan harga: kalau ada insentif harga awal (“Save $10 on the first 3 deliveries”), jelasin pembayaran sekarang dan perubahan ke depan. Kurang informasi bikin pembeli gak percaya, dan di beberapa negara/negara bagian ada undang-undang kejelasan harga yang bisa bikin merchant digugat.

5. Call to Action

Kalau produk wajib plan atau plan lagi dipilih, ganti teks tombol jadi yang spesifik langganan, misalnya “Add subscription to cart”, dan biarin merchant ngustomisasinya lewat locale.

6. Cart dan Order

Di cart, notifikasi cart (dari respons JSON Cart API), dan detail order: tampilin selling_plan.name aja, jangan daftar nilai opsi di sebelahnya, karena nama plan udah dimaksudkan sebagai ringkasan yang akurat.

Nama plan yang bagus: ringkas, nyatain frekuensi pengiriman dan periode prabayar kalau ada, tanpa nominal uang (pakai persen), dan bukan tempat marketing karena teks ini tampil di cart, checkout, order, dan admin merchant.

7. Mata Uang dan Pembulatan

Merchant bisa jual dalam banyak mata uang, dengan pemilih mata uang dan aturan pembulatan harga. Jadi app dan merchant jangan nulis harga di string apapun yang dilihat pembeli (“5”), karena gak ikut konversi dan pembulatan. Pakai persen: “Deliver every week (Save 20%)”. Properti harga dari Liquid dan JSON udah mencerminkan mata uang sesi pembeli dan pembulatannya.

Ringkasan

  1. Selling plan group ≈ produk, selling plan ≈ varian, dihubungin ke varian lewat selling_plan_allocations. Di product/collection lewat variant, di cart/order lewat line_item.
  2. Form produk: loop grup dan alokasi varian, radio “One-time purchase” (kecuali requires_selling_plan), radio per plan dengan data-* harga terformat, dan hidden input name="selling_plan" yang nilainya ID plan atau kosong.
  3. JavaScript: klon input selling_plan ke tiap form /cart/add, dengerin varian (event change + MutationObserver di input id), tampilin <section> varian aktif, update input dan harga.
  4. Cart: tampilin line_item.selling_plan_allocation.selling_plan.name, opsional <select> dari line_item.variant.selling_plan_allocations yang ngirim selling_plan ke /cart/change.js.
  5. Checkout charge: percentage atau price, langganan selalu 100%, atau langsung checkout_charge_amount.
  6. Order: selling_plan.name di customers/order.liquid.
  7. UX harga: price, compare_at_price, per_delivery_price (prabayar), unit_price, badge cuma kalau bukan subscription-only, penghematan dari price_adjustments (“Save up to 30%”), pola harga utama atau inline.
  8. UX pemilih: beli sekali default, radio vertikal, nama grup dan nama opsi selalu tampil, ≤4 opsi radio, >4 dropdown, waktu varian berganti lepas dan nonaktifin opsi tapi jangan nonaktifin tombol submit.
  9. UX detail: jelasin siklus harga (“first 3 payments 9.00”), link shop.subscription_policy, transparansi perubahan harga, CTA “Add subscription to cart”.
  10. Nama plan ringkas tanpa nominal uang, tampil apa adanya di cart/checkout/order, dan jangan pernah tulis harga di string, pakai persen.

Bab selanjutnya kita bahas dua opsi pembelian lain yang pakai mekanisme selling plan yang sama: pre-order (bayar sebagian atau penuh buat barang yang belum ada) dan try before you buy (bayar nanti setelah nyoba), beserta tampilan checkout charge dan pedoman UX-nya.