Plugins › ร้านค้า (ecommerce)
เริ่มต้น
เนื้อหา
Plugins
การดูแลระบบ
การ Deploy
สูตรสำเร็จ
ร้านค้า (ecommerce)
สิ่งที่จะได้เรียนรู้
วิธีขายของบนเว็บ: สินค้าที่มีตัวเลือกและราคาหลายสกุลเงิน ตะกร้าที่ผู้เยี่ยมชมใช้ได้ด้วย ชำระเงินผ่าน Stripe หรือโอนเงิน คำสั่งซื้อที่สร้างครั้งเดียวแน่นอน สต็อก บัญชีลูกค้า อีเมล และ event
อ่านก่อนหน้านี้: Plugins, สมาชิกของเว็บ และ การควบคุมสิทธิ์
ทดลอง (experimental)
plugin นี้อยู่ในขั้นทดลอง ใช้เลขเวอร์ชันเดียวกับ Easy CMS ส่วนอื่น แต่ option, endpoint และข้อมูลยังอาจเปลี่ยนใน minor release ได้ แม้หลัง 1.0 เพราะไม่อยู่ใต้สัญญาความเสถียร changelog และ คู่มืออัปเกรด จะบอกว่าเปลี่ยนอะไร
@easy-cms/plugin-ecommerce ทำให้ CMS เป็นหลังร้าน และให้หน้าเว็บมีตะกร้ากับหน้าชำระเงิน สินค้าและคำสั่งซื้อ
จัดการในระบบจัดการ ส่วนหน้าเว็บเรียก REST API ผ่าน client ตัวเล็ก ๆ ที่มี hook สำหรับ React และ composable
สำหรับ Vue
ติดตั้ง
npm install @easy-cms/plugin-ecommercepnpm add @easy-cms/plugin-ecommerceyarn add @easy-cms/plugin-ecommercebun add @easy-cms/plugin-ecommerceimport { ecommercePlugin, manualAdapter } from '@easy-cms/plugin-ecommerce'
import { stripeAdapter } from '@easy-cms/plugin-ecommerce/stripe'
export default defineConfig({
// …
email: smtp({ /* … */ }), // อีเมลคำสั่งซื้อและลิงก์สมัครสมาชิก
plugins: [
ecommercePlugin({
currencies: { supported: ['THB', 'USD'] }, // ค่าเริ่มต้น ['THB']
payments: {
methods: [
stripeAdapter({
secretKey: process.env.STRIPE_SECRET_KEY!,
publishableKey: process.env.STRIPE_PUBLISHABLE_KEY!,
webhookSecret: process.env.STRIPE_WEBHOOK_SECRET,
}),
manualAdapter({
instructions: {
en: 'Transfer to Kasikorn Bank 123-4-56789-0, then reply with the slip.',
th: 'โอนเข้าบัญชีกสิกรไทย 123-4-56789-0 แล้วตอบกลับพร้อมสลิป',
},
}),
],
},
emails: { notify: 'shop@example.com' },
}),
],
})จากนั้นสร้างการเปลี่ยนแปลงของฐานข้อมูล: npx easy-cms migrate:create shop
plugin เพิ่มเมนูกลุ่ม ร้านค้า ในระบบจัดการ:
| Collection | |
|---|---|
products | ชื่อ slug รายละเอียด รูป ราคาต่อสกุลเงิน SKU สต็อก ประเภทตัวเลือก มีฉบับร่าง: ขายเฉพาะสินค้าที่เผยแพร่แล้ว |
variant-types, variant-options | ประเภทตัวเลือก (ขนาด) และตัวเลือก (M, L) ใช้ร่วมกันได้หลายสินค้า |
variants | สินค้าในตัวเลือกชุดหนึ่ง มีราคา (หรือใช้ราคาสินค้า) SKU และสต็อกของตัวเอง ตั้งชื่อตามตัวเลือก |
carts | ตะกร้าของลูกค้าและผู้เยี่ยมชม |
addresses | ที่อยู่ที่ลูกค้าบันทึกไว้ |
orders | สิ่งที่ซื้อ ราคา ผู้ซื้อ ที่อยู่จัดส่ง และวิธีชำระเงิน |
transactions | การชำระเงินและข้อมูลจากผู้ให้บริการ เฉพาะเจ้าหน้าที่ |
และเพิ่ม role customer สำหรับสมาชิกของเว็บที่สมัครเอง field ชนิด price แผงยอดขายและคำสั่งซื้อที่รอ
บนแดชบอร์ด และ event ด้านล่าง
ราคาและสกุลเงิน
ราคาเป็นจำนวนเต็มในหน่วยย่อยที่สุดของสกุลเงิน: 25000 คือ ฿250.00, 1999 คือ $19.99 แต่ละสกุลเงินเพิ่ม field
ราคาหนึ่งช่อง priceInTHB, priceInUSD ระบบจัดการแสดงเป็นเงิน ไม่มีการแปลงค่าเงิน: ตั้งราคาแต่ละสกุลเอง สินค้าที่ไม่มี
ราคาในสกุลเงินของตะกร้าใส่ตะกร้านั้นไม่ได้
มีให้: THB, USD, EUR, GBP, JPY (ไม่มีทศนิยม) สกุลอื่น: { code, symbol, label, decimals }
import { formatPrice, CURRENCIES } from '@easy-cms/plugin-ecommerce'
formatPrice(123450, CURRENCIES.THB, 'th-TH') // ฿1,234.50หน้าเว็บ
หน้าเว็บคุยกับ endpoint ของร้านผ่าน client ใน React (Next.js):
'use client'
import { ShopProvider } from '@easy-cms/plugin-ecommerce/react'
export function Providers({ children }) {
return <ShopProvider locale="th">{children}</ShopProvider>
}'use client'
import { useCart, useCurrency } from '@easy-cms/plugin-ecommerce/react'
export function AddToCart({ product, variant }) {
const { addItem, busy } = useCart()
return <button disabled={busy} onClick={() => addItem(product, { variant })}>ใส่ตะกร้า</button>
}
export function CartTotal() {
const { cart } = useCart()
const { formatPrice } = useCurrency()
return <span>{formatPrice(cart?.total ?? 0)}</span>
}ใน Vue (Nuxt) ใช้ app.use(shopPlugin()) ใน client plugin แล้วเรียก composable ชื่อเดียวกัน:
import { shopPlugin } from '@easy-cms/plugin-ecommerce/vue'
export default defineNuxtPlugin((nuxtApp) => {
nuxtApp.vueApp.use(shopPlugin({ locale: 'th' }))
})| Hook | |
|---|---|
useCart() | cart (รายการ, subtotal, adjustments, total, count), addItem, updateItem, incrementItem, decrementItem, removeItem, clearCart, busy |
useCurrency() | currency, currencies, setCurrency, formatPrice |
usePayments() | paymentMethods, checkout, confirm |
useCustomer() | user, login, logout, signUp, verifyEmail, forgotPassword, resetPassword |
useAddresses() | addresses ของลูกค้า, createAddress, updateAddress, deleteAddress |
useOrders() | orders ของลูกค้า ล่าสุดก่อน |
ถ้าไม่ใช้ framework ใช้ createShopClient() จาก @easy-cms/plugin-ecommerce/client ซึ่งมีเมธอดเดียวกันและ
subscribe(listener) หน้าสินค้าอ่านสินค้าเหมือนเนื้อหาอื่น ด้วย Local API หรือ GET /api/cms/products
ตะกร้าของผู้เยี่ยมชมจำไว้ใน localStorage (id และรหัสลับ) เมื่อล็อกอิน ตะกร้าจะรวมเข้ากับบัญชี ปิดการซื้อแบบไม่ล็อกอินได้ด้วย
carts: { allowGuests: false }
ชำระเงิน
const { checkout, confirm } = usePayments()
const result = await checkout({
method: 'stripe', // หรือ 'bank-transfer'
email: 'guest@example.com', // ผู้เยี่ยมชม ส่วนลูกค้าใช้อีเมลของบัญชี
shippingAddress: { name: 'สมชาย', line1: '99 ถนนสุขุมวิท', province: 'กรุงเทพฯ', country: 'TH' },
// หรือ address: savedAddress.id
})- ร้านตรวจตะกร้า (ราคา สต็อก ยังขายอยู่) คำนวณยอดรวมด้วย
totalsของคุณ แล้วเก็บทั้งหมดไว้ใน transaction ใหม่ - วิธีชำระเงินเริ่มการชำระ และส่งสิ่งที่หน้าเว็บต้องใช้มาใน
result.payment - โอนเงิน (
manualAdapter): สร้างคำสั่งซื้อทันทีเป็นpendingและresult.payment.instructionsบอกลูกค้าว่าโอนที่ไหน เจ้าหน้าที่กด ได้รับเงินแล้ว ในคำสั่งซื้อเมื่อเงินเข้า คำสั่งซื้อที่ไม่จ่ายภายใน 3 วันจะถูกยกเลิกและคืนสต็อก คำสั่งซื้อที่ไม่จ่ายจึงไม่กันสต็อกไว้ ตั้งเป็นวินาทีได้ด้วยmanualAdapter({ expiresIn })หรือfalseเพื่อรอเจ้าหน้าที่ (เช่น เก็บเงินปลายทาง) - Stripe: ส่ง
result.payment.clientSecretให้ Payment Element ของ Stripe.js (บัตร พร้อมเพย์…) หลังจ่าย หน้าเว็บเรียกconfirm(result.transaction)ร้านถาม Stripe และสร้างคำสั่งซื้อเมื่อได้เงินแล้ว ยืนยันได้เฉพาะคนที่จ่าย: ลูกค้าที่เข้าสู่ระบบ หรือผู้เยี่ยมชมเจ้าของตะกร้า (client ส่ง secret ของตะกร้าไปให้) คนอื่นจะได้404
คำสั่งซื้อถูกสร้างครั้งเดียวแน่นอน ไม่ว่าจะยืนยันกี่ครั้ง: จากหน้าเว็บ การลองใหม่ และ webhook ของ Stripe พร้อมกัน
ครั้งแรกจะจอง transaction ไว้ (update ที่สำเร็จเฉพาะตอนยังเป็น pending) ครั้งอื่นได้คำสั่งซื้อเดิม ตั้ง webhookSecret
และชี้ webhook ของ Stripe มาที่ /api/cms/shop/payments/stripe/webhook (event payment_intent.succeeded,
payment_intent.payment_failed) เพื่อให้มีคำสั่งซื้อแม้ลูกค้าปิดหน้าไปก่อน
ถ้าไม่ได้ตั้ง webhook หรือ webhook หาย งานตามเวลาจะตามให้: การชำระเงินที่ยัง pending อยู่หลังผ่านไปไม่กี่นาทีจะถูกถามซ้ำกับ
provider (confirm ของ adapter) และได้คำสั่งซื้อถ้าเงินเข้าแล้ว วิธีชำระที่ตั้ง expiresIn จะเลิกรอการชำระที่ไม่สำเร็จภายในเวลานั้น
(failed, "expired") เช่น QR ของพร้อมเพย์ที่หมดอายุ ถ้ามีเงินเข้าทีหลังก็ยังได้คำสั่งซื้อ ถ้าไม่ตั้ง expiresIn จะถามซ้ำอยู่หนึ่งวัน
Stripe ต้องติดตั้งแพ็กเกจ stripe เวอร์ชัน 17 ถึง 23 ไม่ว่าติดตั้งเวอร์ชันไหน adapter จะขอ API version ที่ทดสอบไว้จาก Stripe
คือ STRIPE_API_VERSION (2026-09-30.endive) การอัปเกรดแพ็กเกจจึงไม่ทำให้คำตอบของ Stripe เปลี่ยน ถ้าต้องการเวอร์ชันอื่นใช้
stripeAdapter({ apiVersion }) และตอนสร้าง webhook endpoint ใน Stripe ให้เลือก API version เดียวกัน
clientConfig ส่งค่าตั้งเพิ่มให้ Stripe client เช่น { maxNetworkRetries: 2 } ตอนทดสอบให้ชี้ไปที่
stripe-mock หรือ server จำลองของคุณเอง ({ host: 'localhost', port: 12111, protocol: 'http' })
แล้วส่ง event ที่เซ็นแล้ว (Stripe.webhooks.generateTestHeaderStringAsync) เข้า webhook เพื่อสร้างคำสั่งซื้อ
แบบเดียวกับเทส end-to-end ของตัวอย่างร้านค้า
ค่าส่ง ภาษี และส่วนลด
totals เพิ่มรายการเข้าไปในยอดสินค้า (หรือหักออกถ้าติดลบ) ทำงานกับตะกร้า และอีกครั้งตอนชำระเงินพร้อมที่อยู่:
ecommercePlugin({
totals: ({ cart, currency, shippingAddress }) => {
const lines = []
if (cart.subtotal < 100_000) lines.push({ label: 'ค่าส่ง', amount: 5_000 })
if (shippingAddress?.province === 'กรุงเทพฯ') lines.push({ label: 'ส่งด่วนในวัน', amount: 3_000 })
return lines
},
})คำสั่งซื้อเก็บรายการเหล่านี้ไว้ตามตอนสั่ง
คำสั่งซื้อ
| สถานะ | |
|---|---|
pending | รอชำระเงิน (โอนเงิน) |
paid | ชำระแล้ว รอจัดส่ง |
fulfilled | จัดส่งหรือส่งมอบแล้ว |
cancelled | ยกเลิกก่อนชำระ สินค้ากลับเข้าสต็อก |
refunded | คืนเงินแล้ว (Stripe คืนให้) สินค้ากลับเข้าสต็อก |
เจ้าหน้าที่เปลี่ยนสถานะด้วยปุ่มในคำสั่งซื้อ (ได้รับเงินแล้ว, จัดส่งแล้ว, ยกเลิกคำสั่งซื้อ, คืนเงิน) หรือ
POST /api/cms/shop/orders/:id/:status คำสั่งซื้อเก็บแต่ละรายการตามตอนซื้อ (ชื่อ SKU ราคา) การแก้หรือลบสินค้าภายหลัง
จึงไม่กระทบ เลขที่คำสั่งซื้อเริ่มที่ 1001 ตั้งเองได้ด้วย orders: { number: (n) => \A-${n}` }`
ลูกค้าเห็นคำสั่งซื้อของตัวเอง: GET /api/cms/orders (หรือ useOrders())
สต็อก
เมื่อเปิด inventory (ค่าเริ่มต้น) สินค้าและสินค้าย่อยมีช่อง สต็อก เว้นว่างคือไม่นับ ระบบตรวจสต็อกตอนใส่ตะกร้าและตอนชำระเงิน
และตัดสต็อกตอนสร้างคำสั่งซื้อด้วยคำสั่งฐานข้อมูลคำสั่งเดียว ผู้ซื้อสองคนจึงแย่งชิ้นสุดท้ายกันไม่ได้ ถ้าสต็อกหมดระหว่างที่ลูกค้า
กำลังจ่าย ระบบยังสร้างคำสั่งซื้อ (เพราะได้เงินแล้ว) และทำเครื่องหมาย สต็อกไม่พอ คำสั่งซื้อที่ยกเลิกหรือคืนเงินจะคืนสต็อก
อีเมลและ event
เมื่อตั้งค่าอีเมลแล้ว ลูกค้าได้อีเมลเมื่อสร้างคำสั่งซื้อ (พร้อมวิธีโอนเงิน) และเมื่อเจ้าหน้าที่ยืนยันว่าได้รับเงิน
ส่วน emails.notify ได้รับแจ้งคำสั่งซื้อใหม่ แต่ละฉบับใส่ฟังก์ชันของคุณเองหรือ false ได้
Webhooks ฟัง event ของร้านได้: order.created, order.paid, order.fulfilled, order.cancelled,
order.refunded เช่น ส่งให้ระบบคลังสินค้าหรือระบบบัญชี:
webhooks: [{ url: 'https://erp.example.com/hook', events: ['order.paid'], secret }],หลายร้าน
เมื่อใช้ plugin multi-tenant แต่ละ tenant เป็นร้านของตัวเอง: ใส่ collection ของร้านและวาง
multiTenantPlugin ไว้หลัง
import { ecommercePlugin, SHOP_COLLECTIONS } from '@easy-cms/plugin-ecommerce'
plugins: [
ecommercePlugin({ /* … */ }),
multiTenantPlugin({ collections: [...SHOP_COLLECTIONS, 'media'] }),
]สินค้า ตะกร้า และคำสั่งซื้อเป็นของ tenant ตามโดเมนของเว็บ เลขที่คำสั่งซื้อนับแยกต่อ tenant บัญชีลูกค้าหนึ่งบัญชีใช้ได้ทุกเว็บ และแต่ละเว็บแสดงคำสั่งซื้อของตัวเอง บัญชี Stripe หนึ่งบัญชีใช้ร่วมทุก tenant
วิธีชำระเงินของคุณเอง
payment adapter เริ่มการชำระ บอกว่าชำระสำเร็จไหม และคืนเงินได้:
import { definePaymentAdapter, paymentSucceeded } from '@easy-cms/plugin-ecommerce'
export const omise = definePaymentAdapter({
name: 'omise',
label: { en: 'Card', th: 'บัตร' },
async initiate({ transaction }) {
const charge = await createCharge(transaction.amount, transaction.currency)
return { reference: charge.id, data: { charge: charge.id }, client: { authorizeUri: charge.authorize_uri } }
},
async confirm({ transaction }) {
const charge = await getCharge(transaction.data.charge)
return charge.paid ? 'succeeded' : charge.status === 'failed' ? 'failed' : 'processing'
},
async refund({ transaction }) { /* … */ },
// อยู่ที่ /api/cms/shop/payments/omise/webhook
endpoints: [{ path: '/webhook', method: 'post', handler: async (req) => {
const event = await req.json() // ตรวจลายเซ็นก่อน
await paymentSucceeded(event.data.id)
return { ok: true }
} }],
})อย่าเชื่อหน้าเว็บ: confirm ต้องถามผู้ให้บริการเสมอ
ตัวเลือก
| ตัวเลือก | ค่าเริ่มต้น | |
|---|---|---|
payments.methods | — | จำเป็น stripeAdapter(), manualAdapter() หรือของคุณเอง |
currencies.supported | ['THB'] | รหัสสกุลเงิน หรือ { code, symbol, label, decimals } |
currencies.default | ตัวแรก | สำหรับตะกร้าใหม่ |
products.variants | true | ประเภทตัวเลือกและสินค้าย่อย |
products.fields | [] | field เพิ่มเติมของสินค้า |
inventory | true | นับสต็อก |
carts.allowGuests | true | ผู้เยี่ยมชมซื้อและจ่ายได้ |
carts.guestCartDays | 30 | ตะกร้าของผู้เยี่ยมชมที่ไม่ได้ใช้จะถูกลบหลังจากนี้ |
addresses.supportedCountries | ['TH'] | ประเทศที่ร้านส่งของ |
addresses.fields | ที่อยู่แบบไทย | ชื่อ โทร ที่อยู่ แขวง/ตำบล เขต/อำเภอ จังหวัด รหัสไปรษณีย์ ประเทศ |
customers.role | customer | role ของบัญชีลูกค้า |
customers.signUp | true | ผู้เยี่ยมชมสมัครบัญชีเอง (false คือไม่ให้สมัคร หรือใส่คีย์ Turnstile) |
customers.pages | — | หน้าของเว็บสำหรับลิงก์ในอีเมล (สมาชิกของเว็บ) |
totals | — | ค่าส่ง ภาษี และส่วนลด |
orders.number | 1000 + n | เลขที่คำสั่งซื้อ |
emails | — | orderConfirmation, paymentReceived, newOrder: ฟังก์ชันหรือ false และ notify |
overrides | — | { products: (c) => ({ ...c, … }) } เพื่อปรับ collection |
group | Shop / ร้านค้า | ชื่อกลุ่มร้านค้าในเมนู (shop มีแคตตาล็อก การขาย และลูกค้า) |
ยังไม่มี: Omise และผู้ให้บริการอื่นแบบสำเร็จรูป, Stripe Checkout (หน้าจ่ายเงินของ Stripe), การจองสต็อกระหว่างจ่าย, ค่าส่ง VAT และคูปองแบบสำเร็จรูป, สมาชิกรายเดือน และบัญชี Stripe แยกต่อ tenant
ขั้นต่อไป
- สมาชิกของเว็บ: บัญชีลูกค้า การสมัคร และลิงก์ในอีเมล
- Webhooks: แจ้งระบบอื่นเรื่องคำสั่งซื้อ
- ตัวอย่างร้าน:
examples/shop(Next.js)