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:

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 publishableKey khô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-keys là đườ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.

⚠️ apiUrl thiếu /public/v1 là lỗi tốn kém nhất trong bảng

Thiếu hậu tố đó thì mọi request rơi lên một cấp — /store thay 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.json khai báo @xstore/storefront-sdk trỏ tới https://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:local là cách cài duy nhất chạy được. Đừng sửa dòng phụ thuộc trong package.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:

⚠️ Trên một bản clone mới, lệnh này BÁO LỖI — và đó là đúng. xstore.config.js xuất xưởng với khoá mẫu pk_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 /store lú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 trong app.config.js), không phải khoá splash ở cấp cao nhất như các SDK cũ. Khoá splash nay 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 init KHÔNG tự ghi được projectId vào dự án này

eas init chỉ biết ghi vào app.json. Dự án dùng app.config.js động (nó phải đọc xstore.config.js), nên eas init chỉ in projectId ra màn hình rồi dừng. Bạn tự thêm bằng tay vào khối extra có sẵn trong app.config.js:

extra: {
  xstore,
  eas: { projectId: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" },
},

Thiếu dòng này thì eas build dừ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.json cố ý KHÔNG có khối env nào chứa URL

Endpoint 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ào env, 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êm EXPO_PUBLIC_XSTORE_API_URL vào eas.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:

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ó


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:

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-association và assetlinks.json phụ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ới universalLinkHost đã điền.