O2O Booking/Public API
Raw.md
AI / LLM tools: fetch markdown thô tại GET /docs/public-api/raw (Content-Type text/markdown) hoặc bấm nút Copy markdown phía trên để paste vào ChatGPT / Claude.

Public Booking API — Rule cho Web Dev

Base URL prod: https://o2o-maotrung.insightmate.vn Content-Type: application/json

Endpoints:

  • GET /api/public/slot-days — tồn slot 7 ngày tới (chọn ngày). Rate limit: 60 req / 60s / IP
  • GET /api/public/slots — khung giờ còn trống trong 1 ngày (chọn giờ). Rate limit: 60 req / 60s / IP
  • POST /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:

  1. Gọi GET /api/public/slot-days → hiển thị picker 7 ngày kèm số slot còn trống.
  2. User chọn ngày → gọi GET /api/public/slots?slot_date=YYYY-MM-DD → hiển thị các khung giờ với remaining + is_available.
  3. 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):

ParamKiểuDefaultGhi chú
showroom_codestringMAOTRUNGGiữ default cho web
service_codestringtu_vanGiữ default cho web
daysint71–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ính cancelled / 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_configstotal_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):

ParamKiểuGhi chú
showroom_codestringMAOTRUNG
service_codestringtu_van
slot_datestringYYYY-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:

HTTPcodeNguyên nhân
400INVALID_QUERYQuery param thiếu / sai format
404SHOWROOM_NOT_FOUNDshowroom_code sai hoặc inactive
404SERVICE_NOT_FOUNDservice_code sai hoặc inactive
429RATE_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

FieldKiểuGhi chú
customer_namestringTên khách
phonestringSĐT VN, server tự normalize (+84…)
showroom_codestringCố định = "MAOTRUNG" (xem rule mặc định bên dưới)
service_codestringCố định = "tu_van" (xem rule mặc định bên dưới)
slot_datestringYYYY-MM-DD, không được ở quá khứ (timezone Asia/Ho_Chi_Minh)
slot_timestringHH:MM (24h), phải có trong slot_configs và còn chỗ

Rule mặc định cho web:

  • showroom_code luôn gửi "MAOTRUNG" — đang có 1 showroom duy nhất trong hệ thống.
  • service_code luô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_FOUND hoặc SERVICE_NOT_FOUND (404).

Tuỳ chọn — thông tin khách

FieldKiểuGiá trị hợp lệ
customer_genderstringmale / female / other
emailstringEmail hợp lệ, hoặc rỗng
product_interestedstringDanh sách SKU / tên sản phẩm cụ thể khách tick, comma-separated
product_categoriesstring[]Mảng ngành hàng khách quan tâm — xem bảng canonical bên dưới
subjectstringMục đích: xay_de_o / thau_xay_dung / kien_truc_su / kinh_doanh
questionstringCâu hỏi / ghi chú của khách
customer_typestringnew / returning

Tuỳ chọn — nguồn khách (xem rule bên dưới)

FieldKiểuGhi chú
source_channelstringCố định = "online" với web đặt online
source_platformstringCố định = "website"
campaign_namestringTên chiến dịch marketing nội bộ (nếu có)
utm_sourcestringLấy từ URL query ?utm_source=…
utm_mediumstringLấy từ URL query ?utm_medium=…
utm_campaignstringLấ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ĩaAi dùng
walk_inKhách đi ngang showroomSale tạo tay trong Portal
referralNgười thân / bạn bè giới thiệuPortal
onlineKhách đặt qua web / onlineWeb (luôn dùng cái này)
sale_contactHẹn qua SalePortal
otherKhácPortal

Rule cứng cho web:

  • source_channel phải luôn = "online".
  • source_platform phải luôn = "website".
  • Không bao giờ gửi walk_in từ 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_*:

Caseutm_sourceutm_mediumutm_campaign
Google Adsgooglecpctên campaign (VD spring_promo_2026)
Facebook Adsfacebookcpc hoặc paid_socialtên campaign
Zalo Adszalocpctên campaign
Google organic (SEO)googleorganic(để trống)
Direct (gõ URL / bookmark)(direct)(none)(để trống)
Referral từ site khácdomain 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:

  1. utm_source trong URL → dùng trực tiếp. Xong.
  2. gclid (Google Ads click ID, auto-append khi click Google Ads) → ép utm_source="google", utm_medium="cpc".
  3. fbclid (Facebook Ads click ID) → ép utm_source="facebook", utm_medium="cpc".
  4. 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".
  5. 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
gachGạch
thiet_bi_ve_sinhThiết bị vệ sinh
thiet_bi_bepThiết bị bếp
thiet_bi_gia_dungThiết bị gia dụng
daĐá
san_goSà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

HTTPcodeNguyên nhân
400INVALID_PAYLOADField thiếu / sai kiểu
400INVALID_PHONESĐT không normalize được về VN
404SHOWROOM_NOT_FOUNDshowroom_code sai hoặc inactive
404SERVICE_NOT_FOUNDservice_code sai hoặc inactive
404SLOT_NOT_FOUNDslot_date+slot_time không có trong slot_configs
409SLOT_FULLSlot đã hết chỗ
422INVALID_DATENgày đặt ở quá khứ
422NO_ASSIGNEEKhông có sale nào trong ca làm việc
429RATE_LIMITED> 10 request/60s từ IP đó

6. Checklist review cho dev

  • showroom_code hardcode "MAOTRUNG", service_code hardcode "tu_van" — không cho user chọn
  • source_channel luôn = "online" (không bao giờ walk_in)
  • source_platform luôn = "website"
  • UTM capture chạy trên tất cả trang, lưu localStorage với expires_at = now + 7 ngày
  • Lần vào sau nếu expires_at còn hạn → giữ nguyên (first-click thắng), không ghi đè
  • Hết 7 ngày → xoá attribution và capture lại như lần đầu
  • Nếu không có UTM nhưng có gclid → ép utm_source=google, utm_medium=cpc
  • Nếu không có UTM nhưng có fbclid → ép utm_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_date format YYYY-MM-DD, slot_time format HH:MM (24h, có padding 0)
  • product_categories gửi key canonical (gach, thiet_bi_bep…), không gửi label VN có dấu
  • product_interested gửi SKU/tên sản phẩm cụ thể, comma-separated (khác với product_categories)
  • Xử lý response error code rõ ràng cho UX (slot full, ngày quá khứ…)