ทำไมเว็บ EC ควรแยก 'เพิ่มลงตะกร้า' กับ 'ซื้อเลย' และวิธี Implement
บทนำ
เวลาสร้างเว็บ EC เคยลังเลไหมว่าปุ่มซื้อในหน้าสินค้าควร
เพิ่มลงตะกร้า หรือควรพาไปซื้อโดยตรง
นี่คือแนวคิดที่ควรแยกให้ชัดเจนในระดับการออกแบบ UX
ถ้าปนกัน user จะสับสนว่า “เอ๊ะ ซื้อไปแล้วเหรอ หรือแค่ใส่ตะกร้าไว้”
ปัญหาที่สองปุ่มนี้แก้ไข
พฤติกรรมการซื้อสองแบบของ User
พฤติกรรม A: อยากซื้อรวมกันหลายชิ้น
“หนังเรื่องนี้กับเรื่องนั้น แล้วก็อีกเรื่อง อยากเช่ารวมกันสามเรื่อง”
→ เพิ่มลงตะกร้า → ตรวจสอบที่หน้าตะกร้า → ชำระเงินรวมกันทีเดียว
พฤติกรรม B: อยากซื้อแค่อันนี้ตอนนี้เลย
“ชอบหนังเรื่องนี้ อยากดูเลยทันที”
→ จากหน้าสินค้าไปหน้าชำระเงินโดยตรง → เสร็จทันที
ถ้าพยายามจัดการทั้งสองแบบด้วยปุ่มเดียว UX ของแบบใดแบบหนึ่งจะถูกเสียสละไป
ความแตกต่างของบทบาทปุ่ม
| ปุ่ม | บทบาท | ปลายทางที่เปลี่ยนหน้าไป |
|---|---|---|
| เพิ่มลงตะกร้า | เพิ่มสินค้าลงตะกร้า | Cart drawer หรือหน้าตะกร้า |
| ซื้อเลย | เข้าสู่ checkout flow โดยตรง | หน้าชำระเงิน |
ตัวอย่าง Implementation ใน Vue 3
<!-- FilmDetailModal.vue -->
<template>
<div class="film-actions">
<!-- เพิ่มลงตะกร้า: เพิ่มลงตะกร้าและเปิด drawer -->
<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() // เปิด side drawer เพื่อแสดงตะกร้า
}
// ซื้อเลย: ไปหน้าชำระเงินเฉพาะรายการนี้เท่านั้น
function buyNow() {
router.push({
path: '/checkout',
query: {
mode: 'buy-now',
filmId: props.filmId,
},
})
}
</script>
การจัดการ mode “ซื้อเลย” ที่หน้าชำระเงิน
เมื่อถูกเข้าถึงด้วย /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 {
// flow ปกติ: เนื้อหาตะกร้า
checkoutItems.value = cartStore.items
}
})
Cart Drawer UI
เมื่อกด “เพิ่มลงตะกร้า” การเปิด drawer จากด้านข้างโดยไม่เปลี่ยนหน้าคือ UX ที่ดี
ทำให้ user เลือกได้ว่าจะ “ตรวจสอบตะกร้า → ซื้อของต่อ” หรือ “ตรวจสอบตะกร้า → ไปชำระเงิน”
<!-- 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">
ไปที่ Checkout
</button>
</RouterLink>
</div>
</div>
</Transition>
<!-- overlay -->
<div
v-if="cartStore.isDrawerOpen"
class="cart-overlay"
@click="cartStore.closeDrawer()"
/>
</template>
ข้อผิดพลาดที่พบบ่อย
❌ กด “เพิ่มลงตะกร้า” แล้วเปลี่ยนไปหน้าชำระเงินทันที
กลายเป็นการออกแบบที่ไม่มีแนวคิดเรื่องตะกร้าอยู่จริง
user ที่อยากซื้อหลายชิ้นรวมกันจะเดือดร้อน
❌ “ซื้อเลย” เพิ่มลงตะกร้าก่อนแล้วค่อยไปหน้าชำระเงิน
ตะกร้าจะปนเปื้อน
เมื่อเปิดตะกร้าหลังกด “ซื้อเลย” สินค้าที่ซื้อไปแล้วจะยังค้างอยู่
❌ ปุ่มเดียวที่สลับระหว่าง “เพิ่มลงตะกร้า” กับ “ซื้อ”
เพิ่มขั้นตอนให้ user ต้องเลือกความตั้งใจของตัวเอง การวางสองปุ่มแยกกันเข้าใจง่ายกว่า
สรุป
| เพิ่มลงตะกร้า | ซื้อเลย | |
|---|---|---|
| จุดประสงค์ | เตรียมซื้อหลายสินค้ารวมกัน | ซื้อสินค้าชิ้นเดียวทันที |
| ปลายทาง | Cart drawer / หน้าตะกร้า | หน้าชำระเงิน |
| ผลต่อตะกร้า | เพิ่มลงตะกร้า | ไม่แตะตะกร้า |
| พฤติกรรมหลังจากนั้น | หาสินค้าต่อได้ | จดจ่ออยู่กับ flow ชำระเงินจนเสร็จ |
การที่ Amazon และ Rakuten แยก “เพิ่มลงตะกร้า” กับ “ซื้อเลย” ก็มาจากเหตุผลการออกแบบ UX แบบนี้เอง
การใช้งานจริงในแอพนี้
แอพเช่า DVD นี้ไม่ได้ใช้ Vue Router หรือ Pinia แต่ใช้ emit event กับ currentPage ref ในการทำสิ่งนี้
ปุ่ม CTA ในหน้าละเอียดภาพยนตร์ (โค้ดจริง)
<!-- FilmDetailView.vue -->
<div class="cta-row">
<!-- คำที่แสดงจะเปลี่ยนตามว่าเป็น section เช่าหรือไม่ -->
<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>
การกำหนด type ของ 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)
// เพิ่มลงตะกร้า: บันทึกลง localStorage ผ่าน useCart composable
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'
}
ตะกร้าไม่ใช้ router หรือ global store แต่ useCart composable ทำหน้าที่เป็น singleton จัดการบันทึกถาวรลง localStorage
การเปลี่ยนหน้าก็เพียงแค่เขียนทับ currentPage ref ชนิด union literal ก็เสร็จสมบูรณ์
หน้าจอจริง
ปุ่ม CTA ในหน้าละเอียดภาพยนตร์ (“เช่าเดี๋ยวนี้” กับ “เพิ่มลงตะกร้า”):

Cart drawer หลังกด “เพิ่มลงตะกร้า”:

หน้าชำระเงินที่เปลี่ยนไปด้วย “เช่าเดี๋ยวนี้”:
