← Blog

React ve Next.js’te reddedilen ödemelere ve değişen fiyatlara dayanan tek sayfalık bir ödeme akışı nasıl kurulur

Bir ödeme formu kolaydır. Reddedilen ödemeler, 3-D Secure, Öde’den önce değişen fiyatlar ve hiç yanıt vermeyen ödemeler değildir. React ve Next.js’te tek sayfalık bir ödeme akışı.

Preet Suthar · · 6 dk okuma

  • React
  • Next.js
  • E-commerce
  • Payments

Çoğu ödeme sayfası eğitimi bir formla ve Stripe’ı çağıran bir Öde düğmesiyle biter. O kısım ilk denemede çalışır. Sipariş kaybettiren şey etrafındaki her şeydir: reddedilen kart, 3-D Secure doğrulaması isteyen banka, posta koduna göre değişen kargo ücreti, hiç geri dönmeyen istek ve Öde’ye ikinci kez basan müşteri.

React ve Next.js’te tek sayfalık bir ödeme akışını böyle yapılandırıyorum: önemli olan durumlar ve her biri için küçük bir kod parçası. shadcn/ui ya da başka herhangi bir bileşen setiyle çalışır. Zor olan kısım stil değil, akıştır.

Tek sayfa, katlanan üç adım

Tek sayfalık bir ödeme akışı her şeyi görünür tutar: adımlar arasında sayfa yüklemesi olmaz ve tarayıcının Geri düğmesi yarım bir formu çöpe atamaz. Ama tek bir uzun form bir duvardır. Çözüm aynı sayfada üç adımdır: iletişim, teslimat ve ödeme; ve aynı anda yalnızca biri açıktır.

Bir adım bittiğinde bir özet satırına katlanır; böylece tamamlanmış bir ödeme sayfası bir fiş gibi okunur:

Contact   [email protected]                         Change
Ship to   12 Mission St, San Francisco, CA 94110   Change
Method    Standard · Arrives Oct 26 – 28 · Free    Change

Arkasındaki durum küçüktür. Tamamlanan adımların listesini tutun ve açık olanı ondan türetin:

const steps = ["contact", "delivery", "payment"] as const
type Step = (typeof steps)[number]

function openStep(done: Step[], editing: Step | null): Step {
  return editing ?? steps.find((step) => !done.includes(step)) ?? "payment"
}

İki ayrıntı doğru hissettirir. Biri Değiştir’e bastığında odağı açılan adımın başlığına taşıyın; yoksa klavye ve ekran okuyucu kullanıcıları artık var olmayan bir düğmede kalır. Müşteri oturum açmışsa da iletişimi atlayın ve varsayılan adresiyle teslimattan başlayın.

Geniş ekranlarda sipariş özeti formun yanında durur. Telefonlarda formun üstünde, toplamı göstermeye devam eden bir "Sipariş özetini göster" çubuğuna katlanır, çünkü toplam insanların sürekli kontrol ettiği tek rakamdır.

Blur’da doğrula, Öde’de özetle

Bir alanın hatasını müşteri o alandan ayrıldığında gösterin, sonra yazdıkça canlı olarak güncelleyin ki düzeltildiği anda kaybolsun. İlk kez yazarken asla göstermeyin.

Form geçerli olana kadar Öde’yi devre dışı bırakmayın. Devre dışı bir düğme kimseye neyin eksik olduğunu söyleyemez. Basmalarına izin verin, sonra en üste her sorunu listeleyen tek bir özet koyun, her maddeyi kendi alanına bağlayın ve odağı özete taşıyın. GOV.UK Design System’ın hata özeti bu desen için iyi bir referanstır.

Ücretler ve vergiler adresi takip eder

Kargo ücretleri ve vergi adrese bağlıdır, bu yüzden ancak adres tamamlandığında yüklenebilirler. Yazmada kısa bir duraklama bekleyin, adres yeniden değiştiğinde önceki isteği iptal edin ve özette eskimiş bir rakam yerine "Hesaplanıyor…" gösterin.

useEffect(() => {
  if (!isComplete(address)) return
  const controller = new AbortController()
  const pause = setTimeout(async () => {
    setQuote({ status: "loading" })
    try {
      const [rates, tax] = await Promise.all([
        getRates(address, controller.signal),
        estimateTax(address, controller.signal),
      ])
      setQuote({ status: "ready", rates, tax })
    } catch {
      if (!controller.signal.aborted) setQuote({ status: "error" })
    }
  }, 300)
  return () => {
    clearTimeout(pause)
    controller.abort()
  }
}, [address])

Yeni ücretler geldiğinde, seçilen ücret hâlâ sunuluyorsa onu koruyun. Sunulmuyorsa en ucuzunu seçin ve bunu söyleyin: "Ekspres bu adres için kullanılamıyor." Yöntemi sessizce değiştirmek, insanların seçmedikleri kargo için ödeme yapmasına yol açar.

Hızlı ödeme önce gelir

Apple Pay, Google Pay, Shop Pay ve PayPal formun tamamının yerini alır; bu yüzden yerleri en üst, iletişim bölümünün üstüdür ve altlarında bir "Ya da kartla öde" ayırıcısı bulunur. Onları Öde düğmesinin yanına koymak, müşterinin daha hızlı yolu gördüğünde adresini çoktan yazmış olması demektir. Yalnızca tarayıcının gerçekten kullanabildiği cüzdanları render edin. Stripe’ın Express Checkout Element’i ve benzer SDK’lar bunu sizin için belirler.

Öde bir boolean değil, bir durum makinesidir

isSubmitting, Öde’den sonra olanları tanımlayamaz. Bunun yerine sunucunun yanıtına bir tip verin:

type PlaceOrderResult =
  | { status: "succeeded"; orderId: string }
  | { status: "declined"; code: string }
  | { status: "requires_action"; clientSecret: string }
  | { status: "changed"; changes: PriceChange[] }

Sonra her durumu bilinçli olarak ele alın.

Öde iki kez çalışamaz. Çift tıklama, React yeniden render etmeden önce iki işleyiciyi tetikler; bu yüzden disabled={pending} kontrolü yeterli değildir. Bir ref ile koruyun ve sipariş verilirken Değiştir düğmelerini kilitleyin ki sepet ödemenin altından kaymasın.

const placing = useRef(false)

async function pay() {
  if (placing.current) return
  placing.current = true
  try {
    handleResult(await placeOrder(draft, attemptKey))
  } finally {
    placing.current = false
  }
}

Her denemeye bir idempotency anahtarı verilir. Öde’ye basıldığında crypto.randomUUID() ile oluşturun ve ödeme isteğiyle birlikte gönderin. Stripe ve çoğu ödeme API’si bunu kabul eder; aynı anahtarla yeniden denenen bir istek iki kez ücret çekemez.

Ödeme sırasında sayfadan ayrılmadan önce sorulur. Ödeme sürerken bir beforeunload dinleyicisi ekleyin ve ödeme sonuçlanınca kaldırın.

Reddedilen ödemelerin "Bir şeyler ters gitti" değil, kelimelere ihtiyacı var

Reddedilen ödeme, ödeme sayfasındaki en yaygın hata ve metni en kötü yazılan durumdur. Geri aldığınız kodları insanlara ne yapacaklarını söyleyen cümlelere eşleyin, odağı mesaja taşıyın ve kart bilgilerini dolu tutun:

const declineCopy: Record<string, string> = {
  insufficient_funds: "Your card has insufficient funds. Try another card.",
  expired_card: "This card has expired. Check the date or use another card.",
  incorrect_cvc: "The security code doesn't match. Check it and try again.",
}

const fallbackDecline =
  "Your bank declined this payment. Try another card or contact your bank."

requires_action bir hata değildir. Bankanın 3-D Secure istediği anlamına gelir; bu, güçlü müşteri kimlik doğrulaması kapsamında Avrupa’da olağan bir durumdur. İşleniyor durumunu koruyun, bankanın doğrulamasını "Bankanızla onaylayın" başlıklı bir diyalogda açın ve sonuçlandığında devam edin.

Fiyat sepetten beri değiştiğinde

Fiyatlar, stok ve indirimler sepet ile Öde arasında değişebilir. Müşterinin gördüğü toplamı siparişle birlikte gönderin, sunucuda yeniden fiyatlayın ve farklıysa ücret çekmeyi reddedin:

"use server"

export async function placeOrder(draft: Draft, key: string) {
  const cart = await repriceCart(draft.lines)
  if (cart.total !== draft.expectedTotal) {
    return { status: "changed", changes: diffLines(draft, cart) } as const
  }
  return chargeAndCreateOrder(cart, draft, key)
}

İstemcide özeti güncelleyin, eski toplamın üstünü çizin ve her değişikliği sade bir dille listeleyin, örneğin "Ring vase artık $74, önceden $68 idi." Müşteri yeni tutarla yeniden Öde’ye basar. Ödeme sırasında tükenen bir ürün de aynı şekilde çalışır: onu toplamdan çıkarın, işaretleyin ve kaldırmalarına ya da onsuz devam etmelerine izin verin.

Ödeme hiç yanıt vermediğinde

Bazen istek takılı kalır. Zaman aşımından sonra gösterebileceğiniz en kötü şey "Tekrar dene"dir, çünkü ilk ücret çekimi gerçekleşmiş olabilir. Makul bir beklemenin ardından "Ödeme durumunuz kontrol ediliyor…" deyin ve bir sipariş olup olmadığını öğrenene kadar aynı idempotency anahtarıyla bir durum endpoint’ini yoklayın. Yeni bir denemeyi ancak sunucu eskisinin başarısız olduğunu söylediğinde sunun.

Bir kontrol listesi

  • Adımlar özet satırlarına katlanır ve Değiştir, odağı yeniden açılan adıma taşır.
  • Blur’da hatalar, Öde’de tek bir bağlantılı özet, devre dışı Öde düğmesi yok.
  • Ücretler ve vergi tam bir adres bekler, eskimiş istekleri iptal eder ve asla eskimiş bir toplam göstermez.
  • Hızlı ödeme cüzdanları en üstte; yalnızca tarayıcının desteklediği olanlar.
  • Öde’nin etrafında bir ref koruması, bir idempotency anahtarı ve bir beforeunload uyarısı.
  • Her ret kodu için özel metin, form dolu kalırken.
  • 3-D Secure bir hata değil, bir adım.
  • Değişen fiyatlar ve tükenen ürünler ödemeyi durdurur ve nedenini açıklar.
  • Zaman aşımı, yeniden ödeme teklif etmek yerine durumu kontrol eder.

shadcn/ui ile kurmak

shadcn/ui tüm bunlar için input’ları, radio grupları ve collapsible’ları verir. Yukarıdaki akış kendiniz yazdığınız kısımdır ve işin çoğu odur.

Bunu bir kez, ücretsiz HextaUI bileşenlerinden yapılmış HextaUI Pro’daki Checkout bloğu olarak kurdum. Sepeti ve üç callback’i, onGetRates, onEstimateTax ve onPlaceOrder’ı alır ve yukarıdaki dört sonucu ele alır. onPlaceOrder 20 saniye içinde yanıt vermezse yeniden ödeme teklif etmek yerine aynı idempotency anahtarıyla onCheckStatus’u yoklar. Server action’larla Stripe PaymentIntents, Shopify checkout devri ve Medusa için örneklerle gelir. Cüzdanlar, kartlar, reddedilen ödemeler ve 3-D Secure için Payment bloğunu, teslimat için de Address Form’u kullanır. Her bloğun, herhangi bir karar vermeden önce tıklayarak gezebileceğiniz canlı bir önizlemesi var.

Mağazanız Shopify üzerinde çalışıyorsa, yeniden kurmak yerine Shopify’ın barındırılan ödeme sayfasını kullanın. Özel bir ödeme akışı, ödemeyi kendiniz aldığınızda anlamlıdır.

SSS

Tek sayfalık ödeme, çok adımlı olandan daha mı iyi? Çoğu mağaza için katlanan adımlara sahip tek bir sayfa ikisinin de en iyisini sunar: her şey görünür kalır ve insanlar aynı anda yalnızca bir bölümle uğraşır.

Form geçerli olana kadar Öde düğmesini devre dışı bırakmalı mıyım? Hayır. İnsanların basmasına izin verin ve eksik olanların bir özetini, her alana giden bağlantılarla gösterin.

Çift ücretlendirmeyi nasıl önlerim? İşleyiciyi bir ref ile koruyun, her denemeyle bir idempotency anahtarı gönderin ve zaman aşımına uğrayan bir ödemede insanların yeniden ödemesine izin vermek yerine durumunu kontrol edin.

Apple Pay ve Google Pay nereye konmalı? Ödeme sayfasının en üstüne, iletişim bilgilerinden önce, çünkü formun geri kalanının yerini alırlar.