Public Booking API — Rule cho Web Dev
Base URL prod:
https://o2o-maotrung.insightmate.vnContent-Type:application/jsonEndpoints:
GET /api/public/slot-days— tồn slot 7 ngày tới (chọn ngày). Rate limit: 60 req / 60s / IPGET /api/public/slots— khung giờ còn trống trong 1 ngày (chọn giờ). Rate limit: 60 req / 60s / IPPOST /api/public/bookings— tạo booking. Rate limit: 10 req / 60s / IP
Doc này mô tả rule gửi nguồn khách cho dev website đặt booking online. Các trường nguồn dùng sai sẽ làm thống kê lễ tân đếm nhầm loại khách (walk_in vs online vs…). Đọc kỹ phần Source Tracking Rules.
0. Lấy slot trống (GET)
Flow đặt lịch trên web:
- Gọi
GET /api/public/slot-days→ hiển thị picker 7 ngày kèm số slot còn trống. - User chọn ngày → gọi
GET /api/public/slots?slot_date=YYYY-MM-DD→ hiển thị các khung giờ vớiremaining+is_available. - User chọn giờ → submit form →
POST /api/public/bookings.
0.1 GET /api/public/slot-days
Lấy tồn slot theo ngày trong N ngày tới. Tự động bỏ qua các khung giờ đã qua trong ngày hôm nay.
Query params (tất cả tuỳ chọn, có default):
| Param | Kiểu | Default | Ghi chú |
|---|---|---|---|
showroom_code | string | MAOTRUNG | Giữ default cho web |
service_code | string | tu_van | Giữ default cho web |
days | int | 7 | 1–30 |
Ví dụ:
GET /api/public/slot-days
GET /api/public/slot-days?days=14
Response 200:
{
"success": true,
"data": {
"showroom_code": "MAOTRUNG",
"service_code": "tu_van",
"timezone": "Asia/Ho_Chi_Minh",
"from_date": "2026-04-15",
"to_date": "2026-04-21",
"days": [
{
"slot_date": "2026-04-15",
"total_capacity": 12,
"booked_count": 7,
"remaining": 5,
"is_available": true
},
{
"slot_date": "2026-04-16",
"total_capacity": 20,
"booked_count": 20,
"remaining": 0,
"is_available": false
}
]
}
}
total_capacity= tổng sức chứa các slot active trong ngày (đã loại các slot quá giờ của hôm nay).booked_count= số booking active (không tínhcancelled/no_show/rescheduled).remaining=max(total_capacity - booked_count, 0).is_available=remaining > 0. Dùng để gray-out ngày full trên UI.- Ngày không có
slot_configs→total_capacity: 0,is_available: false.
0.2 GET /api/public/slots
Lấy danh sách khung giờ của 1 ngày cụ thể kèm tồn slot.
Query params (bắt buộc):
| Param | Kiểu | Ghi chú |
|---|---|---|
showroom_code | string | MAOTRUNG |
service_code | string | tu_van |
slot_date | string | YYYY-MM-DD |
Ví dụ:
GET /api/public/slots?showroom_code=MAOTRUNG&service_code=tu_van&slot_date=2026-04-20
Response 200:
{
"success": true,
"data": {
"showroom_code": "MAOTRUNG",
"service_code": "tu_van",
"slot_date": "2026-04-20",
"slots": [
{
"slot_time": "09:00",
"slot_date": "2026-04-20",
"showroom_code": "MAOTRUNG",
"service_code": "tu_van",
"capacity": 4,
"booked_count": 2,
"remaining": 2,
"duration_min": 60,
"grace_period_min": 15
}
]
}
}
- Endpoint này không tự filter khung giờ đã qua — FE tự filter nếu
slot_date === today(VN) thì chỉ hiện khung giờslot_time > now. - Slot hết chỗ (
remaining === 0) vẫn trả về — FE disable nút, đừng cho submit.
Lỗi chung cho cả 2 endpoint GET:
| HTTP | code | Nguyên nhân |
|---|---|---|
| 400 | INVALID_QUERY | Query param thiếu / sai format |
| 404 | SHOWROOM_NOT_FOUND | showroom_code sai hoặc inactive |
| 404 | SERVICE_NOT_FOUND | service_code sai hoặc inactive |
| 429 | RATE_LIMITED | > 60 request/60s từ IP đó |
1. Tổng quan field
Payload là JSON. Server validate bằng Zod (app/api/public/bookings/route.ts:18-37).
Bắt buộc
| Field | Kiểu | Ghi chú |
|---|---|---|
customer_name | string | Tên khách |
phone | string | SĐT VN, server tự normalize (+84…) |
showroom_code | string | Cố định = "MAOTRUNG" (xem rule mặc định bên dưới) |
service_code | string | Cố định = "tu_van" (xem rule mặc định bên dưới) |
slot_date | string | YYYY-MM-DD, không được ở quá khứ (timezone Asia/Ho_Chi_Minh) |
slot_time | string | HH:MM (24h), phải có trong slot_configs và còn chỗ |
Rule mặc định cho web:
showroom_codeluôn gửi"MAOTRUNG"— đang có 1 showroom duy nhất trong hệ thống.service_codeluôn gửi"tu_van"— dịch vụ duy nhất khách đặt từ web.- Không cho user chọn ở form — hardcode trong FE (hằng số), không gọi API list để lấy.
- Nếu sau này mở thêm showroom / service, portal team sẽ thông báo trước + đưa mã mới — lúc đó FE mới đổi.
- Gửi mã sai / mã inactive → API trả
SHOWROOM_NOT_FOUNDhoặcSERVICE_NOT_FOUND(404).
Tuỳ chọn — thông tin khách
| Field | Kiểu | Giá trị hợp lệ |
|---|---|---|
customer_gender | string | male / female / other |
email | string | Email hợp lệ, hoặc rỗng |
product_interested | string | Danh sách SKU / tên sản phẩm cụ thể khách tick, comma-separated |
product_categories | string[] | Mảng ngành hàng khách quan tâm — xem bảng canonical bên dưới |
subject | string | Mục đích: xay_de_o / thau_xay_dung / kien_truc_su / kinh_doanh |
question | string | Câu hỏi / ghi chú của khách |
customer_type | string | new / returning |
Tuỳ chọn — nguồn khách (xem rule bên dưới)
| Field | Kiểu | Ghi chú |
|---|---|---|
source_channel | string | Cố định = "online" với web đặt online |
source_platform | string | Cố định = "website" |
campaign_name | string | Tên chiến dịch marketing nội bộ (nếu có) |
utm_source | string | Lấy từ URL query ?utm_source=… |
utm_medium | string | Lấy từ URL query ?utm_medium=… |
utm_campaign | string | Lấy từ URL query ?utm_campaign=… |
2. Source Tracking Rules (QUAN TRỌNG)
Hệ thống có 5 source_channel canonical dùng chung cho Portal + Web:
| Value | Ý nghĩa | Ai dùng |
|---|---|---|
walk_in | Khách đi ngang showroom | Sale tạo tay trong Portal |
referral | Người thân / bạn bè giới thiệu | Portal |
online | Khách đặt qua web / online | Web (luôn dùng cái này) |
sale_contact | Hẹn qua Sale | Portal |
other | Khác | Portal |
Rule cứng cho web:
source_channelphải luôn ="online".source_platformphải luôn ="website".- Không bao giờ gửi
walk_intừ web — sẽ làm stats "Nguồn khách" đếm nhầm thành khách đi ngang showroom.
Phân loại ads / organic / direct không đặt ở source_channel — mà dùng utm_*:
| Case | utm_source | utm_medium | utm_campaign |
|---|---|---|---|
| Google Ads | google | cpc | tên campaign (VD spring_promo_2026) |
| Facebook Ads | facebook | cpc hoặc paid_social | tên campaign |
| Zalo Ads | zalo | cpc | tên campaign |
| Google organic (SEO) | google | organic | (để trống) |
| Direct (gõ URL / bookmark) | (direct) | (none) | (để trống) |
| Referral từ site khác | domain referrer (VD homedy.com) | referral | (để trống) |
Giá trị utm luôn viết thường, không dấu, không khoảng trắng (dùng
_thay cho space).
Fallback khi URL không có utm_source
Không phải lúc nào ads cũng gắn UTM đúng (marketer quên, ads platform rewrite URL…). Thứ tự suy ra attribution:
- Có
utm_sourcetrong URL → dùng trực tiếp. Xong. - Có
gclid(Google Ads click ID, auto-append khi click Google Ads) → éputm_source="google",utm_medium="cpc". - Có
fbclid(Facebook Ads click ID) → éputm_source="facebook",utm_medium="cpc". - Có
document.referrer:- Hostname match search engine (
google.*,bing.*,duckduckgo.*,yahoo.*,coccoc.*) →utm_source=<engine>,utm_medium="organic". - Hostname khác →
utm_source=<hostname không www>,utm_medium="referral".
- Hostname match search engine (
- Không có gì cả →
utm_source="(direct)",utm_medium="(none)".
Quy tắc first-click 7 ngày: attribution ghi vào localStorage ngay lần landing đầu kèm expires_at = now + 7 ngày. Các lần vào sau trong 7 ngày không ghi đè — dù khách quay lại bằng nguồn khác. Hết 7 ngày → xoá, capture lại.
2b. Ngành hàng quan tâm (product_categories)
Field đa chọn (multi-select) — khách có thể tick nhiều ngành hàng cùng lúc. Gửi dạng mảng key canonical, không gửi label hiển thị.
| Key (gửi lên API) | Label hiển thị trên web |
|---|---|
gach | Gạch |
thiet_bi_ve_sinh | Thiết bị vệ sinh |
thiet_bi_bep | Thiết bị bếp |
thiet_bi_gia_dung | Thiết bị gia dụng |
da | Đá |
san_go | Sàn gỗ |
Rule:
- Mảng rỗng nếu khách không chọn → gửi
[]hoặc omit. - Server validate bằng
z.enum— key lạ → trảINVALID_PAYLOAD 400. - Server tự dedupe (khách tick trùng cũng chỉ lưu 1 lần).
- Key là source of truth cho stats sau này → không đổi casing, không thêm dấu.
Liên hệ với product_interested: 2 field độc lập.
product_categories= ngành hàng (6 loại fixed).product_interested= danh sách sản phẩm cụ thể khách tick từ catalog (VD"ARMANIBEIGE, IN2214504"), hoặc free-text khách tự nhập.
3. Frontend pattern — capture UTM (first-click 7 ngày)
Dùng localStorage + TTL 7 ngày để giữ first-click attribution xuyên session (khách đóng tab, quay lại sau vài ngày vẫn nhớ nguồn ban đầu).
Quy tắc:
- Lần landing đầu → capture & lưu
{…, expires_at = now + 7d}. - Lần vào sau, còn hạn → giữ nguyên (first-click thắng, kể cả khi user quay lại qua nguồn khác).
- Hết hạn 7 ngày → xoá, capture lại như lần đầu.
// Chạy trên MỌI trang (đặt trong layout / root script)
(function captureAttribution() {
const KEY = "attribution";
const TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 ngày
// 1. Đọc bản cũ, nếu còn hạn → bỏ qua (giữ first-click)
try {
const raw = localStorage.getItem(KEY);
if (raw) {
const saved = JSON.parse(raw);
if (saved.expires_at && Date.parse(saved.expires_at) > Date.now()) return;
localStorage.removeItem(KEY); // hết hạn → xoá
}
} catch { /* storage disabled */ }
// 2. Capture attribution mới
const params = new URL(window.location.href).searchParams;
const utm_source = params.get("utm_source");
const utm_medium = params.get("utm_medium");
const utm_campaign = params.get("utm_campaign");
const gclid = params.get("gclid"); // Google Ads click ID
const fbclid = params.get("fbclid"); // Facebook Ads click ID
let source = utm_source, medium = utm_medium;
// Thứ tự ưu tiên khi thiếu utm_source
if (!source) {
if (gclid) { source = "google"; medium = medium ?? "cpc"; }
else if (fbclid) { source = "facebook"; medium = medium ?? "cpc"; }
else {
const ref = document.referrer;
if (!ref) {
source = "(direct)";
medium = medium ?? "(none)";
} else {
const host = new URL(ref).hostname.replace(/^www\./, "");
const engine = /^(google|bing|duckduckgo|yahoo|coccoc)\./.exec(host);
if (engine) { source = engine[1]; medium = medium ?? "organic"; }
else { source = host; medium = medium ?? "referral"; }
}
}
}
const now = Date.now();
try {
localStorage.setItem(KEY, JSON.stringify({
utm_source: source ?? null,
utm_medium: medium ?? null,
utm_campaign: utm_campaign ?? null,
landing_at: new Date(now).toISOString(),
expires_at: new Date(now + TTL_MS).toISOString(),
}));
} catch { /* storage disabled */ }
})();
Lúc submit booking — đọc ra, tự động bỏ qua nếu hết hạn:
function readAttribution() {
try {
const saved = JSON.parse(localStorage.getItem("attribution") ?? "null");
if (!saved) return {};
if (saved.expires_at && Date.parse(saved.expires_at) < Date.now()) {
localStorage.removeItem("attribution");
return {};
}
return saved;
} catch { return {}; }
}
const attribution = readAttribution();
await fetch("/api/public/bookings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
customer_name: form.name,
phone: form.phone,
// --- CỐ ĐỊNH (đừng cho user chọn) ---
showroom_code: "MAOTRUNG",
service_code: "tu_van",
slot_date: form.slotDate,
slot_time: form.slotTime,
// --- NGUỒN (rule cứng) ---
source_channel: "online",
source_platform: "website",
// --- ATTRIBUTION (từ sessionStorage) ---
campaign_name: attribution.utm_campaign ?? undefined,
utm_source: attribution.utm_source ?? undefined,
utm_medium: attribution.utm_medium ?? undefined,
utm_campaign: attribution.utm_campaign ?? undefined,
// --- Optional khách ---
email: form.email,
customer_gender: form.gender,
customer_type: "new",
product_interested: form.product,
subject: form.purpose,
question: form.note,
}),
});
4. Ví dụ payload theo case
4.1 Khách từ Google Ads
URL landing: https://domain.com/?utm_source=google&utm_medium=cpc&utm_campaign=spring_promo_2026
{
"customer_name": "Nguyễn Văn A",
"phone": "0901234567",
"showroom_code": "MAOTRUNG",
"service_code": "tu_van",
"slot_date": "2026-04-20",
"slot_time": "09:00",
"source_channel": "online",
"source_platform": "website",
"campaign_name": "spring_promo_2026",
"utm_source": "google",
"utm_medium": "cpc",
"utm_campaign": "spring_promo_2026",
"product_categories": ["gach", "thiet_bi_ve_sinh", "thiet_bi_bep"],
"product_interested": "ARMANIBEIGE, IN2214504"
}
4.2 Khách organic (SEO Google)
Không có UTM, referrer = https://www.google.com/...
{
"customer_name": "Trần Thị B",
"phone": "0912345678",
"showroom_code": "MAOTRUNG",
"service_code": "tu_van",
"slot_date": "2026-04-21",
"slot_time": "14:00",
"source_channel": "online",
"source_platform": "website",
"utm_source": "google",
"utm_medium": "organic"
}
4.3 Khách direct (gõ URL)
Không UTM, không referrer.
{
"customer_name": "Lê Văn C",
"phone": "0987654321",
"showroom_code": "MAOTRUNG",
"service_code": "tu_van",
"slot_date": "2026-04-22",
"slot_time": "10:00",
"source_channel": "online",
"source_platform": "website",
"utm_source": "(direct)",
"utm_medium": "(none)"
}
4.4 Khách từ Facebook Ads
URL landing: ...?utm_source=facebook&utm_medium=cpc&utm_campaign=retargeting_q2&fbclid=xxx
{
"customer_name": "Phạm Thị D",
"phone": "0934567890",
"showroom_code": "MAOTRUNG",
"service_code": "tu_van",
"slot_date": "2026-04-23",
"slot_time": "16:00",
"source_channel": "online",
"source_platform": "website",
"campaign_name": "retargeting_q2",
"utm_source": "facebook",
"utm_medium": "cpc",
"utm_campaign": "retargeting_q2"
}
5. Response
201 Created (thành công)
{
"success": true,
"data": {
"booking_id": "uuid",
"booking_code": "BK_20260420090012345",
"lead_code": "LD_20260420090012345",
"booking_status": "confirmed",
"slot_date": "2026-04-20",
"slot_time": "09:00",
"assignee": {
"id": "uuid",
"full_name": "Tên sale",
"email": "sale@maotrung.vn"
}
}
}
Lỗi thường gặp
| HTTP | code | Nguyên nhân |
|---|---|---|
| 400 | INVALID_PAYLOAD | Field thiếu / sai kiểu |
| 400 | INVALID_PHONE | SĐT không normalize được về VN |
| 404 | SHOWROOM_NOT_FOUND | showroom_code sai hoặc inactive |
| 404 | SERVICE_NOT_FOUND | service_code sai hoặc inactive |
| 404 | SLOT_NOT_FOUND | slot_date+slot_time không có trong slot_configs |
| 409 | SLOT_FULL | Slot đã hết chỗ |
| 422 | INVALID_DATE | Ngày đặt ở quá khứ |
| 422 | NO_ASSIGNEE | Không có sale nào trong ca làm việc |
| 429 | RATE_LIMITED | > 10 request/60s từ IP đó |
6. Checklist review cho dev
-
showroom_codehardcode"MAOTRUNG",service_codehardcode"tu_van"— không cho user chọn -
source_channelluôn ="online"(không bao giờwalk_in) -
source_platformluôn ="website" - UTM capture chạy trên tất cả trang, lưu
localStoragevớiexpires_at = now + 7 ngày - Lần vào sau nếu
expires_atcòn hạn → giữ nguyên (first-click thắng), không ghi đè - Hết 7 ngày → xoá
attributionvà capture lại như lần đầu - Nếu không có UTM nhưng có
gclid→ éputm_source=google,utm_medium=cpc - Nếu không có UTM nhưng có
fbclid→ éputm_source=facebook,utm_medium=cpc - Nếu không có UTM + không có click ID + không có referrer → gửi
utm_source=(direct),utm_medium=(none) - Nếu không có UTM nhưng có referrer search engine → suy ra
utm_medium=organic -
slot_dateformatYYYY-MM-DD,slot_timeformatHH:MM(24h, có padding 0) -
product_categoriesgửi key canonical (gach,thiet_bi_bep…), không gửi label VN có dấu -
product_interestedgửi SKU/tên sản phẩm cụ thể, comma-separated (khác vớiproduct_categories) - Xử lý response error code rõ ràng cho UX (slot full, ngày quá khứ…)