Trang API Storefront (đọc) liệt kê từng endpoint. Trang này
nói về thư viện chính thức gọi chúng: @xstore/storefront-sdk.
Bạn không bắt buộc phải dùng nó — fetch thẳng vẫn chạy. Nhưng thư viện đã lo
sẵn những thứ dễ làm sai: header khoá công khai, phong bì phân trang, lớp lỗi có
mã trạng thái, phiên đăng nhập của khách và việc mỗi yêu cầu phải có một client
riêng khi dựng trang phía máy chủ.
Đây là cùng một thư viện chạy trên theme Liquid dưới tên window.xStore. Tên
module và tên phương thức giống hệt nhau, nên bảng tra cứu ở tab
xStore JS áp dụng cho cả hai: xStore.products.list() và
xstore.products.list() là cùng một hàm.
Cài đặt
npm install @xstore/storefront-sdk
# hoặc: pnpm add @xstore/storefront-sdk
Gói không có phụ thuộc lúc chạy. Cài một gói là được một gói — không kéo
theo thứ gì vào bundle React Native của bạn. Gói viết bằng TypeScript và đã kèm
sẵn khai báo kiểu; không cần cài thêm @types/….
createXstoreClient() — và vì sao nó là hàm khởi tạo, không phải singleton
import { createXstoreClient } from "@xstore/storefront-sdk"
const xstore = createXstoreClient({
baseUrl: "https://api.xstore.vn/public/v1",
publishableKey: process.env.XSTORE_PUBLISHABLE_KEY,
})
Đây là lỗi mà một storefront Next.js dễ mắc nhất:
// ❌ SAI — đừng làm thế này trong Next.js
// lib/xstore.ts
export const xstore = createXstoreClient({ publishableKey: KEY })
Một tiến trình Next trên máy chủ phục vụ nhiều khách cùng lúc. Client giữ
phiên đăng nhập của khách: token khách hàng, mã giỏ hàng, mã giảm giá đang áp.
Đặt client ở phạm vi module nghĩa là hai khách đang mua hàng cùng lúc dùng chung
một phiên — khách A đăng nhập, khách B tải lại trang và nhìn thấy đơn hàng
của khách A. Lỗi này không bao giờ xuất hiện khi chạy next dev một mình.
// ✅ ĐÚNG — mỗi yêu cầu một client
// lib/xstore.ts
import { cookies } from "next/headers"
import { createXstoreClient } from "@xstore/storefront-sdk"
export function xstoreForRequest() {
return createXstoreClient({
baseUrl: process.env.XSTORE_API_URL,
publishableKey: process.env.XSTORE_PUBLISHABLE_KEY,
// Token đọc từ cookie của CHÍNH yêu cầu này.
authToken: cookies().get("xstore_customer_token")?.value ?? null,
})
}
Với dữ liệu công khai thuần tuý (danh sách sản phẩm, menu) thì tạo client mỗi lần gọi cũng rẻ: nó chỉ là một đối tượng, không mở kết nối nào.
Trên trình duyệt thì ngược lại — một tab là một khách — nên tệp
xstore-sdk.js của theme Liquid tạo sẵn một thể hiện và gắn vào
window.xStore. Đó là chỗ duy nhất singleton là đúng.
Khoá công khai
createXstoreClient({ publishableKey: "pk_live_..." }) // gửi thành header X-Xstore-Key
Khoá tạo trong trang quản trị Cài đặt → Khoá API. Khoá công khai theo thiết kế: mọi thứ nó mở ra đều đã là dữ liệu công khai, và bản dựng của bạn sẽ gửi nó tới từng khách truy cập. Đừng cố giấu nó, đừng dựng proxy chỉ để che nó. Khoá bị lạm dụng thì thu hồi và tạo khoá mới.
Khoá cũng là thứ xác định cửa hàng, nên ứng dụng di động gọi thẳng
api.xstore.vn được mà không cần header Host.
Giới hạn nhịp gọi tính theo khoá, không theo địa chỉ IP — vì cả một máy chủ SSR của bạn chỉ là một IP đối với xStore.
Next.js
Thư viện không tự ý quyết định chiến lược cache. Nó chuyển thẳng defaultInit
(và tham số init của từng lời gọi) xuống fetch, nên chỉ thị cache của Next
dùng như bình thường:
// app/san-pham/[slug]/page.tsx
import { notFound } from "next/navigation"
import { createXstoreClient, isXstoreError } from "@xstore/storefront-sdk"
export default async function ProductPage({ params }: { params: { slug: string } }) {
// Vẫn là một client MỚI cho mỗi lần dựng trang.
const xstore = createXstoreClient({
baseUrl: process.env.XSTORE_API_URL,
publishableKey: process.env.XSTORE_PUBLISHABLE_KEY,
// Áp cho MỌI lời gọi của client này.
defaultInit: { next: { revalidate: 60 } } as RequestInit,
})
try {
const product = await xstore.products.getBySlug(params.slug)
return <h1>{product.title}</h1>
} catch (err) {
if (isXstoreError(err) && err.status === 404) notFound()
throw err
}
}
Một lời gọi riêng lẻ vẫn ghi đè được:
// Giá và tồn kho thì đừng cache.
await xstore.cart.quote({}, { cache: "no-store" } as RequestInit)
Đường dẫn do chủ cửa hàng tự đặt
Chủ cửa hàng có thể gán đường dẫn riêng cho bất kỳ nội dung nào
(/khuyen-mai-tet trỏ tới một danh mục). Những đường dẫn đó không khớp route
tĩnh nào của bạn, nên hãy hỏi máy chủ trước khi trả 404:
// app/not-found.tsx (hoặc một catch-all route)
const match = await xstore.routes.resolve(pathname) // { path, target_type, target_id }
Máy chủ có cache âm: một đường dẫn vừa tạo vẫn có thể trượt trong ít phút. Đó là hành vi đúng, không phải lý do để gọi lại trong vòng lặp.
React Native
Không có localStorage trên React Native, nên hãy truyền bộ nhớ của riêng bạn.
Mọi phương thức của TokenStorage đều được phép trả về Promise — thư viện
luôn await chúng:
import * as SecureStore from "expo-secure-store"
import { createXstoreClient, type TokenStorage } from "@xstore/storefront-sdk"
const secureStore: TokenStorage = {
get: (key) => SecureStore.getItemAsync(key),
set: (key, value) => SecureStore.setItemAsync(key, value),
remove: (key) => SecureStore.deleteItemAsync(key),
}
export const xstore = createXstoreClient({
baseUrl: "https://api.xstore.vn/public/v1",
publishableKey: process.env.EXPO_PUBLIC_XSTORE_KEY,
storage: secureStore,
})
Ở đây một thể hiện dùng chung cho cả ứng dụng là đúng: một thiết bị là một khách.
Vì bộ nhớ có thể bất đồng bộ, vài hàm vốn đồng bộ trên theme Liquid lại trả Promise trong gói này:
Trên window.xStore (theme) |
Trong @xstore/storefront-sdk |
|---|---|
xStore.auth.isLoggedIn() → boolean |
await xstore.auth.isLoggedIn() → Promise<boolean> |
xStore.cart.getToken() → string | null |
await xstore.cart.getToken() → Promise<string | null> |
Thư viện không chạm vào window, document hay localStorage ở bất kỳ đâu
ngoài bản dựng dành riêng cho trình duyệt — nên nó nạp được trong Metro, trong
Node và trong Web Worker mà không cần polyfill.
Phân trang
Mọi endpoint danh sách trả về cùng một phong bì:
{ "items": [ ... ], "next_cursor": "2026-08-01 12:00:00|p001", "has_more": true }
Con trỏ là chuỗi mờ. Gửi lại nguyên văn next_cursor; đừng tách nó ra, đừng
tự dựng một con trỏ, đừng đổi nó thành số trang. Có endpoint dùng keyset, có
endpoint (tìm kiếm) dùng offset — hình dạng bên trong là chuyện của máy chủ và
có thể đổi.
Cần duyệt hết thì dùng paginate() / collect():
import { collect, paginate } from "@xstore/storefront-sdk"
// sitemap.xml: cần toàn bộ, nên collect().
const urls = await collect((cursor) =>
xstore.sitemap.section("products", { cursor, limit: 500 }))
// Xử lý dần từng phần tử, không giữ hết trong bộ nhớ.
for await (const product of paginate((cursor) => xstore.products.list({ cursor, limit: 100 }))) {
console.log(product.title)
}
Cả hai đều dừng ở maxPages (mặc định 1000) và ném lỗi nếu máy chủ trả lại
một con trỏ đã trả rồi — nếu không, một truy vấn keyset xếp sai thứ tự sẽ thành
vòng lặp vô tận ngay giữa lúc dựng trang.
collect() dành cho sitemap và feed, nơi thật sự cần cả tập. Đừng dùng nó cho
lưới sản phẩm.
Lỗi
Mọi phản hồi không phải 2xx đều ném ra XstoreError:
import { isXstoreError } from "@xstore/storefront-sdk"
try {
await xstore.products.getBySlug(slug)
} catch (err) {
if (!isXstoreError(err)) throw err
err.status // 404, 401, 429, …
err.detail // thân phản hồi của FastAPI, đã được làm phẳng
err.retryAfter // số giây, CHỈ có ở 429
err.isRateLimited // err.status === 429
}
Thư viện không tự thử lại. Đó là lựa chọn có chủ ý: một lần thử lại âm thầm
bên trong quá trình dựng trang phía máy chủ biến một lần chạm giới hạn nhịp gọi
thành một trang treo — khách chờ hai lần thời gian chờ mạng rồi nhận lỗi 500,
thay vì nhận trang lỗi ngay lập tức. Bạn quyết định: chờ retryAfter giây trong
một tác vụ nền thì hợp lý; chờ trong lúc dựng trang thì không.
Phiên khách hàng
Đăng nhập khách hàng dùng một JWT duy nhất, KHÔNG có refresh token:
await xstore.auth.login({ email, password }) // token được lưu vào storage
await xstore.auth.isLoggedIn() // chỉ kiểm tra có token, không xác thực token
await xstore.auth.logout() // xoá phiên cục bộ
Khi một lời gọi có xác thực trả về 401, thư viện xoá token đang lưu rồi
phát sự kiện auth:logout. Nó không tự đăng nhập lại và không tự chuyển hướng —
ứng dụng của bạn quyết định điều đó:
xstore.on("auth:logout", () => {
router.push("/dang-nhap")
})
Không có endpoint thu hồi token phía máy chủ: logout() chỉ xoá token ở phía
bạn, và JWT vẫn có hiệu lực cho tới khi hết hạn. Đó là lý do token có thời hạn
ngắn và không mang theo gì nhạy cảm.
Giỏ hàng của khách vãng lai tự gộp vào tài khoản sau khi đăng nhập thành
công — bạn không phải gọi cart.merge() bằng tay.
Vận chuyển và thanh toán
Module shipping báo giá theo tier (instant | same_day | standard | pickup),
và trả về danh sách tỉnh/phường/điểm nhận hàng — dùng cho biểu mẫu địa chỉ và
bước chọn gói vận chuyển:
const rates = await xstore.shipping.rates(
{ province_code, ward_code, subtotal },
{ cartToken }, // BẮT BUỘC — thiếu nó báo giá bỏ qua bộ máy khuyến mãi
)
checkout.setShipping() nhận shipping_tier, không còn shipping_amount:
máy chủ tính phí, client không tự đặt giá.
Luồng đầy đủ từ đặt hàng tới xác nhận thanh toán là placeOrder → pay → redirect (hoặc không) → paymentReturn:
// 1. Tạo đơn từ giỏ hàng.
const order = await xstore.checkout.placeOrder(cartToken, { address, shipping, payment })
// 2. Khởi tạo thanh toán ở cổng CHO ĐƠN VỪA TẠO — nhận cartToken, không phải
// order_id, vì route này không yêu cầu đăng nhập.
const pay = await xstore.checkout.pay(cartToken)
// redirect_url là null với cổng không cần chuyển hướng (COD) — luôn kiểm tra
// trước khi điều hướng.
if (pay.redirect_url) {
window.location.href = pay.redirect_url
}
Trên trang khách quay về sau khi rời cổng thanh toán:
const result = await xstore.checkout.paymentReturn({
query_string: window.location.search.slice(1), // NGUYÊN VĂN, không tự parse
})
paymentReturn không ghi gì. Nó chỉ đọc lại trạng thái cho trang kết quả
hiển thị — việc chốt đơn thuộc về IPN máy-tới-máy của cổng, chạy độc lập và có
thể tới trước hoặc sau khi khách bấm quay lại. Vì vậy settled: false
không phải là thất bại: nó chỉ nghĩa là IPN chưa tới. Diễn giải nó thành
"thanh toán thất bại" là nói với một khách đã trả tiền rằng họ chưa trả — đọc
status (succeeded | pending | failed | cancelled | unknown) để biết cổng
thực sự trả lời gì, và hiển thị "đang xác nhận" thay vì đoán khi chưa settled.
checkout.paymentMethods() trả về các cổng tenant này đã bật, để dựng giao
diện chọn — đừng viết cứng danh sách cổng trong ứng dụng của bạn.
Tra cứu đầy đủ
Chữ ký của toàn bộ phương thức nằm ở tab xStore JS, một trang
cho mỗi module (bao gồm shipping và ba phương thức mới của checkout:
paymentMethods, pay, paymentReturn). Danh sách endpoint thô nằm ở
API Storefront (đọc).