Plugins › ร้านค้า (ecommerce)
เริ่มต้น
เนื้อหา
Plugins
การดูแลระบบ
สูตรสำเร็จ
ร้านค้า (ecommerce)
สิ่งที่จะได้เรียนรู้
วิธีขายของบนเว็บ: สินค้าที่มีตัวเลือกและราคาหลายสกุลเงิน ตะกร้าที่ผู้เยี่ยมชมใช้ได้ด้วย ชำระเงินผ่าน Stripe หรือโอนเงิน คำสั่งซื้อที่สร้างครั้งเดียวแน่นอน สต็อก บัญชีลูกค้า อีเมล และ event
อ่านก่อนหน้านี้: Plugins, สมาชิกของเว็บ และ การควบคุมสิทธิ์
@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บอกลูกค้าว่าโอนที่ไหน เจ้าหน้าที่กด ได้รับเงินแล้ว ในคำสั่งซื้อเมื่อเงินเข้า - Stripe: ส่ง
result.payment.clientSecretให้ Payment Element ของ Stripe.js (บัตร พร้อมเพย์…) หลังจ่าย หน้าเว็บเรียกconfirm(result.transaction)ร้านถาม Stripe และสร้างคำสั่งซื้อเมื่อได้เงินแล้ว
คำสั่งซื้อถูกสร้างครั้งเดียวแน่นอน ไม่ว่าจะยืนยันกี่ครั้ง: จากหน้าเว็บ การลองใหม่ และ webhook ของ Stripe พร้อมกัน
ครั้งแรกจะจอง transaction ไว้ (update ที่สำเร็จเฉพาะตอนยังเป็น pending) ครั้งอื่นได้คำสั่งซื้อเดิม ตั้ง webhookSecret
และชี้ webhook ของ Stripe มาที่ /api/cms/shop/payments/stripe/webhook (event payment_intent.succeeded,
payment_intent.payment_failed) เพื่อให้มีคำสั่งซื้อแม้ลูกค้าปิดหน้าไปก่อน
ค่าส่ง ภาษี และส่วนลด
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)