# Panduan Lengkap Visitor Counter (Versi Terbaru)

> Dokumen ini menerangkan **visitor counter first-party** yang digunakan oleh CMS ini: bagaimana ia berfungsi, di mana kodnya, dan cara memaparkan statistik kepada pengguna di frontend site.
>
> Untuk nota ringkas versi Inggeris, lihat [`VISITOR-COUNTER.md`](./VISITOR-COUNTER.md).

---

## 1. Ringkasan pantas

| Perkara | Ringkasan |
|---|---|
| Nama cookie | `cms_visitor_id` (64 aksara hex, HttpOnly, SameSite=Lax) |
| Data disimpan | `visitor_page_views`, `visitor_daily_counters`, `visitor_settings` |
| Data **tidak** disimpan | IP, User-Agent mentah, header, body request, geo, referrer penuh |
| Pengenal pelawat | HMAC-SHA256(token cookie, `APP_KEY`) — pseudonymous |
| Dedup page view | 30 minit (visitor + site + path yang sama) |
| Dedup daily counter | 1 rekod per visitor + site + hari (unique index DB) |
| Middleware | `visitor.log` → `VisitorLogMiddleware` |
| Admin page | `/portal-k7m4x9/visitor` (ikut `ADMIN_PATH`) |
| Laporan bulanan | CSV + PDF gaya laporan rasmi (bulan kalendar penuh) |
| Pembersihan data | `php artisan visitor:prune` (harian, ikut `VISITOR_RETENTION_DAYS`) |
| Toggle | Daily visitor counter & Page-view analytics (berasingan) |

---

## 2. Gambaran seni bina

```
                         ┌──────────────────────────────────────────┐
  Pelawat (browser) ───▶ │ routes/web.php                            │
                         │ Route::middleware('visitor.log')          │
                         │  • home                                   │
                         │  • frontend-site.home                     │
                         │  • frontend-site.page                     │
                         └──────────────────┬───────────────────────┘
                                            ▼
                         ┌──────────────────────────────────────────┐
                         │ VisitorLogMiddleware::handle()            │
                         │  1. jalankan request (next)               │
                         │  2. semak toggle + syarat kelayakan       │
                         │  3. ambil / jana token cookie             │
                         │  4. rekod daily counter (jika ON)         │
                         │  5. rekod page view (jika ON)             │
                         │  6. set cookie cms_visitor_id             │
                         └──────────────────┬───────────────────────┘
                                            ▼
                         ┌──────────────────────────────────────────┐
                         │ VisitorPageViewTracker                    │
                         │  • tokenFrom()  → token 64 hex            │
                         │  • recordDailyCounter() → insertOrIgnore  │
                         │  • record() → dedup 30 min + insert       │
                         │  • siteKey() / pagePath() (normalisasi)   │
                         └──────────────────┬───────────────────────┘
                                            ▼
              ┌─────────────────────────────┬──────────────────────────────┐
              ▼                             ▼                              ▼
   visitor_daily_counters         visitor_page_views             visitor_settings
   (pelawat unik / hari)          (paparan halaman)              (toggle Active/Inactive)

  Penggunaan data:
  ┌────────────────────────────────────────────────────────────────────────┐
  │ Admin: VisitorController@index ──▶ visitor/index.blade.php             │
  │   • Kad, tab, chart harian, laporan bulanan, CSV/PDF                   │
  │                                                                        │
  │ Frontend: PortalHandler ──▶ $todayVisits, $thisWeekVisits, ...         │
  │   • Boleh digunakan dalam entry script, component, header, footer,     │
  │     page content dan layout (semua melalui Blade::render($vars))       │
  └────────────────────────────────────────────────────────────────────────┘
```

## 3. Peta fail & tanggungjawab

| Fail | Tanggungjawab |
|---|---|
| `bootstrap/app.php` | Daftar alias middleware `visitor.log` |
| `routes/web.php` (hujung fail) | Kumpulan route frontend yang guna `visitor.log` |
| `app/Http/Middleware/VisitorLogMiddleware.php` | Semak kelayakan, panggil tracker, set cookie |
| `app/Services/VisitorPageViewTracker.php` | Normalisasi site/path, hash pelawat, dedup 30 minit |
| `app/Models/Backend/VisitorPageView.php` | Query page view + helper (today/week/month/year/total/unique) |
| `app/Models/Backend/VisitorDailyCounter.php` | Query daily counter (`scopeBetweenDates`) |
| `app/Models/Backend/VisitorSetting.php` | Dua toggle: `is_active`, `is_page_view_active` |
| `app/Models/Backend/Visit.php` | ❌ **telah dibuang** (legacy `visits`) |
| `config/visitor.php` | `cookie_days`, `retention_days` |
| `routes/console.php` | Command `visitor:prune` + jadual harian |
| `app/Http/Controllers/Backend/VisitorController.php` | Admin summary, filter, toggle, clear, export CSV, laporan bulanan + PDF |
| `resources/views/backend/module/visitor/index.blade.php` | UI admin (tab Daily visitors / Page-view + laporan bulanan) |
| `resources/views/backend/module/visitor/report-pdf.blade.php` | Template PDF laporan bulanan (Dompdf) |
| `app/Services/PortalHandler.php` | Suntik variabel statistik ke semua template frontend |
| `database/migrations/2026_05_18_194337_create_visitor_settings_table.php` | Jadual `visitor_settings` + baris default |
| `database/migrations/2026_10_20_000000_consolidate_visitor_analytics_schema.php` | **Baseline schema**: `visitor_page_views`, `visitor_daily_counters`, `visitor_settings.is_page_view_active` (idempotent) |
| `tests/Feature/VisitorAnalyticsTest.php` | Ujian middleware/tracker, toggle, site key, filter laporan |
| `tests/Feature/VisitorMonthlyReportTest.php` | Ujian kiraan laporan bulanan + CSV/PDF |

## 4. Aliran penuh satu page view

### 4.1 Route yang dilindungi

Di hujung `routes/web.php`:

```php
Route::prefix('/')->middleware('visitor.log')->group(function () {
    Route::get('/', [FrontendSiteRenderController::class, 'default'])->name('home');

    Route::get('{site}/{slug}', [FrontendSiteRenderController::class, 'resolve'])
        ->where('slug', '.*')
        ->name('frontend-site.page');

    Route::get('{slug}', [FrontendSiteRenderController::class, 'resolve'])
        ->name('frontend-site.home');
});
```

Middleware hanya merekod route dengan nama **`home`**, **`frontend-site.home`** atau **`frontend-site.page`**. Route admin, API, login dan seumpamanya tidak dikira.

### 4.2 Susunan kerja middleware

Fail: `app/Http/Middleware/VisitorLogMiddleware.php`

```php
$response = $next($request);                  // 1. render page dahulu

$settings = VisitorSetting::query()->first(); // 2. baca toggle
$dailyCounterEnabled = (bool) $settings?->is_active;
$pageViewsEnabled     = (bool) $settings?->is_page_view_active;

// 3. jika dua-dua OFF, atau bukan GET / bukan 200 / bukan HTML / route bukan CMS → keluar
$token = $this->tracker->tokenFrom($request);

// 4. rekod daily counter (jika ON)
// 5. rekod page view (jika ON)
// 6. set cookie cms_visitor_id
```

Syarat kelayakan (semua mesti benar):

| Syarat | Sebab |
|---|---|
| Salah satu toggle ON | Boleh matikan tracking tanpa ubah kod |
| `GET` sahaja | POST/PUT tidak dikira sebagai paparan halaman |
| Status response `200` | 404/500/redirect tidak dikira |
| `Content-Type` bermula `text/html` | Fail PDF/JSON/imej tidak dikira |
| Nama route dalam senarai CMS | Elak admin/API dikira |

Kegagalan DB **tidak** memecahkan halaman: setiap rekod dibungkus `try/catch` dan hanya menulis `Log::warning()`.

### 4.3 Token pelawat

Fail: `app/Services/VisitorPageViewTracker.php`

```php
public const COOKIE_NAME = 'cms_visitor_id';

public function tokenFrom(Request $request): string
{
    $token = $request->cookie(self::COOKIE_NAME);

    return is_string($token) && preg_match('/\A[a-f0-9]{64}\z/i', $token) === 1
        ? strtolower($token)
        : bin2hex(random_bytes(32));   // 32 bait rawak → 64 aksara hex
}
```

- Cookie lama yang sah (64 hex) digunakan semula.
- Cookie tiada/rosak → token baharu dijana.
- Cookie **tidak** disimpan dalam DB; DB hanya simpan hash HMAC.

### 4.4 Rekod daily counter

```php
VisitorDailyCounter::query()->insertOrIgnore([
    'site_key'     => $siteKey,
    'visited_on'   => today()->toDateString(),
    'visitor_hash' => hash_hmac('sha256', $token, (string) config('app.key')),
]);
```

- `insertOrIgnore` bergantung pada unique index `(site_key, visited_on, visitor_hash)`.
- Kesannya: seorang pelawat dikira **sekali sahaja** untuk site yang sama pada hari tersebut, walaupun buka 50 halaman.
- Berfungsi walaupun page-view tracking dimatikan.

### 4.5 Rekod page view (dedup 30 minit)

```php
$visitorHash = hash_hmac('sha256', $token, (string) config('app.key'));

$recentPageView = VisitorPageView::query()
    ->where('site_key', $siteKey)
    ->where('page_path', $path)
    ->where('visitor_hash', $visitorHash)
    ->where('viewed_at', '>', now()->subMinutes(30))
    ->exists();

if ($recentPageView) {
    return null;                // reload page sama dalam 30 min → tidak dikira
}

VisitorPageView::query()->create([...]);
```

- Buka halaman **lain** → terus dikira (tak perlu tunggu 30 minit).
- Ini kawalan inflasi reload, **bukan** bot detection.

### 4.6 Cookie yang dihantar balik

```php
$response->headers->setCookie(Cookie::make(
    VisitorPageViewTracker::COOKIE_NAME,
    $token,
    (int) config('visitor.cookie_days', 365) * 24 * 60,
    '/',
    null,
    $request->isSecure(),   // Secure bila HTTPS
    true,                   // HttpOnly
    false,
    'lax',                  // SameSite
));
```

### 4.7 Perubahan `APP_KEY`

`visitor_hash = HMAC(token, APP_KEY)`. Jika `APP_KEY` ditukar:

- Semua cookie lama akan menghasilkan hash **berbeza** → pelawat sedia ada dikira sebagai pelawat baharu.
- Data lama kekal, tetapi tidak lagi boleh dipadankan dengan cookie semasa.

## 5. Struktur pangkalan data

### 5.1 `visitor_page_views` — paparan halaman

| Kolum | Jenis | Nota |
|---|---|---|
| `id` | bigint PK | |
| `site_key` | string(100) | `site:{id}` (contoh `site:1`) |
| `page_path` | string(500) | Path ternormal (tanpa prefix site & query) |
| `visitor_hash` | char(64) | HMAC-SHA256 token |
| `viewed_at` | timestamp | Masa paparan |

Index: `viewed_at`, `(site_key, viewed_at)`, `(site_key, page_path)`, `(visitor_hash, viewed_at)`.
Tiada unique index — dedup 30 minit dibuat di peringkat aplikasi.

### 5.2 `visitor_daily_counters` — pelawat unik harian

| Kolum | Jenis | Nota |
|---|---|---|
| `id` | bigint PK | |
| `site_key` | string(100) | `site:{id}` |
| `visited_on` | date | Tarikh (timezone aplikasi) |
| `visitor_hash` | char(64) | HMAC token |

Unique: `(site_key, visited_on, visitor_hash)` → sumber utama "Pelawat Hari Ini/Minggu/Bulan/Tahun".
Index tambahan: `(site_key, visited_on)`, `(visitor_hash, visited_on)`.

### 5.3 `visitor_settings` — toggle

| Kolum | Jenis | Nota |
|---|---|---|
| `id` | bigint PK | |
| `is_active` | boolean | Daily visitor counter |
| `is_page_view_active` | boolean | Page-view analytics |
| `timestamps` | | |

Baris default (`is_active = true`) dicipta oleh migration `2026_05_18_194337`.
**Penting:** jika baris ini tiada:
- middleware menganggap kedua-dua toggle **OFF** (`first()` → null), dan
- halaman admin akan gagal (`firstOrFail()`).

### 5.4 `visits` — bukan sebahagian daripada schema

Jadual `visits`, model `Visit`, serta trait `HasVisits`/`Visitable` telah **dibuang daripada codebase**. Migration konsolidasi hanya mentakrifkan schema semasa (page views, daily counters, kolum toggle) dan **tiada langsung** rujukan kepada `visits`.

Jika DB lama masih mempunyai table `visits`:

- Ia tidak lagi digunakan oleh aplikasi.
- Selepas backup, ia boleh di-drop secara manual:
  ```php
  Schema::dropIfExists('visits');
  ```
- Jika component footer/widget dashboard lama masih merujuk `Visit::`, kemas kini kepada `VisitorPageView`/variabel PortalHandler (§10.2).

---

## 6. Normalisasi `site_key` dan `page_path`

### 6.1 `site_key`

| Situasi | site_key |
|---|---|
| Site biasa (`/{site}/...`) | `site:{id}` — ID kekal walaupun slug ditukar |
| Default site (root `/`) | `site:{id}` default |
| Fallback (site tidak dijumpai) | slug huruf kecil |

Format semasa ialah `site:{id}` supaya statistik kekal walaupun slug site ditukar. Data lama yang masih menggunakan slug/`default` perlu dinormalisasi secara manual jika perlu.

### 6.2 `page_path`

Peraturan dalam `VisitorPageViewTracker::pagePath()`:

1. Ambil path request, buang trailing slash (`/about/` → `/about`).
2. Jika route `frontend-site.page` dengan parameter `site`:
   - `/web` → `/`
   - `/web/about` → `/about` (prefix site dibuang)
3. Jika route `frontend-site.home`:
   - slug ialah page default site → `/nama-page`
   - slug ialah slug site → `/`
4. Halaman artikel (`article-web`) dengan `?id=CODE` yang **published**:
   - path jadi `/article-web/{article_code}`
5. Semua query string lain **diabaikan**.

Contoh:

| URL | Route | site_key | page_path |
|---|---|---|---|
| `/` | `home` | `site:1` | `/` |
| `/web` | `frontend-site.page` | `site:2` | `/` |
| `/web/about` | `frontend-site.page` | `site:2` | `/about` |
| `/about` (page default site) | `frontend-site.home` | `site:1` | `/about` |
| `/article-web?id=visi-misi` | `frontend-site.home` / `page` | `site:1` | `/article-web/visi-misi` |
| `/web/article-web?x=1` | `frontend-site.page` | `site:2` | `/article-web` |

## 7. Cookie, privasi & keselamatan data

### 7.1 Butiran cookie

| Tetapan | Nilai |
|---|---|
| Nama | `cms_visitor_id` |
| Kandungan | 64 aksara hex (32 bait rawak) |
| Umur | `VISITOR_COOKIE_DAYS` (default 365 hari) |
| `HttpOnly` | Ya — tidak boleh dibaca JavaScript |
| `SameSite` | `Lax` |
| `Secure` | Ya jika request HTTPS |
| Path | `/` |

DB **tidak** menyimpan token cookie. Yang disimpan hanya:

```php
hash_hmac('sha256', $token, (string) config('app.key'))
```

### 7.2 Apa yang tidak dikumpul

- IP address
- User-Agent mentah
- Header HTTP
- Body request / borang
- Geolocation
- Referrer penuh (tidak dikumpul langsung)
- Query string sewenang-wenangnya

### 7.3 Nota pematuhan

- Ini adalah **analytics pseudonymous**, bukan pengenalan individu.
- Pengiraan boleh berubah bila pelawat membersihkan cookie, tukar peranti/incognito, atau `APP_KEY` ditukar.
- **Bukan** bot detection, firewall atau kawalan keselamatan.
- Sebelum go-live: kemas kini **notis privasi/dasar cookie** laman dan sahkan keperluan retensi dengan pegawai perlindungan data (PDPA 2010).
- Retensi dikawal melalui `VISITOR_RETENTION_DAYS` + `php artisan visitor:prune`.

---

## 8. Dua toggle dan maksud metrik

### 8.1 Toggle

| Toggle | Kolum DB | Bila ON | Bila OFF |
|---|---|---|---|
| Daily visitor counter | `visitor_settings.is_active` | Tulis `visitor_daily_counters` (1 rekod/visitor/site/hari) | Tiada rekod harian; metrik pelawat unik fallback ke page view |
| Page-view analytics | `visitor_settings.is_page_view_active` | Tulis `visitor_page_views` (dedup 30 min), trend & top pages | Tiada page view baharu; laporan paparan halaman kosong |
| Kedua-dua OFF | — | Middleware keluar awal; cookie tidak diset | — |

Kedua-duanya boleh dikawal dari admin (`POST /visitor/toggle` dan `/visitor/toggle-page-views`) atau terus di DB.

### 8.2 Definisi metrik

| Metrik | Definisi tepat | Sumber |
|---|---|---|
| **Daily visits** | Bilangan rekod `visitor_daily_counters` dalam tempoh = jumlah "pelawat-hari" | `visitor_daily_counters` |
| **Unique visitors** | Bilangan `visitor_hash` unik. Jika daily counter ON → dari daily counters; jika OFF → dari page views | `visitor_daily_counters` / `visitor_page_views` |
| **Page views** | Bilangan rekod page view (dedup 30 min per visitor+site+path) | `visitor_page_views` |
| **Pages visited** | Bilangan pasangan `(site_key, page_path)` unik | `visitor_page_views` |
| **Average per day** | Page views ÷ bilangan hari yang dilalui dalam bulan (laporan bulanan) | Kiraan controller |
| **Peak day** | Tarikh dengan page view tertinggi | `visitor_page_views` |
| **Peak hour** | Jam (0–23) dengan page view tertinggi | `visitor_page_views` |
| **MoM** | Perubahan % berbanding bulan sebelumnya (page views & unique visitors) | Kiraan controller |
| **Yearly unique** | `visitor_hash` unik sepanjang tahun (dedup merentas bulan) | Kiraan controller |
| **Top pages share** | Page views halaman ÷ jumlah page views bulan/range × 100 | Kiraan controller |

### 8.3 Perkara penting tentang angka

- **Daily visits ≠ unique visitors**: daily visits menjumlahkan "pelawat-hari"; unique visitors mengira hash unik dalam tempoh.
- **Page views ≠ pelawat**: satu pelawat boleh menyumbang banyak page view.
- Reload halaman sama dalam 30 minit tidak menambah page view, tetapi masih dikira dalam daily counter jika hari berbeza.
- Pelawat yang melawat dua site berbeza dikira berasingan (site key berbeza).
- Trafik bot/crawler **tidak** ditapis secara automatik; jika perlu, tapis di peringkat server/CDN.
- Jika halaman di-cache sepenuhnya di peringkat HTTP/CDN (tidak sampai ke Laravel), tiada rekod baharu.

## 9. Halaman admin & laporan

### 9.1 URL & kebenaran

| Perkara | Nilai |
|---|---|
| URL | `/{ADMIN_PATH}/visitor` — default `portal-k7m4x9/visitor` |
| Lihat | `visitor.view` |
| Toggle | `visitor.update` |
| Clear data | `visitor.delete` |

Route:

| Method | URI | Name |
|---|---|---|
| GET | `/visitor` | `visitor.index` |
| POST | `/visitor/toggle` | `visitor.toggle` |
| POST | `/visitor/toggle-page-views` | `visitor.toggle-page-views` |
| GET | `/visitor/export` | `visitor.export` (CSV top pages untuk filter semasa — tiada butang UI, boleh diakses terus) |
| GET | `/visitor/report/export` | `visitor.report.export` (CSV laporan bulanan) |
| GET | `/visitor/report/pdf` | `visitor.report.pdf` (PDF laporan bulanan) |
| POST | `/visitor/clear` | `visitor.clear` |

### 9.2 Struktur UI

**Header + 3 tab**
- `Daily visitors` — fokus pelawat unik harian (violet)
- `Page-view analytics` — fokus paparan halaman (biru langit)
- `Monthly report` — laporan rasmi bulanan (emerald)

**Kad status toggle** (mengikut tab): butang switch `Active/Inactive` untuk daily counter dan page-view.

**Filter laporan** (untuk tab Daily visitors & Page-view sahaja — disembunyikan pada tab Monthly report):
- Reporting period: Day / Week / Month / Year / Custom date range
- Reference date / From–To (gaya date picker sama seperti Content Article, `showPicker()`)
- Site, Page contains
- Quick select: Today, This week, This month, This year, Last 7/30 days
- Butang Apply filters & Reset (auto-submit bila tukar period)

**Tab Daily visitors**
- 4 kad tempoh dalam **satu baris** bermula breakpoint `sm` (grid 4 kolum); pada skrin kecil kad disusun 2x2
- Kad: Today / This week / This month / This year
- Setiap kad memaparkan **julat tarikh** di bawah nilai (cth. `19 Oct – 21 Oct 2026`), bukan label berulang "Daily visits"
- 2 kad metrik: Daily visits & Unique visitors
- **Bar chart "Visits by day"**: satu bar per hari mengikut filter aktif (minggu → 7 bar, bulan → 28–31 bar, tahun → 365 bar, range > 1 tahun → bulanan)
- Nota amaran jika daily counter dipause

**Tab Page-view**
- 2 kad metrik: Page views & Pages visited
- **Traffic trend** (line chart: daily visits + page views)
- **Most visited pages** (top 25 + top-10 bar + jadual + bar popularity + Clear data)
- Nota metodologi

**Tab Monthly report**
- Laporan rasmi **bulan kalendar penuh** (lihat 9.3)
- Kawalan **Month / Year / Site** (auto-apply bila ditukar) + butang PDF & CSV
- 6 KPI, ringkasan MoM, 2 bar chart, jadual Jan–Dis, top pages, nota metodologi

### 9.3 Monthly report (gaya laporan kerajaan)

| Bahagian | Kandungan |
|---|---|
| Kawalan | Pilih **Month + Year + Site** (auto-apply bila ditukar) + butang **PDF** & **CSV** |
| 6 KPI | Page views · Unique visitors · Daily visits · Pages visited · Average per day · Peak day (dengan delta MoM untuk 2 KPI pertama) |
| Ringkasan | Perbandingan bulan sebelumnya, peak day, peak hour |
| Chart | Monthly visitors Jan–Dis (bar, bulan semasa di-highlight) + Visits by hour (24 bar) |
| Jadual | Monthly breakdown: unique visitors, page views, share of year, distribution bar, baris Total |
| Top pages | 10 halaman teratas bulan itu + share % |
| Nota | Sumber data, dedup, tiada IP/UA, bulan kalendar penuh |

**Peraturan laporan:**
- Tempoh = **bulan kalendar penuh** (1hb–akhir bulan), bukan month-to-date.
- Nilai mengikut **site filter** semasa.
- Pilihan bulan/tahun laporan dikekalkan walaupun tukar filter dashboard (hidden inputs dua hala).
- Bulan semasa dikira sehingga hari ini untuk "Average per day" (hari yang belum berlaku tidak dikira).
- `Yearly unique` didedup merentas tahun (tidak menjumlahkan unique bulanan).

### 9.4 CSV & PDF

**CSV laporan** (`visitor.report.export`):

```
Visitor Statistics Report
Period, "October 2026"
Site, "All sites"
Generated, ...

Key indicators
Metric, Value
...
Monthly performance 2026
Month, Unique visitors, Page views, Share of year (%)
...
Total, ...

Top pages - October 2026
#, Page, Site, Page views, Unique visitors, Share (%)
...

Methodology
...
```

**PDF laporan** (`visitor.report.pdf`): A4 portrait, tajuk, tempoh, prepared by, KPI, MoM, jadual 12 bulan, top pages, metodologi — dijana dengan Dompdf melalui `resources/views/backend/module/visitor/report-pdf.blade.php`.

### 9.5 Clear data

`POST /visitor/clear` (permission `visitor.delete`) memadam:

- semua `visitor_page_views`
- semua `visitor_daily_counters`

Tindakan ini **kekal** dan tidak boleh diundur — pastikan backup jika diperlukan.

## 10. Paparan statistik di frontend site

Aliran ringkas:

```
FrontendSiteRenderController → PortalHandler::handle() → HTML
      → resources/views/frontend/cms.blade.php ({!! $html !!})
```

Oleh itu statistik boleh dipaparkan di mana-mana bahagian HTML yang dirender oleh `PortalHandler`: **entry script, component, header, footer, page content** dan **layout**.

### 10.1 Variabel automatic dari `PortalHandler`

Setiap kali page CMS dirender, `PortalHandler` mengira statistik dan memasukkan ke dalam `$vars`:

```php
// app/Services/PortalHandler.php
$totalVisits      = VisitorPageView::totalViews();
$todayVisits      = VisitorPageView::todayViews();
$uniqueVisitors   = VisitorPageView::allTimeUniqueVisitors();
$thisWeekVisits   = VisitorPageView::thisWeekViews();
$thisMonthVisits  = VisitorPageView::thisMonthViews();
$thisYearVisits   = VisitorPageView::thisYearViews();
```

`$vars` kemudiannya:
- di-`extract()` ke dalam **Entry Script** (`ENTRY/PAGE/{site}/{page}.blade.php`), dan
- dihantar ke `Blade::render($blade, $vars)` untuk **component**, **page content**, **header**, **footer** dan **layout**.

Jadi variabel ini boleh terus digunakan tanpa query tambahan:

| Variabel | Maksud | Skop |
|---|---|---|
| `$totalVisits` | Jumlah page view sepanjang masa | Semua site |
| `$todayVisits` | Page view hari ini | Semua site |
| `$thisWeekVisits` | Page view minggu ini (mula Isnin) | Semua site |
| `$thisMonthVisits` | Page view bulan ini | Semua site |
| `$thisYearVisits` | Page view tahun ini | Semua site |
| `$uniqueVisitors` | Pelawat unik sepanjang masa (hash distinct) | Semua site |
| `$uniqueIps` | Alias lama kepada `$uniqueVisitors` | Semua site |

> ⚠️ **Penting:** `$todayVisits`, `$thisWeekVisits`, `$thisMonthVisits`, `$thisYearVisits` dan `$totalVisits` adalah **PAPARAN HALAMAN (page views)**, bukan bilangan pelawat unik. `$uniqueVisitors` ialah pelawat unik sepanjang masa.
>
> Untuk "Pelawat" gaya counter kerajaan (unik per hari), gunakan `visitor_daily_counters` seperti dalam 10.4.

### 10.2 Contoh asas — blok "Statistik Pelawat" dalam footer/component

Letak kod ini dalam component Footer (CMS Builder) atau mana-mana component/page content:

```blade
@php
    $visitorStats = [
        'today'  => $todayVisits ?? 0,
        'week'   => $thisWeekVisits ?? 0,
        'month'  => $thisMonthVisits ?? 0,
        'year'   => $thisYearVisits ?? 0,
        'unique' => $uniqueVisitors ?? 0,
        'total'  => $totalVisits ?? 0,
    ];
@endphp

<div class="visitor-stats">
    <h5>Statistik Pelawat</h5>
    <div class="v-row">
        <div class="v-item">
            <span class="v-label">Hari Ini</span>
            <span class="v-value">{{ number_format($visitorStats['today']) }}</span>
        </div>
        <div class="v-item">
            <span class="v-label">Minggu Ini</span>
            <span class="v-value">{{ number_format($visitorStats['week']) }}</span>
        </div>
        <div class="v-item">
            <span class="v-label">Bulan Ini</span>
            <span class="v-value">{{ number_format($visitorStats['month']) }}</span>
        </div>
        <div class="v-item">
            <span class="v-label">Tahun Ini</span>
            <span class="v-value">{{ number_format($visitorStats['year']) }}</span>
        </div>
        <div class="v-item">
            <span class="v-label">Jumlah Pelawat</span>
            <span class="v-value">{{ number_format($visitorStats['unique']) }}</span>
        </div>
    </div>
    <p class="v-note">Jumlah paparan halaman: {{ number_format($visitorStats['total']) }}</p>
</div>
```

Anda juga boleh paparkan ringkasan ringkas dalam layout:

```blade
<p class="text-xs text-gray-500">
    {{ number_format($todayVisits ?? 0) }} lawatan hari ini ·
    {{ number_format($uniqueVisitors ?? 0) }} pelawat unik
</p>
```

### 10.3 Nota: template lama yang menggunakan `Visit`

Kod `Visit` telah dibuang sepenuhnya daripada codebase. Jika anda menyalin template lama (atau restore backup) yang masih mengandungi:

```blade
@php
    $todayVisits = \App\Models\Backend\Visit::todayVisits();
    $thisWeek    = \App\Models\Backend\Visit::thisWeekVisits();
    $thisMonth   = \App\Models\Backend\Visit::thisMonthVisits();
    $thisYear    = \App\Models\Backend\Visit::thisYearVisits();
    $totalVisits = \App\Models\Backend\Visit::totalVisits();
@endphp
```

...buang blok tersebut dan gunakan variabel baru seperti dalam §10.2 (`$todayVisits`, `$thisWeekVisits`, `$thisMonthVisits`, `$thisYearVisits`, `$uniqueVisitors`, `$totalVisits`). Kod `Visit` tidak lagi wujud.

Contoh gantian:

```blade
<div class="visitor-stats">
    <h5>Statistik Pelawat</h5>
    <div class="v-row">
        <div class="v-item"><span class="v-label">Hari Ini</span><span class="v-value">{{ number_format($todayVisits ?? 0) }}</span></div>
        <div class="v-item"><span class="v-label">Minggu Ini</span><span class="v-value">{{ number_format($thisWeekVisits ?? 0) }}</span></div>
        <div class="v-item"><span class="v-label">Bulan Ini</span><span class="v-value">{{ number_format($thisMonthVisits ?? 0) }}</span></div>
        <div class="v-item"><span class="v-label">Tahun Ini</span><span class="v-value">{{ number_format($thisYearVisits ?? 0) }}</span></div>
        <div class="v-item"><span class="v-label">Jumlah Pelawat</span><span class="v-value">{{ number_format($uniqueVisitors ?? 0) }}</span></div>
    </div>
</div>
```

> Jika anda mengedit melalui **CMS Builder**, kemas kini component Footer di admin (sumber), kemudian pastikan fail mirror di `storage/app/private/PHP/` turut dikemas kini.

### 10.4 Statistik mengikut site (daily counter + page views)

Variabel automatic di 10.1 adalah untuk **semua site**. Untuk counter per site (contoh setiap portal berasingan), gunakan `site_key` semasa. `site_key` disimpan sebagai `site:{id}`.

> ⚠️ **Jangan biar `site_key` ORPHAN.** Bila sesuatu site dipadam, barisnya kekal dgn `site_key` lama
> (`site:{id}` atau slug). Ia **tidak** muncul pada halaman statistik (yang skop site semasa) tetapi
> **masih** dikira oleh variabel global 10.1 → angka footer dan halaman tidak sepadan
> (contoh sebenar: footer 2,023 vs halaman 2,007 ✗).
>
> - `FrontendSiteController::destroy()` kini memadam data pelawat utk site itu ✓
> - Utk data orphan sedia ada: `php storage/app/cms-page-content/cleanup-orphan-site-keys.php --dry-run`
>   (tanpa `--dry-run` ia backup ke `backups/{table}-orphan-*.json` dahulu)


Contoh dalam component/footer (guna FQCN supaya selamat dalam Blade):

```blade
@php
    $siteId   = \App\Models\Backend\FrontendSite::query()->where('slug', $siteSlug)->value('id');
    $siteKey  = $siteId ? 'site:'.$siteId : $siteSlug;
    $today    = today()->toDateString();
    $cacheKey = 'visitor:site:'.$siteKey.':'.$today;

    $siteStats = \Illuminate\Support\Facades\Cache::remember(
        $cacheKey,
        now()->addMinute(),
        function () use ($siteKey, $today) {
            $daily = fn () => \App\Models\Backend\VisitorDailyCounter::query()->where('site_key', $siteKey);

            return [
                // Pelawat (unik per hari) — sesuai untuk "Statistik Pelawat"
                'today'       => $daily()->where('visited_on', $today)->count(),
                'week'        => $daily()->whereBetween('visited_on', [
                    now()->startOfWeek(\Illuminate\Support\Carbon::MONDAY)->toDateString(),
                    $today,
                ])->count(),
                'month'       => $daily()->whereBetween('visited_on', [
                    now()->startOfMonth()->toDateString(),
                    $today,
                ])->count(),
                'year'        => $daily()->whereBetween('visited_on', [
                    now()->startOfYear()->toDateString(),
                    $today,
                ])->count(),
                'unique_all'  => $daily()->distinct()->count('visitor_hash'),

                // Paparan halaman (page views)
                'views_today' => \App\Models\Backend\VisitorPageView::query()
                    ->where('site_key', $siteKey)->whereDate('viewed_at', $today)->count(),
                'views_total' => \App\Models\Backend\VisitorPageView::query()
                    ->where('site_key', $siteKey)->count(),
            ];
        }
    );
@endphp

<div class="visitor-stats">
    <h5>Statistik Pelawat</h5>
    <div class="v-row">
        <div class="v-item"><span class="v-label">Hari Ini</span><span class="v-value">{{ number_format($siteStats['today']) }}</span></div>
        <div class="v-item"><span class="v-label">Minggu Ini</span><span class="v-value">{{ number_format($siteStats['week']) }}</span></div>
        <div class="v-item"><span class="v-label">Bulan Ini</span><span class="v-value">{{ number_format($siteStats['month']) }}</span></div>
        <div class="v-item"><span class="v-label">Tahun Ini</span><span class="v-value">{{ number_format($siteStats['year']) }}</span></div>
        <div class="v-item"><span class="v-label">Jumlah Pelawat</span><span class="v-value">{{ number_format($siteStats['unique_all']) }}</span></div>
        <div class="v-item"><span class="v-label">Jumlah Capaian</span><span class="v-value">{{ number_format($siteStats['views_total']) }}</span></div>
    </div>
</div>
```

> - `site:{id}` mesti sama dengan yang ditulis oleh tracker. Jika site tidak dijumpai, kunci fallback ialah slug (`$siteSlug`).
> - Cache 60 saat mengelakkan 6+ query pada setiap page render. Untuk trafik tinggi, naikkan TTL mengikut keperluan.

### 10.5 "Pelawat" vs "Paparan Halaman" — pilih angka yang betul

| Label yang biasa dipaparkan | Angka yang betul | Sumber |
|---|---|---|
| Pelawat Hari Ini / Minggu Ini / Bulan Ini / Tahun Ini | Bilangan pelawat **unik** (1 per visitor/site/hari) | `visitor_daily_counters` |
| Jumlah Pelawat | `visitor_hash` unik sepanjang masa | `visitor_daily_counters` (disyorkan) atau `visitor_page_views` |
| Jumlah Capaian / Paparan Halaman | Jumlah page view (dedup 30 min) | `visitor_page_views` |
| Halaman Popular | Page view per `page_path` | `visitor_page_views` |

Elakkan mencampurkan dua definisi dalam satu blok (contoh "Jumlah Pelawat" diambil dari page views tetapi "Hari Ini" dari daily counter) kerana angka tidak akan konsisten.

Jika mahu paparan ringkas tanpa query tambahan, gunakan variabel PortalHandler (semua site):

```blade
<div class="visitor-stats">
    <h5>Statistik Pelawat</h5>
    <div class="v-row">
        <div class="v-item"><span class="v-label">Hari Ini</span><span class="v-value">{{ number_format($todayVisits ?? 0) }}</span></div>
        <div class="v-item"><span class="v-label">Minggu Ini</span><span class="v-value">{{ number_format($thisWeekVisits ?? 0) }}</span></div>
        <div class="v-item"><span class="v-label">Bulan Ini</span><span class="v-value">{{ number_format($thisMonthVisits ?? 0) }}</span></div>
        <div class="v-item"><span class="v-label">Tahun Ini</span><span class="v-value">{{ number_format($thisYearVisits ?? 0) }}</span></div>
        <div class="v-item"><span class="v-label">Jumlah Pelawat</span><span class="v-value">{{ number_format($uniqueVisitors ?? 0) }}</span></div>
        <div class="v-item"><span class="v-label">Jumlah Capaian</span><span class="v-value">{{ number_format($totalVisits ?? 0) }}</span></div>
    </div>
</div>
```

Tetapi ingat: dalam contoh ini "Hari Ini/Minggu/Bulan/Tahun" adalah **page views**, manakala "Jumlah Pelawat" adalah **unique sepanjang masa**. Jika konsistensi penting, gunakan snippet per-site di 10.4.

### 10.6 "Online sekarang" (optional)

Anggaran pelawat aktif dalam 5 minit terakhir (perlu page-view tracking ON):

```blade
@php
    $onlineNow = \Illuminate\Support\Facades\Cache::remember(
        'visitor:online:'.$siteKey,
        now()->addSeconds(30),
        fn () => \App\Models\Backend\VisitorPageView::query()
            ->where('site_key', $siteKey)
            ->where('viewed_at', '>=', now()->subMinutes(5))
            ->distinct()
            ->count('visitor_hash')
    );
@endphp

<p class="text-sm text-gray-600">
    <span class="inline-block h-2 w-2 rounded-full bg-emerald-500"></span>
    {{ number_format($onlineNow) }} pelawat aktif (5 minit terakhir)
</p>
```

Ini bukan "live session" sebenar — hanya aktiviti page view yang direkod.

### 10.7 Papar bilangan paparan untuk page semasa (optional)

```php
$siteKey = $siteId ? 'site:'.$siteId : $siteSlug;

// Untuk page biasa: path ternormal tanpa prefix site dan tanpa query
$path = '/'.ltrim(request()->path(), '/');
if ($siteSlug && $path === '/'.$siteSlug) {
    $path = '/';
} elseif ($siteSlug && str_starts_with($path, '/'.$siteSlug.'/')) {
    $path = substr($path, strlen('/'.$siteSlug));
}

// Untuk halaman artikel article-web
if (request()->routeIs('frontend-site.page', 'frontend-site.home')
    && request()->segment(count(request()->segments())) === 'article-web'
    && request()->filled('id')) {
    $path = '/article-web/'.rawurlencode((string) request()->query('id'));
}

$pageViews = \App\Models\Backend\VisitorPageView::query()
    ->where('site_key', $siteKey)
    ->where('page_path', $path)
    ->count();
```

> Path mesti **sama** dengan normalisasi tracker (lihat §6). Jika tidak, kiraan akan 0.

### 10.8 Guna dalam controller / service / command (PHP)

```php
use App\Models\Backend\VisitorDailyCounter;
use App\Models\Backend\VisitorPageView;

VisitorPageView::todayViews();
VisitorPageView::thisWeekViews();          // mula Isnin
VisitorPageView::thisMonthViews();
VisitorPageView::thisYearViews();
VisitorPageView::totalViews();
VisitorPageView::allTimeUniqueVisitors();

VisitorPageView::pageViewsSince(now()->subDays(30));
VisitorPageView::uniqueVisitorsSince(now()->subDays(30));

VisitorDailyCounter::query()
    ->betweenDates($from, $to)              // scope sedia ada
    ->where('site_key', 'site:1')
    ->count();
```

Semua helper ini membuat query agregat (COUNT/DISTINCT) — jangan panggil berulang dalam loop.

### 10.9 Prestasi & caching

| Perkara | Cadangan |
|---|---|
| Query per page render | `PortalHandler` sudah buat 6 query agregat; jika trafik tinggi, cache blok statistik 60 saat |
| Cache key | Sertakan `site_key` + tarikh + (jika perlu) jam/minit |
| TTL | 30–60 saat untuk counter awam; laporan admin tidak perlu cache |
| Index DB | Sudah disediakan; jangan buang index `viewed_at`, `site_key`, `visitor_hash` |
| Saiz data | Jalankan `visitor:prune` setiap hari (retensi default 365 hari) |
| Trafik tinggi | Pertimbangkan cache/CDN untuk HTML **atau** analytics luaran (GA4/Matomo) |

Contoh cache ringkas untuk blok statistik:

```php
$stats = \Illuminate\Support\Facades\Cache::remember(
    'visitor:summary:'.$siteKey.':'.now()->format('YmdHi'),
    now()->addMinute(),
    fn () => [/* query di sini */]
);
```

### 10.10 Bila tracking OFF atau data kosong

| Keadaan | Kesannya |
|---|---|
| Daily counter OFF | `visitor_daily_counters` tidak bertambah; metrik "Daily visits" kekal pada data lama |
| Page-view OFF | `visitor_page_views` tidak bertambah; trend, top pages & laporan paparan halaman kosong |
| Kedua-dua OFF | Tiada rekod baru; cookie tidak diset |
| Baris `visitor_settings` tiada | Middleware anggap OFF; admin `firstOrFail` gagal |
| Selepas "Clear data" | Semua counter kembali 0 |
| Template footer legacy | Masih merujuk `Visit`/jadual `visits` yang telah dibuang → error/0 | Guna variabel PortalHandler (§10.2) |

Sentiasa gunakan fallback `?? 0` supaya paparan tidak error bila variabel tiada.

## 11. Konfigurasi & operasi

### 11.1 Environment

Fail: `config/visitor.php`

```php
return [
    'cookie_days'    => max(1, (int) env('VISITOR_COOKIE_DAYS', 365)),
    'retention_days' => max(1, (int) env('VISITOR_RETENTION_DAYS', 365)),
];
```

| Env | Default | Maksud |
|---|---|---|
| `VISITOR_COOKIE_DAYS` | 365 | Umur cookie `cms_visitor_id` |
| `VISITOR_RETENTION_DAYS` | 365 | Tempoh simpan page view & daily counter sebelum dipangkas |

Selepas ubah `.env`, jalankan:

```bash
php artisan config:clear
```

### 11.2 Pemasangan / deploy

```bash
php artisan migrate
```

Yang berlaku:

1. `visitor_settings` dicipta + baris default (`is_active = true`).
2. Migration `2026_10_20_000000_consolidate_visitor_analytics_schema` mencipta schema semasa:
   - `visitor_page_views` (jika belum wujud);
   - kolum `is_page_view_active` (jika belum wujud);
   - `visitor_daily_counters` (jika belum wujud).

Semak baris setting:

```bash
php artisan tinker --execute="echo App\\Models\\Backend\\VisitorSetting::query()->count();"
```

Jika `0`, cipta baris:

```php
\App\Models\Backend\VisitorSetting::query()->firstOrCreate(
    [],
    ['is_active' => true, 'is_page_view_active' => true]
);
```

Selepas deploy kod baru:

```bash
php artisan optimize:clear   # atau config:clear + view:clear
```

### 11.3 Pembersihan data (retention)

Command:

```bash
php artisan visitor:prune
```

- Padam `visitor_page_views` dengan `viewed_at < now() - retention_days`.
- Padam `visitor_daily_counters` dengan `visited_on < tarikh cutoff`.
- Dijadualkan **harian** dalam `routes/console.php`.

Produksi: pastikan scheduler Laravel berjalan (cron `php artisan schedule:run` setiap minit).
Local (Windows/Laragon): jalankan `php artisan schedule:work` dalam terminal.

### 11.4 Clear data (manual)

Admin: butang **Clear data** (SweetAlert confirmation) → `POST /visitor/clear`.
Atau melalui command/tinker:

```php
DB::transaction(function () {
    \App\Models\Backend\VisitorPageView::query()->delete();
    \App\Models\Backend\VisitorDailyCounter::query()->delete();
});
```

Tindakan ini tidak boleh diundur.

### 11.5 `APP_KEY`

Jangan tukar `APP_KEY` tanpa sebab. Jika ditukar:

- Semua `visitor_hash` baru berbeza.
- Pelawat sedia ada dikira sebagai pelawat baharu.
- Data lama kekal tetapi tidak lagi sepadan dengan cookie semasa.

### 11.6 Backup

Sebelum `migrate` yang mengubah schema dan sebelum **Clear data**, buat backup DB.

---

## 12. Testing

Jalankan ujian visitor:

```bash
php artisan test --filter=Visitor
```

Fail ujian:

| Fail | Liputan |
|---|---|
| `tests/Feature/VisitorAnalyticsTest.php` | Perekodan page view, dedup 30 minit, cookie HttpOnly, article path, site key stabil, toggle berasingan, filter laporan, sanitasi legacy |
| `tests/Feature/VisitorMonthlyReportTest.php` | Kiraan KPI laporan bulanan, MoM, series 12 bulan, peak day/hour, site filter, render UI, CSV & PDF |

Contoh ujian pantas (manual) selepas tracking ON:

```bash
# 1. Lawat page awam dalam incognito
curl -I http://127.0.0.1:8000/

# 2. Semak rekod
php artisan tinker --execute="echo App\\Models\\Backend\\VisitorPageView::query()->count();"
php artisan tinker --execute="echo App\\Models\\Backend\\VisitorDailyCounter::query()->count();"
```

Nota: `curl` tanpa cookie akan dianggap pelawat baharu setiap kali (cookie tidak disimpan) — untuk ujian dedup, gunakan browser atau `curl -c cookies.txt -b cookies.txt`.

---

## 13. Troubleshooting

| Masalah | Punca biasa | Penyelesaian |
|---|---|---|
| Tiada rekod langsung | Kedua-dua toggle OFF | Hidupkan di admin / `visitor_settings` |
| Tiada rekod langsung | Route bukan `home` / `frontend-site.home` / `frontend-site.page` | Pastikan page diakses melalui route CMS |
| Tiada rekod langsung | Response bukan `200` / bukan `text/html` | Uji URL yang betul; redirect/404 tidak dikira |
| Tiada rekod langsung | Baris `visitor_settings` tiada | `firstOrCreate` baris setting (§11.2) |
| Tiada rekod langsung | HTML di-cache sepenuhnya oleh CDN/proxy | Laravel tidak menerima request → tiada rekod |
| Daily counter bertambah, page view tidak | `is_page_view_active = false` | Hidupkan Page-view analytics |
| Page view bertambah, daily counter tidak | `is_active = false` | Hidupkan Daily visitor counter |
| Admin page 404/500 | Baris setting tiada (`firstOrFail`) atau tiada permission | Cipta baris setting; semak `visitor.view` |
| Admin tunjuk 0 tetapi DB ada data | Filter tarikh/site tidak sepadan | Set semula filter; semak timezone aplikasi |
| Laporan bulanan kosong | Bulan/tahun/site salah | Pilih semula Month/Year; kosongkan site |
| CSV/PDF 403 | Tiada `visitor.view` | Beri permission kepada role |
| PDF gagal dijana | Dompdf tiada/extension PHP | Pastikan `dompdf/dompdf` dipasang & `ext-dom` aktif |
| Footer tunjuk angka lama/tak bertambah | Template custom lama masih merujuk `Visit`/jadual `visits` | Guna variabel PortalHandler (§10.2); jadual `visits` telah dibuang |
| Footer 0 | Variabel tiada / tracking OFF / cache | Guna `?? 0`, semak toggle, kosongkan cache |
| Angka pelawat melonjak selepas deploy | `APP_KEY` ditukar / cookie baru | Dijangka; maklumkan dalam laporan |
| `visitor:prune` tidak jalan | Scheduler tidak aktif | Pasang cron `schedule:run` / `schedule:work` |
| Page lambat | Query stats setiap render | Cache blok statistik 30–60 saat |

---

## 14. Checklist go-live

- [ ] `php artisan migrate` berjaya (2 migration visitor: settings + schema konsolidasi).
- [ ] Baris `visitor_settings` wujud.
- [ ] Daily counter & page-view dihidupkan mengikut keperluan.
- [ ] Notis privasi / dasar cookie dikemas kini.
- [ ] Footer/komponen menggunakan variabel **baru** (bukan `Visit::...`).
- [ ] Ujian incognito: layari 3 halaman → semak `visitor_page_views` & `visitor_daily_counters`.
- [ ] Admin `/visitor` memaparkan angka & laporan.
- [ ] Export CSV & PDF berjaya dimuat turun.
- [ ] Scheduler `visitor:prune` aktif.
- [ ] Permission `visitor.view/update/delete` diberikan kepada role berkaitan.
- [ ] Backup DB & polisi retensi disahkan.

---

## 15. Perubahan terkini & dokumen berkaitan

### Ciri terbaru dalam versi ini

1. **Daily visitor counter berasingan** daripada page-view analytics.
2. **Site key stabil `site:{id}`** + migration normalisasi data lama.
3. **UI admin baru**: 3 tab (Daily visitors / Page-view / Monthly report), date picker gaya Content Article, quick ranges, dark mode; 4 kad tempoh (Today / This week / This month / This year) dalam satu baris dengan julat tarikh dinamik.
4. **Bar chart "Visits by day"** yang mengikut filter (hari/minggu/bulan/tahun/custom).
5. **Laporan bulanan gaya kerajaan**: 6 KPI, MoM, peak day/hour, jadual Jan–Dis, top pages, metodologi.
6. **Export CSV & PDF** laporan bulanan (Dompdf).
7. **Ujian regresi**: `VisitorAnalyticsTest` + `VisitorMonthlyReportTest`.
8. **Jadual `visits`, model `Visit` dan trait `HasVisits`/`Visitable` dibuang**; footer & widget dashboard dipindahkan ke `VisitorPageView`/variabel PortalHandler.

### Dokumen berkaitan

| Dokumen | Kandungan |
|---|---|
| [`VISITOR-COUNTER.md`](./VISITOR-COUNTER.md) | Nota ringkas implementasi (English) |
| [`module/frontend-site.md`](./module/frontend-site.md) | Render engine, component & variabel frontend |
| [`CMS-BUILDER-GUIDE.md`](./CMS-BUILDER-GUIDE.md) | Panduan CMS Builder & component |
| [`module/purge-cache.md`](./module/purge-cache.md) | Cache & mirror `storage/app/private` |
| [`module/visitor.md`](./module/visitor.md) | Nota lama modul visitor (rujukan sejarah) |
