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
- Pemilih selling plan di halaman produk, buat milih plan.
- JavaScript, buat ngupdate plan yang tersedia waktu varian berganti, dan ngisi hidden input waktu plan dipilih.
- Tampilan selling plan di cart, nandain item yang pakai plan, opsional dengan pemilih buat nambah/ganti/hapus plan.
- Checkout charge di cart, jumlah yang harus dibayar saat checkout.
- Tampilan di halaman order pelanggan.
Pemilih di Halaman Produk
Pemilih bisa ditaruh di manapun ada form produk. Yang harus ada di form:
- Buat tiap
selling_plan_groupdiproduct.selling_plan_groups, output opsinya. - 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. - 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:
- 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. - Radio “One-time purchase” cuma kalau bukan
product.requires_selling_plan, dancheckedbawaannya (pedoman UX: beli sekali dipilih default). group_idsdirakit dari alokasi varian pakaimap: 'selling_plan_group_id' | uniq, terus tiap grup dicari pakaiwhere: 'id', group_id | first. Ini trik filter array dari 18 Filters String, Math, dan Array buat “join” dua array tanpa loop bersarang.- Buat produk subscription-only, plan pertama di grup pertama yang
checked. - 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. - Di luar semua itu, hidden input
selling_plandengan classselected-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:
appendSellingPlanInputs: nyari semua form denganactionmengandung/cart/adddi section, dan nyalin hidden inputselling_planke masing-masing. Inilah kenapa input aslinya di luar form: biar bisa diklon ke form manapun (termasuk form kedua buat installments).variantIdInput: nyari input/selectname="id". Perhatiin dia nyoba form kedua dulu (addToCartForms[1]), karena form pertama bisa jadi form installments yang gak boleh diganggu.listenToVariantChange: dengerin eventchangedi form, danMutationObserverdi atribut inputid, karena theme biasanya ngubahvaluehidden input itu secara programatik (yang gak nembak eventchange).handleVariantChange: tampilin<section>varian yang aktif, sembunyiin sisanya, update nilai hidden input, pasang ulang listener radio.handleRadioButtonChange: waktu plan dipilih, update hidden input (ID plan, ataunullbuat beli sekali) danupdatePrice.updatePrice: kalau ada harga coret dan beda dari harga, tampilin blok sale (.price__saledengan.price-item--regulardicoret dan.price-item--sale), kalau gak, blok reguler. Selector-nya ngikutin struktur harga Dawn, sesuaikan sama theme kamu.enablePerformanceObserver: dengerin requestfetchyang URL-nya mengandungvariant(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:
percentage: persen dari harga penuh yang dibayar di muka.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
- Hierarki visual yang jelas: pembeli langsung lihat penghematan langganan, opsi plan, dan syaratnya.
- Ungkap informasi bertahap dan logis: tiap keputusan pembeli ngaruh ke pilihan berikutnya, tampilin yang relevan aja.
- Integrasi mulus sama design system theme.
- Ikutin alur kerja merchant yang udah ada di admin.
- 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:
- Harga:
selling_plan_allocation.price, harus mencerminkan plan yang dipilih. - Harga coret:
selling_plan_allocation.compare_at_price. - Harga per pengiriman:
selling_plan_allocation.per_delivery_price, tampil cuma kalau beda dariprice, yaitu waktu plan-nya prabayar (bayar 3 bulan di muka). - Harga per unit:
selling_plan_allocation.unit_price, bisa beda antar alokasi, satuannya darivariant. - Badge langganan: tampil kalau
selling_plan.recurring_deliveries. Tapi jangan tampilin badge kalau produknya subscription-only, biar gak berantakan. - 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:
- Komponen harga utama yang diupdate waktu plan/varian berubah. Tampilin harga plan dan penghematannya dibanding beli sekali, plus badge biar jelas penghematannya bersyarat.
- 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
- Label “subscriptions” (
product.requires_selling_plandanproduct.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). - 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. - Nama grup (
selling_plan_group.name): selalu tampil, harus nyatain manfaatnya (“Subscribe and save 10%”). - Harga inline (
per_delivery_price): tambahin “each” di beli sekali dan grup biar konsisten, dan “from” kalau harganya berubah seiring waktu (“from $7.00”). - 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. - 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:
- Opsi yang gak tersedia dilepas pilihannya, pembeli harus milih lagi.
- Opsi yang gak tersedia buat varian itu dinonaktifin.
- 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
- 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. - Deskripsi plan (
selling_plan.description): teks promosi merchant, syarat, kebijakan pembatalan. Jangan tulis harga pasti. Pertimbangkan link ke kebijakan langganan:shop.subscription_policynyediain halaman/policies/subscription-policyyang 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
- Selling plan group ≈ produk, selling plan ≈ varian, dihubungin ke varian lewat
selling_plan_allocations. Di product/collection lewatvariant, di cart/order lewatline_item. - Form produk: loop grup dan alokasi varian, radio “One-time purchase” (kecuali
requires_selling_plan), radio per plan dengandata-*harga terformat, dan hidden inputname="selling_plan"yang nilainya ID plan atau kosong. - JavaScript: klon input
selling_planke tiap form/cart/add, dengerin varian (eventchange+MutationObserverdi inputid), tampilin<section>varian aktif, update input dan harga. - Cart: tampilin
line_item.selling_plan_allocation.selling_plan.name, opsional<select>dariline_item.variant.selling_plan_allocationsyang ngirimselling_planke/cart/change.js. - Checkout charge:
percentageatauprice, langganan selalu 100%, atau langsungcheckout_charge_amount. - Order:
selling_plan.namedicustomers/order.liquid. - UX harga:
price,compare_at_price,per_delivery_price(prabayar),unit_price, badge cuma kalau bukan subscription-only, penghematan dariprice_adjustments(“Save up to 30%”), pola harga utama atau inline. - 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.
- UX detail: jelasin siklus harga (“first 3 payments 9.00”), link
shop.subscription_policy, transparansi perubahan harga, CTA “Add subscription to cart”. - 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.