s_skaidocsTài liệu
WEBSITE · TỪ API ĐẾN GIAO DIỆN

Dựng trang tài khoản và bán hàng

Xem lộ trình Storefront và trạng thái tính năng trước khi tích hợp.

HTML + JavaScript cùng origin. Các ví dụ ghép với contract Account & Commerce API, không yêu cầu framework.

Cấu trúc theme và route

assets/account-api.js
assets/account-pages.js
pages/register.html
pages/login.html
pages/profile.html
pages/products.html
pages/products/[slug].html
pages/cart.html
pages/checkout.html
pages/my-orders/index.html
pages/my-orders/[id].html
pages/my-orders/[id]/tracking.html
pages/tracking.html

Logout là nút POST, không cần route GET logout. Các trang riêng tư chỉ render shell/loading bằng HTML, sau đó gọi API; không nhúng profile/cart/order vào HTML cache dùng chung. Dùng layout theme hiện có và asset() để nạp script.

{% extends "layouts/default.html" %}
{% block content %}
<h1>Đăng nhập</h1>
<form id="login-form" method="post" action="/_post/auth/login">
  <input type="hidden" name="_token" value="{{ csrf_token }}">
  <label>Email <input name="email" type="email" autocomplete="username" required></label>
  <label>Mật khẩu <input name="password" type="password"
    autocomplete="current-password" required></label>
  <button type="submit">Đăng nhập</button>
</form>
<p id="message" role="status"></p>
<script type="module" src="{{ asset('account-pages.js') }}"></script>
{% endblock %}

Đổi layout/block theo theme của bạn. Đặt account-api.js và account-pages.js cùng thư mục assets để import tương đối.

Trong layouts/default.html thêm <meta name="csrf-token" content="{{ csrf_token }}"> và <body data-authenticated="{% if auth.check %}true{% else %}false{% endif %}">. Token được render sẵn, không gọi API lấy token riêng. Các form tài khoản hỗ trợ POST trực tiếp; JavaScript dưới đây chỉ là tùy chọn cho UI.

1. Client dùng chung — assets/account-api.js

let csrf = document.querySelector('meta[name="csrf-token"]')?.content || "";
export async function api(path, body) {
  const post = body !== undefined;
  if (post && !csrf) throw new Error("Thiếu token. Vui lòng tải lại trang.");
  const response = await fetch("/_post" + path, {
    method: post ? "POST" : "GET",
    credentials: "same-origin",
    cache: "no-store",
    headers: post
      ? {"Content-Type": "application/json", "X-CSRF-Token": csrf}
      : {"Accept": "application/json"},
    ...(post ? {body: JSON.stringify(body)} : {})
  });
  const result = await response.json().catch(() => ({}));
  if (!response.ok) {
    const error = new Error(result.error?.message || "Không thể xử lý yêu cầu.");
    error.status = response.status;
    error.code = result.error?.code;
    error.retryAfter = response.headers.get("Retry-After");
    throw error;
  }
  if (result.csrf_token) csrf = result.csrf_token;
  return result;
}
export const session = async () => ({authenticated: document.body.dataset.authenticated === "true"}); // Snapshot SSR, backend vẫn kiểm tra mỗi request.
export async function requireAccount() {
  const current = await session();
  if (!current.authenticated) {
    location.replace("/login?next=" +
      encodeURIComponent(location.pathname + location.search));
    return false;
  }
  return true;
}
export function safeNext(value) {
  const u = new URL(value || "/profile", location.origin);
  const allowed = /^\/(profile|products(?:\/[^/?#]+)?|cart|checkout|my-orders(?:\/[a-f0-9-]+(?:\/tracking)?)?)$/;
  return u.origin === location.origin && allowed.test(u.pathname)
    ? u.pathname + u.search : "/profile";
}

Không gọi api() với URL do người dùng nhập. Bổ sung catch cho safeNext nếu dữ liệu URL không hợp lệ. Hàm không tự retry POST: với csrf_rejected yêu cầu tải lại trang rồi thao tác lại; riêng checkout giữ payload cũ nếu kết quả chưa rõ.

2. Register, login và logout

Trong account-pages.js, chỉ gắn handler cho form tồn tại trên trang. Các form register/profile có name như JSON tương ứng; thông báo luôn ghi bằng textContent.

import {api, session, requireAccount, safeNext} from "./account-api.js";
const message = document.querySelector("#message");
function show(text) { if (message) message.textContent = text; }
function bind(id, submit) {
  const form = document.getElementById(id);
  if (!form) return;
  let busy = false;
  form.addEventListener("submit", async event => {
    event.preventDefault();
    if (busy) return;
    busy = true;
    const button = form.querySelector('[type="submit"]');
    if (button) button.disabled = true;
    try { await submit(Object.fromEntries(new FormData(form))); }
    catch (error) {
      if (error.code === "csrf_rejected") show("Phiên bảo vệ hết hạn. Hãy tải lại trang.");
      show(error.message);
    } finally {
      busy = false;
      if (button) button.disabled = false;
    }
  });
}
bind("register-form", async fields => {
  const bytes = new TextEncoder().encode(fields.password).length;
  if (bytes < 12 || bytes > 72) throw new Error("Mật khẩu cần 12–72 bytes.");
  const result = await api("/auth/register", {
    email: fields.email, password: fields.password, display_name: fields.display_name
  });
  show(result.message); // 202 không bảo đảm đã tạo; không tự login.
});
bind("login-form", async fields => {
  await api("/auth/login", {email: fields.email, password: fields.password});
  let next = "/profile";
  try { next = safeNext(new URLSearchParams(location.search).get("next")); } catch {}
  location.replace(next);
});
document.querySelector("#logout")?.addEventListener("click", async () => {
  try {
    await api("/auth/logout", {});
    document.querySelector("#private-content")?.replaceChildren();
    location.replace("/login");
  } catch (error) { show(error.message); }
});

Register form cần display_name, email, password; dùng autocomplete=name, email, new-password. Backend kiểm tra policy đăng ký/đăng nhập ở mỗi POST. Không quảng bá OTP hoặc verify khi chưa triển khai.

3. Profile bằng object auth

Dùng auth.check và auth.user để render profile ngay trên server. Đã bỏ cách GET profile/session.profile rồi dùng JavaScript điền input. Việc lưu vẫn gửi POST /_post/profile/update có CSRF với display_name/phone; xem ví dụ đầy đủ trong tài liệu auth.

4. Danh sách và chi tiết bằng Lingo

Dùng object products thay cho fetch catalog. Sản phẩm render phía server, không requireAccount khi xem catalog. Giỏ hàng và checkout dùng session guest phía backend.

{% set listing = products.paginate(limit=12) %}
{% for product in listing.items %}
<a href="/products/{{ product.slug|urlencode }}">{{ product.name }}</a>
{% endfor %}

{# pages/products/[slug].html #}
{% set product = products.find(slug=slug) %}
{% if not product %}{% abort 404 %}{% endif %}
{{ product.name }}

Form thêm giỏ đặt variant.id vào data attribute (template escape mặc định). Khi submit: gửi thẳng POST /_post/cart/add với variant_id và quantity; không cần login, cart_id hay gọi cart/get trước. Backend tự cấp session và tìm giỏ. Không gửi giá từ template để đặt hàng. Ví dụ đầy đủ về trường trả về, lọc và pagination nằm trong tài liệu products.

Giỏ là session Redis 30 ngày của trình duyệt, không đồng bộ theo account. Dùng cart.items, cart.item_count, cart.total_price để render, không cần fetch khi tải trang.

5. Trang giỏ hàng

const cart = await api("/cart/get", {}); // Guest cũng dùng được.
async function setQuantity(variantID, quantity) {
  if (!Number.isInteger(quantity) || quantity < 0 || quantity > 999)
    throw new Error("Số lượng không hợp lệ.");
  return quantity === 0
    ? api("/cart/remove", { variant_id: variantID})
    : api("/cart/update", { variant_id: variantID, quantity}); // Render lại bằng response này.
}
// Xóa: await setQuantity(variantID, 0);
// Cập nhật: await setQuantity(variantID, Number(input.value));
// Cart.Items, Cart.Subtotal — không lấy giá từ DOM để đặt hàng.

Trang thực tế cần trạng thái loading/error/empty; vô hiệu hóa checkout nếu giỏ rỗng hoặc Subtotal=null. Dòng không còn Available vẫn giữ nút xóa. Debounce nhập số lượng và tuần tự hóa request; tránh response cũ ghi đè response mới.

6. Báo giá → xác nhận → đặt hàng

// Guest dùng được, không cần requireAccount(). Các biến giữ trong bộ nhớ của trang.
let pending = null;
let sending = false;
const cart = await api("/cart/get", {});
async function prepareOrder(address, note) {
  if (pending) throw new Error("Cần xử lý lần đặt trước trước khi tạo báo giá mới.");
  const quote = await api("/checkout/quote", { shipping_address: address});
  // Render quote.Items, phí/thuế/giảm giá/GrandTotalAmount và nút xác nhận.
  // Chỉ gọi confirmOrder sau khi người dùng xác nhận quote này.
  return {quote, address: structuredClone(address), note};
}
async function confirmOrder(review) {
  if (sending) return;
  if (!pending) {
    pending = {
      idempotency_key: Array.from(crypto.getRandomValues(new Uint8Array(16)), b => b.toString(16).padStart(2, '0')).join(''),
      quote_fingerprint: review.quote.Fingerprint,
      shipping_address: review.address,
      note: review.note
    };
  }
  sending = true;
  try {
    const receipt = await api("/checkout/place-order", pending);
    pending = null;
    location.replace("/my-orders/" + encodeURIComponent(receipt.ID));
  } catch (error) {
    // Mạng/5xx: giữ pending và địa chỉ, chỉ cho retry thủ công cùng payload.
    // 409: server đã từ chối, bỏ báo giá cũ để xem/xác nhận lại.
    if (error.status === 409 || error.status === 422) pending = null;
    if (error.code === "csrf_rejected") show("Phiên bảo vệ hết hạn. Hãy tải lại trang.");
    throw error; // Handler hiển thị lỗi; không tự bấm lại.
  } finally { sending = false; }
}

crypto.randomUUID() dành cho secure context (HTTPS). Preview HTTP có thể không có hàm này; dùng UUID generator dựa trên crypto.getRandomValues, không Math.random. Luôn giữ key ổn định cho lần gửi chưa rõ kết quả. Khóa sửa địa chỉ khi pending chưa giải quyết; retry dùng chính pending, không nội dung form vừa sửa. Nếu reload, kiểm tra My orders trước khi tạo checkout mới.

Không gọi confirmOrder ngay sau prepareOrder mà bỏ bước xác nhận người dùng. Khi thay địa chỉ/số lượng trước submit, hủy review và báo giá lại. Thêm catch cho các handler để không để promise rejection chỉ xuất hiện trong console.

7. My orders, chi tiết và tracking bằng Lingo

Dùng object orders: orders.paginate(limit=20) cho danh sách, orders.find(id=id) cho chi tiết và tracking thuộc tài khoản đăng nhập. Dữ liệu được render trên server, không gọi API GET đơn hàng.

Trang chi tiết nếu không tìm thấy phải abort 404. Trang list dùng listing.authenticated để hiển thị link đăng nhập; tracking lặp order.shipments. Xóa DOM riêng tư khi pagehide/logout và reload khi khôi phục bfcache.

Trang tra cứu công khai pages/tracking.html dùng form GET với input name="number" và orders.track() để đọc query number, hoặc gọi orders.track(number="SK-000002"). Trả chi tiết đầy đủ hoặc null, không cần đăng nhập, chỉ trong website hiện tại. Không xác minh quyền sở hữu và không che thông tin người nhận: developer chịu trách nhiệm bảo vệ phía server nếu cần. Xem ví dụ hoàn chỉnh và quy tắc orders.track(); không thay find bằng track trên trang đơn hàng riêng tư.

8. Hoàn thiện hành vi FE

  • Logout thành công xóa nội dung riêng tư trong DOM; pagehide xóa DOM riêng tư và hủy request; pageshow từ bfcache phải tải lại trang trước khi hiện dữ liệu.
  • Dùng AbortController hoặc generation token: response của lần chọn đơn cũ không được ghi đè đơn mới hay hiện lại sau logout.
  • Không cache account API trong service worker/CDN; HTML chứa auth/profile phải private, no-store. API đã no-store, nhưng theme vẫn phải xử lý DOM/bfcache.
  • Không redirect theo next tùy ý sang domain khác. Mọi URL tracking mở ngoài cần http/https và noopener noreferrer.
  • Không log password, csrf, cookie, địa chỉ; không đưa chúng vào query string hoặc analytics.
  • Kiểm thử checklist nghiệm thu và đối chiếu đơn trong Console trước bàn giao.

Các snippet là nền tảng để ghép UI, không phải package production hoàn chỉnh. Chưa hỗ trợ OTP/SMS, OAuth, reset/verify FE, online payment, booking hoặc cancel/refund trong bộ endpoint này.