Tech Blog

EC サイトで「カートに入れる」と「今すぐ購入」を分けるべき理由と実装

Vue3 UX TypeScript Frontend

はじめに

EC サイトを作るとき、商品ページに「購入ボタン」を置こうとして、
「カートに追加するのか、直接購入なのか」で迷ったことはありませんか?

これは UX 設計上で明確に分けるべき概念です。
混同すると、ユーザーが「あれ、買ってしまった?カートに入れただけ?」と混乱します。


2つのボタンが解決する問題

ユーザーの2種類の購買行動

行動A:まとめて購入したい

「この映画とあの映画と、もう1本。3本まとめてレンタルしたい」

→ カートに追加 → カートページで確認 → まとめて決済

行動B:今すぐこれだけ買いたい

「この映画が気に入った。すぐ見たい」

→ 商品ページから直接決済画面へ → 即座に完了

この2つを同じボタンで処理しようとすると、どちらかの UX が犠牲になります。


ボタンの役割の違い

ボタン役割画面遷移先
カートに入れる商品をカートに追加するカートドロワー or カートページ
今すぐ購入直接決済フローへ進む決済画面(チェックアウト)

Vue 3 での実装例

<!-- FilmDetailModal.vue -->
<template>
  <div class="film-actions">
    <!-- カートに入れる: カートへ追加してドロワーを開く -->
    <button
      class="btn btn-outline-primary"
      @click="addToCart"
      :disabled="isInCart"
    >
      <i class="bi bi-cart-plus" />
      {{ isInCart ? 'カートに追加済み' : 'カートに入れる' }}
    </button>

    <!-- 今すぐ購入: 直接チェックアウトへ -->
    <button
      class="btn btn-primary"
      @click="buyNow"
    >
      <i class="bi bi-lightning-fill" />
      今すぐ購入
    </button>
  </div>
</template>

<script setup lang="ts">
import { computed } from 'vue'
import { useCartStore } from '@/stores/cartStore'
import { useRouter } from 'vue-router'

interface Props {
  filmId: number
  title: string
  rentalRate: number
}
const props = defineProps<Props>()
const cartStore = useCartStore()
const router = useRouter()

const isInCart = computed(() =>
  cartStore.items.some(item => item.filmId === props.filmId)
)

// カートに入れる
function addToCart() {
  cartStore.addItem({
    filmId: props.filmId,
    title: props.title,
    rentalRate: props.rentalRate,
  })
  cartStore.openDrawer()  // サイドドロワーを開いてカートを見せる
}

// 今すぐ購入: このアイテムだけでチェックアウトへ
function buyNow() {
  router.push({
    path: '/checkout',
    query: {
      mode: 'buy-now',
      filmId: props.filmId,
    },
  })
}
</script>

チェックアウト画面での「今すぐ購入」モードの処理

/checkout?mode=buy-now&filmId=123 でアクセスされた場合、
カートの内容ではなく、指定された商品だけを決済対象にします。

// CheckoutView.vue
import { useRoute } from 'vue-router'
import { useCartStore } from '@/stores/cartStore'
import { ref, onMounted } from 'vue'

const route = useRoute()
const cartStore = useCartStore()

// 決済対象アイテム
const checkoutItems = ref([])

onMounted(async () => {
  if (route.query.mode === 'buy-now' && route.query.filmId) {
    // 今すぐ購入: このアイテムだけ
    const film = await fetchFilm(Number(route.query.filmId))
    checkoutItems.value = [film]
  } else {
    // 通常フロー: カートの内容
    checkoutItems.value = cartStore.items
  }
})

カートドロワー UI

「カートに入れる」を押したとき、画面を遷移させずにサイドからドロワーが開くのが良い UX です。
ユーザーが「カート確認→買い物続行」か「カート確認→決済へ」を選べるようになります。

<!-- CartDrawer.vue -->
<template>
  <Transition name="slide-right">
    <div v-if="cartStore.isDrawerOpen" class="cart-drawer">
      <div class="cart-drawer-header">
        <h5>カート ({{ cartStore.totalItems }}件)</h5>
        <button @click="cartStore.closeDrawer()">✕</button>
      </div>
      
      <div class="cart-drawer-body">
        <CartItem
          v-for="item in cartStore.items"
          :key="item.filmId"
          :item="item"
        />
      </div>

      <div class="cart-drawer-footer">
        <div class="total">合計: ¥{{ cartStore.totalAmount }}</div>
        <RouterLink to="/checkout" @click="cartStore.closeDrawer()">
          <button class="btn btn-primary w-100">
            レジに進む
          </button>
        </RouterLink>
      </div>
    </div>
  </Transition>
  
  <!-- オーバーレイ -->
  <div
    v-if="cartStore.isDrawerOpen"
    class="cart-overlay"
    @click="cartStore.closeDrawer()"
  />
</template>

よくある間違い

❌「カートに入れる」ボタンを押したらすぐ決済ページへ遷移させる

カートの概念がない設計になります。
「複数商品をまとめて買いたい」ユーザーが困ります。

❌「今すぐ購入」がカートに入れてから決済ページに遷移させる

カートが汚染されます。
「今すぐ購入」後にカートを開くと、買い終わった商品が残ってしまいます。

❌ ボタンが1つで「カートに入れる or 購入」を切り替えるデザイン

ユーザーが自分の意図を選ぶ手順が増えます。2つのボタンを別々に配置する方が直感的です。


まとめ

カートに入れる今すぐ購入
目的複数商品をまとめて購入する準備1商品をすぐに購入
遷移先カートドロワー / カートページ決済画面(チェックアウト)
カートへの影響カートに追加するカートには追加しない
処理後の行動引き続き商品を探せる決済完了まで決済フローに集中

Amazon や楽天が「カートに追加」と「今すぐ買う」を分けているのには、こういった UX 設計の根拠があります。


このアプリでの実装

このDVDレンタルアプリでは、Vue Router や Pinia ではなく、emit イベントと currentPage ref によって実現しています。

作品詳細の CTAボタン(実際のコード)

<!-- FilmDetailView.vue -->
<div class="cta-row">
  <!-- レンタル区分のセクションかどうかで文言を変える -->
  <template v-if="isRentalSection">
    <button class="btn-primary" @click="emit('direct-checkout', film)">今すぐレンタル(決済)</button>
    <button v-if="isAuthenticated" class="btn-secondary" @click="emit('add-to-cart', film)">カートへ追加</button>
  </template>
  <template v-else>
    <button class="btn-primary" @click="emit('direct-checkout', film)">今すぐ購入する(決済)</button>
    <button v-if="isAuthenticated" class="btn-secondary" @click="emit('add-to-cart', film)">カートへ追加</button>
  </template>

  <button class="btn-secondary" @click="emit('add-watch-later', film)">お気に入りに追加</button>
</div>

emit の型定義:

const emit = defineEmits<{
  (e: 'back'): void
  (e: 'direct-checkout', film: PublicFilmSummary): void
  (e: 'add-to-cart', film: PublicFilmSummary): void
  (e: 'add-watch-later', film: PublicFilmSummary): void
}>()

App.vue でのハンドリング(実際のコード)

// App.vue(emit受け取り側)

// カートに入れる:useCart composable 経由でlocalStorageに保存
function handleAddToCart(film: PublicFilmSummary) {
  const added = addToCart(film)
  if (added) {
    cartToastMessage.value = `「${film.title}」をカートに追加しました`
    cartToastTimer = setTimeout(() => { cartToastMessage.value = '' }, 2000)
  }
}

// 今すぐ購入:currentPage を切り替えて決済画面へ遷移
const handleDirectCheckout = (film: PublicFilmSummary) => {
  selectedFilm.value = film
  currentPage.value = 'checkout'
}

カートはルーターやグローバルストアを使わず、useCart composable がシングルトンとして localStorage の永続化を担当。 画面遷移も currentPage という Union Literal 型の ref を書き換えるだけで完結します。

実際の画面

作品詳細のCTAボタン(「今すぐレンタル」と「カートへ追加」):

作品詳細CTAボタン

「カートへ追加」後のカートドロワー:

カートドロワー

「今すぐレンタル」で遷移する決済画面:

決済画面


このシリーズの記事マップ

dvdrental 管理アプリと対になるエンドユーザー向けDVDレンタルアプリを作っている話 — Vue 3 + Spring Boot の全体構成と記事マップ

気軽にメッセージください

技術相談・ご感想・ご質問があればメッセージをお願いします。