# 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):

| 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:**

```json
{
  "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_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:**

```json
{
  "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`](../app/api/public/bookings/route.ts)).

### 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_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

| 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_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_*`:

| 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:

1. **Có `utm_source` trong URL** → dùng trực tiếp. Xong.
2. **Có `gclid`** (Google Ads click ID, auto-append khi click Google Ads) → ép `utm_source="google"`, `utm_medium="cpc"`.
3. **Có `fbclid`** (Facebook Ads click ID) → ép `utm_source="facebook"`, `utm_medium="cpc"`.
4. **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"`.
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 |
|---|---|
| `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.

```js
// 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:

```js
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`

```json
{
  "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/...`

```json
{
  "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.

```json
{
  "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`

```json
{
  "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)

```json
{
  "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_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ứ…)
