Blog içeriği
May 30, 2026

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.

Gereksinimler ve Kurulum

Checkout UI Extension geliştirmek için:

  • Shopify Plus veya Partner geliştirme mağazası
  • Shopify CLI (npm install -g @shopify/cli)
  • Node.js 18+

Proje iskeleti:

shopify app generate extension --template checkout_ui --name checkout-upsell

Bu komut shopify.extension.toml ve src/Checkout.jsx oluşturur. Başlangıç yapılandırması:

# uid, handle ve name değerlerini Shopify CLI üretir
api_version = "2026-07"

[[extensions]]
name = "Checkout Upsell"
handle = "checkout-upsell"
type = "ui_extension"
uid = ""

[[extensions.targeting]]
target = "purchase.checkout.block.render"
module = "./src/Checkout.jsx"

# 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.

// Yanlış: kebab-case prop okunmaz, bileşen varsayılan değere düşer


{/* başlık + fiyat, buton */}


// Doğru: camelCase prop doğrudan property setter'a bağlanır


{/* başlık + fiyat, buton */}

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.

Tuzak 4: s-button Checkout Surface'inde icon Prop'unu Açmıyor

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:

  label="Adet"
labelAccessibilityVisibility="exclusive"
controls="stepper"
value={String(line.quantity)}
min={0}
max={99}
step={1}
onChange={(event) => onQuantityChange(Number(event.currentTarget.value))}
/>
{/* inlineSize bu elemanda açılmamış; alan satırın tamamına yayılır */}

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:

// 64px thumbnail | esnek başlık + fiyat | stepper



{offer.title}
{price}

quantity={line.quantity}
isBusy={isBusy}
disabled={disabledOthers}
onDecrease={() => onQuantityChange(line.quantity - 1)}
onIncrease={() => onQuantityChange(line.quantity + 1)}
/>


function QuantityStepper({quantity, isBusy, disabled, onDecrease, onIncrease}) {
const locked = isBusy || disabled;
return (


{isBusy ? (

) : (
{quantity}
)}


);
}

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:

const COLLECTION_QUERY = `
query UpsellCollection($handle: String!, $first: Int!, $country: CountryCode)
@inContext(country: $country) {
collection(handle: $handle) {
products(first: $first, sortKey: BEST_SELLING) {
nodes {
id
title
featuredImage {
url
altText
}
variants(first: 1) {
nodes {
id
availableForSale
image {
url
altText
}
price {
amount
currencyCode
}
}
}
}
}
}
}
`;

// 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;

useEffect(() => {
if (!collectionHandle) {
setOffers([]);
setLoading(false);
return undefined;
}

let cancelled = false;

async function loadOffers() {
setLoading(true);
try {
const {data, errors} = await shopify.query(COLLECTION_QUERY, {
variables: {handle: collectionHandle, first: maxProducts, country},
});
if (errors?.length) console.warn('Storefront API', errors);
if (!cancelled) setOffers(toOffers(data));
} catch (error) {
// api_access kapalıysa hata buraya düşer ve yalnız console'da görünür
console.error(error);
if (!cancelled) setOffers([]);
} finally {
if (!cancelled) setLoading(false);
}
}

loadOffers();
return () => {
cancelled = true;
};
}, [collectionHandle, maxProducts, country]);

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',
};

const DEFAULT_MAX_PRODUCTS = 4;
const MAX_PRODUCTS_LIMIT = 12;

const COLLECTION_QUERY = `
query UpsellCollection($handle: String!, $first: Int!, $country: CountryCode)
@inContext(country: $country) {
collection(handle: $handle) {
products(first: $first, sortKey: BEST_SELLING) {
nodes {
id
title
featuredImage {
url
altText
}
variants(first: 1) {
nodes {
id
availableForSale
image {
url
altText
}
price {
amount
currencyCode
}
}
}
}
}
}
}
`;

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);

useEffect(() => {
if (!collectionHandle) {
setOffers([]);
setLoading(false);
return undefined;
}

let cancelled = false;

async function loadOffers() {
setLoading(true);
try {
const {data, errors} = await shopify.query(COLLECTION_QUERY, {
variables: {handle: collectionHandle, first: maxProducts, country},
});
if (errors?.length) console.warn('Storefront API', errors);
if (!cancelled) setOffers(toOffers(data));
} catch (error) {
// api_access kapalıysa hata buraya düşer ve yalnız console'da görünür
console.error(error);
if (!cancelled) setOffers([]);
} finally {
if (!cancelled) setLoading(false);
}
}

loadOffers();
return () => {
cancelled = true;
};
}, [collectionHandle, maxProducts, country]);

const lineByVariant = useMemo(() => {
const map = new Map();
for (const line of cartLines) map.set(line.merchandise.id, line);
return map;
}, [cartLines]);

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);
}
}

function addOffer(offer) {
return runCartChange(offer.variantId, {
type: 'addCartLine',
merchandiseId: offer.variantId,
quantity: 1,
});
}

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;

if (nextQuantity <= 0) {
return runCartChange(offer.variantId, {
type: 'removeCartLine',
id: line.id,
quantity: line.quantity,
});
}

return runCartChange(offer.variantId, {
type: 'updateCartLine',
id: line.id,
quantity: nextQuantity,
});
}

// 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;

return (


{offers.map((offer) => (
key={offer.variantId}
offer={offer}
line={lineByVariant.get(offer.variantId)}
isBusy={busyId === offer.variantId}
disabledOthers={busyId !== null && busyId !== offer.variantId}
canAdd={lineInstructions.canAddCartLine}
canUpdate={lineInstructions.canUpdateCartLine}
canRemove={lineInstructions.canRemoveCartLine}
onAdd={() => addOffer(offer)}
onQuantityChange={(next) => changeQuantity(offer, next)}
/>
))}


);
}

function OfferCard({
offer,
line,
isBusy,
disabledOthers,
canAdd,
canUpdate,
canRemove,
onAdd,
onQuantityChange,
}) {
const price = shopify.i18n.formatCurrency(Number(offer.price.amount), {
currency: offer.price.currencyCode,
});

return (

{offer.image ? (
src={offer.image.url}
alt={offer.image.altText || offer.title}
aspectRatio="1"
objectFit="cover"
borderRadius="base"
inlineSize="fill"
loading="lazy"
/>
) : (

)}

{offer.title}
{price}

{line ? (
quantity={line.quantity}
isBusy={isBusy}
disabled={disabledOthers}
canDecrease={line.quantity > 1 ? canUpdate : canRemove}
canIncrease={canUpdate}
onDecrease={() => onQuantityChange(line.quantity - 1)}
onIncrease={() => onQuantityChange(line.quantity + 1)}
/>
) : (
variant="secondary"
loading={isBusy}
disabled={disabledOthers || !canAdd}
accessibilityLabel={`${offer.title}: ${TEXT.addLabel}`}
onClick={onAdd}
>
{TEXT.add}

)}

);
}

function QuantityStepper({
quantity,
isBusy,
disabled,
canDecrease,
canIncrease,
onDecrease,
onIncrease,
}) {
const locked = isBusy || disabled;
return (

icon="minus"
label={TEXT.decrease}
disabled={locked || !canDecrease}
onClick={onDecrease}
/>
{isBusy ? (

) : (
{quantity}
)}
icon="plus"
label={TEXT.increase}
disabled={locked || !canIncrease}
onClick={onIncrease}
/>

);
}

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"
>


);
}

function toOffers(data) {
const products = data?.collection?.products?.nodes ?? [];
return products
.map((product) => {
// Satılabilir ilk varyantı öner; stokta olmayan ürünü atla
const variant = product.variants.nodes[0];
if (!variant?.availableForSale) return null;
return {
variantId: variant.id,
title: product.title,
price: variant.price,
image: variant.image ?? product.featuredImage,
};
})
.filter(Boolean);
}

function clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}

Deployment ve Merchant Kurulumu

Extension'ı deploy etmek için

Shopify checkout editöründe Checkout Upsell app bloğunun Order summary içinde Cost summary altına yerleştirilmesi
# Geliştirme mağazasında önizleme (checkout editöründe test için)
shopify app dev

# Yeni uygulama sürümü oluşturur ve yayınlar
shopify app deploy

Deploy tamamlandıktan sonra merchant şu adımları izler:

  1. Shopify admin → Settings → Checkout → Customize (checkout ve hesap editörü açılır)
  2. Checkout sayfasını seç
  3. Sol paneldeki "App blocks" bölümünde "Checkout Upsell"i bul
  4. "Order summary" bölümüne sürükle, "Cost summary"nin altına yerleştir
  5. "Collection handle" alanına upsell koleksiyonunun handle'ını gir
  6. Kaydet

Koleksiyon handle, Shopify admin → Products → Collections → [Koleksiyon] → URL'nin son kısmından okunur.

Sıkça Sorulan Sorular

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:

  1. shopify.extension.toml'da api_access = true var mı?
  2. Target olarak purchase.checkout.block.render kullanıldı mı?
  3. Tüm çok-kelimeli JSX prop'ları camelCase mi?
  4. s-button kullanıldığı yerlerde icon prop'u yazılmadı mı?
  5. Kompakt satır layout'larında s-number-field yerine özel stepper kullanılıyor mu?
  6. Tüm cart mutasyonları tek busyId üzerinden mi geçiyor?
  7. 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.