# Panduan Sistem Multi-Bahasa (Translation)

## Ringkasan

Sistem multi-bahasa mempunyai **2 lapisan**:

| Lapisan | Fungsi | Storage |
|---------|--------|---------|
| **App Translation (UI Strings)** | Label nav, button, footer, text statik | Table `app_translations` + helper `t()` |
| **Content Translation** | Data content (artikel, slider, video, dll) | Table translation berasingan / self-referencing |

---

## 1. App Translation — UI Strings

### Cara Guna

Dalam mana-mana Blade template (termasuk component dalam DB):

```blade
{{ t('login') }}        <!-- Output: Log Masuk / Login / 登录 -->
{{ t('register') }}     <!-- Output: Daftar / Register / 注册 -->
{{ t('nonexistent') }}  <!-- Output: nonexistent (fallback ke key) -->
{{ t('key', 'Default') }} <!-- Output: 'Default' kalau key tak wujud -->
```

### Admin Panel

`/admin/app-translation` → CRUD untuk semua keys.

| key | ms | en | zh |
|-----|----|----|-----|
| login | Log Masuk | Login | 登录 |
| register | Daftar | Register | 注册 |
| dynaweb4_desc | DYNAWEB4 adalah... | DYNAWEB4 is... | DYNAWEB4 是一个... |
| Utama | Utama | Home | 首页 |
| Hubungi Kami | Hubungi Kami | Contact Us | 联系我们 |

- **Index**:
  - **Default view**: Accordion group by language (collapse/expand), paginate language groups 5/page
  - **Filtered view** (`?lang=ms`): Flat list filtered by language, paginate 10/page
  - "Showing" displays language count (not key count)
  - Dropdown: "All Languages" (no dashes)
- **Create**: Tambah key + value untuk 1 bahasa
- **Edit**: Edit semua bahasa untuk 1 key sekali gus

### Helper `t()` Function

**File:** `app/Helpers/Translation.php`

```php
function t(string $key, ?string $default = null): string
{
    static $data = [];
    $locale = session('locale', 'ms');
    
    if (!isset($data[$locale])) {
        $data[$locale] = AppTranslation::where('lang', $locale)
            ->pluck('value', 'key')->all();
    }
    
    return $data[$locale][$key] ?? $default ?? $key;
}
```

- Auto-detect locale dari session (default `'ms'`)
- Cache guna `static` variable dalam 1 request
- Fallback: key → default → key asal

---

## 2. Content Translation

Sistem guna **dua pattern** untuk content translation:

| Pattern | Modules | Cara Kerja |
|---------|---------|------------|
| **Separate Table** | ContentArticle, ContentSlider, ContentVideo, ContentCalendar, ContentPhotoGallery, ContentApplication | Data translatable dalam table berasingan, di-link via `*_parent_id` |
| **Self-Referencing (Sibling)** | ContentDownload, ContentImage, ContentPhotoList | Translation adalah row lain dalam table yang sama, `*_main = 1` untuk parent |

### Language Switching Flow

```
User klik "ENGLISH"
    ↓
GET /lang/en
    ↓
Route set session('locale', 'en')
    ↓
PortalHandler inject $lang = session('locale', 'ms')
    ↓
Components query content WHERE *_language = $lang
```

### Language Switching Route

**File:** `routes/web.php`

```php
Route::get('/lang/{code}', function ($code) {
    $valid = DB::table('ref')->where('cat', 'LANGUAGE')
        ->where('code', $code)->exists();
    if ($valid) session(['locale' => $code]);
    return redirect()->back();
})->name('lang.switch');
```

Navbar links:
```blade
<a href="{{ url('lang/ms') }}">MALAY</a>
<a href="{{ url('lang/en') }}">ENGLISH</a>
<a href="{{ url('lang/zh') }}">中文</a>
```

### PortalHandler — Inject $lang

**File:** `app/Services/PortalHandler.php`

```php
$lang = session('locale', 'ms');

$vars = [
    'lang' => $lang,
    // ... variables lain
];
```

Semua component, page content, dan layout dapat akses `$lang`.

---

## 3. Pattern: Separate Translation Table

Digunakan oleh: **ContentArticle**, **ContentSlider**, **ContentVideo**, **ContentCalendar**, **ContentPhotoGallery**, **ContentApplication**

### Table Structure

```
content_article              content_article_translation
┌──────────────────┐        ┌─────────────────────────────────┐
│ article_id (PK)  │◄──┐   │ article_translation_id (PK)     │
│ article_code     │   └───│ article_translation_parent_id   │
│ article_status   │        │ article_translation_title      │
│ ...              │        │ article_translation_content    │
└──────────────────┘        │ article_translation_language   │
                            │ article_translation_main       │
                            └─────────────────────────────────┘
```

### Query Pattern

```php
// PortalHandler components guna $lang dari session
$news = ContentArticle::with(['translations' => function($q) use ($lang) {
    $q->where('article_translation_language', $lang);
}])->where('article_category', 'NEWS')->get();
```

### Modules

| Module | Parent Table | Translation Table | FK | Language Field |
|--------|-------------|-------------------|----|---------------|
| ContentArticle | `content_article` | `content_article_translation` | `article_translation_parent_id` | `article_translation_language` |
| ContentSlider | `content_slider` | `content_slider_translation` | `slider_translation_parent_id` | `slider_translation_language` |
| ContentVideo | `content_video` | `content_video_translation` | `video_translation_parent_id` | `video_translation_language` |
| ContentCalendar | `content_calendar` | `content_calendar_translation` | `calendar_translation_parent_id` | `calendar_translation_language` |
| ContentPhotoGallery | `content_photo_gallery` | `content_photo_gallery_translations` | `gallery_translation_parent_id` | `gallery_translation_language` |
| ContentApplication | `content_applications` | `content_application_translations` | `application_translation_parent_id` | `application_translation_language` |

---

## 4. Pattern: Self-Referencing (Sibling)

Digunakan oleh: **ContentDownload**, **ContentImage**, **ContentPhotoList**

### Table Structure

```
content_downloads
┌──────────────────────────────┐
│ download_id (PK)            │
│ download_main = 1/0         │ ← 1 = parent, 0 = sibling
│ download_parent_id (FK)     │ ← null utk parent, pointing ke parent utk sibling
│ download_language = 'ms'    │
│ download_title              │
│ download_file               │
│ ...                         │
└──────────────────────────────┘
```

### Query Pattern

```php
// Parent row dalam locale semasa
$downloads = ContentDownload::where('download_language', $lang)
    ->where('download_status', 'ACTIVE')
    ->orderBy('download_category')->get();
```

### Modules

| Module | Table | Main Flag | Parent FK | Language Field |
|--------|-------|-----------|-----------|---------------|
| ContentDownload | `content_downloads` | `download_main` | `download_parent_id` | `download_language` |
| ContentImage | `content_images` | `image_main` | `image_parent_id` | `image_language` |
| ContentPhotoList | `content_photo_list` | `photo_main` | `photo_parent_id` | `photo_language` |

---

## 5. Frontend Menu Translation

Menu names disimpan dalam table `frontend_menu` dalam BM. PortalHandler akan auto-translate menggunakan `t()`.

**File:** `app/Services/PortalHandler.php`

```php
$translateMenuName = function ($name) {
    return t($name);  // lookup dalam app_translations
};

// Build menu tree
$menuTree = $buildTree($publicMappings);

// Translate all menu names
array_walk_recursive($menuTree, function (&$v, $k) use ($translateMenuName) {
    if ($k === 'menu_name') $v = $translateMenuName($v);
});
```

Keys yang perlu ada dalam `app_translations`:
- `Utama`, `Mengenai Kami`, `Sejarah`, `Visi Misi`, `Carta Organisasi`, `Piagam Pelanggan`
- `Perkhidmatan`, `Dalam Talian`, `e-Permohonan`, `FAQ`, `Muat Turun`
- `Media`, `Berita`, `Galeri Foto`, `Video`, `Penerbitan`, `Hubungi Kami`

---

## 6. Seeder

### ContentTranslationSeeder

**File:** `database/seeders/portal/ContentTranslationSeeder.php`

Seed data awal untuk `app_translations` table. Guard: `count() > 0`.

```php
$data = [
    ['key' => 'login', 'ms' => 'Log Masuk', 'en' => 'Login', 'zh' => '登录'],
    ['key' => 'Utama', 'ms' => 'Utama', 'en' => 'Home', 'zh' => '首页'],
    // ... lebih banyak keys
];
```

### Content Seeders

Semua portal content seeders (ContentSliderSeeder, ContentVideoSeeder, dll) sekarang insert **ms, en, zh** untuk setiap content. Guard guna `->where('column', 'CATEGORY')->count()` spesifik mengikut kategori.

---

## 7. Admin Module: App Translation

**Routes:** `/admin/app-translation`

| Item | Nama |
|------|------|
| Table | `app_translations` |
| Model | `AppTranslation` |
| Controller | `AppTranslationController` |
| View folder | `appTranslation/` |
| Menu | Translations (dalam group Content) |
| Permission | `app-translation.view`, `.create`, `.update`, `.delete` |

### Features

- **Index**: Accordion group by language (collapse/expand), paginate by language 5/page, flat filter by language 10/page, search, dropdown "All Languages"
- **Create**: Add key + value for 1 language
- **Edit**: Edit all languages for a key simultaneously
- **Delete**: Delete entire key (all languages)

### Validation — App Translation

| Field | Rule | UI |
|-------|------|----|
| `key` | `required|string|max:255` | Label `Key *`, border error + mesej |
| `lang` | `required|string|max:255` | Label `Language *`, border error + mesej |
| `value` | `required|string` | Label `Value *`, textarea `rows="6"`, border error + mesej |

HTML `required` tidak digunakan. Rujukan penuh: [`docs/VALIDATION-UI-GUIDE.md`](VALIDATION-UI-GUIDE.md).

---

## 8. Language Codes (ISO 639-1)

| Code | Language (Ref) |
|------|---------------|
| `ms` | Bahasa Melayu |
| `en` | English |
| `zh` | 中文 (Mandarin) |

Defined in `ref` table with `cat = 'LANGUAGE'`. Admin boleh tambah bahasa baru di menu Ref.

---

## 9. Files Reference

| File | Function |
|------|----------|
| `app/Helpers/Translation.php` | Helper `t()` function |
| `app/Models/Backend/AppTranslation.php` | Model untuk `app_translations` |
| `app/Http/Controllers/Backend/AppTranslationController.php` | CRUD controller + paginate by language |
| `app/Http/Requests/Backend/AppTranslationRequest.php` | Validation |
| `app/Services/PortalHandler.php` | Inject `$lang`, translate menu names |
| `resources/views/backend/module/appTranslation/*.blade.php` | Admin views |
| `resources/views/vendor/pagination/custom.blade.php` | Pagination template (showing language count) |
| `database/seeders/portal/ContentTranslationSeeder.php` | Seeder app_translations |
| `database/migrations/2026_07_05_000001_create_app_translations_table.php` | Migration |
| `routes/web.php` | Route `/lang/{code}` + admin routes |
| `database/seeders/portal/ContentSliderSeeder.php` | Slider seeder (ms, en, zh) |
| `database/seeders/portal/ContentVideoSeeder.php` | Video seeder (ms, en, zh) |
| `database/seeders/portal/ContentCalendarSeeder.php` | Calendar seeder (ms, en, zh) |
| `database/seeders/portal/ContentGallerySeeder.php` | Gallery seeder (ms, en, zh) |
| `database/seeders/portal/ContentApplicationSeeder.php` | Application seeder (ms, en, zh) |
| `database/seeders/portal/ContentDownloadSeeder.php` | Download seeder (ms, en, zh) |
| `database/seeders/portal/ContentImageSeeder.php` | Image seeder (ms, en, zh) |
| `database/seeders/portal/ContentArticleAboutUsSeeder.php` | Article PRESS (ms, en, zh) |
| `database/seeders/portal/ContentArticleFaqSeeder.php` | Article FAQ (ms, en, zh) |
| `database/seeders/portal/ContentNewsSeeder.php` | Article NEWS (ms, en, zh) |
| `database/seeders/portal/WebSiteBackupSeeder.php` | Portal components with `$lang` queries |

---

## 10. Fasa 1 — Teks UI KOMPONEN dipindah ke `app_translations` (2026-10-25)

**Latar belakang:** komponen portal menyimpan teks UI secara **keras dalam kod** — samada ternary mentah `{{ $xLang === 'en' ? 'EN' : 'BM' }}` atau array `$frtT = $xLang === 'en' ? [ … ] : [ … ]`. Kesannya teks **tidak boleh diubah dari admin** ✗.

**Prinsip yang digunakan:**

| Jenis | Simpan di | Sebab |
|---|---|---|
| **Teks UI** (tajuk, butang, label, mesej) | `app_translations` + `t()` | boleh edit dari `/admin/app-translation` ✓ |
| **Kandungan** (tajuk pautan, artikel, nama agensi) | `content_*_translation` | ia DATA — ada medan lain (URL, imej, sort) |
| **Tarikh/bulan** | `Carbon::translatedFormat()` | tiada baris terjemahan perlu |

### Konvensyen key

- **key = teks BM huruf biasa** (cth. `Hubungi Kami`, `Berita Terkini`) — selaras dengan seeder sedia ada (`'Utama'`, `'Penerbitan'`)
- **Huruf BESAR di paparan dibuat oleh CSS** `text-transform:uppercase`, **bukan** oleh teks — supaya teks dalam admin mudah dibaca ✓
- Key sedia ada yang berbahasa Inggeris (cth. `Visitor Statistics`, `Integrity Complaint`) **diguna semula** — ia padanan `frontend_pages.name` untuk sitemap ✓

### Fasa 1 — 40 key · 5 komponen

| Komponen | Teks | Perubahan |
|---|---|---|
| `footer_nres` (#21) | 19 + 1 | array `$frtT` (42 baris) **dibuang** · `$frtT['x']` → `t('…')` |
| `news_nres` (#24) | 13 | ternary → `t()` |
| `agency_nres` (#25) | 6 | ternary → `t()` |
| `slider_nres` (#22) | 4 | ternary → `t()` |
| `quick_links_nres` (#23) | 1 | ternary → `t()` |

**CSS ditambah** (`text-transform:uppercase`): `.panel-title` · `.sec-title` · `.agency-title` · `.site-footer h6`

**Skrip:**
```powershell
php storage/app/cms-page-content/seed-ui-translations.php   # 40 key → app_translations
php storage/app/cms-page-content/apply-component-t.php      # patch komponen + CSS
php storage/app/cms-page-content/apply-component-t.php --dry-run
```

**Perangkap yang ditemui:**
1. Komponen menggunakan **HURUF BESAR** (`'BERITA TERKINI'`) tetapi nilai ms dalam DB huruf biasa (`'Berita Terkini'`) → carian ms→key **mesti tanpa kes peka** (`mb_strtolower`)
2. Komponen disimpan dengan **CRLF** → regex pembuangan array perlu `\r?\n`, bukan `\n`
3. Selepas `$frtT['x']` → `t()`, **array `$frtT` mesti dibuang** — jika tidak ia jadi kod mati

**Pengesahan (BM + EN):** 7 halaman `ralat=0` ✓ · footer utuh (12 pautan · 5 ikon · 5 baris statistik · f-bottom) ✓ · `$frtT` sisa = 0 ✓ · teks BM & EN betul ✓ · tangkapan skrin: tajuk masih HURUF BESAR (dari CSS) ✓

**Backup:** `backups/component{21,22,23,24,25}-*-t-backup-*.json` · `backups/site2-layout-t-css-backup-*.json`

> **Belum (fasa seterusnya):** ~114 teks dalam **20 halaman** masih ternary — Fasa 2 (halaman mudah), Fasa 3 (sederhana), Fasa 4 (besar).

---

## 11. Pembersihan `app_translations` — ms + en sahaja (2026-10-25)

**Permintaan:** buang entri yang tidak berguna + hanya guna **Bahasa Melayu (ms)** dan **English (en)** sahaja.

### Hasil audit

| Ukuran | Sebelum | Selepas |
|---|---|---|
| Jumlah baris | 156 | **108** |
| Bahasa | ms 68 · en 68 · **zh 20** | **ms 54 · en 54** |
| Jumlah key | 68 | **54** |

### 14 key yang dibuang (sisa seeder demo `dynaweb4`)

`login` · `register` · `dynaweb4_desc` · `Utama` · `Mengenai Kami` · `Sejarah` · `Visi Misi` · `Perkhidmatan` · `Dalam Talian` · `e-Permohonan` · `Muat Turun` · `Media` · `Galeri Foto` · `Video`

### ⚠️ Cara menentukan key "digunakan" — ada DUA cara

1. **`t()` STATIK** — `t('Berita Terkini')` terus dalam template → 45 key
2. **`t()` DINAMIK** — `PortalHandler.php:111` memanggil `t($name)` untuk **nama menu** (`frontend_menu.menu_name`) dan **nama halaman** (`frontend_pages.name`) → 9 key

Key yang hanya kelihatan "tidak digunakan" oleh carian statik **mesti disemak terhadap kedua-dua jadual itu** sebelum dibuang ✗:

| Key dikekalkan | Sebab |
|---|---|
| `Carta Organisasi` · `Piagam Pelanggan` · `Penerbitan` | padan `frontend_menu.menu_name` |
| `FAQ` · `Berita` | padan menu **dan** halaman |
| `Calender` · `e-Services` · `Parliament QA` · `Tenders and Quotations` | padan `frontend_pages.name` (sitemap `t($smP['name'])`) |

### Seeder juga dikemas kini

`database/seeders/portal/ContentTranslationSeeder.php` — **20 key → 6 key** (hanya yang digunakan) + loop `['ms','en','zh']` → `['ms','en']` ✓

> Kalau tidak, pemasangan baharu (`migrate:fresh --seed`) akan memasukkan semula 14 key + 20 baris `zh` ✗. Seeder ada guard `if (AppTranslation::count() > 0) skip` ✓ jadi `db:seed` biasa tidak mengganggu.

### Guna

```powershell
php storage/app/cms-page-content/cleanup-translations.php            # buang zh + 14 key
php storage/app/cms-page-content/cleanup-translations.php --dry-run
```

**Pengesahan:** tiada `zh` ✓ · hanya ms+en ✓ · 14 key dibuang ✓ · 9 key dinamik kekal ✓ · setiap key ada ms+en ✓ · `/nres/home` BM & EN `ralat=0` ✓ · sitemap `ralat=0` ✓

**Backup:** `backups/app_translations-backup-*.json` (42.5 KB — 156 baris penuh)

> **Nota:** entri bahasa `zh` dalam `ref` (`cat = 'LANGUAGE'`) **telah dibuang** (2026-10-25) — `/lang/zh` kini **ditolak** (route menyemak `ref` cat LANGUAGE). `RefDataSeeder.php` juga dikemas kini (2 entri: `en` + `ms`) supaya pemasangan baharu tidak memasukkan semula.
>
> **Skrip:** `cleanup-language-zh.php` (`--dry-run`) · **Backup:** `backups/ref-language-zh-backup-*.json` (3 baris LANGUAGE)
>
> **Pengesahan:** `/lang/en` → EN ✓ · `/lang/zh` → **HTTP 302, locale kekal EN** ✓ · `ref` LANGUAGE = **2 entri** ✓ · 0 baris `zh` dalam semua `content_*_translation` ✓ · 0 sebutan `zh` dalam kod ✓

---

## 12. Terjemahan NAMA MENU navbar + Fasa 2 halaman (2026-10-25)

### 12.1 Nama menu navbar — **sudah guna `t()`**

`PortalHandler.php:110-119` memanggil **`t($menu_name)`** untuk setiap item menu ✓ — jadi navbar **dan** sitemap sudah melalui `app-translations`. Masalahnya cuma **key belum ada** ✗.

| Status | Sebelum | Selepas |
|---|---|---|
| Menu ada key | 4/51 ✗ | **51/51** ✓ |
| Navbar EN | `UTAMA MAKLUMAT KORPORAT SEKTOR…` ✗ | `Home Corporate Information Sectors Media Contact Us Community` ✓ |
| Sitemap EN (tajuk seksyen) | BM ✗ | EN ✓ |

**Pilihan B dilaksana** (selaras dgn konvensyen footer):
1. 6 nama L1 ditukar ke huruf biasa dlm `frontend_menu`: `UTAMA`→`Utama` · `MAKLUMAT KORPORAT`→`Maklumat Korporat` · `SEKTOR`→`Sektor` · `MEDIA`→`Media` · `HUBUNGI KAMI`→`Hubungi Kami` · `WARGA`→`Warga`
2. CSS `.navbar .nav-link{…;text-transform:uppercase}` ditambah — huruf besar di paparan kini dari **CSS**, bukan teks ✓
3. **45 key** ditambah (huruf biasa)

> ⚠️ **MySQL padanan tanpa kes peka** — `where('menu_name','MAKLUMAT KORPORAT')` juga padan `Maklumat Korporat` (#20, menu lama `menu_status=0`) ✗ → 7 baris dipadankan, tetapi 1 daripadanya no-op ✓ (selamat).
>
> **Skrip:** `seed-menu-translations.php` · `apply-menu-names.php` (`--dry-run`)

### 12.2 Fasa 2 — 5 halaman

| Halaman | ternary → `t()` |
|---|---|
| `faq` | 19 |
| `visitor-statistics` | 22 |
| `location` | 12 |
| `sitemap` | 3 |
| `officer-directory` | 0 (hanya kelas CSS) |

**+ 10 key nama halaman** untuk sitemap "Pautan Lain" (`Home` · `Officer Directory` · `Gallery` · `Publication` · `Media Statement` · `Electronic Archive` · `Article` · `Speech` · `Location` · `Sitemap`) — halaman ini kini berada dlm menu jadi tidak muncul dlm "Pautan Lain", tetapi key disediakan untuk masa depan ✓

### ⚠️ Perangkap penting — **2 bentuk ternary**

Halaman menggunakan **DUA** corak ✗ dan sebahagiannya berada dlm **array PHP** (bukan `{{ }}`) ✗:

```blade
{{ $vsLang === 'en' ? 'Total Visitors' : 'Jumlah Pelawat' }}     ← bentuk 1
'title' => $faqIsEn ? 'Complaints and Feedback' : 'Aduan dan…',   ← bentuk 2 (array PHP)
```

Corak regex mesti menyokong **kedua-duanya** + `{{ }}` **opsyenal**:
```php
"/(?:\{\{\s*)?\\\$(\w+)(?:Lang\s*===\s*'en'|IsEn)\s*\?\s*'…'\s*:\s*'…'(?:\s*\}\})?/"
```

**Override konflik:** `<h1>` FAQ perlu `en = Frequently Asked Questions` ✗ tetapi key `Soalan Lazim` ada `en = FAQ` ✗ → guna key EN berasingan + override `<h1>{{ t('Soalan Lazim') }}</h1>` ✓

**Skrip:** `seed-phase2-translations.php` (56 key) · `apply-page-t.php` (`--dry-run`)

**Pengesahan:** 19 halaman BM `ralat=0` ✓ · 5 halaman Fasa 2 EN betul ✓ · sitemap "Pautan Lain" papar nama BM ✓ · 0 ralat render ✓

**Backup:** `backups/frontend_menu-l1-names-backup-*.json` · `site2-layout-navlink-css-backup-*.json` · `page{28,38,39,40,41}-*-t-backup-*.json`

> **Belum (Fasa 3 & 4):** `publication` (25) · `gallery` (6) · `tenders-and-quotations` (12) · `article` (16) · `calender` (8) · `parliament-qa` (5) · `speech` (50) · `media-statement` (42) · `electronic-archive` (40) — ~204 ternary.

---

## 13. Migrasi key → BERSEMPENA (namespaced) — 2026-10-25

**Masalah:** key = teks BM penuh ✗ — maks **131 aksara** ✗ (`Nota: Bagi pertanyaan melalui telefon…`), susah dibaca dlm admin.

### Hasil

| Ukuran | Sebelum | Selepas |
|---|---|---|
| Panjang maks | 131 ✗ | **56** ✓ |
| Purata | 26 | **18** ✓ |
| Key ≤ 20 aksara | 86 (59%) | **108 (73%)** ✓ |
| Key > 60 aksara | 9 ✗ | **0** ✓ |
| Rujukan `t()` diganti | — | **111** ✓ |

**Skop:** `nav.*` · `footer.*` · `news.*` · `faq.*` · `vs.*` · `loc.*` · `agency.*` · `sm.*` · `quick.*` · `slider.*` · `layout.*` · `page.*` · `common.*` · `misc.*`

### ⚠️⚠️ PERANGKAT PALING PENTING — 69 key **TIDAK BOLEH** dinamakan bersempena

`PortalHandler.php:110` memanggil **`t($menu_name)`** dan sitemap memanggil **`t($smP['name'])`** — carian **DINAMIK** ✗. Key **MESTI** sama dengan teks dlm `frontend_menu.menu_name` / `frontend_pages.name` ✗.

Migrasi pertama menukar **SEMUA** 147 key ✗ → **navbar EN ROSAK** ✗ (`Corporate Information` jadi `Maklumat Korporat` ✗).

**Betulan:** pulihkan **69 key dinamik** (`nav.home` → `Utama` ✗, `page.gallery` → `Gallery` ✗) + 20 rujukan statik ✓

| Kategori | Bilangan | Key |
|---|---|---|
| **Statik** (boleh bersempena ✓) | **78** | `nav.faq_label` · `footer.contact` · `faq.no_result` · `vs.title` … |
| **Dinamik** (mesti teks ✗) | **69** | nama menu + nama halaman |

**Skrip:** `gen-key-map.php` (jana peta) · `migrate-keys.php` (migrasi; `--dry-run`) · `fix-dynamic-keys.php` (pulihkan key dinamik; `--dry-run`)

**Backup:** `backups/app_translations-BEFORE-keymigrate-*.json` · `backups/{table}-{id}-{label}-keymigrate-*.json` · `_key-map-final-*.json`

**Pengesahan:** 16 halaman BM `ralat=0` ✓ · 16 halaman EN `ralat=0` ✓ · navbar EN `Home Corporate Information Sectors Media Contact Us Community` ✓ · navbar BM kekal ✓ · 0 sisa rujukan lama ✓

> **Cadangan masa depan:** jika mahu key bersempena untuk menu juga, `PortalHandler` perlu guna **peta nama→key** (cth. lajur `menu_key` dlm `frontend_menu`) — bukan `t($name)` terus ✗

---

## 14. Teks KERAS (bukan ternary) — audit render & pembetulan

**Punca halaman `officer-directory` kekal BM walaupun tukar bahasa** ✗:

| # | Punca | Lokasi | Betulan |
|---|---|---|---|
| 1 | **Teks keras dlm HTML** ✗ | `officer-directory.blade.php` (31 tempat) | tukar ke `t()` + key `od.*` |
| 2 | **Chatbot layout** ✗ | `nres.blade.php:312` | `layout.help_needed` / `layout.ask_me` / `layout.agent_name` / `layout.close` |
| 3 | **Brand navbar** ✗ | `navbar_nres` #20 | `layout.official_portal` + `layout.ministry_name` (semua halaman ✗) |
| 4 | **Hero homepage** ✗ | `home` #22 + `hero_quick_nres` #26 | `home.hero_title` / `home.hero_sub` + `home.hq_*` (6 kad) |
| 5 | **Jawatan pegawai** ✗ | `content_directory_staff` simpan **teks BM** ✗ | `ref(cat=DIRECTORY_STAFF_POSITION).descr_en` + `$dirPosition()` |
| 6 | **Pautan agensi/footer** ✗ | `content_application_translations` (ms 42 / en 9 ✗) | tambah **34 baris EN** ✓ → ms 42 / en 43 ✓ |

### ⚠️ AUDIT TERNARY **TIDAK CUKUP** ✗

Audit Fasa 1-2 hanya kira **ternary** ✗ → **terlepas teks keras** ✗ (hampir semua halaman ✗). Contoh: `officer-directory` dilaporkan "2 ternary" ✗ tetapi sebenarnya **31 teks keras** ✗.

**Audit yang BETUL** — bandingkan **HTML render** BM vs EN:

```bash
# 1. Muat turun BM (tanpa sesi) + EN (selepas /lang/en)
# 2. Bandingkan teks yg SAMA dlm kedua-dua bahasa
php storage/app/cms-page-content/audit-render.php bm.html en.html     # satu halaman
php storage/app/cms-page-content/audit-all-render.php <folder>        # pukal
```

### Hasil `officer-directory`

| Ukuran | Sebelum | Selepas |
|---|---|---|
| Teks tidak diterjemah | **52** ✗ | **22** ✓ |
| Baki | 31 teks keras ✗ + jawatan ✗ | **semuanya nama khas** ✓ (akronim `UKK`/`BPM`, nama pegawai, `[@nres.gov.my]`) |

### 🪤 Perangkap ditemui

| Perangkap | Kesan |
|---|---|
| `t('Direktori Pegawai')` ✗ | Key dinamik sebenar = **nama halaman dlm DB** ✗ (`Officer Directory` ✓) → `t()` gagal ✗ → kekal BM ✗ |
| Audit ternary sahaja ✗ | Terlepas 31 teks keras ✗ |
| `Ref` ada `descr` **dan** `descr_en` ✓ | Guna ini utk jawatan ✓ (jangan cipta kolum baharu ✗) |
| `content_directory_departments` ada `_name_en` ✓ | Sudah diguna `$dirName()` ✓ — bahagian OK ✓, jawatan tidak ✗ |
| CSS `text-transform:uppercase` ✗ | Bila teks jadi huruf kecil dlm `t()` ✓, tambah CSS semula ✗ |
| Blade view cache ✗ | Perlu `php artisan view:clear` selepas tampal ✗ |

### Skrip

`audit-hardcoded.php` · `audit-render.php` · `audit-all-render.php` · `apply-officer-directory-t.php` · `_od-keys.php` · `_od-reps1.php` · `_od-reps2.php` · `apply-layout-od-t.php` · `seed-app-translations-en.php` · `apply-brand-t.php` · `apply-home-hero-t.php` · `seed-directory-positions.php` · `fix-page-key-title.php` · `apply-brand-css.php`

**Pengesahan:** BM **19/19** halaman `ralat=0` ✓ · EN **19/19** ✓ · `app_translations` **199 key** (ms=en=199 ✓) · `ref` DIRECTORY_STAFF_POSITION **7** ✓ · `content_application_translations` ms 42 / en 43 ✓

### ⚠️ PERANGKAP: templat `.txt` yang LAPUK ✗

Skrip `apply-*.php` menulis `template-*.txt` ke `frontend_pages.page_content` **verbatim** ✗ — jadi templat
yang tidak dikemas kini akan **REGRES** ✗ (buang key `t()`, skop, dsb.) bila skrip dijalankan semula ✗.

Selepas migrasi `t()` (Fasa 1–2) + perubahan skop pelawat, **5 templat** didapati lapuk ✗:

| Templat | `t()` dlm templat | `t()` dlm halaman | Tindakan |
|---|---|---|---|
| `faq` | 1 ✗ | 20 ✓ | diselaraskan ✓ |
| `location` | 0 ✗ | 12 ✓ | diselaraskan ✓ |
| `officer-directory` | 3 ✗ | 46 ✓ | diselaraskan ✓ |
| `visitor-statistics` | 1 ✗ | 23 ✓ | diselaraskan ✓ |
| `article` | 1 | 1 | diselaraskan ✓ (beza 5 bait) |

**Skrip:** `sync-templates-from-live.php` (`--dry-run` ✓ · backup ke `backups/template-*-<stamp>.txt` ✓)

**Pengesahan:** **12/12** templat sepadan dgn halaman live ✓ · `apply-visitor-stats.php --dry-run` = `10461 → 10461 bait` ✓ (tiada perubahan ✓)

> **Amalan:** selepas mengedit mana-mana halaman melalui CMS/patch, jalankan `sync-templates-from-live.php` ✗ supaya templat tidak tertinggal ✗.

---

## 15. Fasa 3 — 7 halaman teks keras (SIAP)

**Skop:** `complaints-feedback` · `gallery` · `parliament-qa` · `calender` · `tenders-and-quotations` · `visitor-statistics` · `sitemap`

### Enjin `_tfix-engine.php`

```php
tfix([
    'slug'   => 'gallery',
    'prefix' => 'gal',
    'map'    => ['Utama' => 'Home', ...],      // BM => EN (auto key prefix.snake_case(EN))
    'keys'   => ['Utama' => 'Utama', ...],     // guna key SEDIA ADA / key eksplisit
    'seed'   => ['Pusat Media' => ['Pusat Media', 'Media Centre']],  // key utk padanan 'extra'
    'extra'  => ['<span>TEXT</span>' => '<span>{{ t(\'x\') }}</span>'],  // padanan literal
]);
```

Padanan `>TEXT<` **tahan whitespace** ✓ (normalisasi `\s+`) + diikuti `<` ✓ / `@directive` ✓ / `{{ echo` ✓ → menangani teks berbilang baris ✓ + teks sebelum `@if` ✗.

### Hasil

| Halaman | Teks keras | Diganti | Baki |
|---|---|---|---|
| `complaints-feedback` | 3 | **4** ✓ | 0 (kini nama khas ✓) |
| `gallery` | 11 | **12** ✓ | 0 UI ✓ (1 `Video` sama ✓ + 2 lokasi DATA ✓) |
| `parliament-qa` | 12 | **10** ✓ | 0 UI ✓ |
| `calender` | 14 | **15** ✓ | 0 UI ✓ (5 kategori DATA ✓) |
| `tenders-and-quotations` | 27 | **38** ✓ | 0 UI ✓ (no. rujukan/tajuk/kategori DATA ✓) |
| `visitor-statistics` | 13 | **1** ✓ | 0 UI ✓ |
| `sitemap` | 1 | key `Landing` ✓ | 0 UI ✓ |
| **Jumlah** | **~81** | **~81** ✓ | **0 teks UI keras** ✓ |

**Audit render:** 568 → 323 → **236** ✓ (baki = DATA modul ✓ + nama khas ✓ + laluan URL ✓)

### ⚠️ Perangkap ditemui

| Perangkap | Kesan |
|---|---|
| `&` dlm sumber ialah **`&amp;`** ✗ | `'Tajuk & No. Rujukan'` ✗ tidak padan → guna `&amp;` ✓ |
| Teks diikuti **`@if`** ✗ (bukan `<` ✗) | `aktiviti pada bulan ini` ✗ terlepas → set aksara `[<@{]` ✓ |
| Teks diikuti **`{{ }}`** ✗ | `Lampiran (` ✗ terlepas → tambah `{` ✓ |
| Lookahead tanpa tangkapan ✗ | `<`/`@` hilang → tangkap `([<@{])` + `$3` ✓ |
| Tab label dlm **PHP array** ✗ | `'arkib' => 'Arkib',` ✗ → `extra` + `seed` ✓ |
| Teks dibina dari **DB** ✗ | `Parlimen 15 \| Penggal 5 \| …` ✗ → **DATA** ✓ jangan sentuh ✗ |
| `$argv` tiada dlm fungsi ✗ | guna `$GLOBALS['argv']` ✓ |

### Skrip

`_tfix-engine.php` · `fix-complaints-feedback.php` · `fix-gallery.php` · `fix-parliament-qa.php` · `fix-calender.php` · `fix-tenders.php` · `fix-last-texts.php` · `fix-page-name-keys.php` · `inventory-hardcoded.php` · `hardcoded-full.php` · `classify-remaining.php`

**Backup:** `backups/frontend_pages-{24,29,30,31,23,40}-*-tfix-*.json` ✓

> **Baki = DATA modul** ✓ (bukan kod ✗): `publication` 87 tajuk ✓, `speech` 6 nama/jawatan ✓, `article` 6 ✓, `calendar` 5 kategori ✓, `tenders` 4 ✓, `gallery` 2 lokasi ✓ — semua diisi melalui CMS per modul ✓ (`content_*_translation` ✓ / `Ref` ✓).
