Bạn đã có storefront web. Trang này nói về ứng dụng di động — một dự án Expo hoàn chỉnh mà bạn clone, đổi thương hiệu, tự build và tự nộp lên App Store / Play Store bằng tài khoản của chính mình.
Trang API Storefront (đọc) liệt kê từng endpoint; trang SDK cho storefront tự dựng mô tả thư viện gọi chúng; trang Starter Next.js là dự án mẫu tương đương cho web. Trang này là dự án mẫu cho di động.
1. Ứng dụng này là gì
xstore-storefront-mobile-starter là một cửa hàng chạy được, không phải bộ khung rỗng:
- Expo SDK 57 + React Native + expo-router + NativeWind, TypeScript.
- Đầy đủ hành trình mua hàng: trang chủ, danh mục, chi tiết sản phẩm (kèm biến thể, đánh giá), bộ sưu tập, tìm kiếm, flash sale, tin tức, trang CMS, giỏ hàng, thanh toán, đăng nhập/đăng ký, và 9 màn hình tài khoản khách (đơn hàng, địa chỉ, điểm thưởng, yêu thích, đã xem, đánh giá của tôi, mã giảm giá đã dùng, thông tin, đổi mật khẩu).
- Năm tab tiếng Việt: Trang chủ · Danh mục · Tìm kiếm · Giỏ hàng · Tài khoản.
- Duyệt ẩn danh là mặc định. Không tab nào bắt đăng nhập. Giỏ hàng hoạt động cho khách vãng lai và được gộp tự động vào tài khoản khi họ đăng nhập.
- Toàn bộ dữ liệu đi qua
@xstore/storefront-sdk. Không cófetch()thẳng tới API, không có driver cơ sở dữ liệu, không có HTTP client thứ hai. - Sơ đồ đường dẫn tiếng Việt trùng với storefront web (
/san-pham/{slug},/danh-muc/{slug},/thanh-toan,/tai-khoan/*…), nên một liên kết chia sẻ từ web mở được trong ứng dụng và ngược lại — xem §10. - 60 bộ test / 612 test chạy sẵn trong dự án, trên transport giả: không cần backend, không cần mạng, không cần cửa hàng mẫu.
Nó KHÔNG phải cái gì
Đây không phải ứng dụng quản trị. Ứng dụng cho nhân viên (xem đơn, sửa kho, duyệt trả hàng) là một sản phẩm khác của xStore. Dự án này là ứng dụng cho khách hàng của bạn. Nó không có màn hình quản trị nào, không dùng thư viện nội bộ của xStore, và không nhận bất kỳ thông tin kết nối cơ sở dữ liệu nào — đó là ranh giới bảo mật của nền tảng, không phải một tính năng còn thiếu.
xStore không build hộ bạn. Một bản build thật cần tài khoản Expo và, với iOS, credentials Apple
trả phí. Không thứ nào trong đó thuộc về xStore, nên eas.json được ship sẵn dưới dạng cấu hình
và bạn là người chạy lệnh (§7).
2. Chuẩn bị
| Thành phần | Phiên bản | Bắt buộc khi |
|---|---|---|
| Node.js | >= 20 (đã kiểm chứng trên 24.17.0) | luôn luôn |
| pnpm | 11 (pnpm 11 đòi Node >= 22.13), hoặc pnpm 10 nếu bạn ở Node 20 | luôn luôn |
| Expo Go (điện thoại) | bản mới nhất | chạy thử nhanh nhất |
| Xcode | 16+ | chạy iOS Simulator (chỉ macOS) |
| Android Studio | mới nhất | chạy máy ảo Android |
| Tài khoản Expo | miễn phí để bắt đầu | build bằng EAS (§7) |
| Apple Developer Program | 99 USD/năm | nộp App Store (§8) |
| Google Play Console | 25 USD một lần | nộp Play Store (§8) |
Bạn không cần Xcode để bắt đầu. Cài Expo Go trên điện thoại, chạy pnpm start, quét mã QR — dự
án không có mã native riêng nên Expo Go chạy được toàn bộ.
Và một khoá publishable — mục tiếp theo.
3. Sửa xstore.config.js
Đây là tệp duy nhất bạn bắt buộc phải sửa để starter trở thành cửa hàng của bạn.
module.exports = {
appName: "Cửa hàng An Phát",
slug: "an-phat-store",
bundleId: "vn.anphat.app",
publishableKey: "pk_live_…",
apiUrl: "https://api.xstore.vn/public/v1",
brandColor: "#0F172A",
scheme: "anphat",
universalLinkHost: "anphat.vn",
homeCollectionSlug: "",
}
| Trường | Ý nghĩa |
|---|---|
appName |
tên hiện dưới biểu tượng trên màn hình chính |
slug |
định danh Expo — chữ thường và gạch ngang |
bundleId |
bundle identifier (iOS) / package name (Android). Không đổi được sau khi phát hành |
publishableKey |
khoá pk_live_… — xem cảnh báo dưới |
apiUrl |
endpoint API. ⚠️ phải kèm hậu tố /public/v1 |
brandColor |
màu thương hiệu — nền màn hình chờ và nền adaptive icon Android |
scheme |
URL scheme cho liên kết sâu: một từ, bắt đầu bằng chữ cái, không dấu cách |
universalLinkHost |
tên miền cửa hàng cho universal link. Chỉ tên miền, không https://, không /. Để trống nếu chưa có |
homeCollectionSlug |
slug bộ sưu tập hiển thị ở dải "Nổi bật" trên trang chủ. Để trống thì dải đó không hiện |
Khoá pk_live_ lấy ở đâu
Trang quản trị → Nhà phát triển → Khoá storefront (/manage/developer/storefront-keys) → Tạo
khoá. Khoá chỉ hiện đầy đủ đúng một lần — chép ngay khi tạo. Mất thì thu hồi hoặc xoay
trong chính màn hình đó; khoá cũ ngừng hiệu lực ngay lập tức.
Khoá pk_live_ công khai theo thiết kế, giống publishable key của Stripe: mọi thứ nó mở ra đều
là dữ liệu công khai của cửa hàng. Nhúng nó vào bundle di động là bình thường và đúng.
⚠️ Ba loại khoá còn lại là khoá BÍ MẬT, và trang tạo chúng nằm ngay cạnh
Khoá Trang Được nhúng vào ứng dụng? pk_live_…Nhà phát triển → Khoá storefront ( /manage/developer/storefront-keys)CÓ sk_live_…Nhà phát triển → Khoá API ( /manage/developer/api-keys)KHÔNG dk_live_…(token triển khai)— KHÔNG ak_live_…(token agent)— KHÔNG Mọi chuỗi ký tự trong một bản build di động đều đọc được bởi bất kỳ ai tải ứng dụng về. Nên dán nhầm một trong ba khoá dưới vào
publishableKeykhông phải là "cấu hình sai ứng dụng", mà là đã công bố một bí mật — và cách duy nhất để sửa là thu hồi khoá đó rồi phát hành lại ứng dụng.pnpm xstore:doctor(§4) chặn đúng lỗi này trước khi bạn build.
/manage/website/api-keyslà đường dẫn cũ và nay chỉ còn là trang chuyển hướng. Dùng đường dẫn chính thức ở bảng trên.
⚠️
apiUrlthiếu/public/v1là lỗi tốn kém nhất trong bảngThiếu hậu tố đó thì mọi request rơi lên một cấp —
/storethay vì/public/v1/store— và triệu chứng là một ứng dụng cài được, mở được, rồi hỏng toàn bộ mà không báo gì rõ ràng. Đây không phải giả định: đúng lỗi này đã xảy ra trên lần deploy storefront thật đầu tiên và sống sót qua hai lần rà soát.
4. Cài đặt, kiểm tra, chạy
pnpm sdk:local # BẮT BUỘC lúc này — xem cảnh báo bên dưới
pnpm install
pnpm xstore:doctor # kiểm tra xstore.config.js TRƯỚC KHI chạy
pnpm start # Metro; bấm i để mở iOS Simulator, a để mở Android
⚠️ SDK chưa được phát hành ra
docs.xstore.vn.package.jsonkhai báo@xstore/storefront-sdktrỏ tớihttps://docs.xstore.vn/downloads/xstore-storefront-sdk-2.0.0.tgz. Đó là địa chỉ đúng và là hợp đồng dành cho tenant, nhưng tệp đó chưa được deploy. Cho tới lúc đó,pnpm sdk:locallà cách cài duy nhất chạy được. Đừng sửa dòng phụ thuộc trongpackage.json— nó không sai.
pnpm xstore:doctor
Nó đọc xstore.config.js (và biến môi trường EXPO_PUBLIC_* nếu bạn đặt, vì đó mới là giá trị
ứng dụng thật sự dùng) rồi báo lỗi trên:
- khoá vẫn là giá trị mẫu, hoặc để trống;
- khoá là khoá bí mật
sk_/dk_/ak_; - khoá sai tiền tố;
apiUrlthiếu/public/v1, hoặc để trống;bundleIdkhông có dạng tên miền ngược;schemecó dấu cách / sai định dạng;universalLinkHostbị dán cảhttps://hoặc dấu/.
⚠️ Trên một bản clone mới, lệnh này BÁO LỖI — và đó là đúng.
xstore.config.jsxuất xưởng với khoá mẫupk_live_xxxxxxxx…, vì khoá là của riêng từng cửa hàng và không thể ship sẵn. Điều đầu tiên trung thực mà doctor có thể nói với bạn là "bạn chưa điền khoá". Một doctor xanh trên bản clone chưa cấu hình là một phép thử không bao giờ đỏ được, tức là vô dụng.
Các lệnh khác
pnpm test # 60 bộ, 612 test — transport giả, không cần backend
pnpm typecheck # tsc --noEmit
pnpm guards # các guard mã nguồn (xem §9 của README trong dự án)
pnpm ios # build native cục bộ rồi chạy (chậm; chỉ cần khi thêm mã native)
5. Đổi biểu tượng và màn hình chờ
assets/icon.png 1024×1024 — biểu tượng (iOS + lớp trước adaptive icon Android)
assets/splash-icon.png 1024×1024 — hình giữa màn hình chờ, nền TRONG SUỐT
Thay bằng hình của bạn, giữ nguyên tên tệp và kích thước, rồi build lại. Màu nền màn hình chờ và
nền adaptive icon lấy từ brandColor, nên bạn không phải mở app.config.js.
⚠️ Biểu tượng, màn hình chờ và
bundleIdđược biên dịch vào bản build — không tải được lúc chạy. Đó là ranh giới cố ý: tên cửa hàng, logo và liên kết mạng xã hội đến từGET /storelúc chạy, nên đổi tên cửa hàng không cần nộp lại App Store; còn ba thứ trên thì có.
⚠️ Trong Expo SDK 57, màn hình chờ cấu hình bằng config plugin
expo-splash-screen(đã có sẵn trongapp.config.js), không phải khoásplashở cấp cao nhất như các SDK cũ. Khoásplashnay chỉ còn dành cho PWA và bị cả iOS lẫn Android bỏ qua — viết vào đó thì bạn được một màn hình chờ không bao giờ xuất hiện.
6. Thêm một màn hình mới
Định tuyến là cây thư mục app/. Thêm tệp là thêm màn hình, và cũng là thêm một liên kết sâu
(§10) — không cần khai báo ở đâu khác.
// app/khuyen-mai/[slug].tsx
import { useLocalSearchParams } from "expo-router"
import { Text } from "react-native"
import { Screen } from "@/components/ui"
export default function PromotionScreen() {
const { slug } = useLocalSearchParams<{ slug: string }>()
return (
<Screen>
<Text className="text-title text-fg">Khuyến mãi: {slug}</Text>
</Screen>
)
}
Tệp trên tạo ngay anphat://khuyen-mai/sale-thang-9 mà không cần cấu hình gì thêm.
Cần dữ liệu? Viết một hook cạnh các hook có sẵn trong lib/xstore/queries/ và gọi qua client —
đừng gọi fetch():
import { useQuery } from "@tanstack/react-query"
import { client } from "@/lib/xstore/client"
const products = useQuery({
queryKey: ["promotion", slug],
queryFn: () => client.products.list({ collection: slug, limit: 24 }),
})
Một vài quy tắc trong dự án có guard tự động (pnpm guards) giữ, và mỗi cái tương ứng với một
lỗi đã từng xảy ra thật. Bảng đầy đủ nằm trong README.md của dự án; ba cái hay gặp nhất:
| Quy tắc | Vì sao |
|---|---|
Mọi liên kết thực thể qua <MaybePress> và trường url của chính thực thể — đừng tự ghép /san-pham/${slug} |
url có thể là null một cách hợp lệ, và merchant đổi được sơ đồ URL |
Tiền luôn qua formatCurrency() |
Tổng giỏ/đơn là chuỗi (Pydantic tuần tự hoá Decimal), giá sản phẩm là số |
Ngày giờ qua formatDate() / formatDateTime(), không new Date(<chuỗi từ server>) |
API trả UTC không có hậu tố Z, mà JavaScript đọc chuỗi không offset là giờ địa phương |
Các guard chạy trên mã đã tách bỏ chú thích, nên một docblock giải thích tại sao một tên trường bị cấm thì không bị tính là vi phạm, còn cùng chuỗi đó nằm trong một chuỗi thật thì bị tính. Nếu một guard đỏ: sửa mã, đừng sửa lời chú thích cho guard hết đỏ.
7. Build bằng EAS
pnpm xstore:doctor # LÀM VIỆC NÀY TRƯỚC
pnpm dlx eas-cli login
pnpm dlx eas-cli init # in ra projectId — xem cảnh báo
pnpm dlx eas-cli build --profile preview --platform ios
pnpm dlx eas-cli build --profile production --platform all
eas.json ship sẵn ba hồ sơ:
| Hồ sơ | Dùng khi |
|---|---|
development |
dev client cài nội bộ; iOS chạy trên Simulator, Android ra .apk |
preview |
bản nội bộ chạy trên máy thật (.apk / .ipa ad-hoc) |
production |
bản nộp cửa hàng, autoIncrement bật |
⚠️
eas initKHÔNG tự ghi đượcprojectIdvào dự án này
eas initchỉ biết ghi vàoapp.json. Dự án dùngapp.config.jsđộng (nó phải đọcxstore.config.js), nêneas initchỉ in projectId ra màn hình rồi dừng. Bạn tự thêm bằng tay vào khốiextracó sẵn trongapp.config.js:extra: { xstore, eas: { projectId: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" }, },Thiếu dòng này thì
eas builddừng với một lỗi về projectId, và nguyên nhân thật (config động) không hề xuất hiện trong thông báo.
⚠️
eas.jsoncố ý KHÔNG có khốienvnào chứa URLEndpoint của ứng dụng đến từ
xstore.config.js— tệp của bạn. Nếu một hồ sơ build cắm sẵn URL của xStore vàoenv, nó sẽ âm thầm đè lên cấu hình bạn vừa sửa và bản build sẽ trỏ vào nơi bạn không chọn. Đừng thêmEXPO_PUBLIC_XSTORE_API_URLvàoeas.json.
Credentials do EAS quản lý: lần build iOS đầu tiên, nó hỏi tài khoản Apple và tự tạo/lưu chứng chỉ
và provisioning profile. Với Android, nó tự sinh keystore và giữ hộ — hãy tải bản sao lưu về
(eas credentials), vì mất keystore là mất khả năng cập nhật ứng dụng đã phát hành.
8. Nộp App Store / Play Store
pnpm dlx eas-cli submit --profile production --platform ios
pnpm dlx eas-cli submit --profile production --platform android
Trước lần nộp đầu tiên bạn cần tự chuẩn bị, ngoài phạm vi starter:
- App Store Connect: tạo app record, điền
bundleIdđúng bằng giá trị trongxstore.config.js, ảnh chụp màn hình cho từng kích thước máy, mô tả, từ khoá, và URL chính sách quyền riêng tư. - Play Console: tạo ứng dụng, khai Data safety, phân loại nội dung, và chính sách quyền riêng tư.
- Cả hai đều hỏi ứng dụng thu thập dữ liệu gì. Ứng dụng này gửi lên xStore: thông tin tài khoản khách, địa chỉ giao hàng và nội dung đơn hàng. Nó không có thông báo đẩy, không có SDK quảng cáo và không có bộ theo dõi hành vi nào.
bundleIdkhông đổi được sau khi phát hành. Chọn kỹ trước lần nộp đầu tiên.
Xét duyệt lần đầu của Apple thường vài ngày. Hai lý do bị từ chối hay gặp nhất với ứng dụng thương mại điện tử: thiếu đường dẫn xoá tài khoản, và ảnh chụp màn hình không khớp giao diện thật.
9. Giới hạn hiện tại
Danh sách này có thật và được liệt kê đầy đủ, để bạn không mất một buổi đi tìm thứ không tồn tại. Starter không giả lập thứ nào trong số này. Một giới hạn phát hiện sau khi phát hành tốn kém hơn nhiều so với một giới hạn đọc được từ trước.
Không có thông báo đẩy (push notification). Không có expo-notifications, không có đăng ký
thiết bị, và /public/v1 không có endpoint nào để gửi. Thêm vào là việc của bạn và nó cần cả hạ
tầng phía server.
Không có khái niệm "sản phẩm nổi bật". API không có cột is_featured, không có tham số lọc
featured, không có kiểu sắp xếp featured. Dải Nổi bật trên trang chủ được điều khiển bằng
một bộ sưu tập bạn tự tạo trong trang quản trị rồi điền slug vào homeCollectionSlug. Đó là
toàn bộ cơ chế — không có gì khác đứng sau cái nhãn đó.
Không lọc theo thuộc tính trên máy chủ. GET /filters trả cấu hình facet (thương hiệu,
thuộc tính, khoảng giá) mà bạn khai trong trang quản trị, nhưng products.list() không nhận tham
số facet nào: ListProductsParams là {limit, cursor, sort, category, collection, q, ids}. Vì
vậy màn hình danh mục và danh sách sản phẩm chỉ có sắp xếp, không có ô lọc — một ô tích
không làm gì là thứ tệ hơn không có ô tích, và lọc phía client chỉ lọc được đúng trang con trỏ đã
tải rồi trình bày sai mọi trang sau đó ở HTTP 200. Sắp xếp thì có thật và chỉ có ba giá trị:
newest, price_asc, price_desc. Riêng màn hình Tìm kiếm có lọc theo danh mục và
thương hiệu, vì SearchParams là {q, limit, cursor, categoryId, brandId} — đúng hai facet đó
và không hơn.
Chi tiết đơn hàng không có dòng sản phẩm. GET /customer/orders/{id} trả đúng mười trường vô
hướng: id, code, current_status, total, subtotal, discount_total,
shipping_total, currency, placed_at, note. Không có items, không có địa chỉ giao, và
không có payment_status. Ứng dụng hiển thị current_status (chuỗi do server sở hữu, in nguyên
văn) cùng total, và nói thẳng với khách rằng danh sách sản phẩm chưa có thay vì để một khối trống.
Bài viết không có bình luận và không có lượt xem. /public/v1/articles/* không trả hai thứ đó,
và reviews là API đánh giá theo thực thể, không phải bình luận bài viết.
Giảm giá cấp đơn hàng không đi vào đơn. Coupon và khuyến mãi cấp đơn được tính trong báo giá giỏ hàng nhưng không ghi vào đơn khi đặt. Giảm giá cấp sản phẩm, điểm thưởng và giá flash sale thì có.
Những thứ ĐÃ CÓ và hay bị tưởng là chưa có
- Cổng thanh toán trực tuyến — CÓ.
GET /checkout/payment-methodstrả đúng các phương thức mà tenant của bạn đã bật;POST /checkout/paytrả URL chuyển hướng sang cổng; ứng dụng mở cổng trong trình duyệt trong ứng dụng và màn hình/thanh-toan/ket-quaxác minh payload khi khách quay về (POST /checkout/payment-return). Starter không cắm cứngcod+bank_transfer: một cửa hàng chưa cấu hình gì sẽ nhận danh sách rỗng và ứng dụng hiện thông báo chặn đặt hàng, thay vì mời khách chọn một phương thức không tồn tại. - Phí vận chuyển tính theo địa chỉ — CÓ.
POST /shipping-methodstrả các mức phí do server tính, kèm danh mục 34 tỉnh/thành và phường/xã (mô hình hai cấp sau 2025-07-01) và danh sách điểm nhận hàng. Ba ô địa chỉ không phải ô nhập tự do. Endpoint này bị giới hạn 30 request/phút cho mỗi IP khách, nên bộ chọn trong starter có debounce sẵn — đừng gỡ. - Gộp giỏ hàng khi đăng nhập — CÓ. Khách vãng lai bỏ hàng vào giỏ rồi đăng nhập ở bước thanh toán vẫn giữ nguyên giỏ đó.
10. Liên kết sâu (deep link)
Cây thư mục app/ CHÍNH LÀ bảng định tuyến. expo-router dựng bảng đó từ app/** lúc khởi động,
nên dự án không có — và không được có — một đối tượng linking với prefixes/screens viết
tay: chỗ duy nhất nhận cấu hình đó là <ExpoRoot>, mà ứng dụng này không bao giờ render.
Các đường dẫn dưới đây cố ý trùng với sơ đồ URL của storefront web:
| Đường dẫn | Tệp route |
|---|---|
/san-pham/:slug |
app/san-pham/[slug].tsx |
/danh-muc/:slug |
app/danh-muc/[slug].tsx |
/bo-suu-tap/:slug |
app/bo-suu-tap/[slug].tsx |
/bai-viet/:slug |
app/bai-viet/[slug].tsx |
/trang/:slug |
app/trang/[slug].tsx |
/flash-sale |
app/flash-sale/index.tsx |
/thanh-toan |
app/thanh-toan/index.tsx |
/thanh-toan/ket-qua |
app/thanh-toan/ket-qua.tsx (màn hình quay về từ cổng thanh toán) |
/tai-khoan/don-hang/:id |
app/tai-khoan/don-hang/[id].tsx |
Hai lối vào cùng dùng bảng này:
- Scheme riêng —
anphat://san-pham/ao-thun, lấy từschemetrongxstore.config.js. - Universal link —
https://anphat.vn/san-pham/ao-thun, chỉ bật khiuniversalLinkHostkhác rỗng. Khi để trống,app.config.jskhông sinhassociatedDomains(iOS) và không sinh intent filter (Android): mộtapplinks:rỗng còn tệ hơn là không có.
Thêm màn hình là thêm liên kết sâu, không cần cấu hình gì thêm. Tạo app/khuyen-mai/[slug].tsx
(§6) là ngay lập tức có anphat://khuyen-mai/….
Universal link còn cần một tệp
apple-app-site-associationvàassetlinks.jsonphục vụ từ tên miền của bạn. EAS in ra nội dung cần đặt khi bạn build vớiuniversalLinkHostđã điền.