Skip to content

🔌 HƯỚNG DẪN FRONTEND KẾT NỐI BACKEND (gghut-core)

Tài liệu cho FE tích hợp với core BE. Cập nhật theo Phase 0.


1 · BASE URL

https://gghut-core-24817855140.asia-southeast1.run.app
  • Region: asia-southeast1 (Singapore), gần Neon DB.
  • ⚠️ Cold start: Cloud Run free tier ngủ khi không dùng. Request đầu sau khi ngủ có thể mất vài giây. Sau đó nhanh bình thường.
  • Health check: GET /livez (process sống), GET /readyz (DB connect được).

2 · RESPONSE FORMAT (chuẩn cho MỌI endpoint)

jsonc
// Thành công
{
  "data": { ... },              // payload chính
  "meta": { "page": 1, "page_size": 20, "total": 42 }  // chỉ có ở list
}

// Lỗi
{
  "error": {
    "code": "SLOT_FULL",         // machine-readable — FE switch theo code này
    "message": "Campaign đã hết slot"  // human-readable
  }
}

HTTP status đi kèm: 400 (validation) · 401 (chưa đăng nhập/token hết hạn) · 403 (không đủ quyền) · 404 (không tìm thấy) · 409 (xung đột trạng thái) · 500 (lỗi server).

Quy tắc FE: luôn đọc error.code để xử lý logic, đừng parse message.


3 · AUTH FLOW (JWT)

BE dùng access token (15 phút) + refresh token (7 ngày).

3.1 Register / Login → nhận cặp token

http
POST /api/v1/auth/register
Content-Type: application/json

{ "email": "user@example.com", "password": "password123", "full_name": "Tên" }
http
POST /api/v1/auth/login
Content-Type: application/json

{ "email": "user@example.com", "password": "password123" }

Response (200/201):

json
{
  "data": {
    "access_token":  "eyJhbGciOi...",
    "refresh_token": "eyJhbGciOi...",
    "token_type":    "Bearer",
    "expires_in":    900,
    "user": { "id": 1, "email": "user@example.com", "full_name": "Tên", "is_admin": false }
  }
}

3.2 Gọi API có auth → gắn Authorization header

Authorization: Bearer <access_token>
js
// fetch example
const res = await fetch(`${BASE}/api/v1/orders`, {
  headers: { Authorization: `Bearer ${accessToken}` },
});

3.3 Access token hết hạn (401) → refresh

Khi nhận 401, gọi refresh để lấy access token mới, rồi retry request cũ:

http
POST /api/v1/auth/refresh
Content-Type: application/json

{ "refresh_token": "<refresh_token>" }

Response giống 3.1 (có access_token mới).

Flow chuẩn cho FE (viết 1 lần, dùng chung):

js
async function api(path, options = {}) {
  const doFetch = (token) =>
    fetch(`${BASE}${path}`, {
      ...options,
      headers: {
        "Content-Type": "application/json",
        ...(token ? { Authorization: `Bearer ${token}` } : {}),
        ...options.headers,
      },
    });

  let res = await doFetch(getAccessToken());

  if (res.status === 401) {
    // thử refresh
    const ok = await refreshTokens();   // gọi /auth/refresh, lưu token mới
    if (ok) res = await doFetch(getAccessToken());
  }
  return res.json();
}

refresh_token hết hạn (7 ngày) hoặc bị revoke → /auth/refresh trả 401 → FE đưa user về trang login.

3.4 Logout + Me

http
POST /api/v1/auth/logout        # body: { "refresh_token": "..." }  (cần auth)
GET  /api/v1/auth/me            # thông tin user hiện tại (cần auth)

3.5 Lưu token ở FE + Remember me

FE (cả storefront lẫn admin) lưu token trong cookies qua js-cookie, không dùng localStorage để giảm bề mặt tấn công XSS:

Cookie"Ghi nhớ đăng nhập" ✅ (mặc định)Bỏ chọn ghi nhớ
access_token~15 phút (khớp TTL token BE)session cookie
refresh_token7 ngày (khớp JWT_REFRESH_TTL BE)session cookie
remember_me (marker)7 ngàysession cookie

Hành vi:

  • Bỏ chọn "Ghi nhớ đăng nhập" khi login → cả 3 cookie là session cookie, đóng browser là đăng xuất.
  • Access token hết hạn giữa phiên → interceptor tự gọi /auth/refresh rồi ghi lại cookie theo đúng chế độ đã chọn lúc đăng nhập (đọc từ marker remember_me).
  • Refresh token hết hạn/bị revoke → xoá cookie + về trang login như flow 3.3.
  • Logout → xoá sạch cả 3 cookie.
  • TTL cookie FE cố tình khớp TTL token của BE. Muốn kéo dài thời gian "ghi nhớ" quá 7 ngày phải tăng env JWT_REFRESH_TTL ở BE.

4 · DANH SÁCH ENDPOINT

Auth (public)

MethodPathMô tả
POST/api/v1/auth/registerĐăng ký
POST/api/v1/auth/loginĐăng nhập
POST/api/v1/auth/refreshĐổi access token

Auth (cần đăng nhập)

MethodPathMô tả
POST/api/v1/auth/logoutĐăng xuất (revoke refresh)
GET/api/v1/auth/meThông tin user hiện tại

Campaign (public)

MethodPathMô tả
GET/api/v1/campaignsList campaign (mặc định trả OPEN + CLOSED + CANCELLED, ẩn DRAFT; lọc bằng ?status=all (trả đủ cả DRAFT) hoặc 1/nhiều trạng thái phân tách dấu phẩy ?status=DRAFT,OPEN, paging ?page=&page_size=)
GET/api/v1/campaigns/{slug}Chi tiết campaign (kèm tiers + remaining slots)

Campaign object:

json
{
  "id": 1, "title": "Áo Thun GroupBuy", "slug": "ao-thun-groupbuy",
  "description": "Áo thun cotton",
  "status": "OPEN",                      // DRAFT | OPEN | CLOSED | CANCELLED | FAILED
  "banner_url": "https://pub-xxxx.r2.dev/campaigns/1/banner.jpg",
  "deposit_per_unit": 50000,             // tiền cọc / 1 sản phẩm (VND)
  "default_price": 180000,               // giá gốc hiển thị (tuỳ chọn)
  "promo_price": 165000,                 // giá khuyến mãi hiển thị (tuỳ chọn)
  "settlement_window_h": 72,
  "shipping_fee": 0,
  "capacity": 100, "taken": 30, "remaining": 70,
  "moq": 10,                             // = qty_threshold của tier thấp nhất — số lượng tối thiểu để chốt giá
  "moq_reached": true,                   // taken đã đạt MOQ chưa (BE computed)
  "current_unit_price": 150000,          // giá hiệu lực hiện tại: tier cao nhất đã đạt → fallback promo_price → default_price (BE computed)
  "closes_at": "2026-09-01T00:00:00Z",
  "opened_at": "2026-08-20T00:00:00Z",
  "created_at": "2026-08-18T09:30:00Z",
  "tiers": [
    { "id": 1, "qty_threshold": 10, "unit_price": 150000 },
    { "id": 2, "qty_threshold": 50, "unit_price": 120000 }
  ],
  "images": [
    { "id": 10, "url": "https://pub-xxxx.r2.dev/campaigns/1/3f9a….jpg", "sort_order": 1 },
    { "id": 11, "url": "https://pub-xxxx.r2.dev/campaigns/1/b7c2….png", "sort_order": 2 }
  ]
}

⚠️ List vs detail: GET /campaigns (list) KHÔNG trả tiers lẫn images. Chỉ GET /campaigns/{slug} (chi tiết) mới có đủ. Các field tuỳ chọn (banner_url, images, closes_at, default_price, promo_price…) bị lược bỏ hẳn khỏi JSON khi chưa có giá trị (không trả null). Ngược lại moq, moq_reached, current_unit_price là field computed — BE trả ở cả list lẫn detail để card không phải fetch detail.

Trạng thái campaign: ngoài DRAFT/OPEN/CLOSED/CANCELLED có thêm FAILED — campaign hết giờ mà tổng qty cọc không đạt MOQ → hệ thống tự chuyển FAILED và đưa mọi đơn DEPOSIT_HELD vào hàng hoàn cọc (REFUND_PENDING). Chi tiết luồng: docs/groupbuy-flow.md.

Order (cần đăng nhập)

MethodPathMô tả
POST/api/v1/ordersĐặt slot (giữ 15 phút). Body: { "campaign_id": 1, "qty": 30 }
GET/api/v1/ordersĐơn của tôi (paging)
GET/api/v1/orders/{id}Chi tiết đơn (chỉ chủ đơn hoặc admin)

Đặt slot — lỗi thường gặp:

  • SLOT_FULL (409): hết slot → hiển thị "đã hết chỗ"
  • INVALID_CAMPAIGN_STATE (409): campaign chưa mở/đã đóng

Order object:

json
{
  "id": 5, "code": "GB-US28BP", "user_id": 2, "campaign_id": 1,
  "status": "RESERVED",
  // RESERVED | EXPIRED | DEPOSIT_HELD | SETTLEMENT_DUE | CONFIRMED
  //         | SHIPPED | DELIVERED | REFUND_PENDING | REFUNDED | CANCELLED
  "qty": 30,
  "deposit_due": 1500000, "deposit_paid": 0,
  "settlement_paid": 0,                  // tổng tiền phần còn lại đã chuyển (cộng dồn qua webhook settlement)
  "expires_at": "2026-08-25T04:15:00Z",  // hết hạn giữ slot
  "final_unit_price": null, "amount_due": null,  // chỉ có sau khi settle
  "payment": {                            // chỉ có khi đơn còn chờ cọc (RESERVED)
    "bank_code": "VCB", "account_number": "1017588888", "account_holder": "CONG TY GG",
    "content": "GB-US28BP",               // nội dung CK = mã đơn
    "qr_url": "https://vietqr.app/img?acc=1017588888&amount=1500000&bank=VCB&des=GB-US28BP&template=compact"
  },
  "created_at": "2026-08-25T04:00:00Z"
}

Vòng đời đơn + tự động hoá (chi tiết đầy đủ ở docs/groupbuy-flow.md):

  • RESERVED →(webhook cọc đủ)→ DEPOSIT_HELD →(settle)→ SETTLEMENT_DUE →(chuyển đủ amount_due)→ CONFIRMED → SHIPPED → DELIVERED.
  • BE tự settle campaign OPEN khi đủ slot (taken == capacity, chốt ngay) hoặc quá closes_at: đạt MOQ → CLOSED; thiếu MOQ → FAILED + toàn bộ đơn DEPOSIT_HELDREFUND_PENDING.
  • Admin hoàn cọc chủ động hoặc xử lý hàng hoàn: POST /admin/orders/{id}/refund.

Admin (đăng nhập + is_admin=true)

MethodPathMô tả
POST/api/v1/admin/campaignsTạo campaign (draft) → 201 + object trạng thái DRAFT
PUT/api/v1/admin/campaigns/{id}Sửa campaign DRAFT/OPEN → 200 + object mới
POST/api/v1/admin/campaigns/{id}/openMở bán (DRAFT → OPEN)
POST/api/v1/admin/campaigns/{id}/closeĐóng nhóm
POST/api/v1/admin/campaigns/{id}/cancelHuỷ
POST/api/v1/admin/campaigns/{id}/settleChốt giá + tính amount_due (vẫn dùng tay được; hệ thống cũng tự settle — xem lifecycle)
POST/api/v1/admin/campaigns/{id}/fulfill"Đã về hàng": campaign phải CLOSED → mọi đơn CONFIRMED chuyển SHIPPED
GET/api/v1/admin/campaigns/{id}/ordersĐơn theo campaign
POST/api/v1/admin/orders/{id}/refundHoàn cọc: DEPOSIT_HELD/SETTLEMENT_DUE/REFUND_PENDINGREFUNDED
POST/api/v1/admin/orders/{id}/shipGửi hàng 1 đơn: CONFIRMEDSHIPPED
POST/api/v1/admin/orders/{id}/deliverXác nhận đã giao: SHIPPEDDELIVERED
POST/api/v1/admin/campaigns/{id}/bannerUpload/đổi banner (multipart)
POST/api/v1/admin/campaigns/{id}/imagesThêm nhiều ảnh gallery trong 1 request (multipart, field image lặp lại, tối đa 10 ảnh)
DELETE/api/v1/admin/campaigns/{id}/images/{imageID}Xoá 1 ảnh gallery

Tạo campaign — body (POST /api/v1/admin/campaigns):

FieldBắt buộcKiểuGhi chú
titlestringSlug tự sinh từ title: viết thường, giữ nguyên dấu tiếng Việt, khoảng trắng/-/_ gộp thành một dấu - (vd: "Áo Thun HOT 2026" → áo-thun-hot-2026). Trùng slug đã có → lỗi DB duplicate
descriptionstring
deposit_per_unitint ≥ 0Tiền cọc / 1 slot (VND)
default_priceint ≥ 0Giá gốc hiển thị trên storefront
promo_priceint ≥ 0Giá khuyến mãi hiển thị (fallback của giá hiệu lực khi chưa đạt tier nào)
settlement_window_hint ≥ 1Gửi 0 hoặc bỏ trống → BE mặc định 72
shipping_feeint ≥ 0Mặc định 0
capacityint ≥ 1Tổng số slot
closes_atRFC3339 UTCPhải đủ giây + múi giờ, vd 2026-09-01T00:00:00Z. Chuỗi thiếu múi giờ (vd giá trị của <input type="datetime-local">: 2026-09-01T00:00) sẽ fail parse → INVALID_JSON. FE phải convert sang ISO UTC trước khi gửi
tiersarray ≥ 1 mốcqty_threshold phải tăng dần nghiêm ngặt (mốc sau > mốc trước), unit_price ≥ 0 — vi phạm → INVALID_TIER
json
{
  "title": "Áo Thun GroupBuy",
  "description": "Áo thun cotton",
  "deposit_per_unit": 50000,
  "default_price": 180000,
  "promo_price": 165000,
  "settlement_window_h": 72,
  "shipping_fee": 0,
  "capacity": 100,
  "closes_at": "2026-09-01T00:00:00Z",
  "tiers": [
    { "qty_threshold": 10, "unit_price": 150000 },
    { "qty_threshold": 50, "unit_price": 120000 }
  ]
}

Response: 201 + campaign object trạng thái DRAFT (đã có slug, chưa có banner_url/images — upload sau bằng endpoint multipart bên dưới).

Lỗi hay gặp khi tạo: VALIDATION_FAILED (400 — thiếu title/capacity/tiers…) · INVALID_JSON (400 — body sai JSON hoặc closes_at sai RFC3339) · INVALID_TIER (400 — qty_threshold không tăng dần) · UNAUTHORIZED/FORBIDDEN (401/403 — token hết hạn hoặc không phải admin).

⚠️ Admin là ai? Hiện Phase 0 user tạo qua /register mặc định is_admin=false. Để có admin, phải set trực tiếp trong DB (UPDATE users SET is_admin=true WHERE email=...). Sau này sẽ có luồng mời admin.

Upload ảnh campaign (admin, multipart)

Ảnh campaign lưu trên Cloudflare R2, đọc công khai qua CDN *.r2.dev. FE không upload trực tiếp lên R2 — luôn đẩy file qua BE, nhận lại URL rồi render qua next/image.

⚠️ next/image trên FE: cả nextjs-storefront lẫn nextjs-admin chạy output: "export" (static), không có server tối ưu ảnh — vì vậy next.config.ts phải giữ images: { unoptimized: true }. <Image> vẫn cho lazy-load + chống layout shift; khi sau này bật optimizer thật (vd Cloudflare Images) chỉ cần thêm custom loader và khai báo domain R2 trong images.remotePatterns, không phải đổi code render.

3 endpoint:

EndpointResponse
POST /api/v1/admin/campaigns/{id}/banner200 + campaign object (đã cập nhật banner_url)
POST /api/v1/admin/campaigns/{id}/images201 + mảng ảnh: { "data": [ { "id": 10, "url": "...", "sort_order": 3 }, … ] }
DELETE /api/v1/admin/campaigns/{id}/images/{imageID}204 (không body)

Quy tắc chung:

  • Body là multipart/form-data, file đặt trong field tên bắt buộc image.
  • Banner: 1 request = 1 file. Gallery: 1 request được nhiều file — append nhiều part trùng tên image, thứ tự part chính là thứ tự hiển thị.
  • Giới hạn 10 MB/ảnh, tối đa 10 ảnh/request gallery. Server tự dò MIME từ nội dung file (không tin header client gửi) — chỉ nhận jpeg / png / webp / gif.
  • Upload gallery là all-or-nothing: lỗi bất kỳ ảnh nào (R2/DB) thì BE dọn sạch, không bao giờ lưu lại một nửa. FE chỉ cần xử lý thành công/thất bại của cả request.
  • Upload được ở mọi trạng thái campaign (DRAFT/OPEN/CLOSED), chỉ cần token admin.
  • Banner: upload lần sau ghi đè banner cũ (object cũ bị xoá nếu đổi định dạng). Không có khái niệm nhiều banner.
  • Gallery: ảnh mới luôn append cuốisort_order tăng dần từ 1 theo thứ tự gửi. BE chưa có API đổi thứ tự/xoá hàng loạt; muốn sắp xếp lại thì xoá rồi upload lại. Chưa có giới hạn tổng số ảnh/campaign (gọi endpoint nhiều lần thoải mái).
  • Delete ảnh: xoá cả DB record lẫn object trên R2. imageID không thuộc campaign đó → NOT_FOUND.
  • URL trả về là link public vĩnh viễn — cache được, không cần ký/token.
js
// Thêm NHIỀU ảnh gallery trong 1 request
const form = new FormData();
for (const file of selectedFiles) {
  form.append("image", file); // field name PHẢI là "image" — lặp lại cho từng file
}

const res = await fetch(`${BASE}/admin/campaigns/${campaignId}/images`, {
  method: "POST",
  headers: { Authorization: `Bearer ${getAccessToken()}` },
  // ⚠️ KHÔNG tự set Content-Type — browser phải tự sinh boundary của multipart
  body: form,
});
const { data } = await res.json();
// data = [{ id: 10, url: "https://pub-xxxx.r2.dev/campaigns/1/a1b2….jpg", sort_order: 3 }, ...]
bash
# curl test nhanh (mỗi -F "image=@..." là 1 file)
curl -X POST "$BASE/admin/campaigns/1/images" \
  -H "Authorization: Bearer $TOKEN" \
  -F "image=@ao-thun-1.jpg" \
  -F "image=@ao-thun-2.png"

Luồng gợi ý cho màn hình tạo campaign (admin) — ảnh gallery gộp thành đúng 1 call:

js
// Bước 1 · Tạo campaign (JSON thuần, chưa có ảnh)
const { data: campaign } = await api("/admin/campaigns", {
  method: "POST",
  body: JSON.stringify({ title, description, deposit_per_unit, capacity, tiers, /* … */ }),
});
const id = campaign.id;

// Bước 2 · Upload banner nếu admin chọn ảnh bìa (tuỳ chọn, 1 file)
if (bannerFile) {
  const fd = new FormData();
  fd.append("image", bannerFile);
  await api(`/admin/campaigns/${id}/banner`, { method: "POST", body: fd });
}

// Bước 3 · Upload TẤT CẢ ảnh gallery trong 1 request (tuỳ chọn, ≤10 file; >10 thì chia lô)
if (galleryFiles.length > 0) {
  const fd = new FormData();
  for (const f of galleryFiles) fd.append("image", f);
  const { data: images } = await api(`/admin/campaigns/${id}/images`, { method: "POST", body: fd });
  // images theo đúng thứ tự admin sắp trong form → render preview từ URL này
}

// Bước 4 (tuỳ chọn) · Mở bán ngay
await api(`/admin/campaigns/${id}/open`, { method: "POST" });

Nếu admin sửa campaign DRAFT/OPEN và thêm ảnh mới → gọi lại bước 2/3 với cùng {id}; ảnh cũ vẫn giữ nguyên, ảnh mới append cuối.

Lỗi riêng của upload: IMAGE_TOO_LARGE (413, 1 ảnh vượt 10MB) · TOO_MANY_IMAGES (400, quá 10 ảnh/request) · INVALID_IMAGE (400, sai định dạng) · MISSING_IMAGE (400, thiếu field) · INVALID_MULTIPART (400, body không phải multipart) · STORAGE_NOT_CONFIGURED (503, BE chưa cấu hình R2 — chỉ gặp ở môi trường dev).

Payment webhook (BE nhận từ SePay — FE KHÔNG gọi)

POST /api/v1/webhooks/sepay — endpoint cho SePay bắn biến động số dư ngân hàng, không phải cho FE. Auth bằng header Authorization: Apikey <SEPAY_API_KEY>; gửi thành công ⟺ BE trả HTTP 200/201 kèm body {"success": true} (các lỗi khác SePay tự retry, tối đa 7 lần).

Tiền vào (transferType = "in") được tách mã đơn GB-XXXXXX từ nội dung chuyển khoản rồi phân nhánh theo trạng thái đơn:

Trạng thái đơn khi tiền vềKết quả
RESERVEDTạo payment DEPOSIT → đơn DEPOSIT_HELD (chuyển thiếu vẫn giữ đơn, ghi cảnh báo đối soát)
SETTLEMENT_DUETạo payment SETTLEMENT, cộng dồn settlement_paid; khi ≥ amount_due → đơn CONFIRMED. Chuyển thừa được ghi nhận, không lỗi
  • Idempotent theo transaction id của SePay (webhook_events.idempotency_key = "sepay:<tx_id>") — retry/resend không bị double-process.
  • Giao dịch không chứa mã đơn / mã không tồn tại / đơn sai trạng thái: vẫn lưu vào webhook_events để đối soát nhưng ack 200 (retry cũng vô ích).
  • User chuyển phần còn lại bằng cùng nội dung CK GB-XXXXXX như lúc cọc.

5 · ERROR CODES (FE cần biết)

CodeHTTPÝ nghĩa
INVALID_CREDENTIALS401Email/password sai
EMAIL_EXISTS409Email đã đăng ký
UNAUTHORIZED401Token thiếu/sai/hết hạn
FORBIDDEN403Không đủ quyền (cần admin, hoặc không phải chủ đơn)
NOT_FOUND404Campaign/order không tồn tại
INVALID_CAMPAIGN_STATE409Campaign không ở trạng thái cho phép (message kèm status thật, vd campaign is CLOSED, must be OPEN)
SLUG_EXISTS409Title tạo ra slug đã có campaign khác dùng — đổi title
SLOT_FULL409Hết slot
INVALID_ORDER_STATE409Đơn không ở trạng thái cho phép (message kèm trạng thái thật của đơn)
NO_DEPOSITS409Settle campaign khi chưa có đơn cọc nào
VALIDATION_FAILED400Body không hợp lệ. Message là danh sách field dễ đọc, vd title is required; tiers[0].qty_threshold must be at least 1
INVALID_JSON400Body không parse được JSON (gồm cả field thời gian sai định dạng RFC3339, vd closes_at thiếu múi giờ)
INVALID_TIER400Tiers không hợp lệ: tạo/sửa thì qty_threshold không tăng dần nghiêm ngặt; settle thì tổng qty chưa đạt ngưỡng tier thấp nhất (message kèm số liệu)
INVALID_MULTIPART400Upload ảnh nhưng body không phải multipart/form-data
MISSING_IMAGE400Upload thiếu file field image
TOO_MANY_IMAGES400Upload gallery vượt quá 10 ảnh trong 1 request
INVALID_IMAGE400File không phải ảnh hợp lệ (chỉ nhận jpeg/png/webp/gif)
IMAGE_TOO_LARGE413Ảnh vượt quá 10 MB
STORAGE_NOT_CONFIGURED503BE chưa cấu hình storage R2 (môi trường dev chưa có R2 env)
INTERNAL500Lỗi server. Message luôn là internal server error — nguyên nhân thật nằm ở log Grafana/Loki (lọc theo trace_id trong header response X-Trace-Id)

6 · CORS + TRACE ID

Hiện BE cho phép * (mọi origin) để dev. Khi FE có domain thật, sẽ siết lại đúng domain đó. Nếu FE gọi bị CORS lỗi, báo BE để thêm origin.

Trace xuyên suốt (đã bật): FE tự sinh header traceparent chuẩn W3C cho mọi request (1 trace-id / phiên theo tab, span-id mới mỗi request — xem src/lib/api.ts của storefront/admin). BE extract và nối toàn bộ span vào cùng 1 trace trên Tempo. BE trả về header X-Trace-Id (đã expose qua CORS) — dùng để hiện cho user khi báo lỗi hoặc tra nhanh trên trang ops /trace/<id>.


7 · VÍ DỤ LUỒNG ĐẦY ĐỦ (FE)

js
const BASE = "https://gghut-core-24817855140.asia-southeast1.run.app/api/v1";

// 1. Đăng ký / đăng nhập → lưu token
const { data } = await fetch(`${BASE}/auth/login`, {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ email, password }),
}).then(r => r.json());
saveTokens(data.access_token, data.refresh_token);

// 2. Xem danh sách campaign đang mở
const campaigns = await fetch(`${BASE}/campaigns?status=OPEN`).then(r => r.json());

// 3. Đặt slot (cần auth)
const order = await fetch(`${BASE}/orders`, {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    Authorization: `Bearer ${getAccessToken()}`,
  },
  body: JSON.stringify({ campaign_id: 1, qty: 2 }),
}).then(r => r.json());
// → order.data.payment.qr_url = link ảnh QR (nhúng <img src>), content = mã đơn GB-XXXXXX

8 · LƯU Ý

  • Tiền tệ: mọi amount là VND số nguyên (không có số thập phân).
  • Slot giữ 15 phút: sau khi đặt (RESERVED), user phải cọc trong 15 phút, không thì đơn tự EXPIRED và slot được trả lại. FE nên đếm ngược từ expires_at.
  • Đặt cọc: Phase 0 cọc qua chuyển khoản — BE sinh sẵn QR (vietqr.app) trong order.payment, SePay webhook xác nhận tự động khi tiền về. FE nhúng payment.qr_url và nhắc user giữ nguyên nội dung CK. Sau settle, trang thanh toán/lịch sử mua hiển thị amount_due (phần còn lại) — user chuyển tiếp với cùng order_code.
  • MOQ & campaign FAILED: MOQ = qty_threshold tier thấp nhất, set từ lúc tạo và hiển thị luôn trên storefront. Hết giờ không đạt MOQ → campaign FAILED, đơn cọc vào hàng hoàn (REFUND_PENDING). Spec đầy đủ: docs/groupbuy-flow.md.
  • Realtime: Phase 0 chưa có SSE/websocket. Trang detail storefront poll GET /campaigns/{slug} mỗi 15–30s (dừng khi tab ẩn) để cập nhật giá theo mốc/progress; list page dùng field computed (current_unit_price, moq_reached) từ list endpoint, không fetch detail từng card.