Shopify Plus Checkout Extension: Koleksiyona Bağlı Ürün Önerme Yapısını Sıfırdan Kurun
Shopify Plus Checkout Extension: Koleksiyona Bağlı Ürün Önerme Yapısını Sıfırdan Kurun
Shopify Plus checkout'ta belirli bir koleksiyonun ürünlerini sipariş özeti altında önerme rehberi. Gerçek bir implementasyonda 18 deploy'da öğrenilen 6 tuzak.
Shopify Checkout UI Extension, checkout sayfasına uygulama üzerinden özel arayüz bileşeni ekleyen Shopify geliştirme yüzeyidir. Bilgi, kargo ve ödeme adımlarındaki extension'lar yalnızca Shopify Plus ve Partner geliştirme mağazalarında çalışır; Teşekkürler ve Sipariş durumu sayfalarındakiler Starter dışındaki bütün planlarda çalışır. Bu rehber, sipariş özetinin altına koleksiyona bağlı ürün önerisi ekleyen bir extension'ı Preact ve Storefront API ile kuruyor.
Shopify Plus checkout'ta belirli bir koleksiyonun ürünlerini sipariş özeti altında önermenin adı Checkout UI Extension'dır. Kurulum belgesi 80 sayfayı aşıyor, ama asıl zorluğun nerede gizlendiğini o belgeler söylemiyor. Bu yazı, gerçek bir implementation sırasında 18 deploy'da öğrenilen 6 tuzağı tam kod örnekleriyle birlikte aktarıyor.
Sonunda elinizde şu çalışacak: sipariş özetinin toplam satırının hemen altında, Storefront koleksiyonundan dinamik ürünler çeken, "Ekle" ile adet stepper'ı arasında geçiş yapan ve çift tıklama yarış koşuluna karşı korunan bir extension. Merchant, Shopify checkout editöründen hangi koleksiyonun kullanılacağını kod değiştirmeden ayarlayabilir.
Ne İnşa Edeceğiz
Checkout upsell extension'ı, Shopify Plus'a özel bir API yüzeyini kullanır: Checkout UI Extensions. Standart Shopify planlarında checkout'a kod enjekte etmek mümkün değil. Bu nedenle extension geliştirme Shopify Plus veya Shopify Developers Preview kapsamında kalır. Shopify Plus'ın standart plandan asıl farklılaştığı nokta tam olarak burasıdır; bu farkın kapsamını Shopify Plus Nedir? yazımızda ele aldık.
İnşa edeceğimiz bileşen şuna benzer:
+------------------------------------------------------------+ | Bunları da beğenebilirsiniz | | | | [ 64px ] Ürün adı [ Ekle ] | | [ img ] ₺249,90 | | | | [ 64px ] Ürün adı [ - ] 2 [ + ] | | [ img ] ₺189,90 | +------------------------------------------------------------+
Üç kolon: 64 piksel thumbnail, esnek başlık + fiyat bloğu, ve sepete eklenmemiş ürün için "Ekle" butonu, sepetteyse miktar stepper'ı. Stack: Preact + JSX, Shopify checkout web component'leri (<s-grid>, <s-stack>, <s-button>), shopify.query üzerinden Storefront API. Harici CSS yok, harici state library yok. ~330 satır, tek dosya.
# Storefront API çağrıları (shopify.query) için zorunlu [extensions.capabilities] api_access = true
Kritik: api_access = true satırı olmadan Storefront API'ye yapılan her çağrı sessizce başarısız olur. Bu, kurulumun en kolay atlanabilen ve en uzun süre kafa karıştıran adımı.
Tuzak 1: api_access Capability - Dokümanda İki Kez Geçiyor
shopify.query(...) ile Storefront API'ye ilk çağrıyı yaptığınızda browser console'unda şu hatayı görürsünüz:
Uncaught (in promise) TypeError: shopify.query is not a function
Shopify Partners dashboard'unda "Network access" adında bir toggle var. Bu toggle harici URL'lere istek atmak içindir; Storefront API için farklı bir capability gerekiyor. İkisi birbirinin yerine geçmez.
Hata mesajı olmadan ne olur: useEffect sessizce başarısız olur, extension sıfır ürünle render olur ve ekranda hiçbir hata görünmez. Tek sinyal console mesajıdır UI düzeyinde hiçbir şey bozulmuş gibi görünmez.
Çözüm shopify.extension.toml'a tek satır eklemektir:
# Storefront API (shopify.query) için gereken capability [extensions.capabilities] api_access = true # network_access yalnız harici URL'lere istek atmak içindir; Storefront API için gerekmez
Bu satır shopify.query çağrısını ve dolayısıyla tüm GraphQL Storefront API sorgularını açar. Yeni extension oluşturduğunuzda ilk iş bu satırı eklemek, sonra kodlamaya başlamaktır.
Tuzak 2: Render Hedefi Seçimi Conversion Rate'i Doğrudan Etkiler
Checkout extension'ları piksel koordinat almaz; DOM'da önceden tanımlanmış hedeflere enjekte edilir. Sipariş özeti panelinde kullanılabilir hedefler:
Target
Render Konumu
Upsell için Uygun mu?
purchase.checkout.cart-line-list.render-after
Ürün listesinin altı, toplam satırından önce
Hayır - Toplam satırını ekranın altına iter.
purchase.checkout.reductions.render-before
İndirim kodu kutusunun üstü
Hayır - Yanlış bağlam, indirim alanıyla karışır.
purchase.checkout.reductions.render-after
İndirim kodu kutusunun altı
Kısmi - Toplam tutar görünür kalır ama sayfa üzerindeki yer değişkendir.
purchase.checkout.block.render
Merchant'ın (Satıcının) checkout editöründe belirlediği dinamik yer
Evet - Tam kontrol sağlar, tam olarak toplam alanının altına yerleştirilebilir.
Çoğu Shopify tutorial'ı cart-line-list.render-after kullanır. Pratikte bu hedef bir sorun yaratır: upsell şeridi sipariş toplamını görünür alanın dışına iter. Müşteri "Ekle" butonuna tıklamadan önce ne ödediğini görmek ister; toplam ekranın altındaysa bu bağlantı kopar.
purchase.checkout.block.render, extension'ı checkout editöründe bağımsız bir App block olarak sunar. Merchant bu bloğu sol panelden sürükleyerek "Cost summary'nin altı"na yerleştirir. Kurulum adımı tek bir drag-drop'tur; karşılığında tam yerleştirme kontrolü ve gelecekteki değişikliklerde esneklik kazanırsınız.
shopify.extension.toml'da targeting bu şekilde tanımlanır:
[[extensions.targeting]] target = "purchase.checkout.block.render" module = "./src/Checkout.jsx" # Editörde ilk yerleşim önerisi: sipariş özetinde toplam satırlarının altı. # Mobilde bu yerleşim Öde butonunun altına düşer; merchant bloğu editörden taşıyabilir. default_placement = "ORDER_SUMMARY4"
Tuzak 3: camelCase Zorunluluğu - Sessiz Hata
Shopify checkout extension'ları custom element olarak gelir: <s-grid>, <s-stack>, <s-button>, <s-image>. Preact JSX'te bu element'lerin attribute'ları hem kebab-case hem camelCase yazılabilir görünür. Sadece camelCase çalışır.
@shopify/ui-extensions/preact register'ı, JSX prop adlarını doğrudan component property setter'larına bağlar. Setter'lar yalnızca camelCase isimlerine yanıt verir; kebab-case attribute'lar okunmaz ve component default'una düşer.
Hata mesajı yok. Ekranda sadece yanlış layout görünür. Etkilenen prop'lar: gridTemplateColumns, alignItems, inlineSize, borderRadius, paddingBlock, paddingInline, labelAccessibilityVisibility, accessibilityLabel. Checkout component'inin "stilini almıyorsa" kontrol edilecek ilk nokta budur.
Pratik kural: Her yeni <s- component'ine prop yazarken önce camelCase olduğunu kontrol edin. Tek kelimeli prop'larda (gap, border, background) fark yok; iki kelimeli her prop'ta bu kural geçerli.
Kompakt − / + stepper butonları için s-button'a icon="minus" ve icon="plus" yazmak mantıklı görünür. Sonuç boş, kenarlıklı bir kare olur.
s-button checkout surface'inde temel tipten yalnızca belirli prop'ları miras alır:
// @shopify/ui-extensions, surfaces/checkout/components/Button.d.ts (2026-07) // icon temel tipte (ButtonProps$1) tanımlı ama bu listede yok export interface ButtonElementProps extends Pick< ButtonProps$1, | 'accessibilityLabel' | 'command' | 'commandFor' | 'disabled' | 'href' | 'id' | 'inlineSize' | 'interestFor' | 'loading' | 'target' | 'tone' | 'type' | 'variant' > { // tone, type, variant ve target burada daraltılmış tiplerle yeniden tanımlanır }
// Olay olarak yalnız onClick açılır export interface ButtonEvents extends Pick {}
icon bu Pick<> içinde yok. JSX'te yazarsınız, component okumaz, hiçbir şey render olmaz. Bu pattern checkout surface'indeki birçok component için geçerli: base type'ta var ama element seviyesinde açılmamış prop'lar sessizce yutulur.
Çözüm stepper butonunu s-clickable + s-icon ile inşa etmektir:
function StepButton({icon, label, disabled, onClick}) { return ( onClick={onClick} disabled={disabled} accessibilityLabel={label} // border="base base": kalınlık + renk. Yalnız "base" görünmez bir çerçeve çizer border="base base" borderRadius="base" paddingBlock="small-200" paddingInline="small-100" >
); }
İki detay daha:
border="base base" ikinci base renk değeridir. Sadece border="base" yazarsanız yalnızca kalınlık atanır, transparent 1px border render olur, hiçbir şey görünmez. Mutlaka renk değeri eklenmelidir.
paddingBlock ve paddingInline dikey/yatay eksenlerini bağımsız olarak kontrol eder. Kare görünümlü ama dikdörtgen içerikli butonlarda kullanışlıdır.
Tuzak 5: s-number-field Daraltılamaz
Shopify'ın s-number-field component'i controls="stepper" özelliğiyle gerçekten Polaris kalitesinde görünür: tek bordered kutu içinde − ve + kontrolleri, klavye desteği, tüm erişilebilirlik özellikleri:
Sorun: bu component'in minimum inline genişliği var ve daraltılamıyor. inlineSize="fit-content" prop'u s-number-field'da çalışmaz. 64px thumbnail ve ürün başlığıyla aynı satırda kullandığınızda kontrol, satırın tamamına yayılır ve layout bozulur.
Yaklaşım
Görünüm
Kompakt Satırda Çalışır mı?
s-number-field controls="stepper"
Native Polaris, bağlı input + butonlar
Hayır - Daraltılamaz, layout bozulur.
s-clickable + s-icon ile özel stepper
Üç ayrı eleman, bağlı-input görüntüsü yok
Evet - Tam kontrol sağlar, kompakt olarak sorunsuz çalışır.
Thumbnail + başlık + miktar kontrolünün aynı satırda olduğu layout için özel stepper kullanın:
s-number-field, quantity input'unun kendi satırını aldığı geniş arayüzlerde kullanışlıdır. Satırı paylaşması gereken her yerleşimde özel stepper tercih edin.
Tuzak 6: Async Mutasyon Yarış Koşulu ve Global busyId
applyCartLinesChange async bir işlemdir. Müşteri + butonuna hızlıca iki kez tıkladığında iki updateCartLine çağrısı birbirinin üzerine biner ve sonuç yanlış adet olabilir. Şerit boyunca birden fazla ürüne arka arkaya tıklandığında response'lar araya girebilir.
Nodus Works'ün uyguladığı pattern: component'in tepesinde tek bir busyId state'i. Sadece variantId tutar hangi ürün üzerinde işlem yapıldığını.
// Şeritteki bütün satırlar için tek kilit: o an işlenen variantId ya da null const [busyId, setBusyId] = useState(null); // Aynı karede gelen ikinci tıklama state güncellenmeden önce ref'e takılır const busyRef = useRef(null);
async function runCartChange(variantId, change) { if (busyRef.current) return; busyRef.current = variantId; setBusyId(variantId); try { const result = await shopify.applyCartLinesChange(change); // result.message kullanıcıya gösterilmez; yalnız hata ayıklama içindir if (result.type === 'error') console.warn(result.message); } catch (error) { console.error(error); } finally { busyRef.current = null; setBusyId(null); } }
Her offer card iki prop alır:
key={offer.variantId} offer={offer} line={lineByVariant.get(offer.variantId)} // Bu satırda işlem sürüyor: CTA'da loading gösterilir isBusy={busyId === offer.variantId} // Başka satırda işlem sürüyor: bu satır disabled disabledOthers={busyId !== null && busyId !== offer.variantId} onAdd={() => addOffer(offer)} onQuantityChange={(next) => changeQuantity(offer, next)} />
isBusy olan satırın CTA'sında loading={true} gösterilir. Diğer tüm satırlar disabled olur. Müşteri hangi satırın işlem yaptığını görür ve aynı anda iki mutasyon fire edilemez.
"Tek buton var, gerek yok" gibi bir yargıyla bu pattern atlanmamalı. İkinci bir buton stok uyarısı, kampanya butonu, bundle önerisi bir hafta içinde eklenebilir ve yarış koşulu o noktada kaynağa izlenmesi güç bir bug olarak geri döner.
Koleksiyon Sorgusunu Doğru Yazmak
Storefront API sorgusu shopify.query üzerinden yapılır ve api_access = true gerektirir. Koleksiyon handle ve maksimum ürün sayısı merchant settings'ten okunur:
sortKey: BEST_SELLING koleksiyonun en çok satılanlarını üstte getirir. Koleksiyon Shopify admin'de "el ile sıralanmış" olarak ayarlanmışsa bu sort key'i değiştirebilirsiniz.
Settings yapılandırması shopify.extension.toml'da şöyle tanımlanır:
[extensions.settings] [[extensions.settings.fields]] key = "collection_handle" type = "single_line_text_field" name = "Collection handle" description = "Önerilecek ürünlerin geldiği koleksiyonun handle değeri"
[[extensions.settings.fields]] key = "max_products" type = "number_integer" name = "Maksimum ürün sayısı" description = "Şeritte gösterilecek ürün sayısı (1-12, boşsa 4)"
[[extensions.settings.fields.validations]] name = "min" value = "1"
[[extensions.settings.fields.validations]] name = "max" value = "12"
Merchant bu ayarları Shopify checkout editöründen, kod değiştirmeden günceller.
Tam Extension Kodu
Aşağıdaki Checkout.jsx, yukarıdaki altı tuzağın tamamını çözmüş halidir:
import '@shopify/ui-extensions/preact'; import {render} from 'preact'; import {useEffect, useMemo, useRef, useState} from 'preact/hooks';
// Görünen metinler; çok dilli mağazada locales/*.json + shopify.i18n.translate kullanın const TEXT = { heading: 'Bunları da beğenebilirsiniz', add: 'Ekle', addLabel: 'sepete ekle', decrease: 'Adedi azalt', increase: 'Adedi artır', updating: 'Sepet güncelleniyor', };
export default function extension() { render(, document.body); }
function Extension() { // Ayarlar merchant tarafından checkout editöründe girilir; boşsa varsayılana düş const settings = shopify.settings.value; const collectionHandle = String(settings.collection_handle ?? '').trim(); const maxProducts = clamp( Number(settings.max_products) || DEFAULT_MAX_PRODUCTS, 1, MAX_PRODUCTS_LIMIT, ); // Fiyatlar alıcının ülkesindeki para birimiyle gelir const country = shopify.localization.country.value?.isoCode ?? null;
// lines bir signal: sepet değişince bileşen yeniden render olur const cartLines = shopify.lines?.value ?? []; const lineInstructions = shopify.instructions.value.lines;
const [offers, setOffers] = useState([]); const [loading, setLoading] = useState(true); // Şeritteki bütün satırlar için tek kilit: o an işlenen variantId ya da null const [busyId, setBusyId] = useState(null); // Aynı karede gelen ikinci tıklama state güncellenmeden önce ref'e takılır const busyRef = useRef(null);
function changeQuantity(offer, nextQuantity) { // Satır id'leri sabit değil; mutasyondan hemen önce güncel lines'tan oku const line = shopify.lines.value.find( (item) => item.merchandise.id === offer.variantId, ); if (!line) return undefined;
// Editörde ayar girilene kadar settings boş gelir: hiçbir şey çizme if (!collectionHandle) return null; if (loading) return ; if (offers.length === 0) return null;
Checkout UI Extension Shopify Plus olmadan çalışır mı? Checkout adımlarında hayır: bilgi, kargo ve ödeme adımlarına extension eklemek yalnızca Shopify Plus ve Shopify Partner geliştirme mağazalarında mümkündür. Teşekkürler ve Sipariş durumu sayfası extension'ları ise Starter dışındaki bütün planlarda çalışır. Standart planlarda checkout adımlarının kodu değiştirilemez; bu Shopify'ın bilinçli bir güvenlik kısıtlamasıdır. Kod yazmadan ürün önerisi kurmak isteyen standart plan mağazaları Bundle Works ile bundle, upsell ve cross-sell kullanabilir.
Birden fazla koleksiyondan ürün gösterebilir miyim? Evet. settings.fields'a ikinci bir collection_handle alanı ekleyebilir, iki ayrı sorgu yapabilir ve sonuçları birleştirebilirsiniz. Ancak iki ayrı API çağrısı yaptığınız için useEffect dependency array'ini ve loading state'ini buna göre düzenlemeniz gerekir.
Storefront API çağrıları ücretli mi? Storefront API, Shopify Plus fiyatlandırmasına dahildir. Checkout extension başına ek API maliyet yoktur. Ancak aşırı yüksek rate limit ihlali (dakikada binlerce istek) throttle tetikleyebilir. maxProducts değerini 12'nin altında tutmak yeterlidir.
Extension Analytics'te görünmüyor, neden? Checkout extension'ların purchase.checkout.block.render hedefini kullanması için merchant'ın editörde App block'u aktif etmiş olması gerekir. Sadece deploy etmek yetmez; editörde yerleştirilmeden extension render olmaz ve Analytics'te görünmez.
s-button'da neden onClick çalışmıyor? Checkout surface'inde s-button'un onClick prop'u buttonProps değil doğrudan prop olarak yazılır. Ayrıca disabled={true} olan butonlarda onClick tetiklenmez bu beklenen davranıştır. disabledOthers state'ini kontrol edin.
Upsell ürünü sepete eklendi ama anlık yansımıyor gibi görünüyor? lines?.value Shopify'ın cart state reaktivitesine bağlıdır. useMemo bağımlılık array'indeki cartLines değeri güncellenince lineByVariant map'i yeniden hesaplanır. Gecikme varsa Shopify checkout'un kendi cart state güncelleme döngüsünden kaynaklanır; extension tarafında yapılacak bir şey yoktur.
Türkçe fiyat formatı için ne yapmalıyım? i18n.formatCurrency(amount, { currency: "TRY" }) kullanabilirsiniz. Ancak para birimi mağaza ayarlarından gelir; price.currencyCode değerini sabit "TRY" ile değiştirmek çok dövizli mağazalarda sorun yaratır. price.currencyCode değerini kullanmak daha güvenlidir.
Kontrol Listesi: Deploy Öncesi
Her yeni checkout extension deploy'undan önce şu yedi noktayı kontrol etmek, ilk deploy'da çalışan kod yazmanın kısayoludur:
shopify.extension.toml'da api_access = true var mı?
Target olarak purchase.checkout.block.render kullanıldı mı?
Tüm çok-kelimeli JSX prop'ları camelCase mi?
s-button kullanıldığı yerlerde icon prop'u yazılmadı mı?
Kompakt satır layout'larında s-number-field yerine özel stepper kullanılıyor mu?
Tüm cart mutasyonları tek busyId üzerinden mi geçiyor?
border değerleri renk içeriyor mu ("base base" gibi)?
Bu liste, Nodus Works'ün Shopify Plus mağazalarında checkout extension geliştirirken biriken 18 deploy deneyiminin distile edilmiş halidir. Listedeki her madde en az bir boşa giden deploy'a karşılık gelir.
Shopify Plus checkout özelleştirme projenizi planlarken Nodus Works teknik ekibiyle görüşmek için Shopify Plus çözümleri sayfamızı inceleyebilirsiniz.