Skip to content

ร้านค้า (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

ติดตั้ง ​

bash
npm install @easy-cms/plugin-ecommerce
bash
pnpm add @easy-cms/plugin-ecommerce
bash
yarn add @easy-cms/plugin-ecommerce
bash
bun add @easy-cms/plugin-ecommerce
ts
import { 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 }

ts
import { formatPrice, CURRENCIES } from '@easy-cms/plugin-ecommerce'

formatPrice(123450, CURRENCIES.THB, 'th-TH') // ฿1,234.50

หน้าเว็บ ​

หน้าเว็บคุยกับ endpoint ของร้านผ่าน client ใน React (Next.js):

tsx
'use client'
import { ShopProvider } from '@easy-cms/plugin-ecommerce/react'

export function Providers({ children }) {
  return <ShopProvider locale="th">{children}</ShopProvider>
}
tsx
'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 ชื่อเดียวกัน:

ts
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 }

ชำระเงิน ​

ts
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
})
  1. ร้านตรวจตะกร้า (ราคา สต็อก ยังขายอยู่) คำนวณยอดรวมด้วย totals ของคุณ แล้วเก็บทั้งหมดไว้ใน transaction ใหม่
  2. วิธีชำระเงินเริ่มการชำระ และส่งสิ่งที่หน้าเว็บต้องใช้มาใน result.payment
  3. โอนเงิน (manualAdapter): สร้างคำสั่งซื้อทันทีเป็น pending และ result.payment.instructions บอกลูกค้าว่าโอนที่ไหน เจ้าหน้าที่กด ได้รับเงินแล้ว ในคำสั่งซื้อเมื่อเงินเข้า คำสั่งซื้อที่ไม่จ่ายภายใน 3 วันจะถูกยกเลิกและคืนสต็อก คำสั่งซื้อที่ไม่จ่ายจึงไม่กันสต็อกไว้ ตั้งเป็นวินาทีได้ด้วย manualAdapter({ expiresIn }) หรือ false เพื่อรอเจ้าหน้าที่ (เช่น เก็บเงินปลายทาง)
  4. 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) เพื่อให้มีคำสั่งซื้อแม้ลูกค้าปิดหน้าไปก่อน

ค่าส่ง ภาษี และส่วนลด ​

totals เพิ่มรายการเข้าไปในยอดสินค้า (หรือหักออกถ้าติดลบ) ทำงานกับตะกร้า และอีกครั้งตอนชำระเงินพร้อมที่อยู่:

ts
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 เช่น ส่งให้ระบบคลังสินค้าหรือระบบบัญชี:

ts
webhooks: [{ url: 'https://erp.example.com/hook', events: ['order.paid'], secret }],

หลายร้าน ​

เมื่อใช้ plugin multi-tenant แต่ละ tenant เป็นร้านของตัวเอง: ใส่ collection ของร้านและวาง multiTenantPlugin ไว้หลัง

ts
import { ecommercePlugin, SHOP_COLLECTIONS } from '@easy-cms/plugin-ecommerce'

plugins: [
  ecommercePlugin({ /* … */ }),
  multiTenantPlugin({ collections: [...SHOP_COLLECTIONS, 'media'] }),
]

สินค้า ตะกร้า และคำสั่งซื้อเป็นของ tenant ตามโดเมนของเว็บ เลขที่คำสั่งซื้อนับแยกต่อ tenant บัญชีลูกค้าหนึ่งบัญชีใช้ได้ทุกเว็บ และแต่ละเว็บแสดงคำสั่งซื้อของตัวเอง บัญชี Stripe หนึ่งบัญชีใช้ร่วมทุก tenant

วิธีชำระเงินของคุณเอง ​

payment adapter เริ่มการชำระ บอกว่าชำระสำเร็จไหม และคืนเงินได้:

ts
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.variantstrueประเภทตัวเลือกและสินค้าย่อย
products.fields[]field เพิ่มเติมของสินค้า
inventorytrueนับสต็อก
carts.allowGueststrueผู้เยี่ยมชมซื้อและจ่ายได้
carts.guestCartDays30ตะกร้าของผู้เยี่ยมชมที่ไม่ได้ใช้จะถูกลบหลังจากนี้
addresses.supportedCountries['TH']ประเทศที่ร้านส่งของ
addresses.fieldsที่อยู่แบบไทยชื่อ โทร ที่อยู่ แขวง/ตำบล เขต/อำเภอ จังหวัด รหัสไปรษณีย์ ประเทศ
customers.rolecustomerrole ของบัญชีลูกค้า
customers.signUptrueผู้เยี่ยมชมสมัครบัญชีเอง (false คือไม่ให้สมัคร หรือใส่คีย์ Turnstile)
customers.pages—หน้าของเว็บสำหรับลิงก์ในอีเมล (สมาชิกของเว็บ)
totals—ค่าส่ง ภาษี และส่วนลด
orders.number1000 + nเลขที่คำสั่งซื้อ
emails—orderConfirmation, paymentReceived, newOrder: ฟังก์ชันหรือ false และ notify
overrides—{ products: (c) => ({ ...c, … }) } เพื่อปรับ collection
groupShop / ร้านค้าชื่อกลุ่มร้านค้าในเมนู (shop มีแคตตาล็อก การขาย และลูกค้า)

ยังไม่มี: Omise และผู้ให้บริการอื่นแบบสำเร็จรูป, Stripe Checkout (หน้าจ่ายเงินของ Stripe), การจองสต็อกระหว่างจ่าย, ค่าส่ง VAT และคูปองแบบสำเร็จรูป, สมาชิกรายเดือน และบัญชี Stripe แยกต่อ tenant

ขั้นต่อไป ​

  • สมาชิกของเว็บ: บัญชีลูกค้า การสมัคร และลิงก์ในอีเมล
  • Webhooks: แจ้งระบบอื่นเรื่องคำสั่งซื้อ
  • ตัวอย่างร้าน: examples/shop (Next.js)

เผยแพร่ภายใต้สัญญาอนุญาต MIT